From 8a2b2305e6cef1a0e376fd0dbff74f04f22bcf4d Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 02:42:13 -0400 Subject: [PATCH 01/28] feat(chat): add cohort capability and capture archive contract Adds the server-owned, fail-closed chat_first_ui capability and operator guardrails, plus the singleton source filter and Firestore indexes required for the Omi capture archive. Regenerates all affected app clients.\n\nVerification:\n- BACKEND_UNIT_TEST_FILE_LIST=/tmp/omi-chat-first-backend-tests.txt bash backend/test.sh\n- backend/scripts/openapi_runner.sh scripts/export_openapi.py --surface app-client --check ../docs/api-reference/app-client-openapi.json\n- Python Swift/TypeScript OpenAPI generator freshness checks\n- app-client OpenAPI compatibility against origin/main\n- Black and git diff --check\n- fresh Sol pre-commit review --- backend/database/conversations.py | 27 +- backend/database/firestore_index_registry.py | 15 + backend/database/task_intelligence_control.py | 15 +- backend/models/task_intelligence.py | 21 +- backend/routers/candidates.py | 23 +- backend/routers/conversations.py | 26 +- ...activate_task_intelligence_dogfood_user.py | 71 ++++- ...activate_task_intelligence_dogfood_user.py | 75 ++++- backend/tests/unit/test_candidates_router.py | 98 ++++++- ...st_conversation_archive_filter_contract.py | 273 ++++++++++++++++++ .../tests/unit/test_conversations_count.py | 36 ++- ...est_conversations_date_range_validation.py | 36 +++ backend/tests/unit/test_firestore_indexes.py | 17 ++ .../unit/test_task_intelligence_rollout.py | 19 +- .../test_what_matters_now_smoke_fixture.py | 19 +- backend/utils/task_intelligence/rollout.py | 12 +- .../Sources/Generated/OmiApi.generated.swift | 11 +- .../src/renderer/src/lib/omiApi.generated.ts | 3 +- docs/api-reference/app-client-openapi.json | 29 +- firestore.indexes.json | 48 +++ .../lib/services/omi-api/omiApi.generated.ts | 3 +- web/app/src/lib/omiApi.generated.ts | 3 +- .../src/lib/omiApi.generated.ts | 3 +- 23 files changed, 821 insertions(+), 62 deletions(-) create mode 100644 backend/tests/unit/test_conversation_archive_filter_contract.py diff --git a/backend/database/conversations.py b/backend/database/conversations.py index d28151d04f8..54e056cdd1b 100644 --- a/backend/database/conversations.py +++ b/backend/database/conversations.py @@ -476,9 +476,19 @@ def get_conversations_count( if not include_discarded: conversations_ref = conversations_ref.where(filter=FieldFilter('discarded', '==', False)) if sources: - conversations_ref = conversations_ref.where(filter=FieldFilter('source', 'in', sources)) + # The archive's `sources=omi` must compose with the multi-status `in` + # filter below. Firestore allows only one disjunctive `in` filter per + # query, so a singleton source is an equality predicate, not a + # degenerate `in` predicate. + if len(sources) == 1: + conversations_ref = conversations_ref.where(filter=FieldFilter('source', '==', sources[0])) + else: + conversations_ref = conversations_ref.where(filter=FieldFilter('source', 'in', sources)) if statuses: - conversations_ref = conversations_ref.where(filter=FieldFilter('status', 'in', statuses)) + if len(statuses) == 1: + conversations_ref = conversations_ref.where(filter=FieldFilter('status', '==', statuses[0])) + else: + conversations_ref = conversations_ref.where(filter=FieldFilter('status', 'in', statuses)) if categories: conversations_ref = conversations_ref.where(filter=FieldFilter('structured.category', 'in', categories)) if folder_id: @@ -500,6 +510,7 @@ def get_conversations_without_photos( offset: int = 0, include_discarded: bool = False, statuses: List[str] = [], + sources: Optional[List[str]] = None, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, categories: Optional[List[str]] = None, @@ -513,8 +524,18 @@ def get_conversations_without_photos( conversations_ref = db.collection('users').document(uid).collection(conversations_collection) if not include_discarded: conversations_ref = conversations_ref.where(filter=FieldFilter('discarded', '==', False)) + if sources: + # Keep the paginated list semantically identical to the count query; + # see `get_conversations_count` for why a singleton is equality. + if len(sources) == 1: + conversations_ref = conversations_ref.where(filter=FieldFilter('source', '==', sources[0])) + else: + conversations_ref = conversations_ref.where(filter=FieldFilter('source', 'in', sources)) if len(statuses) > 0: - conversations_ref = conversations_ref.where(filter=FieldFilter('status', 'in', statuses)) + if len(statuses) == 1: + conversations_ref = conversations_ref.where(filter=FieldFilter('status', '==', statuses[0])) + else: + conversations_ref = conversations_ref.where(filter=FieldFilter('status', 'in', statuses)) if categories: conversations_ref = conversations_ref.where(filter=FieldFilter('structured.category', 'in', categories)) diff --git a/backend/database/firestore_index_registry.py b/backend/database/firestore_index_registry.py index 4ca3973fdd2..9e9f68faa78 100644 --- a/backend/database/firestore_index_registry.py +++ b/backend/database/firestore_index_registry.py @@ -125,6 +125,21 @@ def _desc(field_path: str) -> FirestoreIndexField: 'COLLECTION', (_asc('discarded'), _asc('status'), _asc('structured.category'), _desc('created_at'), _desc('__name__')), ), + # `GET /v1/conversations?sources=...` retains the legacy + # `include_discarded=true` default, so this is distinct from the archive + # query below that explicitly excludes discarded captures. + FirestoreIndexRequirement( + 'conversations_source_status_created', + 'conversations', + 'COLLECTION', + (_asc('source'), _asc('status'), _desc('created_at'), _desc('__name__')), + ), + FirestoreIndexRequirement( + 'conversations_discarded_source_status_created', + 'conversations', + 'COLLECTION', + (_asc('discarded'), _asc('source'), _asc('status'), _desc('created_at'), _desc('__name__')), + ), FirestoreIndexRequirement( 'memory_items_tier_status_updated', 'memory_items', diff --git a/backend/database/task_intelligence_control.py b/backend/database/task_intelligence_control.py index c9acdcbf24e..6ed5c8961d3 100644 --- a/backend/database/task_intelligence_control.py +++ b/backend/database/task_intelligence_control.py @@ -30,7 +30,7 @@ def get_task_workflow_control(uid: str) -> TaskWorkflowControl: def set_task_workflow_control(uid: str, control: TaskWorkflowControl) -> None: ref = _control_ref(uid) - ref.set(control.model_dump(mode='json')) + ref.set(control.persisted_payload()) def ensure_development_smoke_fixture(uid: str, *, stage: str | None = None) -> bool: @@ -38,15 +38,22 @@ def ensure_development_smoke_fixture(uid: str, *, stage: str | None = None) -> b if not is_development_smoke_fixture(uid, stage=stage): return False - expected_payload = _SMOKE_FIXTURE_CONTROL.model_dump(mode='json') + expected_payload = _SMOKE_FIXTURE_CONTROL.persisted_payload() ref = _control_ref(uid) try: # Firestore's create operation is an atomic exists=false compare-and-create. ref.create(expected_payload) except (AlreadyExists, Conflict): snapshot = ref.get() - if snapshot.exists and snapshot.to_dict() == expected_payload: - return False + if snapshot.exists: + payload = snapshot.to_dict() + if isinstance(payload, dict): + try: + existing = TaskWorkflowControl.model_validate(cast(dict[str, Any], payload)) + except ValueError: + existing = None + if existing is not None and existing.persisted_payload() == expected_payload: + return False raise DevelopmentSmokeFixtureConflictError( 'development smoke fixture control already exists with differing state' ) from None diff --git a/backend/models/task_intelligence.py b/backend/models/task_intelligence.py index 28ed7c83036..7c53ec6319f 100644 --- a/backend/models/task_intelligence.py +++ b/backend/models/task_intelligence.py @@ -92,12 +92,31 @@ class TaskIntelligenceRolloutDecision(BaseModel): class TaskWorkflowControl(BaseModel): - """Per-user universal-contract migration control; defaults preserve legacy behavior.""" + """Per-user task-intelligence control plus its fail-closed API capability. + + ``chat_first_ui_enabled`` is the persisted, operator-owned rollout input. + ``chat_first_ui`` is a response-only derived capability: it is composed in + the candidates router from the explicit flag and the authoritative + canonical-memory/task-intelligence rollout decision. Persistence must use + :meth:`persisted_payload` so the derived response value can never become a + local or stale source of truth. + """ model_config = ConfigDict(extra='forbid', frozen=True) workflow_mode: TaskWorkflowMode = TaskWorkflowMode.off account_generation: int = Field(default=0, ge=0) + chat_first_ui_enabled: bool = Field(default=False, exclude=True) + chat_first_ui: bool = False + + def persisted_payload(self) -> dict[str, object]: + """Return the exact Firestore control-record shape, excluding derived API state.""" + + return { + 'workflow_mode': self.workflow_mode.value, + 'account_generation': self.account_generation, + 'chat_first_ui_enabled': self.chat_first_ui_enabled, + } class TaskIntelligenceAttributionEvent(BaseModel): diff --git a/backend/routers/candidates.py b/backend/routers/candidates.py index fc2418f4dff..962bb31c77f 100644 --- a/backend/routers/candidates.py +++ b/backend/routers/candidates.py @@ -26,7 +26,7 @@ from utils.task_intelligence import candidate_service from utils.task_intelligence.capture_policy import MINIMUM_CAPTURE_CONFIDENCE from utils.task_intelligence.recommendations import candidate_recommendation_dedupe_key -from utils.task_intelligence.rollout import resolve_task_intelligence_for_user +from utils.task_intelligence.rollout import resolve_chat_first_ui, resolve_task_intelligence_for_user from utils.task_intelligence.task_links import TaskLinkValidationError from utils.task_intelligence.staged_migration import migrate_staged_tasks @@ -232,7 +232,26 @@ def migrate_staged_candidates( @router.get('/v1/candidates/control', response_model=TaskWorkflowControl, tags=['candidates']) def get_candidate_workflow_control(uid: str = Depends(auth.get_current_user_uid)) -> TaskWorkflowControl: - return task_control_db.get_task_workflow_control(uid) + try: + control = task_control_db.get_task_workflow_control(uid) + except Exception: + # This endpoint selects a shell. An unavailable control record must not + # expose a partial new experience or hide the legacy-safe response. + return TaskWorkflowControl() + + try: + rollout = resolve_task_intelligence_for_user( + uid=uid, + workflow_mode=control.workflow_mode, + account_generation=control.account_generation, + ) + chat_first_ui = resolve_chat_first_ui(rollout, control.chat_first_ui_enabled) + except Exception: + # Cohort resolution is intentionally fail-closed: a backend outage or + # stale selector keeps this user in the existing shell. + chat_first_ui = False + + return control.model_copy(update={'chat_first_ui': chat_first_ui}) @router.post('/v1/candidates/integrations/drain', tags=['candidates']) diff --git a/backend/routers/conversations.py b/backend/routers/conversations.py index a8bd56b46f5..a10b5d4532f 100644 --- a/backend/routers/conversations.py +++ b/backend/routers/conversations.py @@ -330,6 +330,10 @@ def get_conversations( offset: NonNegativeOffset = 0, statuses: Optional[str] = "processing,completed", include_discarded: bool = True, + sources: Optional[str] = Query( + None, + description="Comma-separated source filter (e.g. friend,omi); combine with statuses only for one source.", + ), start_date: Optional[datetime] = Query(None, description="Filter by start date (inclusive)"), end_date: Optional[datetime] = Query(None, description="Filter by end date (inclusive)"), folder_id: Optional[str] = Query(None, description="Filter by folder ID"), @@ -338,10 +342,19 @@ def get_conversations( ): if start_date is not None and end_date is not None and _ensure_aware(start_date) > _ensure_aware(end_date): raise HTTPException(status_code=400, detail="start_date must be earlier than or equal to end_date") - logger.info(f'get_conversations {uid} {limit} {offset} {statuses} {folder_id} {starred}') + logger.info(f'get_conversations {uid} {limit} {offset} {statuses} {sources} {folder_id} {starred}') # force convos statuses to processing, completed on the empty filter if len(statuses) == 0: statuses = "processing,completed" + source_list = [source.strip() for source in sources.split(',') if source.strip()] if sources else [] + if len(source_list) > 1 and len([status.strip() for status in statuses.split(',') if status.strip()]) > 1: + # Firestore permits one disjunctive `in` predicate. The archive's + # supported `sources=omi&statuses=processing,completed` path uses an + # equality source filter; reject only the unsupported two-`in` shape. + raise HTTPException( + status_code=400, + detail='multiple sources cannot be combined with multiple statuses', + ) conversations = conversations_db.get_conversations_without_photos( uid, @@ -349,6 +362,7 @@ def get_conversations( offset, include_discarded=include_discarded, statuses=statuses.split(",") if len(statuses) > 0 else [], + sources=source_list, start_date=start_date, end_date=end_date, folder_id=folder_id, @@ -367,16 +381,18 @@ def get_conversations_count( end_date: Optional[datetime] = Query(None, description="Filter by end date (inclusive)"), folder_id: Optional[str] = Query(None, description="Filter by folder ID"), starred: Optional[bool] = Query(None, description="Filter by starred status"), - sources: Optional[str] = Query(None, description="Comma-separated source filter (e.g. friend,omi)"), + sources: Optional[str] = Query( + None, + description="Comma-separated source filter (e.g. friend,omi); combine with statuses only for one source.", + ), uid: str = Depends(auth.get_current_user_uid), ): if start_date is not None and end_date is not None and _ensure_aware(start_date) > _ensure_aware(end_date): raise HTTPException(status_code=400, detail="start_date must be earlier than or equal to end_date") status_list = [s.strip() for s in statuses.split(',') if s.strip()] if statuses else [] source_list = [s.strip() for s in sources.split(',') if s.strip()] if sources else [] - if status_list and source_list: - # Combining status+source `in` filters would need a composite index; keep them exclusive. - raise HTTPException(status_code=400, detail="statuses and sources filters cannot be combined") + if len(source_list) > 1 and len(status_list) > 1: + raise HTTPException(status_code=400, detail='multiple sources cannot be combined with multiple statuses') count = conversations_db.get_conversations_count( uid, include_discarded=include_discarded, diff --git a/backend/scripts/activate_task_intelligence_dogfood_user.py b/backend/scripts/activate_task_intelligence_dogfood_user.py index d388d8c7e65..5caaccdeea9 100644 --- a/backend/scripts/activate_task_intelligence_dogfood_user.py +++ b/backend/scripts/activate_task_intelligence_dogfood_user.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Safely activate Smart Tasks for the single approved dogfood account. +"""Safely configure Smart Tasks and Chat-first UI for one approved dogfood account. The default is a dry-run. An apply is deliberately limited to the existing canonical-memory dogfood UID, requires explicit UID/mode/generation -confirmations, and writes only that user's task-intelligence control document. +confirmations plus an exact Chat-first UI confirmation, and writes only that +user's task-intelligence control document. """ from __future__ import annotations @@ -157,6 +158,8 @@ def _firestore_value(value: object) -> object: return raw if kind == 'integerValue' and isinstance(raw, str) and raw.isdigit(): return int(raw) + if kind == 'booleanValue' and isinstance(raw, bool): + return raw raise RuntimeError('Firestore control field has an unsupported type') @@ -197,17 +200,25 @@ def read_control_with_gcloud_user_token( return _control_snapshot_from_firestore_document(payload) -def build_activation_plan(uid: str, current: TaskWorkflowControl) -> ActivationPlan: +def build_activation_plan( + uid: str, + current: TaskWorkflowControl, + *, + chat_first_ui_enabled: bool | None = None, +) -> ActivationPlan: require_dogfood_uid(uid) target = TaskWorkflowControl( workflow_mode=TaskWorkflowMode.read, account_generation=current.account_generation, + chat_first_ui_enabled=( + current.chat_first_ui_enabled if chat_first_ui_enabled is None else chat_first_ui_enabled + ), ) return ActivationPlan( uid=uid, document_path=control_path(uid), - current_control=current.model_dump(mode='json'), - target_control=target.model_dump(mode='json'), + current_control=current.persisted_payload(), + target_control=target.persisted_payload(), canonical_memory_whitelisted=True, ) @@ -217,6 +228,7 @@ def apply_activation( *, uid: str, expected_account_generation: int, + chat_first_ui_enabled: bool, rpc_timeout_seconds: float = DEFAULT_FIRESTORE_RPC_TIMEOUT_SECONDS, ) -> bool: """Write the `read` control only if the observed generation remains current. @@ -245,19 +257,21 @@ def activate(transaction) -> bool: target = TaskWorkflowControl( workflow_mode=TaskWorkflowMode.read, account_generation=current.account_generation, + chat_first_ui_enabled=chat_first_ui_enabled, ) if current == target: return False - transaction.set(ref, target.model_dump(mode='json')) + transaction.set(ref, target.persisted_payload()) return True return activate(db_client.transaction()) -def _firestore_control_fields(control: TaskWorkflowControl) -> dict[str, dict[str, str]]: +def _firestore_control_fields(control: TaskWorkflowControl) -> dict[str, dict[str, str | bool]]: return { 'workflow_mode': {'stringValue': control.workflow_mode.value}, 'account_generation': {'integerValue': str(control.account_generation)}, + 'chat_first_ui_enabled': {'booleanValue': control.chat_first_ui_enabled}, } @@ -266,6 +280,7 @@ def apply_activation_with_gcloud_user_token( firestore_project: str, uid: str, expected_account_generation: int, + chat_first_ui_enabled: bool, current: FirestoreControlSnapshot, rpc_timeout_seconds: float, http_open=None, @@ -280,6 +295,7 @@ def apply_activation_with_gcloud_user_token( target = TaskWorkflowControl( workflow_mode=TaskWorkflowMode.read, account_generation=current.control.account_generation, + chat_first_ui_enabled=chat_first_ui_enabled, ) if current.control == target: return False @@ -293,7 +309,7 @@ def apply_activation_with_gcloud_user_token( query = urlencode( { **precondition, - 'updateMask.fieldPaths': ['workflow_mode', 'account_generation'], + 'updateMask.fieldPaths': ['workflow_mode', 'account_generation', 'chat_first_ui_enabled'], }, doseq=True, ) @@ -333,19 +349,31 @@ def build_report( 'credential_source': credential_source, 'plan': asdict(plan), 'applied': applied, - 'verified_control': verified_control.model_dump(mode='json') if verified_control is not None else None, + 'verified_control': verified_control.persisted_payload() if verified_control is not None else None, 'operator_notes': [ 'This tool is restricted to the one approved Smart Tasks dogfood UID.', 'It writes only users/{uid}/task_intelligence_control/state when --apply is supplied.', 'It does not change the canonical-memory cohort, runtime environment, or any other user.', 'The transaction preserves account_generation and rejects a stale expected generation.', + 'The Chat-first UI flag is an explicit per-user input and must be confirmed exactly on apply.', 'The gcloud-user source obtains a short-lived token without including it in output or reports.', ], } +def parse_bool(value: str) -> bool: + normalized = value.strip().lower() + if normalized == 'true': + return True + if normalized == 'false': + return False + raise argparse.ArgumentTypeError('must be true or false') + + def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description='Dry-run/apply Smart Tasks read mode for the approved dogfood UID.') + parser = argparse.ArgumentParser( + description='Dry-run/apply Smart Tasks read mode and Chat-first UI for the approved dogfood UID.' + ) parser.add_argument('--uid', default=TASK_INTELLIGENCE_DOGFOOD_UID) parser.add_argument('--firestore-project', help='Required for --inspect-existing or --apply.') parser.add_argument( @@ -368,6 +396,16 @@ def parse_args() -> argparse.Namespace: '--confirm-workflow-mode', help='Required with --apply; must exactly equal read.', ) + parser.add_argument( + '--chat-first-ui-enabled', + type=parse_bool, + help='Requested per-user Chat-first UI flag; required with --apply and accepted as true or false.', + ) + parser.add_argument( + '--confirm-chat-first-ui-enabled', + type=parse_bool, + help='Required with --apply; must exactly match --chat-first-ui-enabled.', + ) return parser.parse_args() @@ -385,6 +423,12 @@ def main() -> int: raise SystemExit('--confirm-workflow-mode must exactly equal read when --apply is used') if args.expected_account_generation is None: raise SystemExit('--expected-account-generation is required when --apply is used') + if args.chat_first_ui_enabled is None: + raise SystemExit('--chat-first-ui-enabled is required when --apply is used') + if args.confirm_chat_first_ui_enabled != args.chat_first_ui_enabled: + raise SystemExit( + '--confirm-chat-first-ui-enabled must exactly match --chat-first-ui-enabled when --apply is used' + ) if (args.inspect_existing or args.apply) and not args.firestore_project: raise SystemExit('--firestore-project is required for inspection or apply') @@ -408,7 +452,7 @@ def main() -> int: f'expected {args.expected_account_generation}, found {current.account_generation}' ) - plan = build_activation_plan(args.uid, current) + plan = build_activation_plan(args.uid, current, chat_first_ui_enabled=args.chat_first_ui_enabled) applied = None verified_control = None if args.apply: @@ -419,6 +463,7 @@ def main() -> int: firestore_project=args.firestore_project, uid=args.uid, expected_account_generation=args.expected_account_generation, + chat_first_ui_enabled=args.chat_first_ui_enabled, current=current_snapshot, rpc_timeout_seconds=args.rpc_timeout_seconds, ) @@ -432,6 +477,7 @@ def main() -> int: db_client, uid=args.uid, expected_account_generation=args.expected_account_generation, + chat_first_ui_enabled=args.chat_first_ui_enabled, rpc_timeout_seconds=args.rpc_timeout_seconds, ) verified_control = read_control( @@ -442,8 +488,9 @@ def main() -> int: if verified_control != TaskWorkflowControl( workflow_mode=TaskWorkflowMode.read, account_generation=current.account_generation, + chat_first_ui_enabled=args.chat_first_ui_enabled, ): - raise RuntimeError('post-apply control verification did not match the requested read-mode control') + raise RuntimeError('post-apply control verification did not match the requested control') print( json.dumps( build_report( diff --git a/backend/tests/unit/test_activate_task_intelligence_dogfood_user.py b/backend/tests/unit/test_activate_task_intelligence_dogfood_user.py index 0f44734725e..1bbf92b3dc8 100644 --- a/backend/tests/unit/test_activate_task_intelligence_dogfood_user.py +++ b/backend/tests/unit/test_activate_task_intelligence_dogfood_user.py @@ -20,6 +20,11 @@ def load_script(): return module +@pytest.fixture +def script(): + return load_script() + + class _Snapshot: def __init__(self, data=None): self._data = data @@ -85,15 +90,18 @@ def read(self): return json.dumps(self._payload).encode('utf-8') -def test_missing_control_plans_explicit_read_at_default_generation(): - script = load_script() +def test_missing_control_plans_explicit_read_at_default_generation(script): db = _Db() current = script.read_control(db, uid=script.TASK_INTELLIGENCE_DOGFOOD_UID) - plan = script.build_activation_plan(script.TASK_INTELLIGENCE_DOGFOOD_UID, current) + plan = script.build_activation_plan( + script.TASK_INTELLIGENCE_DOGFOOD_UID, + current, + chat_first_ui_enabled=True, + ) - assert plan.current_control == {'workflow_mode': 'off', 'account_generation': 0} - assert plan.target_control == {'workflow_mode': 'read', 'account_generation': 0} + assert plan.current_control == {'workflow_mode': 'off', 'account_generation': 0, 'chat_first_ui_enabled': False} + assert plan.target_control == {'workflow_mode': 'read', 'account_generation': 0, 'chat_first_ui_enabled': True} assert plan.canonical_memory_whitelisted is True assert db.read_timeouts == [script.DEFAULT_FIRESTORE_RPC_TIMEOUT_SECONDS] @@ -108,12 +116,14 @@ def test_activation_preserves_existing_generation_and_is_idempotent(monkeypatch) db, uid=script.TASK_INTELLIGENCE_DOGFOOD_UID, expected_account_generation=7, + chat_first_ui_enabled=True, ) - assert db.docs[path] == {'workflow_mode': 'read', 'account_generation': 7} + assert db.docs[path] == {'workflow_mode': 'read', 'account_generation': 7, 'chat_first_ui_enabled': True} assert not script.apply_activation( db, uid=script.TASK_INTELLIGENCE_DOGFOOD_UID, expected_account_generation=7, + chat_first_ui_enabled=True, ) assert len(db.writes) == 1 @@ -129,6 +139,7 @@ def test_activation_rejects_stale_generation_without_a_write(monkeypatch): db, uid=script.TASK_INTELLIGENCE_DOGFOOD_UID, expected_account_generation=2, + chat_first_ui_enabled=True, ) assert db.writes == [] @@ -142,6 +153,52 @@ def test_tool_rejects_any_non_dogfood_uid(): script.build_activation_plan('another-user', script.TaskWorkflowControl()) +def test_apply_requires_an_explicit_chat_first_ui_value(monkeypatch): + script = load_script() + monkeypatch.setattr( + sys, + 'argv', + [ + str(SCRIPT), + '--apply', + '--confirm-uid', + script.TASK_INTELLIGENCE_DOGFOOD_UID, + '--confirm-workflow-mode', + 'read', + '--expected-account-generation', + '0', + ], + ) + + with pytest.raises(SystemExit, match='--chat-first-ui-enabled is required'): + script.main() + + +def test_apply_requires_an_exact_chat_first_ui_confirmation(monkeypatch): + script = load_script() + monkeypatch.setattr( + sys, + 'argv', + [ + str(SCRIPT), + '--apply', + '--confirm-uid', + script.TASK_INTELLIGENCE_DOGFOOD_UID, + '--confirm-workflow-mode', + 'read', + '--expected-account-generation', + '0', + '--chat-first-ui-enabled', + 'true', + '--confirm-chat-first-ui-enabled', + 'false', + ], + ) + + with pytest.raises(SystemExit, match='must exactly match'): + script.main() + + def test_gcloud_user_transport_uses_current_document_precondition_and_never_reports_token(): script = load_script() requests = [] @@ -164,12 +221,15 @@ def http_open(request, timeout): http_open=http_open, access_token='short-lived-token', ) - assert snapshot.control == script.TaskWorkflowControl(workflow_mode='off', account_generation=7) + assert snapshot.control == script.TaskWorkflowControl( + workflow_mode='off', account_generation=7, chat_first_ui_enabled=False + ) assert script.apply_activation_with_gcloud_user_token( firestore_project='based-hardware', uid=script.TASK_INTELLIGENCE_DOGFOOD_UID, expected_account_generation=7, + chat_first_ui_enabled=True, current=snapshot, rpc_timeout_seconds=5, http_open=http_open, @@ -182,4 +242,5 @@ def http_open(request, timeout): assert 'currentDocument.updateTime=2026-07-12T00%3A00%3A00.000000Z' in patch_request.full_url assert b'"workflow_mode": {"stringValue": "read"}' in patch_request.data assert b'"account_generation": {"integerValue": "7"}' in patch_request.data + assert b'"chat_first_ui_enabled": {"booleanValue": true}' in patch_request.data assert b'short-lived-token' not in patch_request.data diff --git a/backend/tests/unit/test_candidates_router.py b/backend/tests/unit/test_candidates_router.py index 1635b298530..879f952dc0c 100644 --- a/backend/tests/unit/test_candidates_router.py +++ b/backend/tests/unit/test_candidates_router.py @@ -3,6 +3,7 @@ import pytest from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient from jsonschema import ValidationError as JsonSchemaValidationError, validate import database.candidates as candidates_db @@ -35,10 +36,18 @@ def _record(*, candidate_id='candidate-1', proposal=None, created_at=None): ) -def test_candidate_router_publishes_complete_lifecycle_openapi(): +@pytest.fixture(scope='module') +def candidate_router_openapi(): + """Build the expensive schema once as shared file setup, not per-test work.""" + app = FastAPI() app.include_router(candidates_router.router) - paths = app.openapi()['paths'] + return app.openapi() + + +def test_candidate_router_publishes_complete_lifecycle_openapi(candidate_router_openapi): + spec = candidate_router_openapi + paths = spec['paths'] assert set(paths['/v1/candidates']) == {'get', 'post'} assert set(paths['/v1/candidates/{candidate_id}']) == {'get'} @@ -60,24 +69,99 @@ def test_candidate_router_publishes_complete_lifecycle_openapi(): } list_parameters = paths['/v1/candidates']['get']['parameters'] assert {'status', 'limit', 'offset', 'surface'}.issubset({parameter['name'] for parameter in list_parameters}) - candidate_schema = app.openapi()['components']['schemas']['CandidateCreate'] + candidate_schema = spec['components']['schemas']['CandidateCreate'] assert 'oneOf' in candidate_schema assert candidate_schema['discriminator']['propertyName'] == 'subject_kind' assert candidate_schema['discriminator']['mapping'] == { 'task': '#/components/schemas/TaskCandidate', 'workstream': '#/components/schemas/WorkstreamCreateCandidate', } - task_union = app.openapi()['components']['schemas']['TaskCandidate'] + task_union = spec['components']['schemas']['TaskCandidate'] assert task_union['discriminator']['propertyName'] == 'proposed_action' assert len(task_union['oneOf']) == 5 - assert len(app.openapi()['components']['schemas']['CandidateRecord']['oneOf']) == 6 + assert len(spec['components']['schemas']['CandidateRecord']['oneOf']) == 6 + workflow_control_schema = spec['components']['schemas']['TaskWorkflowControl'] + assert 'chat_first_ui' in workflow_control_schema['properties'] + assert 'chat_first_ui_enabled' not in workflow_control_schema['properties'] -def test_candidate_workflow_control_exposes_current_mode_and_generation(monkeypatch): +def _workflow_control_client() -> TestClient: + app = FastAPI() + app.include_router(candidates_router.router) + app.dependency_overrides[candidates_router.auth.get_current_user_uid] = lambda: 'user-1' + return TestClient(app) + + +def test_candidate_workflow_control_exposes_composed_chat_first_ui_capability(monkeypatch): + control = TaskWorkflowControl(workflow_mode='read', account_generation=8, chat_first_ui_enabled=True) + monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', lambda uid: control) + monkeypatch.setattr( + candidates_router, + 'resolve_task_intelligence_for_user', + lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), + ) + + response = _workflow_control_client().get('/v1/candidates/control') + + assert response.status_code == 200 + assert response.json() == { + 'workflow_mode': 'read', + 'account_generation': 8, + 'chat_first_ui': True, + } + + +def test_candidate_workflow_control_fails_closed_when_chat_first_composition_raises(monkeypatch): + control = TaskWorkflowControl(workflow_mode='read', account_generation=8, chat_first_ui_enabled=True) + monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', lambda uid: control) + monkeypatch.setattr( + candidates_router, + 'resolve_task_intelligence_for_user', + lambda **kwargs: (_ for _ in ()).throw(RuntimeError('canonical selector unavailable')), + ) + + response = _workflow_control_client().get('/v1/candidates/control') + + assert response.status_code == 200 + assert response.json()['chat_first_ui'] is False + assert 'chat_first_ui_enabled' not in response.json() + + +def test_candidate_workflow_control_fails_closed_when_control_lookup_raises(monkeypatch): + def unavailable(_uid): + raise RuntimeError('task workflow control unavailable') + + monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', unavailable) + monkeypatch.setattr( + candidates_router, + 'resolve_task_intelligence_for_user', + lambda **kwargs: pytest.fail('a missing control record must not attempt cohort resolution'), + ) + + response = _workflow_control_client().get('/v1/candidates/control') + + assert response.status_code == 200 + assert response.json() == { + 'workflow_mode': 'off', + 'account_generation': 0, + 'chat_first_ui': False, + } + + +def test_candidate_workflow_control_defaults_chat_first_ui_off_when_the_flag_is_missing(monkeypatch): control = TaskWorkflowControl(workflow_mode='read', account_generation=8) monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', lambda uid: control) + monkeypatch.setattr( + candidates_router, + 'resolve_task_intelligence_for_user', + lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), + ) + + response = _workflow_control_client().get('/v1/candidates/control') - assert candidates_router.get_candidate_workflow_control(uid='user-1') == control + assert response.status_code == 200 + assert response.json()['chat_first_ui'] is False + assert 'chat_first_ui_enabled' not in response.json() def test_candidate_record_serialization_satisfies_its_response_schema(): diff --git a/backend/tests/unit/test_conversation_archive_filter_contract.py b/backend/tests/unit/test_conversation_archive_filter_contract.py new file mode 100644 index 00000000000..314d48ea6c1 --- /dev/null +++ b/backend/tests/unit/test_conversation_archive_filter_contract.py @@ -0,0 +1,273 @@ +"""Hermetic contract tests for the server-side Omi capture archive filter.""" + +import os +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from testing.import_isolation import AutoMockModule, load_module_fresh, stub_modules + +_BACKEND = Path(__file__).resolve().parents[2] + + +class _FieldFilter: + def __init__(self, field_path, op_string, value): + self.field_path = field_path + self.op_string = op_string + self.value = value + + +class _Snapshot: + update_time = None + + def __init__(self, row): + self._row = row + + def to_dict(self): + return dict(self._row) + + +class _CountResult: + def __init__(self, value): + self.value = value + + +class _Query: + def __init__(self, rows): + self._rows = rows + self.filters = [] + self.events = [] + self._order_field = None + self._limit = None + self._offset = 0 + + def where(self, *, filter): + self.filters.append((filter.field_path, filter.op_string, filter.value)) + self.events.append(("where", filter.field_path, filter.op_string, filter.value)) + return self + + def order_by(self, field_path, direction=None): + self._order_field = field_path + self.events.append(("order_by", field_path, direction)) + return self + + def limit(self, value): + self._limit = value + self.events.append(("limit", value)) + return self + + def offset(self, value): + self._offset = value + self.events.append(("offset", value)) + return self + + def _matching_rows(self): + rows = list(self._rows) + for field_path, operator, expected in self.filters: + if operator == "==": + rows = [row for row in rows if row.get(field_path) == expected] + elif operator == "in": + rows = [row for row in rows if row.get(field_path) in expected] + else: # pragma: no cover - this test contract only uses equality and membership. + raise AssertionError(f"unexpected Firestore operator: {operator}") + if self._order_field: + rows.sort(key=lambda row: row[self._order_field], reverse=True) + return rows + + def stream(self): + self.events.append(("stream",)) + rows = self._matching_rows() + if self._limit is not None: + rows = rows[self._offset : self._offset + self._limit] + return [_Snapshot(row) for row in rows] + + def count(self): + self.events.append(("count",)) + return SimpleNamespace(get=lambda: [[_CountResult(len(self._matching_rows()))]]) + + +class _Firestore: + def __init__(self, rows): + self.rows = rows + self.queries = [] + + def document(self, uid): + assert uid == "user-1" + return self + + def collection(self, name): + assert name in {"users", "conversations"} + if name == "users": + return self + query = _Query(self.rows) + self.queries.append(query) + return query + + +def _decorator(*_args, **_kwargs): + return lambda func: func + + +@pytest.fixture +def conversations_db(): + """Load the production query function against a minimal in-memory Firestore seam.""" + firestore = _Firestore([]) + + google = ModuleType("google") + google.__path__ = [] + google_cloud = ModuleType("google.cloud") + google_cloud.__path__ = [] + google_api_core = ModuleType("google.api_core") + google_api_core.__path__ = [] + + firestore_module = ModuleType("google.cloud.firestore") + firestore_module.Query = SimpleNamespace(DESCENDING="DESCENDING") + firestore_v1_module = ModuleType("google.cloud.firestore_v1") + firestore_v1_module.FieldFilter = _FieldFilter + exceptions_module = ModuleType("google.api_core.exceptions") + exceptions_module.AlreadyExists = type("AlreadyExists", (Exception,), {}) + exceptions_module.Conflict = type("Conflict", (Exception,), {}) + exceptions_module.NotFound = type("NotFound", (Exception,), {}) + + database_client = ModuleType("database._client") + database_client.db = firestore + database_client.get_firestore_client = MagicMock() + database_helpers = ModuleType("database.helpers") + database_helpers.set_data_protection_level = MagicMock() + database_helpers.prepare_for_write = _decorator + database_helpers.prepare_for_read = _decorator + database_helpers.with_photos = _decorator + + models = ModuleType("models") + models.__path__ = [] + utils = ModuleType("utils") + utils.__path__ = [] + utils_other = ModuleType("utils.other") + utils_other.__path__ = [] + + fakes = { + "google": google, + "google.cloud": google_cloud, + "google.cloud.firestore": firestore_module, + "google.cloud.firestore_v1": firestore_v1_module, + "google.api_core": google_api_core, + "google.api_core.exceptions": exceptions_module, + "database._client": database_client, + "database.helpers": database_helpers, + "database.users": AutoMockModule("database.users"), + "models": models, + "models.audio_file": AutoMockModule("models.audio_file"), + "models.conversation_enums": AutoMockModule("models.conversation_enums"), + "models.conversation_photo": AutoMockModule("models.conversation_photo"), + "models.transcript_segment": AutoMockModule("models.transcript_segment"), + "utils": utils, + "utils.encryption": AutoMockModule("utils.encryption"), + "utils.other": utils_other, + "utils.other.hume": AutoMockModule("utils.other.hume"), + "utils.other.storage": AutoMockModule("utils.other.storage"), + } + + with stub_modules(fakes): + module = load_module_fresh( + "database.conversations", + os.path.join(str(_BACKEND), "database", "conversations.py"), + ) + yield module, firestore + + +def _conversation(conversation_id, *, created_at, source, status="completed", discarded=False): + return { + "id": conversation_id, + "created_at": created_at, + "source": source, + "status": status, + "discarded": discarded, + } + + +def test_archive_filter_precedes_pagination_and_matches_count(conversations_db): + module, firestore = conversations_db + firestore.rows = [ + _conversation("discarded-omi", created_at=8, source="omi", discarded=True), + _conversation("failed-omi", created_at=7, source="omi", status="failed"), + _conversation("friend-new", created_at=6, source="friend"), + _conversation("desktop-next", created_at=5, source="desktop"), + _conversation("omi-new", created_at=4, source="omi", status="processing"), + _conversation("friend-old", created_at=3, source="friend"), + _conversation("omi-middle", created_at=2, source="omi"), + _conversation("omi-old", created_at=1, source="omi"), + ] + + query = dict( + uid="user-1", + limit=2, + include_discarded=False, + statuses=["processing", "completed"], + sources=["omi"], + ) + first_page = module.get_conversations_without_photos(offset=0, **query) + second_page = module.get_conversations_without_photos(offset=2, **query) + count = module.get_conversations_count( + "user-1", include_discarded=False, statuses=["processing", "completed"], sources=["omi"] + ) + + assert [row["id"] for row in first_page] == ["omi-new", "omi-middle"] + assert [row["id"] for row in second_page] == ["omi-old"] + assert count == 3 + + list_query = firestore.queries[0] + assert list_query.filters == [ + ("discarded", "==", False), + ("source", "==", "omi"), + ("status", "in", ["processing", "completed"]), + ] + assert [event[0] for event in list_query.events] == [ + "where", + "where", + "where", + "order_by", + "limit", + "offset", + "stream", + ] + + +def test_sources_honor_legacy_include_discarded_default(conversations_db): + module, firestore = conversations_db + firestore.rows = [ + _conversation("discarded-omi", created_at=3, source="omi", discarded=True), + _conversation("friend", created_at=2, source="friend"), + _conversation("omi", created_at=1, source="omi"), + ] + + results = module.get_conversations_without_photos( + "user-1", + limit=10, + include_discarded=True, + statuses=["processing", "completed"], + sources=["omi"], + ) + + assert [row["id"] for row in results] == ["discarded-omi", "omi"] + assert firestore.queries[0].filters == [ + ("source", "==", "omi"), + ("status", "in", ["processing", "completed"]), + ] + + +def test_sources_omitted_preserves_legacy_filter_chain(conversations_db): + module, firestore = conversations_db + firestore.rows = [ + _conversation("discarded-omi", created_at=3, source="omi", discarded=True), + _conversation("friend", created_at=2, source="friend"), + _conversation("omi", created_at=1, source="omi"), + ] + + results = module.get_conversations_without_photos( + "user-1", limit=10, include_discarded=True, statuses=["processing", "completed"] + ) + + assert [row["id"] for row in results] == ["discarded-omi", "friend", "omi"] + assert firestore.queries[0].filters == [("status", "in", ["processing", "completed"])] diff --git a/backend/tests/unit/test_conversations_count.py b/backend/tests/unit/test_conversations_count.py index 659751c5cb9..305087b96f5 100644 --- a/backend/tests/unit/test_conversations_count.py +++ b/backend/tests/unit/test_conversations_count.py @@ -40,9 +40,15 @@ def get_conversations_count( if not include_discarded: conversations_ref = conversations_ref.where(filter=FieldFilter('discarded', '==', False)) if sources: - conversations_ref = conversations_ref.where(filter=FieldFilter('source', 'in', sources)) + if len(sources) == 1: + conversations_ref = conversations_ref.where(filter=FieldFilter('source', '==', sources[0])) + else: + conversations_ref = conversations_ref.where(filter=FieldFilter('source', 'in', sources)) if statuses: - conversations_ref = conversations_ref.where(filter=FieldFilter('status', 'in', statuses)) + if len(statuses) == 1: + conversations_ref = conversations_ref.where(filter=FieldFilter('status', '==', statuses[0])) + else: + conversations_ref = conversations_ref.where(filter=FieldFilter('status', 'in', statuses)) if categories: conversations_ref = conversations_ref.where(filter=FieldFilter('structured.category', 'in', categories)) if folder_id: @@ -73,7 +79,9 @@ def test_source_matches_implementation(self): source = f.read() assert 'def get_conversations_count(' in source assert "FieldFilter('discarded', '==', False)" in source + assert "FieldFilter('source', '==', sources[0])" in source assert "FieldFilter('source', 'in', sources)" in source + assert "FieldFilter('status', '==', statuses[0])" in source assert "FieldFilter('status', 'in', statuses)" in source assert "FieldFilter('folder_id', '==', folder_id)" in source assert "FieldFilter('starred', '==', starred)" in source @@ -109,6 +117,22 @@ def test_count_with_statuses_applies_correct_filters(self): assert f1.field_path == 'status' assert f1.value == ['processing', 'completed'] + def test_count_composes_sources_and_statuses(self): + ref = MagicMock() + mock_db.collection.return_value.document.return_value.collection.return_value = ref + ref.where.return_value = ref + ref.count.return_value.get.return_value = self._make_result(3) + + result = get_conversations_count('uid1', statuses=['processing', 'completed'], sources=['omi']) + + assert result == 3 + filters = [call.kwargs['filter'] for call in ref.where.call_args_list] + assert [(f.field_path, f.op_string, f.value) for f in filters] == [ + ('discarded', '==', False), + ('source', '==', 'omi'), + ('status', 'in', ['processing', 'completed']), + ] + def test_count_include_discarded_skips_filter(self): ref = MagicMock() mock_db.collection.return_value.document.return_value.collection.return_value = ref @@ -153,7 +177,7 @@ def test_count_include_discarded_with_statuses(self): assert ref.where.call_count == 1 f = ref.where.call_args.kwargs['filter'] assert f.field_path == 'status' - assert f.value == ['processing'] + assert (f.op_string, f.value) == ('==', 'processing') def test_count_applies_list_filter_parity(self): ref = MagicMock() @@ -174,7 +198,7 @@ def test_count_applies_list_filter_parity(self): filters = [call.kwargs['filter'] for call in ref.where.call_args_list] assert [(f.field_path, f.op_string, f.value) for f in filters] == [ ('discarded', '==', False), - ('status', 'in', ['completed']), + ('status', '==', 'completed'), ('folder_id', '==', 'folder-a'), ('starred', '==', False), ('created_at', '>=', '2026-06-01T00:00:00Z'), @@ -270,11 +294,11 @@ def test_route_forwards_sources_as_list(self): source = f.read() assert 'sources=source_list' in source - def test_route_rejects_statuses_combined_with_sources(self): + def test_route_does_not_reject_statuses_combined_with_sources(self): source_path = os.path.join(os.path.dirname(__file__), '..', '..', 'routers', 'conversations.py') with open(source_path, encoding='utf-8') as f: source = f.read() - assert 'statuses and sources filters cannot be combined' in source + assert 'statuses and sources filters cannot be combined' not in source def test_route_echoes_sources_when_filtered(self): # Clients rely on the echo to distinguish a filtered count from an diff --git a/backend/tests/unit/test_conversations_date_range_validation.py b/backend/tests/unit/test_conversations_date_range_validation.py index 155c04bd95b..70df87d6670 100644 --- a/backend/tests/unit/test_conversations_date_range_validation.py +++ b/backend/tests/unit/test_conversations_date_range_validation.py @@ -142,6 +142,7 @@ def _call_list(conv, **overrides): offset=0, statuses="processing,completed", include_discarded=True, + sources=None, start_date=None, end_date=None, folder_id=None, @@ -199,3 +200,38 @@ def test_valid_range_passes_through(conv): conv, start_date=datetime(2024, 1, 1), end_date=datetime(2024, 12, 31, tzinfo=timezone.utc) ) assert result == {"count": 0} + + +def test_list_forwards_sources_and_statuses_together(conv): + with patch.object(conv.conversations_db, "get_conversations_without_photos", return_value=[]) as query: + assert _call_list(conv, statuses="processing,completed", sources="omi") == [] + + assert query.call_args.kwargs["statuses"] == ["processing", "completed"] + assert query.call_args.kwargs["sources"] == ["omi"] + + +def test_count_forwards_sources_and_statuses_together_without_400(conv): + with patch.object(conv.conversations_db, "get_conversations_count", return_value=3) as query: + result = _call_count(conv, statuses="processing,completed", sources="omi") + + assert result == {"count": 3, "sources": ["omi"]} + assert query.call_args.kwargs["statuses"] == ["processing", "completed"] + assert query.call_args.kwargs["sources"] == ["omi"] + + +def test_multi_source_with_multi_status_returns_a_clear_400_instead_of_a_firestore_error(conv): + with pytest.raises(HTTPException, match="multiple sources") as exc: + _call_list(conv, sources="omi,friend") + assert exc.value.status_code == 400 + + with pytest.raises(HTTPException, match="multiple sources") as exc: + _call_count(conv, statuses="processing,completed", sources="omi,friend") + assert exc.value.status_code == 400 + + +def test_multi_source_with_one_status_forwards_without_a_disjunction_conflict(conv): + with patch.object(conv.conversations_db, "get_conversations_without_photos", return_value=[]) as query: + assert _call_list(conv, statuses="completed", sources="omi,friend") == [] + + assert query.call_args.kwargs["statuses"] == ["completed"] + assert query.call_args.kwargs["sources"] == ["omi", "friend"] diff --git a/backend/tests/unit/test_firestore_indexes.py b/backend/tests/unit/test_firestore_indexes.py index 6d0e8556795..0df73b9b740 100644 --- a/backend/tests/unit/test_firestore_indexes.py +++ b/backend/tests/unit/test_firestore_indexes.py @@ -24,6 +24,23 @@ def test_memory_firestore_indexes_are_checked_in_for_unified_memory_store(): assert "memory_short_term" not in collection_groups assert "memory_archive" not in collection_groups + assert ( + "conversations", + "COLLECTION", + (("source", "ASCENDING"), ("status", "ASCENDING"), ("created_at", "DESCENDING"), ("__name__", "DESCENDING")), + ) in signatures + assert ( + "conversations", + "COLLECTION", + ( + ("discarded", "ASCENDING"), + ("source", "ASCENDING"), + ("status", "ASCENDING"), + ("created_at", "DESCENDING"), + ("__name__", "DESCENDING"), + ), + ) in signatures + assert ( "memory_items", "COLLECTION", diff --git a/backend/tests/unit/test_task_intelligence_rollout.py b/backend/tests/unit/test_task_intelligence_rollout.py index 4aca9bd0c3f..a373806f272 100644 --- a/backend/tests/unit/test_task_intelligence_rollout.py +++ b/backend/tests/unit/test_task_intelligence_rollout.py @@ -3,7 +3,11 @@ from models.task_intelligence import TaskWorkflowMode from config.what_matters_now_smoke_fixture import WHAT_MATTERS_NOW_SMOKE_UID from utils.task_intelligence import rollout as rollout_module -from utils.task_intelligence.rollout import resolve_task_intelligence_for_user, resolve_task_intelligence_rollout +from utils.task_intelligence.rollout import ( + resolve_chat_first_ui, + resolve_task_intelligence_for_user, + resolve_task_intelligence_rollout, +) from utils.memory.memory_system import MemorySystem @@ -26,6 +30,19 @@ def test_rollout_matrix_keeps_workflow_and_memory_axes_independent(mode, memory_ assert decision.intelligence_product_enabled is (memory_eligible and mode == TaskWorkflowMode.read) +@pytest.mark.parametrize('mode', list(TaskWorkflowMode)) +@pytest.mark.parametrize('memory_eligible', [False, True]) +@pytest.mark.parametrize('ui_flag_enabled', [False, True]) +def test_chat_first_ui_requires_the_complete_read_mode_canonical_cohort(mode, memory_eligible, ui_flag_enabled): + rollout = resolve_task_intelligence_rollout( + uid='user-1', workflow_mode=mode, memory_cohort_eligible=memory_eligible, account_generation=7 + ) + + assert resolve_chat_first_ui(rollout, ui_flag_enabled) is ( + mode == TaskWorkflowMode.read and memory_eligible and ui_flag_enabled + ) + + def test_production_resolver_uses_authoritative_memory_selector(monkeypatch): calls = [] diff --git a/backend/tests/unit/test_what_matters_now_smoke_fixture.py b/backend/tests/unit/test_what_matters_now_smoke_fixture.py index a7d3ddd186e..4e0bc885656 100644 --- a/backend/tests/unit/test_what_matters_now_smoke_fixture.py +++ b/backend/tests/unit/test_what_matters_now_smoke_fixture.py @@ -47,12 +47,12 @@ def test_fixture_setup_create_only_writes_the_minimal_control_document(monkeypat monkeypatch.setattr(task_control_db, '_control_ref', lambda _uid: control_ref) assert task_control_db.ensure_development_smoke_fixture(task_control_db.WHAT_MATTERS_NOW_SMOKE_UID, stage='dev') - assert control_ref.payload == expected.model_dump(mode='json') - assert control_ref.create_payloads == [expected.model_dump(mode='json')] + assert control_ref.payload == expected.persisted_payload() + assert control_ref.create_payloads == [expected.persisted_payload()] def test_fixture_setup_is_idempotent_when_expected_control_already_exists(monkeypatch): - expected = TaskWorkflowControl(workflow_mode=TaskWorkflowMode.read, account_generation=0).model_dump(mode='json') + expected = TaskWorkflowControl(workflow_mode=TaskWorkflowMode.read, account_generation=0).persisted_payload() control_ref = _CreateOnlyControlReference(expected) monkeypatch.setattr(task_control_db, '_control_ref', lambda _uid: control_ref) @@ -62,6 +62,17 @@ def test_fixture_setup_is_idempotent_when_expected_control_already_exists(monkey assert control_ref.create_payloads == [expected] +def test_fixture_setup_treats_a_legacy_control_without_the_ui_flag_as_the_default_off_state(monkeypatch): + control_ref = _CreateOnlyControlReference({'workflow_mode': 'read', 'account_generation': 0}) + + monkeypatch.setattr(task_control_db, '_control_ref', lambda _uid: control_ref) + + assert not task_control_db.ensure_development_smoke_fixture(task_control_db.WHAT_MATTERS_NOW_SMOKE_UID, stage='dev') + assert control_ref.create_payloads == [ + TaskWorkflowControl(workflow_mode=TaskWorkflowMode.read, account_generation=0).persisted_payload() + ] + + def test_fixture_setup_preserves_differing_existing_control_and_fails_smoke(monkeypatch): differing_control = {'workflow_mode': 'write', 'account_generation': 3} control_ref = _CreateOnlyControlReference(differing_control) @@ -73,7 +84,7 @@ def test_fixture_setup_preserves_differing_existing_control_and_fails_smoke(monk assert control_ref.payload == differing_control assert control_ref.create_payloads == [ - TaskWorkflowControl(workflow_mode=TaskWorkflowMode.read, account_generation=0).model_dump(mode='json') + TaskWorkflowControl(workflow_mode=TaskWorkflowMode.read, account_generation=0).persisted_payload() ] diff --git a/backend/utils/task_intelligence/rollout.py b/backend/utils/task_intelligence/rollout.py index 576ec875680..0b51c21722d 100644 --- a/backend/utils/task_intelligence/rollout.py +++ b/backend/utils/task_intelligence/rollout.py @@ -77,4 +77,14 @@ def resolve_task_intelligence_for_user( ) -__all__ = ['resolve_task_intelligence_for_user', 'resolve_task_intelligence_rollout'] +def resolve_chat_first_ui(rollout: TaskIntelligenceRolloutDecision, ui_flag_enabled: bool) -> bool: + """Return the server-owned Chat-first capability for one resolved user. + + The explicit UI flag is necessary but never sufficient: only the canonical + read-mode task-intelligence product cohort may receive the new shell. + """ + + return bool(rollout.intelligence_product_enabled and ui_flag_enabled) + + +__all__ = ['resolve_chat_first_ui', 'resolve_task_intelligence_for_user', 'resolve_task_intelligence_rollout'] diff --git a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift index a1c9ab66b94..a7752e777e1 100644 --- a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift +++ b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift @@ -3819,21 +3819,25 @@ public enum OmiAPI { public struct TaskWorkflowControl: Codable { public let accountGeneration: Int? + public let chatFirstUi: Bool? public let workflowMode: TaskWorkflowMode? private enum CodingKeys: String, CodingKey { case accountGeneration = "account_generation" + case chatFirstUi = "chat_first_ui" case workflowMode = "workflow_mode" } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) accountGeneration = try c.decodeIfPresent(Int.self, forKey: .accountGeneration) + chatFirstUi = try c.decodeIfPresent(Bool.self, forKey: .chatFirstUi) workflowMode = try c.decodeIfPresent(TaskWorkflowMode.self, forKey: .workflowMode) } - public init(accountGeneration: Int?, workflowMode: TaskWorkflowMode?) { + public init(accountGeneration: Int?, chatFirstUi: Bool?, workflowMode: TaskWorkflowMode?) { self.accountGeneration = accountGeneration + self.chatFirstUi = chatFirstUi self.workflowMode = workflowMode } } @@ -6709,7 +6713,7 @@ public enum OmiAPI { return try JSONDecoder().decode(CandidateResolutionReceipt.self, from: data) } - public static func getConversationsV1ConversationsGet(client: OmiApiClient, limit: Int? = nil, offset: Int? = nil, statuses: String? = nil, includeDiscarded: Bool? = nil, startDate: String? = nil, endDate: String? = nil, folderId: String? = nil, starred: Bool? = nil, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> [Conversation] { + public static func getConversationsV1ConversationsGet(client: OmiApiClient, limit: Int? = nil, offset: Int? = nil, statuses: String? = nil, includeDiscarded: Bool? = nil, sources: String? = nil, startDate: String? = nil, endDate: String? = nil, folderId: String? = nil, starred: Bool? = nil, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> [Conversation] { let _path = "/v1/conversations" guard var components = URLComponents(string: client.baseURL + _path) else { throw OmiApiError.invalidURL @@ -6727,6 +6731,9 @@ public enum OmiAPI { if let includeDiscarded { queryItems.append(URLQueryItem(name: "include_discarded", value: String(includeDiscarded))) } + if let sources { + queryItems.append(URLQueryItem(name: "sources", value: String(sources))) + } if let startDate { queryItems.append(URLQueryItem(name: "start_date", value: String(startDate))) } diff --git a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts index bcac4e6ad38..f16eb437408 100644 --- a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts +++ b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts @@ -3218,6 +3218,7 @@ export interface TaskUpdateCandidate { export interface TaskWorkflowControl { account_generation?: number; + chat_first_ui?: boolean; workflow_mode?: TaskWorkflowMode; } @@ -9784,7 +9785,7 @@ export async function reject_candidate_v1_candidates__candidate_id__reject_post( return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { +export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, sources?: string | null, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/conversations`; const _params = query ? Object.entries(query) diff --git a/docs/api-reference/app-client-openapi.json b/docs/api-reference/app-client-openapi.json index a121d685614..9e858037231 100644 --- a/docs/api-reference/app-client-openapi.json +++ b/docs/api-reference/app-client-openapi.json @@ -19627,7 +19627,7 @@ }, "TaskWorkflowControl": { "additionalProperties": false, - "description": "Per-user universal-contract migration control; defaults preserve legacy behavior.", + "description": "Per-user task-intelligence control plus its fail-closed API capability.\n\n``chat_first_ui_enabled`` is the persisted, operator-owned rollout input.\n``chat_first_ui`` is a response-only derived capability: it is composed in\nthe candidates router from the explicit flag and the authoritative\ncanonical-memory/task-intelligence rollout decision. Persistence must use\n:meth:`persisted_payload` so the derived response value can never become a\nlocal or stale source of truth.", "properties": { "account_generation": { "default": 0, @@ -19635,6 +19635,11 @@ "title": "Account Generation", "type": "integer" }, + "chat_first_ui": { + "default": false, + "title": "Chat First Ui", + "type": "boolean" + }, "workflow_mode": { "$ref": "#/components/schemas/TaskWorkflowMode", "default": "off" @@ -30248,6 +30253,24 @@ "type": "boolean" } }, + { + "description": "Comma-separated source filter (e.g. friend,omi); combine with statuses only for one source.", + "in": "query", + "name": "sources", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Comma-separated source filter (e.g. friend,omi); combine with statuses only for one source.", + "title": "Sources" + } + }, { "description": "Filter by start date (inclusive)", "in": "query", @@ -30590,7 +30613,7 @@ } }, { - "description": "Comma-separated source filter (e.g. friend,omi)", + "description": "Comma-separated source filter (e.g. friend,omi); combine with statuses only for one source.", "in": "query", "name": "sources", "required": false, @@ -30603,7 +30626,7 @@ "type": "null" } ], - "description": "Comma-separated source filter (e.g. friend,omi)", + "description": "Comma-separated source filter (e.g. friend,omi); combine with statuses only for one source.", "title": "Sources" } }, diff --git a/firestore.indexes.json b/firestore.indexes.json index e8233d23a53..9aaea629e83 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -48,6 +48,54 @@ } ] }, + { + "collectionGroup": "conversations", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "source", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "created_at", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "conversations", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "discarded", + "order": "ASCENDING" + }, + { + "fieldPath": "source", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "created_at", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "memory_items", "queryScope": "COLLECTION", diff --git a/web/admin/lib/services/omi-api/omiApi.generated.ts b/web/admin/lib/services/omi-api/omiApi.generated.ts index bcac4e6ad38..f16eb437408 100644 --- a/web/admin/lib/services/omi-api/omiApi.generated.ts +++ b/web/admin/lib/services/omi-api/omiApi.generated.ts @@ -3218,6 +3218,7 @@ export interface TaskUpdateCandidate { export interface TaskWorkflowControl { account_generation?: number; + chat_first_ui?: boolean; workflow_mode?: TaskWorkflowMode; } @@ -9784,7 +9785,7 @@ export async function reject_candidate_v1_candidates__candidate_id__reject_post( return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { +export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, sources?: string | null, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/conversations`; const _params = query ? Object.entries(query) diff --git a/web/app/src/lib/omiApi.generated.ts b/web/app/src/lib/omiApi.generated.ts index bcac4e6ad38..f16eb437408 100644 --- a/web/app/src/lib/omiApi.generated.ts +++ b/web/app/src/lib/omiApi.generated.ts @@ -3218,6 +3218,7 @@ export interface TaskUpdateCandidate { export interface TaskWorkflowControl { account_generation?: number; + chat_first_ui?: boolean; workflow_mode?: TaskWorkflowMode; } @@ -9784,7 +9785,7 @@ export async function reject_candidate_v1_candidates__candidate_id__reject_post( return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { +export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, sources?: string | null, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/conversations`; const _params = query ? Object.entries(query) diff --git a/web/personas-open-source/src/lib/omiApi.generated.ts b/web/personas-open-source/src/lib/omiApi.generated.ts index bcac4e6ad38..f16eb437408 100644 --- a/web/personas-open-source/src/lib/omiApi.generated.ts +++ b/web/personas-open-source/src/lib/omiApi.generated.ts @@ -3218,6 +3218,7 @@ export interface TaskUpdateCandidate { export interface TaskWorkflowControl { account_generation?: number; + chat_first_ui?: boolean; workflow_mode?: TaskWorkflowMode; } @@ -9784,7 +9785,7 @@ export async function reject_candidate_v1_candidates__candidate_id__reject_post( return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { +export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, sources?: string | null, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/conversations`; const _params = query ? Object.entries(query) From c5e81fca34b79598363aa8734d2a1d0c99eb89e0 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 03:47:24 -0400 Subject: [PATCH 02/28] feat(chat): gate structured blocks in the local runtime Projects the server-owned Chat-first capability through the desktop agent runtime and adds a main-Chat-only structured block tool. The backend validates canonical references; the local journal atomically binds receipts to the producing assistant turn. Legacy surfaces keep the unchanged base manifest and render new block cases inert.\n\nVerification:\n- backend focused chat-first capability/block suite (64 tests)\n- desktop agent focused journal/manifest/session suite (103 tests)\n- desktop agent build and tool-surface generator check\n- desktop Swift debug build and agent-logic harness\n- app-client OpenAPI export, generator freshness, compatibility, Black, desktop test-quality, and git diff checks\n- fresh Sol review-and-fix gate --- backend/main.py | 2 + backend/models/chat_first.py | 108 +++++++++++++ backend/routers/chat_first.py | 106 +++++++++++++ .../scripts/generate_swift_openapi_types.py | 5 +- .../unit/test_app_client_swift_generator.py | 2 + backend/tests/unit/test_chat_first_blocks.py | 139 ++++++++++++++++ .../Desktop/Sources/Chat/AgentBridge.swift | 2 + .../Desktop/Sources/Chat/AgentClient.swift | 6 +- .../Sources/Chat/AgentRuntimeProcess.swift | 58 ++++++- .../Chat/AuthorizedToolExecution.swift | 39 +++-- .../Sources/Chat/ChatContentBlockCodec.swift | 31 ++++ .../Chat/ChatFirstBlockValidation.swift | 140 +++++++++++++++++ .../Chat/ChatFirstCapabilityProjection.swift | 22 +++ .../FloatingControlBar/AIResponseView.swift | 8 + .../FloatingControlBar/AgentPill.swift | 2 +- .../FloatingBarVoicePlaybackService.swift | 2 + .../FloatingControlBarState.swift | 4 + .../Generated/GeneratedToolExecutors.swift | 9 +- .../Sources/Generated/OmiApi.generated.swift | 112 ++++++------- .../MainWindow/Components/ChatBubble.swift | 5 + .../Onboarding/OnboardingChatView.swift | 2 + .../Sources/Providers/ChatProvider.swift | 16 ++ .../Sources/Providers/ChatToolExecutor.swift | 100 ++++++++++++ .../agent/scripts/generate-tool-surfaces.mjs | 7 +- desktop/macos/agent/src/index.ts | 116 ++++++++++++++ desktop/macos/agent/src/omi-tools-stdio.ts | 11 +- desktop/macos/agent/src/protocol.ts | 39 ++++- .../src/runtime/chat-first-capability.ts | 40 +++++ .../agent/src/runtime/context-snapshot.ts | 47 +++++- .../agent/src/runtime/conversation-journal.ts | 119 ++++++++++++++ .../agent/src/runtime/jsonl-transport.ts | 9 ++ .../macos/agent/src/runtime/kernel-core.ts | 4 + .../agent/src/runtime/kernel-sessions.ts | 53 ++++++- .../agent/src/runtime/omi-tool-manifest.ts | 77 ++++++++- .../agent/src/runtime/run-tool-capability.ts | 41 +++++ desktop/macos/agent/src/runtime/types.ts | 11 ++ .../agent/tests/conversation-journal.test.ts | 148 +++++++++++++++++- .../agent/tests/omi-tool-manifest.test.ts | 30 ++++ .../macos/agent/tests/surface-session.test.ts | 39 +++++ 39 files changed, 1614 insertions(+), 97 deletions(-) create mode 100644 backend/models/chat_first.py create mode 100644 backend/routers/chat_first.py create mode 100644 backend/tests/unit/test_chat_first_blocks.py create mode 100644 desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift create mode 100644 desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift create mode 100644 desktop/macos/agent/src/runtime/chat-first-capability.ts diff --git a/backend/main.py b/backend/main.py index 918ae2f970b..8bea9109b07 100644 --- a/backend/main.py +++ b/backend/main.py @@ -41,6 +41,7 @@ auth, action_items, candidates, + chat_first, task_integrations, integrations, x_connector, @@ -118,6 +119,7 @@ app.include_router(conversations.router) app.include_router(action_items.router) app.include_router(candidates.router) +app.include_router(chat_first.router) app.include_router(task_integrations.router) app.include_router(integrations.router) app.include_router(x_connector.router) diff --git a/backend/models/chat_first.py b/backend/models/chat_first.py new file mode 100644 index 00000000000..f56f71c02bc --- /dev/null +++ b/backend/models/chat_first.py @@ -0,0 +1,108 @@ +"""Strict, content-free contracts for chat-first structured-block admission.""" + +from hashlib import sha256 +from typing import Annotated, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from models.task_intelligence import StableId + + +class _StrictModel(BaseModel): + model_config = ConfigDict(extra='forbid', frozen=True) + + +class ChatFirstSubject(_StrictModel): + kind: Literal['task', 'goal', 'capture'] + id: StableId + + +class QuestionOption(_StrictModel): + option_id: StableId + label: str = Field(min_length=1, max_length=80) + prepared_answer: str = Field(min_length=1, max_length=500) + defer: bool = False + + +class QuestionCardSpec(_StrictModel): + type: Literal['questionCard'] + question_id: StableId + text: str = Field(min_length=1, max_length=300) + subject: ChatFirstSubject + options: list[QuestionOption] = Field(min_length=1, max_length=4) + + @model_validator(mode='after') + def validate_options(self): + option_ids = [option.option_id for option in self.options] + if len(option_ids) != len(set(option_ids)): + raise ValueError('question option IDs must be unique') + if sum(option.defer for option in self.options) > 1: + raise ValueError('question card may contain at most one defer option') + return self + + +class TaskCardSpec(_StrictModel): + type: Literal['taskCard'] + task_id: StableId + + +class GoalLinkSpec(_StrictModel): + type: Literal['goalLink'] + goal_id: StableId + summary: str = Field(min_length=1, max_length=200) + + +class CaptureLinkSpec(_StrictModel): + type: Literal['captureLink'] + conversation_id: StableId + moment_timestamp_ms: int | None = Field(default=None, ge=0) + summary: str = Field(min_length=1, max_length=200) + + +ChatFirstBlockSpec = Annotated[ + Union[QuestionCardSpec, TaskCardSpec, GoalLinkSpec, CaptureLinkSpec], + Field(discriminator='type'), +] + + +class ChatFirstBlockValidationRequest(_StrictModel): + source_surface: Literal['main_chat'] + control_generation: int = Field(ge=0) + owner_fence: StableId + run_id: StableId + attempt_id: StableId + blocks: list[ChatFirstBlockSpec] = Field(min_length=1, max_length=8) + + +class ChatFirstBlockValidationReceipt(_StrictModel): + accepted: bool + code: Literal[ + 'accepted', + 'capability_unavailable', + 'generation_mismatch', + 'entity_unavailable', + 'invalid_request', + ] + blocks: list[dict[str, object]] = Field(default_factory=list) + + +def stable_block_id(*, uid: str, generation: int, block: ChatFirstBlockSpec) -> str: + """Generate an opaque, retry-stable block ID without exposing block text.""" + + canonical = block.model_dump_json(exclude_none=True) + digest = sha256(f'{uid}:{generation}:{canonical}'.encode()).hexdigest()[:24] + return f'cfb_{digest}' + + +__all__ = [ + 'CaptureLinkSpec', + 'ChatFirstBlockSpec', + 'ChatFirstBlockValidationReceipt', + 'ChatFirstBlockValidationRequest', + 'ChatFirstSubject', + 'GoalLinkSpec', + 'QuestionCardSpec', + 'QuestionOption', + 'TaskCardSpec', + 'stable_block_id', +] diff --git a/backend/routers/chat_first.py b/backend/routers/chat_first.py new file mode 100644 index 00000000000..40a349d41b7 --- /dev/null +++ b/backend/routers/chat_first.py @@ -0,0 +1,106 @@ +"""Server authorization for the local desktop chat-first block tool. + +The local kernel owns its journal. This route validates capability and +canonical references only; it never creates, updates, or syncs a chat row. +""" + +from typing import Annotated, Any + +from fastapi import APIRouter, Body, Depends +from pydantic import ValidationError + +import database.action_items as action_items_db +import database.conversations as conversations_db +import database.goals as goals_db +import database.task_intelligence_control as task_control_db +from models.chat_first import ( + CaptureLinkSpec, + ChatFirstBlockSpec, + ChatFirstBlockValidationReceipt, + ChatFirstBlockValidationRequest, + GoalLinkSpec, + QuestionCardSpec, + TaskCardSpec, + stable_block_id, +) +from utils.other import endpoints as auth +from utils.task_intelligence.rollout import resolve_chat_first_ui, resolve_task_intelligence_for_user + +router = APIRouter() + + +def _entity_available(uid: str, block: ChatFirstBlockSpec) -> bool: + if isinstance(block, TaskCardSpec): + task = action_items_db.get_action_item(uid, block.task_id) + return bool(task and not task.get('is_locked', False)) + if isinstance(block, GoalLinkSpec): + return goals_db.get_goal_by_id(uid, block.goal_id) is not None + if isinstance(block, CaptureLinkSpec): + capture = conversations_db.get_conversation(uid, block.conversation_id) + return bool(capture and capture.get('source') == 'omi' and not capture.get('discarded', False)) + if isinstance(block, QuestionCardSpec): + subject = block.subject + if subject.kind == 'task': + task = action_items_db.get_action_item(uid, subject.id) + return bool(task and not task.get('is_locked', False)) + if subject.kind == 'goal': + return goals_db.get_goal_by_id(uid, subject.id) is not None + capture = conversations_db.get_conversation(uid, subject.id) + return bool(capture and capture.get('source') == 'omi' and not capture.get('discarded', False)) + return False + + +@router.post( + '/v1/chat-first/blocks/validate', + response_model=ChatFirstBlockValidationReceipt, + tags=['chat-first'], +) +def validate_chat_first_blocks( + payload: Annotated[Any, Body()], + uid: str = Depends(auth.get_current_user_uid), +) -> ChatFirstBlockValidationReceipt: + """Validate all requested blocks or return a typed no-mutation rejection.""" + + try: + request = ChatFirstBlockValidationRequest.model_validate(payload) + except ValidationError: + return ChatFirstBlockValidationReceipt(accepted=False, code='invalid_request') + + # The local runtime binds this fence to its signed-in owner before it can + # append a receipt. Fail closed if a stale/cross-account command reaches + # the backend with another user's authenticated token. + if request.owner_fence != uid: + return ChatFirstBlockValidationReceipt(accepted=False, code='capability_unavailable') + + try: + control = task_control_db.get_task_workflow_control(uid) + rollout = resolve_task_intelligence_for_user( + uid=uid, + workflow_mode=control.workflow_mode, + account_generation=control.account_generation, + ) + enabled = resolve_chat_first_ui(rollout, control.chat_first_ui_enabled) + except Exception: + return ChatFirstBlockValidationReceipt(accepted=False, code='capability_unavailable') + + if not enabled: + return ChatFirstBlockValidationReceipt(accepted=False, code='capability_unavailable') + if control.account_generation != request.control_generation: + return ChatFirstBlockValidationReceipt(accepted=False, code='generation_mismatch') + if not all(_entity_available(uid, block) for block in request.blocks): + return ChatFirstBlockValidationReceipt(accepted=False, code='entity_unavailable') + + block_ids = [ + stable_block_id(uid=uid, generation=control.account_generation, block=block) for block in request.blocks + ] + if len(block_ids) != len(set(block_ids)): + return ChatFirstBlockValidationReceipt(accepted=False, code='invalid_request') + + return ChatFirstBlockValidationReceipt( + accepted=True, + code='accepted', + blocks=[ + {'id': block_id, **block.model_dump(exclude_none=True)} + for block_id, block in zip(block_ids, request.blocks) + ], + ) diff --git a/backend/scripts/generate_swift_openapi_types.py b/backend/scripts/generate_swift_openapi_types.py index a78ce957d47..aa0f570b694 100644 --- a/backend/scripts/generate_swift_openapi_types.py +++ b/backend/scripts/generate_swift_openapi_types.py @@ -340,7 +340,10 @@ def _render_struct(name: str, schema: dict[str, Any]) -> str: lines.append('') # Memberwise init for adapter construction. - param_list = ', '.join(f'{sn}: {te}' + ('?' if opt else '') for sn, _, te, opt in fields) + # Optional fields need defaults to preserve source compatibility when the + # OpenAPI schema adds a new response member. Existing desktop fixtures and + # adapters can keep constructing the DTO with their known subset. + param_list = ', '.join(f'{sn}: {te}' + ('? = nil' if opt else '') for sn, _, te, opt in fields) lines.append(f' public init({param_list}) {{') for swift_name, _, _, _ in fields: lines.append(f' self.{swift_name} = {swift_name}') diff --git a/backend/tests/unit/test_app_client_swift_generator.py b/backend/tests/unit/test_app_client_swift_generator.py index 2dee086b89a..aa76c5ab949 100644 --- a/backend/tests/unit/test_app_client_swift_generator.py +++ b/backend/tests/unit/test_app_client_swift_generator.py @@ -100,6 +100,8 @@ def test_swift_generator_handles_refs_optionals_and_enums(): # Refs render as the referenced type name; required ref is non-optional. assert 'public let nested: Nested' in widget_block assert 'public let tags: [String]?' in widget_block + # Newly optional fields must not break existing construction call sites. + assert 'tags: [String]? = nil' in widget_block # AdditionalProperties renders as a typed dictionary. assert '[String: Double]?' in widget_block diff --git a/backend/tests/unit/test_chat_first_blocks.py b/backend/tests/unit/test_chat_first_blocks.py new file mode 100644 index 00000000000..e168f456b34 --- /dev/null +++ b/backend/tests/unit/test_chat_first_blocks.py @@ -0,0 +1,139 @@ +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from models.task_intelligence import TaskWorkflowControl +import routers.chat_first as chat_first_router + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(chat_first_router.router) + app.dependency_overrides[chat_first_router.auth.get_current_user_uid] = lambda: 'user-1' + return TestClient(app) + + +def _enable_chat_first(monkeypatch, *, generation: int = 7) -> None: + monkeypatch.setattr( + chat_first_router.task_control_db, + 'get_task_workflow_control', + lambda uid: TaskWorkflowControl( + workflow_mode='read', account_generation=generation, chat_first_ui_enabled=True + ), + ) + monkeypatch.setattr( + chat_first_router, + 'resolve_task_intelligence_for_user', + lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), + ) + + +def _request(*, generation: int = 7, blocks: list[dict] | None = None) -> dict: + return { + 'source_surface': 'main_chat', + 'control_generation': generation, + 'owner_fence': 'user-1', + 'run_id': 'run-1', + 'attempt_id': 'attempt-1', + 'blocks': blocks or [{'type': 'taskCard', 'task_id': 'task-1'}], + } + + +def test_chat_first_validate_admits_canonical_blocks_with_retry_stable_ids(monkeypatch): + _enable_chat_first(monkeypatch) + monkeypatch.setattr(chat_first_router.action_items_db, 'get_action_item', lambda uid, task_id: {'id': task_id}) + + client = _client() + first = client.post('/v1/chat-first/blocks/validate', json=_request()) + second = client.post('/v1/chat-first/blocks/validate', json=_request()) + + assert first.status_code == 200 + assert first.json() == second.json() + assert first.json()['accepted'] is True + assert first.json()['code'] == 'accepted' + assert first.json()['blocks'][0] == { + 'id': first.json()['blocks'][0]['id'], + 'type': 'taskCard', + 'task_id': 'task-1', + } + assert first.json()['blocks'][0]['id'].startswith('cfb_') + + +def test_chat_first_validate_rejects_the_entire_receipt_when_any_reference_is_unavailable(monkeypatch): + _enable_chat_first(monkeypatch) + monkeypatch.setattr( + chat_first_router.action_items_db, + 'get_action_item', + lambda uid, task_id: {'id': task_id} if task_id == 'task-1' else None, + ) + + response = _client().post( + '/v1/chat-first/blocks/validate', + json=_request( + blocks=[ + {'type': 'taskCard', 'task_id': 'task-1'}, + {'type': 'taskCard', 'task_id': 'missing-task'}, + ] + ), + ) + + assert response.status_code == 200 + assert response.json() == {'accepted': False, 'code': 'entity_unavailable', 'blocks': []} + + +def test_chat_first_validate_fails_closed_before_entity_resolution_outside_the_cohort(monkeypatch): + monkeypatch.setattr( + chat_first_router.task_control_db, + 'get_task_workflow_control', + lambda uid: TaskWorkflowControl(workflow_mode='read', account_generation=7, chat_first_ui_enabled=False), + ) + monkeypatch.setattr( + chat_first_router, + 'resolve_task_intelligence_for_user', + lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), + ) + monkeypatch.setattr( + chat_first_router.action_items_db, + 'get_action_item', + lambda *args: (_ for _ in ()).throw(AssertionError('entity resolution must not run when capability is off')), + ) + + response = _client().post('/v1/chat-first/blocks/validate', json=_request()) + + assert response.status_code == 200 + assert response.json() == {'accepted': False, 'code': 'capability_unavailable', 'blocks': []} + + +def test_chat_first_validate_rejects_a_stale_owner_fence_before_any_entity_read(monkeypatch): + _enable_chat_first(monkeypatch) + monkeypatch.setattr( + chat_first_router.action_items_db, + 'get_action_item', + lambda *args: (_ for _ in ()).throw(AssertionError('stale owner must not resolve entities')), + ) + + response = _client().post( + '/v1/chat-first/blocks/validate', + json={**_request(), 'owner_fence': 'another-user'}, + ) + + assert response.status_code == 200 + assert response.json() == {'accepted': False, 'code': 'capability_unavailable', 'blocks': []} + + +def test_chat_first_validate_returns_a_typed_rejection_for_malformed_block_schema(monkeypatch): + _enable_chat_first(monkeypatch) + monkeypatch.setattr( + chat_first_router.action_items_db, + 'get_action_item', + lambda *args: (_ for _ in ()).throw(AssertionError('malformed input must not resolve entities')), + ) + + response = _client().post( + '/v1/chat-first/blocks/validate', + json=_request(blocks=[{'type': 'taskCard'}]), + ) + + assert response.status_code == 200 + assert response.json() == {'accepted': False, 'code': 'invalid_request', 'blocks': []} diff --git a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift index c3184958cc9..b00437c952a 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift @@ -1165,6 +1165,7 @@ actor AgentBridge { _ surface: AgentSurfaceReference, title: String? = nil, creationProfile: AgentSessionCreationProfile? = nil, + chatFirstCapability: ChatFirstCapabilityProjection? = nil, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil ) async throws -> AgentSurfaceSession { let authorization = try resolveAuthorization(authorizationSnapshot) @@ -1177,6 +1178,7 @@ actor AgentBridge { surface: surface, title: title, creationProfile: creationProfile, + chatFirstCapability: chatFirstCapability, authorizationSnapshot: authorization ) } diff --git a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift index 9140779a9a5..9f2aea3e122 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift @@ -164,12 +164,14 @@ enum AgentClient { func resolveSurfaceSession( _ surface: AgentSurfaceReference, title: String? = nil, - creationProfile: AgentSessionCreationProfile? = nil + creationProfile: AgentSessionCreationProfile? = nil, + chatFirstCapability: ChatFirstCapabilityProjection? = nil ) async throws -> AgentSurfaceSession { try await bridge.resolveSurfaceSession( surface, title: title, - creationProfile: creationProfile + creationProfile: creationProfile, + chatFirstCapability: chatFirstCapability ) } diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift index a05b900b775..e536d1b8745 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift @@ -249,6 +249,7 @@ actor AgentRuntimeProcess { nonisolated static let requiredRuntimeCapabilities: Set = [ "journal_import_remote_turn", "runtime_adapter_availability", + "chat_first_capability_projection", ] private static let ownerTransitionClientID = "runtime-owner-transition" @@ -891,6 +892,7 @@ actor AgentRuntimeProcess { surface: AgentSurfaceReference, title: String?, creationProfile: AgentSessionCreationProfile?, + chatFirstCapability: ChatFirstCapabilityProjection? = nil, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot ) async throws -> AgentSurfaceSession { try assertAuthorization(authorizationSnapshot) @@ -900,7 +902,8 @@ actor AgentRuntimeProcess { ownerId: authorizationSnapshot.ownerID, surface: surface, title: title, - creationProfile: creationProfile + creationProfile: creationProfile, + chatFirstCapability: chatFirstCapability ) let result = try await kernelContractRequest( payload: payload, @@ -1400,7 +1403,8 @@ actor AgentRuntimeProcess { ownerId: String?, surface: AgentSurfaceReference, title: String?, - creationProfile: AgentSessionCreationProfile? = nil + creationProfile: AgentSessionCreationProfile? = nil, + chatFirstCapability: ChatFirstCapabilityProjection? = nil ) -> [String: Any] { var message = protocolEnvelope( type: "resolve_surface_session", @@ -1413,6 +1417,7 @@ actor AgentRuntimeProcess { message["externalRefId"] = surface.externalRefId if let title { message["title"] = title } if let creationProfile { message["creationProfile"] = creationProfile.dictionary } + if let chatFirstCapability { message["chatFirstCapability"] = chatFirstCapability.dictionary } return message } @@ -1918,6 +1923,51 @@ actor AgentRuntimeProcess { ).clearedCount } + /// Append server-validated structured blocks to exactly the assistant turn + /// produced by this capability's run/attempt. The Node kernel re-checks the + /// live capability and performs the sole journal mutation. + func appendChatFirstBlocks( + clientId: String, + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + runID: String, + attemptID: String, + capabilityRef: String, + controlGeneration: Int, + blocks: [[String: Any]], + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> KernelJournalTurn { + guard surface.surfaceKind == "main_chat", + controlGeneration >= 0, + !blocks.isEmpty, + blocks.count <= 8 + else { + throw BridgeError.agentError("Invalid chat-first journal append") + } + let result = try await journalOperation( + type: "append_chat_first_blocks", + operation: "append_chat_first_blocks", + clientId: clientId, + surface: surface, + ownerID: ownerID, + payload: [ + "sessionId": sessionID, + "runId": runID, + "attemptId": attemptID, + "capabilityRef": capabilityRef, + "controlGeneration": controlGeneration, + "blocks": blocks, + ], + authorizationSnapshot: authorizationSnapshot + ) + guard let turn = result.turn else { + throw BridgeError.agentError("Chat-first journal append returned no turn") + } + recordLifecycleJournalMutation(turn) + return turn + } + private func journalOperation( type: String, operation: String, @@ -3289,7 +3339,11 @@ actor AgentRuntimeProcess { ? AgentClientScope.floatingPill : nil, originatingSurfaceRef: surface, + originatingSessionID: command.sessionID, originatingRunId: command.runID, + originatingAttemptId: command.attemptID, + toolCapabilityRef: command.capabilityRef, + chatFirstControlGeneration: command.chatFirstControlGeneration, originatingUserText: command.originatingUserText, isOnboardingSurface: command.surfaceKind == "onboarding", expectedOwnerID: command.ownerID, diff --git a/desktop/macos/Desktop/Sources/Chat/AuthorizedToolExecution.swift b/desktop/macos/Desktop/Sources/Chat/AuthorizedToolExecution.swift index 9f87cfc9ca5..dc71a08e936 100644 --- a/desktop/macos/Desktop/Sources/Chat/AuthorizedToolExecution.swift +++ b/desktop/macos/Desktop/Sources/Chat/AuthorizedToolExecution.swift @@ -48,6 +48,7 @@ struct AuthorizedToolExecution: @unchecked Sendable { case invalidRetryPolicy case invalidPolicyRecovery case inputHashMismatch + case invalidChatFirstCapability case ownerChangedDuringExecution var code: String { @@ -59,6 +60,7 @@ struct AuthorizedToolExecution: @unchecked Sendable { case .invalidRetryPolicy: return "invalid_execution_retry_policy" case .invalidPolicyRecovery: return "invalid_execution_policy_recovery" case .inputHashMismatch: return "authorized_execution_input_hash_mismatch" + case .invalidChatFirstCapability: return "authorized_execution_chat_first_capability_mismatch" case .ownerChangedDuringExecution: return "authorized_execution_owner_changed" } } @@ -74,6 +76,7 @@ struct AuthorizedToolExecution: @unchecked Sendable { let manifestDigest: String let daemonBootEpoch: String let executionGeneration: Int + let capabilityRef: String let canonicalToolName: String let input: [String: Any] let inputHash: String @@ -88,6 +91,7 @@ struct AuthorizedToolExecution: @unchecked Sendable { let precedingAssistantText: String? let runMode: String let chatMode: String? + let chatFirstControlGeneration: Int? static func parse( _ payload: [String: Any], @@ -107,20 +111,23 @@ struct AuthorizedToolExecution: @unchecked Sendable { guard currentOwnerID == ownerID else { throw Rejection.wrongOwner } + let requestedToolName = try requiredString("toolName") + guard let resolvedTool = GeneratedToolExecutors.resolve(requestedToolName), + let executor = GeneratedToolExecutors.executorByTool[resolvedTool] + else { + throw Rejection.unsupportedExecutor + } let manifestVersion = payload["manifestVersion"] as? Int ?? 0 guard manifestVersion == GeneratedToolExecutors.manifestVersion else { throw Rejection.staleManifest } let manifestDigest = try requiredString("manifestDigest") - guard manifestDigest == GeneratedToolExecutors.manifestDigest else { + let expectedManifestDigest = resolvedTool == .renderChatBlocks + ? GeneratedToolExecutors.chatFirstManifestDigest + : GeneratedToolExecutors.manifestDigest + guard manifestDigest == expectedManifestDigest else { throw Rejection.staleManifest } - let requestedToolName = try requiredString("toolName") - guard let resolvedTool = GeneratedToolExecutors.resolve(requestedToolName), - let executor = GeneratedToolExecutors.executorByTool[resolvedTool] - else { - throw Rejection.unsupportedExecutor - } guard let effectClass = EffectClass(rawValue: try requiredString("effectClass")), let retryPolicy = RetryPolicy(rawValue: try requiredString("retryPolicy")) @@ -158,6 +165,18 @@ struct AuthorizedToolExecution: @unchecked Sendable { guard try inputHash(for: input) == expectedInputHash else { throw Rejection.inputHashMismatch } + let surfaceKind = try requiredString("surfaceKind") + let chatFirstControlGeneration = payload["chatFirstControlGeneration"] as? Int + if resolvedTool == .renderChatBlocks { + guard surfaceKind == "main_chat", + let chatFirstControlGeneration, + chatFirstControlGeneration >= 0 + else { + throw Rejection.invalidChatFirstCapability + } + } else if chatFirstControlGeneration != nil { + throw Rejection.invalidChatFirstCapability + } return AuthorizedToolExecution( invocationID: try requiredString("invocationId"), @@ -170,6 +189,7 @@ struct AuthorizedToolExecution: @unchecked Sendable { manifestDigest: manifestDigest, daemonBootEpoch: try requiredString("daemonBootEpoch"), executionGeneration: executionGeneration, + capabilityRef: try requiredString("capabilityRef"), canonicalToolName: resolvedTool.rawValue, input: input, inputHash: expectedInputHash, @@ -177,13 +197,14 @@ struct AuthorizedToolExecution: @unchecked Sendable { retryPolicy: retryPolicy, policyRecovery: policyRecovery, executor: executor, - surfaceKind: try requiredString("surfaceKind"), + surfaceKind: surfaceKind, externalRefKind: payload["externalRefKind"] as? String, externalRefID: payload["externalRefId"] as? String, originatingUserText: payload["originatingUserText"] as? String ?? "", precedingAssistantText: payload["precedingAssistantText"] as? String, runMode: runMode, - chatMode: payload["chatMode"] as? String) + chatMode: payload["chatMode"] as? String, + chatFirstControlGeneration: chatFirstControlGeneration) } static func inputHash(for input: [String: Any]) throws -> String { diff --git a/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift b/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift index 6d9d299a838..e3eb385ab56 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift @@ -77,6 +77,24 @@ enum ChatContentBlockCodec { blocks.append( .discoveryCard(id: id, title: title, summary: summary, fullText: fullText) ) + case "questionCard": + guard let questionId = dict["questionId"] as? String, + let text = dict["text"] as? String, + let subject = dict["subject"] as? [String: Any], + let subjectKind = subject["kind"] as? String, + let subjectId = subject["id"] as? String, + let options = dict["options"] as? [[String: Any]] + else { continue } + blocks.append(.questionCard(id: id, questionId: questionId, text: text, subjectKind: subjectKind, subjectId: subjectId, options: options)) + case "taskCard": + guard let taskId = dict["taskId"] as? String else { continue } + blocks.append(.taskCard(id: id, taskId: taskId)) + case "goalLink": + guard let goalId = dict["goalId"] as? String, let summary = dict["summary"] as? String else { continue } + blocks.append(.goalLink(id: id, goalId: goalId, summary: summary)) + case "captureLink": + guard let conversationId = dict["conversationId"] as? String, let summary = dict["summary"] as? String else { continue } + blocks.append(.captureLink(id: id, conversationId: conversationId, momentTimestampMs: dict["momentTimestampMs"] as? Int, summary: summary)) case "agentSpawn": guard let sessionId = dict["sessionId"] as? String, !sessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, @@ -192,6 +210,19 @@ enum ChatContentBlockCodec { "summary": summary, "fullText": fullText, ] + case .questionCard(let id, let questionId, let text, let subjectKind, let subjectId, let options): + return [ + "type": "questionCard", "id": id, "questionId": questionId, "text": text, + "subject": ["kind": subjectKind, "id": subjectId], "options": options, + ] + case .taskCard(let id, let taskId): + return ["type": "taskCard", "id": id, "taskId": taskId] + case .goalLink(let id, let goalId, let summary): + return ["type": "goalLink", "id": id, "goalId": goalId, "summary": summary] + case .captureLink(let id, let conversationId, let momentTimestampMs, let summary): + var dict: [String: Any] = ["type": "captureLink", "id": id, "conversationId": conversationId, "summary": summary] + if let momentTimestampMs { dict["momentTimestampMs"] = momentTimestampMs } + return dict case .agentSpawn( let id, let pillId, let sessionId, let runId, let title, let objective, let provider ): diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift new file mode 100644 index 00000000000..d6df49303d4 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Wire-only adapter for the server-authoritative structured-block validator. +/// The server receives snake_case Pydantic contracts; the local journal keeps +/// the established camelCase `ChatContentBlockCodec` contract. +struct ChatFirstBlockValidationRequest: Encodable { + let sourceSurface = "main_chat" + let controlGeneration: Int + let ownerFence: String + let runID: String + let attemptID: String + let blocks: [OmiAnyCodable] + + enum CodingKeys: String, CodingKey { + case sourceSurface = "source_surface" + case controlGeneration = "control_generation" + case ownerFence = "owner_fence" + case runID = "run_id" + case attemptID = "attempt_id" + case blocks + } +} + +struct ChatFirstBlockValidationReceipt: Decodable { + let accepted: Bool + let code: String + let blocks: [OmiAnyCodable] +} + +enum ChatFirstBlockWire { + static func backendBlocks(from input: [String: Any]) -> [[String: Any]]? { + guard let blocks = input["blocks"] as? [[String: Any]], (1...8).contains(blocks.count) else { + return nil + } + return blocks.compactMap(backendBlock) + } + + static func journalBlocks(from receipt: ChatFirstBlockValidationReceipt) -> [[String: Any]]? { + guard receipt.accepted, (1...8).contains(receipt.blocks.count) else { return nil } + let blocks = receipt.blocks.compactMap { $0.value as? [String: Any] }.compactMap(journalBlock) + return blocks.count == receipt.blocks.count ? blocks : nil + } + + private static func backendBlock(_ block: [String: Any]) -> [String: Any]? { + guard let type = block["type"] as? String else { return nil } + switch type { + case "questionCard": + guard let questionID = block["questionId"] as? String, + let text = block["text"] as? String, + let subject = block["subject"] as? [String: Any], + let kind = subject["kind"] as? String, + let subjectID = subject["id"] as? String, + let options = block["options"] as? [[String: Any]] + else { return nil } + let backendOptions = options.compactMap { option -> [String: Any]? in + guard let optionID = option["optionId"] as? String, + let label = option["label"] as? String, + let preparedAnswer = option["preparedAnswer"] as? String + else { return nil } + var result: [String: Any] = [ + "option_id": optionID, + "label": label, + "prepared_answer": preparedAnswer, + ] + if let isDeferred = option["defer"] as? Bool { result["defer"] = isDeferred } + return result + } + guard backendOptions.count == options.count else { return nil } + return [ + "type": type, + "question_id": questionID, + "text": text, + "subject": ["kind": kind, "id": subjectID], + "options": backendOptions, + ] + case "taskCard": + guard let taskID = block["taskId"] as? String else { return nil } + return ["type": type, "task_id": taskID] + case "goalLink": + guard let goalID = block["goalId"] as? String, let summary = block["summary"] as? String else { return nil } + return ["type": type, "goal_id": goalID, "summary": summary] + case "captureLink": + guard let conversationID = block["conversationId"] as? String, let summary = block["summary"] as? String else { return nil } + var result: [String: Any] = ["type": type, "conversation_id": conversationID, "summary": summary] + if let timestamp = block["momentTimestampMs"] as? Int { result["moment_timestamp_ms"] = timestamp } + return result + default: + return nil + } + } + + private static func journalBlock(_ block: [String: Any]) -> [String: Any]? { + guard let id = block["id"] as? String, let type = block["type"] as? String else { return nil } + switch type { + case "questionCard": + guard let questionID = block["question_id"] as? String, + let text = block["text"] as? String, + let subject = block["subject"] as? [String: Any], + let kind = subject["kind"] as? String, + let subjectID = subject["id"] as? String, + let options = block["options"] as? [[String: Any]] + else { return nil } + let journalOptions = options.compactMap { option -> [String: Any]? in + guard let optionID = option["option_id"] as? String, + let label = option["label"] as? String, + let preparedAnswer = option["prepared_answer"] as? String + else { return nil } + var result: [String: Any] = [ + "optionId": optionID, + "label": label, + "preparedAnswer": preparedAnswer, + ] + if let isDeferred = option["defer"] as? Bool { result["defer"] = isDeferred } + return result + } + guard journalOptions.count == options.count else { return nil } + return [ + "type": type, + "id": id, + "questionId": questionID, + "text": text, + "subject": ["kind": kind, "id": subjectID], + "options": journalOptions, + ] + case "taskCard": + guard let taskID = block["task_id"] as? String else { return nil } + return ["type": type, "id": id, "taskId": taskID] + case "goalLink": + guard let goalID = block["goal_id"] as? String, let summary = block["summary"] as? String else { return nil } + return ["type": type, "id": id, "goalId": goalID, "summary": summary] + case "captureLink": + guard let conversationID = block["conversation_id"] as? String, let summary = block["summary"] as? String else { return nil } + var result: [String: Any] = ["type": type, "id": id, "conversationId": conversationID, "summary": summary] + if let timestamp = block["moment_timestamp_ms"] as? Int { result["momentTimestampMs"] = timestamp } + return result + default: + return nil + } + } +} diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift new file mode 100644 index 00000000000..44cedc78ef3 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Immutable process-local projection of server-owned workflow control. +/// The cohort shell samples this before resolving Main Chat; no local data or +/// preference may reconstruct it, and absence means capability-off. +struct ChatFirstCapabilityProjection: Equatable, Sendable { + let chatFirstUi: Bool + let controlGeneration: Int + + init?(control: OmiAPI.TaskWorkflowControl) { + guard control.chatFirstUi == true, + let generation = control.accountGeneration, + generation >= 0 + else { return nil } + chatFirstUi = true + controlGeneration = generation + } + + var dictionary: [String: Any] { + ["chatFirstUi": chatFirstUi, "controlGeneration": controlGeneration] + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift index bf0e080a047..ad2acd2ab7d 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift @@ -123,6 +123,14 @@ struct AIResponseView: View { return ["thinking", id, text].joined(separator: "\u{1E}") case .discoveryCard(let id, let title, let summary, let fullText): return ["discovery", id, title, summary, fullText].joined(separator: "\u{1E}") + case .questionCard(let id, _, _, _, _, _): + return ["chatFirstQuestion", id].joined(separator: "\u{1E}") + case .taskCard(let id, _): + return ["chatFirstTask", id].joined(separator: "\u{1E}") + case .goalLink(let id, _, _): + return ["chatFirstGoal", id].joined(separator: "\u{1E}") + case .captureLink(let id, _, _, _): + return ["chatFirstCapture", id].joined(separator: "\u{1E}") case .agentSpawn( let id, let pillId, let sessionId, let runId, let title, let objective, let provider ): diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift index d700800fa9a..08722e835da 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift @@ -2216,7 +2216,7 @@ final class AgentPillsManager: ObservableObject { if !trimmed.isEmpty { return String(trimmed.prefix(110)) } - case .thinking, .discoveryCard: + case .thinking, .discoveryCard, .questionCard, .taskCard, .goalLink, .captureLink: continue } } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift index d1d48513103..bec3d4b3994 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift @@ -910,6 +910,8 @@ final class FloatingBarVoicePlaybackService: NSObject, AVAudioPlayerDelegate, AV return "\(title). \(objective)" case .agentCompletion(_, _, _, _, let title, _, let output, _): return "\(title). \(output)" + case .questionCard, .taskCard, .goalLink, .captureLink: + return nil case .toolCall, .thinking: return nil } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift index f8ce14e7475..ec9410786f4 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift @@ -730,6 +730,10 @@ extension ChatContentBlock { case .toolCall(let id, let name, let status, _, _, _): return "c:\(id):\(name):\(status)" case .thinking(let id, _): return "h:\(id)" case .discoveryCard(let id, _, _, _): return "d:\(id)" + case .questionCard(let id, _, _, _, _, _): return "q:\(id)" + case .taskCard(let id, _): return "t:\(id)" + case .goalLink(let id, _, _): return "g:\(id)" + case .captureLink(let id, _, _, _): return "c:\(id)" case .agentSpawn(let id, let pillId, _, _, _, _, _): return "s:\(id):\(pillId?.uuidString ?? "")" case .agentCompletion(let id, let pillId, _, _, _, _, _, _): return "a:\(id):\(pillId?.uuidString ?? "")" } diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift index df07b6e08b3..55041a55f60 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift @@ -32,6 +32,7 @@ enum GeneratedSwiftTool: String, CaseIterable { case reportScreenObservation = "report_screen_observation" case pointClick = "point_click" case getWorkContext = "get_work_context" + case renderChatBlocks = "render_chat_blocks" } enum GeneratedSwiftToolExecutor: String { @@ -41,7 +42,8 @@ enum GeneratedSwiftToolExecutor: String { enum GeneratedToolExecutors { static let manifestVersion = 1 - static let manifestDigest = "sha256:eddc8fda648c0e53797d0892c9409780ffb32eeb4d839a6510d027f310917544" + static let manifestDigest = "sha256:bddf77bb08d251aade514f1406e51abd1f51d1753a26bfbb0cf5a5c4a2f05458" + static let chatFirstManifestDigest = "sha256:7e390dbb46f41b48198a4cb24df578c98f6d0934cc31c4a348dcd26e70e5ce0f" static let aliasToCanonical: [String: GeneratedSwiftTool] = [ "search_screen_history": .semanticSearch, @@ -79,7 +81,8 @@ enum GeneratedToolExecutors { .screenshot: .realtimeHub, .reportScreenObservation: .realtimeHub, .pointClick: .realtimeHub, - .getWorkContext: .chatToolExecutor + .getWorkContext: .chatToolExecutor, + .renderChatBlocks: .chatToolExecutor ] static func resolve(_ name: String) -> GeneratedSwiftTool? { @@ -136,6 +139,7 @@ enum GeneratedToolExecutors { case getEmailInsights case createCalendarEvent case getWorkContext + case renderChatBlocks case unhandled } @@ -169,6 +173,7 @@ enum GeneratedToolExecutors { case .getEmailInsights: return .getEmailInsights case .createCalendarEvent: return .createCalendarEvent case .getWorkContext: return .getWorkContext + case .renderChatBlocks: return .renderChatBlocks default: return .unhandled } } diff --git a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift index a7752e777e1..5a9b90c708c 100644 --- a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift +++ b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift @@ -192,7 +192,7 @@ public enum OmiAPI { updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt) } - public init(candidateAction: String?, captureConfidence: Double?, captureKind: String?, captureOwner: String?, completed: Bool?, completedAt: String?, concreteDeliverable: Bool?, conversationId: String?, createdAt: String?, description_: String, dueAt: String?, ownershipConfidence: Double?, targetTaskId: String?, updatedAt: String?) { + public init(candidateAction: String? = nil, captureConfidence: Double? = nil, captureKind: String? = nil, captureOwner: String? = nil, completed: Bool? = nil, completedAt: String? = nil, concreteDeliverable: Bool? = nil, conversationId: String? = nil, createdAt: String? = nil, description_: String, dueAt: String? = nil, ownershipConfidence: Double? = nil, targetTaskId: String? = nil, updatedAt: String? = nil) { self.candidateAction = candidateAction self.captureConfidence = captureConfidence self.captureKind = captureKind @@ -283,7 +283,7 @@ public enum OmiAPI { workstreamId = try c.decodeIfPresent(String.self, forKey: .workstreamId) } - public init(appleReminderId: String?, completed: Bool?, conversationId: String?, description_: String, dueAt: String?, dueConfidence: Double?, exportDate: String?, exportPlatform: String?, exported: Bool?, goalId: String?, indentLevel: Int?, isLocked: Bool?, owner: TaskOwner?, priority: TaskPriority?, provenance: [EvidenceRef]?, recurrenceParentId: String?, recurrenceRule: String?, sortOrder: Int?, source: String?, status: TaskStatus?, workstreamId: String?) { + public init(appleReminderId: String? = nil, completed: Bool? = nil, conversationId: String? = nil, description_: String, dueAt: String? = nil, dueConfidence: Double? = nil, exportDate: String? = nil, exportPlatform: String? = nil, exported: Bool? = nil, goalId: String? = nil, indentLevel: Int? = nil, isLocked: Bool? = nil, owner: TaskOwner? = nil, priority: TaskPriority? = nil, provenance: [EvidenceRef]? = nil, recurrenceParentId: String? = nil, recurrenceRule: String? = nil, sortOrder: Int? = nil, source: String? = nil, status: TaskStatus? = nil, workstreamId: String? = nil) { self.appleReminderId = appleReminderId self.completed = completed self.conversationId = conversationId @@ -399,7 +399,7 @@ public enum OmiAPI { workstreamId = try c.decodeIfPresent(String.self, forKey: .workstreamId) } - public init(appleReminderId: String?, completed: Bool, completedAt: String?, conversationId: String?, createdAt: String?, description_: String, dueAt: String?, dueConfidence: Double?, exportDate: String?, exportPlatform: String?, exported: Bool?, goalId: String?, id: String, indentLevel: Int?, isLocked: Bool?, owner: TaskOwner?, priority: TaskPriority?, provenance: [EvidenceRef]?, recurrenceParentId: String?, recurrenceRule: String?, sortOrder: Int?, source: String?, status: TaskStatus?, supersededBy: String?, taskId: String?, updatedAt: String?, workstreamId: String?) { + public init(appleReminderId: String? = nil, completed: Bool, completedAt: String? = nil, conversationId: String? = nil, createdAt: String? = nil, description_: String, dueAt: String? = nil, dueConfidence: Double? = nil, exportDate: String? = nil, exportPlatform: String? = nil, exported: Bool? = nil, goalId: String? = nil, id: String, indentLevel: Int? = nil, isLocked: Bool? = nil, owner: TaskOwner? = nil, priority: TaskPriority? = nil, provenance: [EvidenceRef]? = nil, recurrenceParentId: String? = nil, recurrenceRule: String? = nil, sortOrder: Int? = nil, source: String? = nil, status: TaskStatus? = nil, supersededBy: String? = nil, taskId: String? = nil, updatedAt: String? = nil, workstreamId: String? = nil) { self.appleReminderId = appleReminderId self.completed = completed self.completedAt = completedAt @@ -695,7 +695,7 @@ public enum OmiAPI { hasMore = try c.decodeIfPresent(Bool.self, forKey: .hasMore) } - public init(actionItems: [ActionItemResponse], hasMore: Bool?) { + public init(actionItems: [ActionItemResponse], hasMore: Bool? = nil) { self.actionItems = actionItems self.hasMore = hasMore } @@ -717,7 +717,7 @@ public enum OmiAPI { content = try c.decode(String.self, forKey: .content) } - public init(appId: String?, content: String) { + public init(appId: String? = nil, content: String) { self.appId = appId self.content = content } @@ -772,7 +772,7 @@ public enum OmiAPI { workstreamId = try c.decode(String.self, forKey: .workstreamId) } - public init(artifactId: String, contentHash: String, createdAt: String, evidenceEventIds: [String]?, evidenceRefs: [EvidenceRef]?, kind: String, logicalKey: String, sourceRunId: String?, status: ArtifactStatus?, supersedesArtifactId: String?, uri: String, version: Int, workstreamId: String) { + public init(artifactId: String, contentHash: String, createdAt: String, evidenceEventIds: [String]? = nil, evidenceRefs: [EvidenceRef]? = nil, kind: String, logicalKey: String, sourceRunId: String? = nil, status: ArtifactStatus? = nil, supersedesArtifactId: String? = nil, uri: String, version: Int, workstreamId: String) { self.artifactId = artifactId self.contentHash = contentHash self.createdAt = createdAt @@ -826,7 +826,7 @@ public enum OmiAPI { version = try c.decode(Int.self, forKey: .version) } - public init(contentHash: String, evidenceEventIds: [String]?, evidenceRefs: [EvidenceRef]?, kind: String, logicalKey: String, sourceRunId: String?, supersedesArtifactId: String?, uri: String, version: Int) { + public init(contentHash: String, evidenceEventIds: [String]? = nil, evidenceRefs: [EvidenceRef]? = nil, kind: String, logicalKey: String, sourceRunId: String? = nil, supersedesArtifactId: String? = nil, uri: String, version: Int) { self.contentHash = contentHash self.evidenceEventIds = evidenceEventIds self.evidenceRefs = evidenceRefs @@ -899,7 +899,7 @@ public enum OmiAPI { uid = try c.decode(String.self, forKey: .uid) } - public init(chunkTimestamps: [Double], conversationId: String, duration: Double, id: String, provider: String?, startedAt: String?, uid: String) { + public init(chunkTimestamps: [Double], conversationId: String, duration: Double, id: String, provider: String? = nil, startedAt: String? = nil, uid: String) { self.chunkTimestamps = chunkTimestamps self.conversationId = conversationId self.duration = duration @@ -941,7 +941,7 @@ public enum OmiAPI { title = try c.decode(String.self, forKey: .title) } - public init(attendeeEmails: [String]?, attendees: [String]?, endTime: String, eventId: String, htmlLink: String?, startTime: String, title: String) { + public init(attendeeEmails: [String]? = nil, attendees: [String]? = nil, endTime: String, eventId: String, htmlLink: String? = nil, startTime: String, title: String) { self.attendeeEmails = attendeeEmails self.attendees = attendees self.endTime = endTime @@ -983,7 +983,7 @@ public enum OmiAPI { hasMore = try c.decodeIfPresent(Bool.self, forKey: .hasMore) } - public init(candidates: [CandidateRecord], hasMore: Bool?) { + public init(candidates: [CandidateRecord], hasMore: Bool? = nil) { self.candidates = candidates self.hasMore = hasMore } @@ -1070,7 +1070,7 @@ public enum OmiAPI { workstreamProposal = try c.decodeIfPresent(WorkstreamProposalOutput.self, forKey: .workstreamProposal) } - public init(accountGeneration: Int, candidateId: String, captureConfidence: Double, createdAt: String, evidenceRefs: [EvidenceRef], goalId: String?, idempotencyKey: String, ownershipConfidence: Double, proposedAction: CandidateAction, resolutionReason: String?, resolvedAt: String?, resultTaskId: String?, resultWorkstreamId: String?, sourceSurface: String, status: CandidateStatus?, subjectKind: CandidateSubjectKind, taskChange: CandidateTaskChange?, taskId: String?, workstreamId: String?, workstreamProposal: WorkstreamProposalOutput?) { + public init(accountGeneration: Int, candidateId: String, captureConfidence: Double, createdAt: String, evidenceRefs: [EvidenceRef], goalId: String? = nil, idempotencyKey: String, ownershipConfidence: Double, proposedAction: CandidateAction, resolutionReason: String? = nil, resolvedAt: String? = nil, resultTaskId: String? = nil, resultWorkstreamId: String? = nil, sourceSurface: String, status: CandidateStatus? = nil, subjectKind: CandidateSubjectKind, taskChange: CandidateTaskChange? = nil, taskId: String? = nil, workstreamId: String? = nil, workstreamProposal: WorkstreamProposalOutput? = nil) { self.accountGeneration = accountGeneration self.candidateId = candidateId self.captureConfidence = captureConfidence @@ -1125,7 +1125,7 @@ public enum OmiAPI { workstreamId = try c.decodeIfPresent(String.self, forKey: .workstreamId) } - public init(candidateId: String, newlyResolved: Bool, receiptId: String, resolvedAt: String, status: CandidateStatus, taskId: String?, workstreamId: String?) { + public init(candidateId: String, newlyResolved: Bool, receiptId: String, resolvedAt: String, status: CandidateStatus, taskId: String? = nil, workstreamId: String? = nil) { self.candidateId = candidateId self.newlyResolved = newlyResolved self.receiptId = receiptId @@ -1145,7 +1145,7 @@ public enum OmiAPI { reason = try c.decodeIfPresent(String.self, forKey: .reason) } - public init(reason: String?) { + public init(reason: String? = nil) { self.reason = reason } } @@ -1267,7 +1267,7 @@ public enum OmiAPI { workstreamId = try c.decode(String.self, forKey: .workstreamId) } - public init(checkpointId: String, contextSummary: String, evidenceRefs: [EvidenceRef]?, lastEventSequence: Int, runtimeId: String, updatedAt: String, workstreamId: String) { + public init(checkpointId: String, contextSummary: String, evidenceRefs: [EvidenceRef]? = nil, lastEventSequence: Int, runtimeId: String, updatedAt: String, workstreamId: String) { self.checkpointId = checkpointId self.contextSummary = contextSummary self.evidenceRefs = evidenceRefs @@ -1300,7 +1300,7 @@ public enum OmiAPI { runtimeId = try c.decode(String.self, forKey: .runtimeId) } - public init(contextSummary: String, evidenceRefs: [EvidenceRef]?, lastEventSequence: Int, runtimeId: String) { + public init(contextSummary: String, evidenceRefs: [EvidenceRef]? = nil, lastEventSequence: Int, runtimeId: String) { self.contextSummary = contextSummary self.evidenceRefs = evidenceRefs self.lastEventSequence = lastEventSequence @@ -1420,7 +1420,7 @@ public enum OmiAPI { visibility = try c.decodeIfPresent(ConversationVisibility.self, forKey: .visibility) } - public init(appId: String?, appsResults: [AppResult]?, audioFiles: [AudioFile]?, calendarEvent: CalendarEventLink?, callId: String?, clientDeviceId: String?, clientPlatform: String?, conversationAudio: ConversationAudio?, createdAt: String, dataProtectionLevel: String?, deferred: Bool?, discarded: Bool?, externalData: [String: OmiAnyCodable]?, finishedAt: String?, folderId: String?, geolocation: Geolocation?, id: String, isLocked: Bool?, language: String?, photos: [ConversationPhoto]?, pluginsResults: [PluginResult]?, privateCloudSyncEnabled: Bool?, processingConversationId: String?, processingMemoryId: String?, source: ConversationSource?, starred: Bool?, startedAt: String?, status: ConversationStatus?, structured: Structured, suggestedSummarizationApps: [String]?, transcriptSegments: [TranscriptSegment]?, transcriptSegmentsCompressed: Bool?, updatedAt: String?, visibility: ConversationVisibility?) { + public init(appId: String? = nil, appsResults: [AppResult]? = nil, audioFiles: [AudioFile]? = nil, calendarEvent: CalendarEventLink? = nil, callId: String? = nil, clientDeviceId: String? = nil, clientPlatform: String? = nil, conversationAudio: ConversationAudio? = nil, createdAt: String, dataProtectionLevel: String? = nil, deferred: Bool? = nil, discarded: Bool? = nil, externalData: [String: OmiAnyCodable]? = nil, finishedAt: String? = nil, folderId: String? = nil, geolocation: Geolocation? = nil, id: String, isLocked: Bool? = nil, language: String? = nil, photos: [ConversationPhoto]? = nil, pluginsResults: [PluginResult]? = nil, privateCloudSyncEnabled: Bool? = nil, processingConversationId: String? = nil, processingMemoryId: String? = nil, source: ConversationSource? = nil, starred: Bool? = nil, startedAt: String? = nil, status: ConversationStatus? = nil, structured: Structured, suggestedSummarizationApps: [String]? = nil, transcriptSegments: [TranscriptSegment]? = nil, transcriptSegmentsCompressed: Bool? = nil, updatedAt: String? = nil, visibility: ConversationVisibility? = nil) { self.appId = appId self.appsResults = appsResults self.audioFiles = audioFiles @@ -1486,7 +1486,7 @@ public enum OmiAPI { spans = try c.decodeIfPresent([ConversationAudioSpan].self, forKey: .spans) } - public init(audioFilesFingerprint: String, builtAt: String?, capturedDuration: Double, contentType: String?, duration: Double, spans: [ConversationAudioSpan]?) { + public init(audioFilesFingerprint: String, builtAt: String? = nil, capturedDuration: Double, contentType: String? = nil, duration: Double, spans: [ConversationAudioSpan]? = nil) { self.audioFilesFingerprint = audioFilesFingerprint self.builtAt = builtAt self.capturedDuration = capturedDuration @@ -1554,7 +1554,7 @@ public enum OmiAPI { id = try c.decodeIfPresent(String.self, forKey: .id) } - public init(base64: String, createdAt: String?, dataProtectionLevel: String?, description_: String?, discarded: Bool?, id: String?) { + public init(base64: String, createdAt: String? = nil, dataProtectionLevel: String? = nil, description_: String? = nil, discarded: Bool? = nil, id: String? = nil) { self.base64 = base64 self.createdAt = createdAt self.dataProtectionLevel = dataProtectionLevel @@ -1697,7 +1697,7 @@ public enum OmiAPI { subjectKind = try c.decode(RecommendationSubjectKind.self, forKey: .subjectKind) } - public init(decisionSummary: String, eligibility: ShortlistEligibility, evaluatedAt: String, evaluationId: String, evidenceRefs: [EvidenceRef]?, expiresAt: String, factDefinitionVersion: String, factsSnapshot: DeterministicFacts, finalOutputRef: String, modelVersion: String, policyVersion: String, promptVersion: String, reasonCodes: [String], shortlistIds: [String], subjectId: String, subjectKind: RecommendationSubjectKind) { + public init(decisionSummary: String, eligibility: ShortlistEligibility, evaluatedAt: String, evaluationId: String, evidenceRefs: [EvidenceRef]? = nil, expiresAt: String, factDefinitionVersion: String, factsSnapshot: DeterministicFacts, finalOutputRef: String, modelVersion: String, policyVersion: String, promptVersion: String, reasonCodes: [String], shortlistIds: [String], subjectId: String, subjectKind: RecommendationSubjectKind) { self.decisionSummary = decisionSummary self.eligibility = eligibility self.evaluatedAt = evaluatedAt @@ -1745,7 +1745,7 @@ public enum OmiAPI { someoneBlocked = try c.decodeIfPresent(Bool.self, forKey: .someoneBlocked) } - public init(captureConfidence: Double, contextMatchSignals: [ContextMatchSignal]?, daysToDue: Double?, focusedGoalLinked: Bool?, hasConcreteNextAction: Bool, someoneBlocked: Bool?) { + public init(captureConfidence: Double, contextMatchSignals: [ContextMatchSignal]? = nil, daysToDue: Double? = nil, focusedGoalLinked: Bool? = nil, hasConcreteNextAction: Bool, someoneBlocked: Bool? = nil) { self.captureConfidence = captureConfidence self.contextMatchSignals = contextMatchSignals self.daysToDue = daysToDue @@ -1771,7 +1771,7 @@ public enum OmiAPI { materialHint = try c.decodeIfPresent(String.self, forKey: .materialHint) } - public init(deviceId: String?, materialHint: String?) { + public init(deviceId: String? = nil, materialHint: String? = nil) { self.deviceId = deviceId self.materialHint = materialHint } @@ -1802,7 +1802,7 @@ public enum OmiAPI { title = try c.decode(String.self, forKey: .title) } - public init(created: Bool?, description_: String?, duration: Int?, start: String, title: String) { + public init(created: Bool? = nil, description_: String? = nil, duration: Int? = nil, start: String, title: String) { self.created = created self.description_ = description_ self.duration = duration @@ -1857,7 +1857,7 @@ public enum OmiAPI { sourceType = try c.decodeIfPresent(String.self, forKey: .sourceType) } - public init(artifactRef: [String: OmiAnyCodable]?, captureConfidence: Double?, clientDeviceId: String?, createdAt: String?, evidenceId: String, extractorId: String?, extractorVersion: String?, independenceGroup: String, redactionStatus: String?, sourceId: String?, sourceSignal: String?, sourceType: String?) { + public init(artifactRef: [String: OmiAnyCodable]? = nil, captureConfidence: Double? = nil, clientDeviceId: String? = nil, createdAt: String? = nil, evidenceId: String, extractorId: String? = nil, extractorVersion: String? = nil, independenceGroup: String, redactionStatus: String? = nil, sourceId: String? = nil, sourceSignal: String? = nil, sourceType: String? = nil) { self.artifactRef = artifactRef self.captureConfidence = captureConfidence self.clientDeviceId = clientDeviceId @@ -1918,7 +1918,7 @@ public enum OmiAPI { version = try c.decodeIfPresent(String.self, forKey: .version) } - public init(deviceId: String?, excerptHash: String?, id: String, kind: EvidenceKind, scope: EvidenceScope, version: String?) { + public init(deviceId: String? = nil, excerptHash: String? = nil, id: String, kind: EvidenceKind, scope: EvidenceScope, version: String? = nil) { self.deviceId = deviceId self.excerptHash = excerptHash self.id = id @@ -1971,7 +1971,7 @@ public enum OmiAPI { subjectKind = try c.decode(FeedbackSubjectKind.self, forKey: .subjectKind) } - public init(action: TaskIntelligenceFeedbackAction, contextSnapshotHash: String?, interventionId: String?, laterUntil: String?, reason: TaskIntelligenceFeedbackReason?, subjectId: String, subjectKind: FeedbackSubjectKind) { + public init(action: TaskIntelligenceFeedbackAction, contextSnapshotHash: String? = nil, interventionId: String? = nil, laterUntil: String? = nil, reason: TaskIntelligenceFeedbackReason? = nil, subjectId: String, subjectKind: FeedbackSubjectKind) { self.action = action self.contextSnapshotHash = contextSnapshotHash self.interventionId = interventionId @@ -2031,7 +2031,7 @@ public enum OmiAPI { subjectKind = try c.decode(FeedbackSubjectKind.self, forKey: .subjectKind) } - public init(action: TaskIntelligenceFeedbackAction, attributionChainId: String, contextSnapshotHash: String?, createdAt: String, dedupeKey: String?, feedbackId: String, interventionId: String?, laterUntil: String?, proposedCompletion: Bool?, proposedCompletionCandidateId: String?, reason: TaskIntelligenceFeedbackReason?, subjectId: String, subjectKind: FeedbackSubjectKind) { + public init(action: TaskIntelligenceFeedbackAction, attributionChainId: String, contextSnapshotHash: String? = nil, createdAt: String, dedupeKey: String? = nil, feedbackId: String, interventionId: String? = nil, laterUntil: String? = nil, proposedCompletion: Bool? = nil, proposedCompletionCandidateId: String? = nil, reason: TaskIntelligenceFeedbackReason? = nil, subjectId: String, subjectKind: FeedbackSubjectKind) { self.action = action self.attributionChainId = attributionChainId self.contextSnapshotHash = contextSnapshotHash @@ -2088,7 +2088,7 @@ public enum OmiAPI { longitude = try c.decode(Double.self, forKey: .longitude) } - public init(address: String?, googlePlaceId: String?, latitude: Double, locationType: String?, longitude: Double) { + public init(address: String? = nil, googlePlaceId: String? = nil, latitude: Double, locationType: String? = nil, longitude: Double) { self.address = address self.googlePlaceId = googlePlaceId self.latitude = latitude @@ -2146,7 +2146,7 @@ public enum OmiAPI { unit = try c.decodeIfPresent(String.self, forKey: .unit) } - public init(current: Double, max: Double?, min: Double?, target: Double, type: GoalType, unit: String?) { + public init(current: Double, max: Double? = nil, min: Double? = nil, target: Double, type: GoalType, unit: String? = nil) { self.current = current self.max = max self.min = min @@ -2181,7 +2181,7 @@ public enum OmiAPI { title = try c.decode(String.self, forKey: .title) } - public init(anchorTaskDescription: String, goalId: String, objective: String, origin: String?, title: String) { + public init(anchorTaskDescription: String, goalId: String, objective: String, origin: String? = nil, title: String) { self.anchorTaskDescription = anchorTaskDescription self.goalId = goalId self.objective = objective @@ -2224,7 +2224,7 @@ public enum OmiAPI { summary = try c.decode(String.self, forKey: .summary) } - public init(createdAt: String, eventId: String, evidenceRefs: [EvidenceRef]?, goalId: String, kind: GoalProgressEventKind, metric: GoalMetric?, sequence: Int, summary: String) { + public init(createdAt: String, eventId: String, evidenceRefs: [EvidenceRef]? = nil, goalId: String, kind: GoalProgressEventKind, metric: GoalMetric? = nil, sequence: Int, summary: String) { self.createdAt = createdAt self.eventId = eventId self.evidenceRefs = evidenceRefs @@ -2329,7 +2329,7 @@ public enum OmiAPI { whyItMatters = try c.decodeIfPresent(String.self, forKey: .whyItMatters) } - public init(advice: String?, createdAt: String, currentValue: Double, desiredOutcome: String, endedAt: String?, focusRank: Int?, goalId: String, goalType: String, horizonAt: String?, id: String, isActive: Bool, latestProgressSequence: Int?, maxValue: Double, metric: GoalMetric?, minValue: Double, source: GoalSource, status: GoalStatus, successCriteria: [String]?, targetValue: Double, title: String, unit: String?, updatedAt: String, whyItMatters: String?) { + public init(advice: String? = nil, createdAt: String, currentValue: Double, desiredOutcome: String, endedAt: String? = nil, focusRank: Int? = nil, goalId: String, goalType: String, horizonAt: String? = nil, id: String, isActive: Bool, latestProgressSequence: Int? = nil, maxValue: Double, metric: GoalMetric? = nil, minValue: Double, source: GoalSource, status: GoalStatus, successCriteria: [String]? = nil, targetValue: Double, title: String, unit: String? = nil, updatedAt: String, whyItMatters: String? = nil) { self.advice = advice self.createdAt = createdAt self.currentValue = currentValue @@ -2575,7 +2575,7 @@ public enum OmiAPI { surface = try c.decode(InterventionSurface.self, forKey: .surface) } - public init(dedupeKey: String, evidenceRefs: [EvidenceRef]?, expiresAt: String, subjectId: String, subjectKind: FeedbackSubjectKind, surface: InterventionSurface) { + public init(dedupeKey: String, evidenceRefs: [EvidenceRef]? = nil, expiresAt: String, subjectId: String, subjectKind: FeedbackSubjectKind, surface: InterventionSurface) { self.dedupeKey = dedupeKey self.evidenceRefs = evidenceRefs self.expiresAt = expiresAt @@ -2622,7 +2622,7 @@ public enum OmiAPI { surface = try c.decode(InterventionSurface.self, forKey: .surface) } - public init(attributionChainId: String, createdAt: String, dedupeKey: String, evidenceRefs: [EvidenceRef]?, expiresAt: String, interventionId: String, subjectId: String, subjectKind: FeedbackSubjectKind, surface: InterventionSurface) { + public init(attributionChainId: String, createdAt: String, dedupeKey: String, evidenceRefs: [EvidenceRef]? = nil, expiresAt: String, interventionId: String, subjectId: String, subjectKind: FeedbackSubjectKind, surface: InterventionSurface) { self.attributionChainId = attributionChainId self.createdAt = createdAt self.dedupeKey = dedupeKey @@ -2795,7 +2795,7 @@ public enum OmiAPI { visibility = try c.decodeIfPresent(String.self, forKey: .visibility) } - public init(appId: String?, arguments: [String: OmiAnyCodable]?, captureConfidence: Double?, captureDeviceIds: [String]?, category: MemoryCategory?, content: String, conversationId: String?, createdAt: String, dataProtectionLevel: String?, durability: String?, edited: Bool?, evidence: [Evidence]?, headline: String?, id: String, invalidAt: String?, isLocked: Bool?, kgExtracted: Bool?, layer: String?, manuallyAdded: Bool?, memoryId: String?, memoryTier: MemoryLayer?, objectEntityIds: [String]?, predicate: String?, primaryCaptureDevice: String?, qualifiers: [String: OmiAnyCodable]?, reviewed: Bool?, scoring: String?, subjectAttribution: SubjectAttribution?, subjectEntityId: String?, supersededBy: String?, tags: [String]?, uid: String, uncertaintyReasons: [String]?, updatedAt: String, userReview: Bool?, validAt: String?, veracity: Double?, visibility: String?) { + public init(appId: String? = nil, arguments: [String: OmiAnyCodable]? = nil, captureConfidence: Double? = nil, captureDeviceIds: [String]? = nil, category: MemoryCategory? = nil, content: String, conversationId: String? = nil, createdAt: String, dataProtectionLevel: String? = nil, durability: String? = nil, edited: Bool? = nil, evidence: [Evidence]? = nil, headline: String? = nil, id: String, invalidAt: String? = nil, isLocked: Bool? = nil, kgExtracted: Bool? = nil, layer: String? = nil, manuallyAdded: Bool? = nil, memoryId: String? = nil, memoryTier: MemoryLayer? = nil, objectEntityIds: [String]? = nil, predicate: String? = nil, primaryCaptureDevice: String? = nil, qualifiers: [String: OmiAnyCodable]? = nil, reviewed: Bool? = nil, scoring: String? = nil, subjectAttribution: SubjectAttribution? = nil, subjectEntityId: String? = nil, supersededBy: String? = nil, tags: [String]? = nil, uid: String, uncertaintyReasons: [String]? = nil, updatedAt: String, userReview: Bool? = nil, validAt: String? = nil, veracity: Double? = nil, visibility: String? = nil) { self.appId = appId self.arguments = arguments self.captureConfidence = captureConfidence @@ -2904,7 +2904,7 @@ public enum OmiAPI { snapshotId = try c.decode(String.self, forKey: .snapshotId) } - public init(deviceId: String, expiresAt: String, generatedAt: String, matches: [NormalizedContextMatch]?, schemaVersion: Int?, snapshotId: String) { + public init(deviceId: String, expiresAt: String, generatedAt: String, matches: [NormalizedContextMatch]? = nil, schemaVersion: Int? = nil, snapshotId: String) { self.deviceId = deviceId self.expiresAt = expiresAt self.generatedAt = generatedAt @@ -2945,7 +2945,7 @@ public enum OmiAPI { updatedAt = try c.decode(String.self, forKey: .updatedAt) } - public init(blockingOnId: String?, kind: OpenLoopKind, loopId: String, nextActionCode: String, status: OpenLoopStatus, subjectId: String, updatedAt: String) { + public init(blockingOnId: String? = nil, kind: OpenLoopKind, loopId: String, nextActionCode: String, status: OpenLoopStatus, subjectId: String, updatedAt: String) { self.blockingOnId = blockingOnId self.kind = kind self.loopId = loopId @@ -3014,7 +3014,7 @@ public enum OmiAPI { workstreamId = try c.decode(String.self, forKey: .workstreamId) } - public init(checkpointRef: String?, contextPacketVersion: String, conversationId: String, deviceId: String, expiresAt: String, generatedAt: String, openLoopSnapshot: [OpenLoopDescriptor]?, owner: String, runtimeId: String, schemaVersion: Int?, workstreamId: String) { + public init(checkpointRef: String? = nil, contextPacketVersion: String, conversationId: String, deviceId: String, expiresAt: String, generatedAt: String, openLoopSnapshot: [OpenLoopDescriptor]? = nil, owner: String, runtimeId: String, schemaVersion: Int? = nil, workstreamId: String) { self.checkpointRef = checkpointRef self.contextPacketVersion = contextPacketVersion self.conversationId = conversationId @@ -3127,7 +3127,7 @@ public enum OmiAPI { pluginId = try c.decodeIfPresent(String.self, forKey: .pluginId) } - public init(content: String, pluginId: String?) { + public init(content: String, pluginId: String? = nil) { self.content = content self.pluginId = pluginId } @@ -3194,7 +3194,7 @@ public enum OmiAPI { whyNow = try c.decode(String.self, forKey: .whyNow) } - public init(alternativeAction: String?, dedupeKey: String, destinationTaskId: String?, destinationWorkstreamId: String?, evidencePreview: String, evidenceRefs: [EvidenceRef], expiresAt: String, feedbackSubjectId: String, feedbackSubjectKind: FeedbackSubjectKind, goalOrWorkstreamLabel: String?, headline: String, interventionId: String, outputVersion: String, recommendedAction: String, subjectId: String, subjectKind: RecommendationSubjectKind, whyNow: String) { + public init(alternativeAction: String? = nil, dedupeKey: String, destinationTaskId: String? = nil, destinationWorkstreamId: String? = nil, evidencePreview: String, evidenceRefs: [EvidenceRef], expiresAt: String, feedbackSubjectId: String, feedbackSubjectKind: FeedbackSubjectKind, goalOrWorkstreamLabel: String? = nil, headline: String, interventionId: String, outputVersion: String, recommendedAction: String, subjectId: String, subjectKind: RecommendationSubjectKind, whyNow: String) { self.alternativeAction = alternativeAction self.dedupeKey = dedupeKey self.destinationTaskId = destinationTaskId @@ -3319,7 +3319,7 @@ public enum OmiAPI { title = try c.decodeIfPresent(String.self, forKey: .title) } - public init(actionItems: [ActionItem]?, category: CategoryEnum?, emoji: String?, events: [Event]?, overview: String?, title: String?) { + public init(actionItems: [ActionItem]? = nil, category: CategoryEnum? = nil, emoji: String? = nil, events: [Event]? = nil, overview: String? = nil, title: String? = nil) { self.actionItems = actionItems self.category = category self.emoji = emoji @@ -3383,7 +3383,7 @@ public enum OmiAPI { workstreamId = try c.decodeIfPresent(String.self, forKey: .workstreamId) } - public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String?, ownershipConfidence: Double, proposedAction: String?, sourceSurface: String, subjectKind: String?, taskChange: TaskChangePayload, taskId: String, workstreamId: String?) { + public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String? = nil, ownershipConfidence: Double, proposedAction: String? = nil, sourceSurface: String, subjectKind: String? = nil, taskChange: TaskChangePayload, taskId: String, workstreamId: String? = nil) { self.captureConfidence = captureConfidence self.evidenceRefs = evidenceRefs self.goalId = goalId @@ -3434,7 +3434,7 @@ public enum OmiAPI { supersededBy = try c.decodeIfPresent(String.self, forKey: .supersededBy) } - public init(description_: String?, dueAt: String?, dueConfidence: Double?, owner: TaskOwner?, priority: TaskPriority?, recurrenceParentId: String?, recurrenceRule: String?, status: TaskStatus?, supersededBy: String?) { + public init(description_: String? = nil, dueAt: String? = nil, dueConfidence: Double? = nil, owner: TaskOwner? = nil, priority: TaskPriority? = nil, recurrenceParentId: String? = nil, recurrenceRule: String? = nil, status: TaskStatus? = nil, supersededBy: String? = nil) { self.description_ = description_ self.dueAt = dueAt self.dueConfidence = dueConfidence @@ -3487,7 +3487,7 @@ public enum OmiAPI { workstreamId = try c.decodeIfPresent(String.self, forKey: .workstreamId) } - public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String?, ownershipConfidence: Double, proposedAction: String?, sourceSurface: String, subjectKind: String?, taskChange: TaskChangePayload, taskId: String, workstreamId: String?) { + public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String? = nil, ownershipConfidence: Double, proposedAction: String? = nil, sourceSurface: String, subjectKind: String? = nil, taskChange: TaskChangePayload, taskId: String, workstreamId: String? = nil) { self.captureConfidence = captureConfidence self.evidenceRefs = evidenceRefs self.goalId = goalId @@ -3538,7 +3538,7 @@ public enum OmiAPI { workstreamId = try c.decodeIfPresent(String.self, forKey: .workstreamId) } - public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String?, ownershipConfidence: Double, proposedAction: String?, sourceSurface: String, subjectKind: String?, taskChange: TaskCreatePayload, workstreamId: String?) { + public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String? = nil, ownershipConfidence: Double, proposedAction: String? = nil, sourceSurface: String, subjectKind: String? = nil, taskChange: TaskCreatePayload, workstreamId: String? = nil) { self.captureConfidence = captureConfidence self.evidenceRefs = evidenceRefs self.goalId = goalId @@ -3582,7 +3582,7 @@ public enum OmiAPI { recurrenceRule = try c.decodeIfPresent(String.self, forKey: .recurrenceRule) } - public init(description_: String, dueAt: String?, dueConfidence: Double?, owner: TaskOwner?, priority: TaskPriority?, recurrenceParentId: String?, recurrenceRule: String?) { + public init(description_: String, dueAt: String? = nil, dueConfidence: Double? = nil, owner: TaskOwner? = nil, priority: TaskPriority? = nil, recurrenceParentId: String? = nil, recurrenceRule: String? = nil) { self.description_ = description_ self.dueAt = dueAt self.dueConfidence = dueConfidence @@ -3660,7 +3660,7 @@ public enum OmiAPI { title = try c.decodeIfPresent(String.self, forKey: .title) } - public init(objective: String?, origin: String?, taskId: String, title: String?) { + public init(objective: String? = nil, origin: String? = nil, taskId: String, title: String? = nil) { self.objective = objective self.origin = origin self.taskId = taskId @@ -3748,7 +3748,7 @@ public enum OmiAPI { workstreamId = try c.decodeIfPresent(String.self, forKey: .workstreamId) } - public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String?, ownershipConfidence: Double, proposedAction: String?, sourceSurface: String, subjectKind: String?, taskChange: TaskChangePayload, taskId: String, workstreamId: String?) { + public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String? = nil, ownershipConfidence: Double, proposedAction: String? = nil, sourceSurface: String, subjectKind: String? = nil, taskChange: TaskChangePayload, taskId: String, workstreamId: String? = nil) { self.captureConfidence = captureConfidence self.evidenceRefs = evidenceRefs self.goalId = goalId @@ -3802,7 +3802,7 @@ public enum OmiAPI { workstreamId = try c.decodeIfPresent(String.self, forKey: .workstreamId) } - public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String?, ownershipConfidence: Double, proposedAction: String?, sourceSurface: String, subjectKind: String?, taskChange: TaskChangePayload, taskId: String, workstreamId: String?) { + public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String? = nil, ownershipConfidence: Double, proposedAction: String? = nil, sourceSurface: String, subjectKind: String? = nil, taskChange: TaskChangePayload, taskId: String, workstreamId: String? = nil) { self.captureConfidence = captureConfidence self.evidenceRefs = evidenceRefs self.goalId = goalId @@ -3835,7 +3835,7 @@ public enum OmiAPI { workflowMode = try c.decodeIfPresent(TaskWorkflowMode.self, forKey: .workflowMode) } - public init(accountGeneration: Int?, chatFirstUi: Bool?, workflowMode: TaskWorkflowMode?) { + public init(accountGeneration: Int? = nil, chatFirstUi: Bool? = nil, workflowMode: TaskWorkflowMode? = nil) { self.accountGeneration = accountGeneration self.chatFirstUi = chatFirstUi self.workflowMode = workflowMode @@ -3899,7 +3899,7 @@ public enum OmiAPI { translations = try c.decodeIfPresent([Translation].self, forKey: .translations) } - public init(end: Double, id: String?, isUser: Bool, personId: String?, speaker: String?, speakerId: Int?, speechProfileProcessed: Bool?, start: Double, sttProvider: String?, text: String, translations: [Translation]?) { + public init(end: Double, id: String? = nil, isUser: Bool, personId: String? = nil, speaker: String? = nil, speakerId: Int? = nil, speechProfileProcessed: Bool? = nil, start: Double, sttProvider: String? = nil, text: String, translations: [Translation]? = nil) { self.end = end self.id = id self.isUser = isUser @@ -3962,7 +3962,7 @@ public enum OmiAPI { schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) } - public init(evaluationId: String, expiresAt: String, generatedAt: String, materialVersion: String, outputVersion: String, recommendations: [Recommendation], schemaVersion: Int?) { + public init(evaluationId: String, expiresAt: String, generatedAt: String, materialVersion: String, outputVersion: String, recommendations: [Recommendation], schemaVersion: Int? = nil) { self.evaluationId = evaluationId self.expiresAt = expiresAt self.generatedAt = generatedAt @@ -4001,7 +4001,7 @@ public enum OmiAPI { workstreamId = try c.decode(String.self, forKey: .workstreamId) } - public init(createdAt: String, goalId: String?, newlyCreated: Bool, receiptId: String, taskId: String, workstreamId: String) { + public init(createdAt: String, goalId: String? = nil, newlyCreated: Bool, receiptId: String, taskId: String, workstreamId: String) { self.createdAt = createdAt self.goalId = goalId self.newlyCreated = newlyCreated @@ -4054,7 +4054,7 @@ public enum OmiAPI { workstreamId = try c.decode(String.self, forKey: .workstreamId) } - public init(createdAt: String, currentStateSummary: String?, goalId: String?, lastMeaningfulProgressAt: String?, latestEventSequence: Int?, nextReviewAt: String?, objective: String, status: WorkstreamStatus, title: String, updatedAt: String, workstreamId: String) { + public init(createdAt: String, currentStateSummary: String? = nil, goalId: String? = nil, lastMeaningfulProgressAt: String? = nil, latestEventSequence: Int? = nil, nextReviewAt: String? = nil, objective: String, status: WorkstreamStatus, title: String, updatedAt: String, workstreamId: String) { self.createdAt = createdAt self.currentStateSummary = currentStateSummary self.goalId = goalId @@ -4106,7 +4106,7 @@ public enum OmiAPI { workstreamProposal = try c.decode(WorkstreamProposal.self, forKey: .workstreamProposal) } - public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String?, ownershipConfidence: Double, proposedAction: String?, sourceSurface: String, subjectKind: String?, workstreamId: String?, workstreamProposal: WorkstreamProposal) { + public init(captureConfidence: Double, evidenceRefs: [EvidenceRef], goalId: String? = nil, ownershipConfidence: Double, proposedAction: String? = nil, sourceSurface: String, subjectKind: String? = nil, workstreamId: String? = nil, workstreamProposal: WorkstreamProposal) { self.captureConfidence = captureConfidence self.evidenceRefs = evidenceRefs self.goalId = goalId @@ -4187,7 +4187,7 @@ public enum OmiAPI { workstreamId = try c.decode(String.self, forKey: .workstreamId) } - public init(createdAt: String, eventId: String, evidenceRefs: [EvidenceRef]?, kind: WorkstreamEventKind, sensitivity: WorkstreamSensitivity, sequence: Int, summary: String, workstreamId: String) { + public init(createdAt: String, eventId: String, evidenceRefs: [EvidenceRef]? = nil, kind: WorkstreamEventKind, sensitivity: WorkstreamSensitivity, sequence: Int, summary: String, workstreamId: String) { self.createdAt = createdAt self.eventId = eventId self.evidenceRefs = evidenceRefs @@ -4221,7 +4221,7 @@ public enum OmiAPI { summary = try c.decode(String.self, forKey: .summary) } - public init(evidenceRefs: [EvidenceRef]?, kind: WorkstreamEventKind, sensitivity: WorkstreamSensitivity?, summary: String) { + public init(evidenceRefs: [EvidenceRef]? = nil, kind: WorkstreamEventKind, sensitivity: WorkstreamSensitivity? = nil, summary: String) { self.evidenceRefs = evidenceRefs self.kind = kind self.sensitivity = sensitivity diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index fdbd915e1c8..1eb8d4382dc 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -913,6 +913,11 @@ enum ContentBlockGroup: Identifiable { case .discoveryCard(let id, let title, let summary, let fullText): flushToolCalls() groups.append(.discoveryCard(id: id, title: title, summary: summary, fullText: fullText)) + // Chat-first blocks are decoded at the shared journal boundary in T02. + // T07 owns their main-chat visual treatment; suppress them on the legacy + // grouping projection until that renderer lands. + case .questionCard, .taskCard, .goalLink, .captureLink: + flushToolCalls() case .agentSpawn( let id, let pillId, let sessionId, let runId, let title, let objective, let provider ): diff --git a/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift b/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift index d062e6d9152..ec35f9cfcf8 100644 --- a/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift +++ b/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift @@ -1876,6 +1876,8 @@ struct OnboardingChatBubble: View { return false case .discoveryCard: return true + case .questionCard, .taskCard, .goalLink, .captureLink: + return false case .agentSpawn, .agentCompletion: return true } diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index 022da71341f..f3909dbfad6 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -324,6 +324,10 @@ enum ChatContentBlock: Identifiable { case thinking(id: String, text: String) /// Collapsible card showing a summary with expandable full text (used for AI profile/discovery) case discoveryCard(id: String, title: String, summary: String, fullText: String) + case questionCard(id: String, questionId: String, text: String, subjectKind: String, subjectId: String, options: [[String: Any]]) + case taskCard(id: String, taskId: String) + case goalLink(id: String, goalId: String, summary: String) + case captureLink(id: String, conversationId: String, momentTimestampMs: Int?, summary: String) case agentSpawn( id: String, pillId: UUID?, @@ -350,6 +354,10 @@ enum ChatContentBlock: Identifiable { case .toolCall(let id, _, _, _, _, _): return id case .thinking(let id, _): return id case .discoveryCard(let id, _, _, _): return id + case .questionCard(let id, _, _, _, _, _): return id + case .taskCard(let id, _): return id + case .goalLink(let id, _, _): return id + case .captureLink(let id, _, _, _): return id case .agentSpawn(let id, _, _, _, _, _, _): return id case .agentCompletion(let id, _, _, _, _, _, _, _): return id } @@ -891,6 +899,14 @@ extension ChatContentBlock { case .discoveryCard(_, let title, _, let fullText): let trimmed = fullText.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? title : "\(title)\n\(trimmed)" + case .questionCard(_, _, let text, _, _, _): + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + case .taskCard: + return nil + case .goalLink(_, _, let summary), .captureLink(_, _, _, let summary): + let trimmed = summary.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed case .agentSpawn(_, _, _, _, let title, let objective, _): let trimmed = objective.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? title : "\(title)\n\(trimmed)" diff --git a/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift b/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift index 3fcaec1f22d..b92f16fa9e5 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift @@ -132,7 +132,11 @@ class ChatToolExecutor { originatingChatMode: ChatMode? = nil, originatingClientScope: String? = nil, originatingSurfaceRef: AgentSurfaceReference? = nil, + originatingSessionID: String? = nil, originatingRunId: String? = nil, + originatingAttemptId: String? = nil, + toolCapabilityRef: String? = nil, + chatFirstControlGeneration: Int? = nil, originatingUserText: String? = nil, isOnboardingSurface: Bool = false, expectedOwnerID: String? = nil, @@ -169,7 +173,11 @@ class ChatToolExecutor { originatingChatMode: originatingChatMode, originatingClientScope: originatingClientScope, originatingSurfaceRef: originatingSurfaceRef, + originatingSessionID: originatingSessionID, originatingRunId: originatingRunId, + originatingAttemptId: originatingAttemptId, + toolCapabilityRef: toolCapabilityRef, + chatFirstControlGeneration: chatFirstControlGeneration, isOnboardingSurface: isOnboardingSurface, expectedOwnerID: pinnedOwnerID, backendAPIClient: backendAPIClient) @@ -185,7 +193,11 @@ class ChatToolExecutor { originatingChatMode: ChatMode?, originatingClientScope: String?, originatingSurfaceRef: AgentSurfaceReference?, + originatingSessionID: String?, originatingRunId: String?, + originatingAttemptId: String?, + toolCapabilityRef: String?, + chatFirstControlGeneration: Int?, isOnboardingSurface: Bool, expectedOwnerID: String?, backendAPIClient: APIClient @@ -233,6 +245,19 @@ class ChatToolExecutor { toolCall.arguments, expectedOwnerID: expectedOwnerID) + case .renderChatBlocks: + return await executeRenderChatBlocks( + toolCall.arguments, + surface: originatingSurfaceRef, + sessionID: originatingSessionID, + runID: originatingRunId, + attemptID: originatingAttemptId, + capabilityRef: toolCapabilityRef, + controlGeneration: chatFirstControlGeneration, + expectedOwnerID: expectedOwnerID, + authorizationSnapshot: currentOwnerAuthorizationSnapshot, + api: backendAPIClient) + // Onboarding tools case .requestPermission: let isOnboardingRequest = isOnboardingSurface @@ -1613,6 +1638,81 @@ class ChatToolExecutor { } } + /// The server admits the canonical references as one all-or-nothing receipt; + /// only then does the kernel append the receipt to the producing assistant + /// turn. This path deliberately never calls the legacy Python chat writer. + private static func executeRenderChatBlocks( + _ args: [String: Any], + surface: AgentSurfaceReference?, + sessionID: String?, + runID: String?, + attemptID: String?, + capabilityRef: String?, + controlGeneration: Int?, + expectedOwnerID: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot?, + api: APIClient + ) async -> String { + guard let expectedOwnerID, + let authorizationSnapshot, + let surface, + surface.surfaceKind == "main_chat", + let sessionID, + let runID, + let attemptID, + let capabilityRef, + let controlGeneration, + controlGeneration >= 0, + let backendBlocks = ChatFirstBlockWire.backendBlocks(from: args) + else { + return #"{"ok":false,"error":{"code":"chat_first_invalid_authority"}}"# + } + guard isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { + return authorizedOwnerChangedResult() + } + + do { + let request = ChatFirstBlockValidationRequest( + controlGeneration: controlGeneration, + ownerFence: expectedOwnerID, + runID: runID, + attemptID: attemptID, + blocks: backendBlocks.map(OmiAnyCodable.init) + ) + let receipt: ChatFirstBlockValidationReceipt = try await api.post( + "v1/chat-first/blocks/validate", + body: request, + expectedOwnerId: expectedOwnerID, + authorizationSnapshot: authorizationSnapshot + ) + guard isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { + return authorizedOwnerChangedResult() + } + guard let journalBlocks = ChatFirstBlockWire.journalBlocks(from: receipt) else { + return #"{"ok":false,"error":{"code":"chat_first_blocks_rejected"}}"# + } + _ = try await AgentRuntimeProcess.shared.appendChatFirstBlocks( + clientId: "chat-first-render", + surface: surface, + ownerID: expectedOwnerID, + sessionID: sessionID, + runID: runID, + attemptID: attemptID, + capabilityRef: capabilityRef, + controlGeneration: controlGeneration, + blocks: journalBlocks, + authorizationSnapshot: authorizationSnapshot + ) + return #"{"ok":true,"rendered":#(journalBlocks.count)}"# + } catch { + guard isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { + return authorizedOwnerChangedResult() + } + logError("Chat-first block rendering failed", error: error) + return #"{"ok":false,"error":{"code":"chat_first_blocks_unavailable"}}"# + } + } + // MARK: - Onboarding Tools /// Request a specific macOS permission diff --git a/desktop/macos/agent/scripts/generate-tool-surfaces.mjs b/desktop/macos/agent/scripts/generate-tool-surfaces.mjs index 7c03972a38d..70cc64a284b 100644 --- a/desktop/macos/agent/scripts/generate-tool-surfaces.mjs +++ b/desktop/macos/agent/scripts/generate-tool-surfaces.mjs @@ -10,7 +10,9 @@ import { fileURLToPath } from "node:url"; import { OMI_TOOL_MANIFEST_DIGEST, + OMI_CHAT_FIRST_TOOL_MANIFEST_DIGEST, OMI_TOOL_MANIFEST_VERSION, + allOmiToolManifest, omiToolManifest, } from "../dist/runtime/omi-tool-manifest.js"; @@ -99,7 +101,7 @@ function validateManifest() { const names = new Set(); const aliases = new Map(); - for (const tool of omiToolManifest) { + for (const tool of allOmiToolManifest) { if (tool.intendedForAgents !== false && !tool.surfaces?.length) { throw new Error(`Tool ${tool.name} is missing surfaces`); } @@ -485,7 +487,7 @@ function swiftToolCaseName(name) { } function generateExecutorsSwift() { - const swiftTools = omiToolManifest.filter((tool) => tool.executor.kind === "swiftTool"); + const swiftTools = allOmiToolManifest.filter((tool) => tool.executor.kind === "swiftTool"); const enumCases = swiftTools .map((tool) => ` case ${swiftToolCaseName(tool.name)} = "${tool.name}"`) .join("\n"); @@ -527,6 +529,7 @@ enum GeneratedSwiftToolExecutor: String { enum GeneratedToolExecutors { static let manifestVersion = ${OMI_TOOL_MANIFEST_VERSION} static let manifestDigest = ${JSON.stringify(OMI_TOOL_MANIFEST_DIGEST)} + static let chatFirstManifestDigest = ${JSON.stringify(OMI_CHAT_FIRST_TOOL_MANIFEST_DIGEST)} static let aliasToCanonical: [String: GeneratedSwiftTool] = [ ${aliasMapEntries.join(",\n")} diff --git a/desktop/macos/agent/src/index.ts b/desktop/macos/agent/src/index.ts index 34f1e512138..e7ba5c328e8 100644 --- a/desktop/macos/agent/src/index.ts +++ b/desktop/macos/agent/src/index.ts @@ -58,6 +58,7 @@ import type { JournalTerminalizeTurnMessage, JournalListTurnsMessage, JournalClearTurnsMessage, + AppendChatFirstBlocksMessage, EnsureAgentSpawnJournalMessage, JournalBackendSyncResultMessage, JournalBackendDeleteResultMessage, @@ -120,6 +121,7 @@ import { LEGACY_MAIN_CHAT_SESSION_COMPATIBILITY } from "./runtime/surface-sessio import { ackBackendConversationDeleteOutbox, ackBackendTurnOutboxWithWakes, + appendChatFirstBlocksToProducingTurn, applyBackendReconcilePage, beginBackendReconcilesForOwner, clearJournalConversation, @@ -840,6 +842,7 @@ function startOmiToolsRelay(): Promise { manifestDigest: authorized.manifestDigest, daemonBootEpoch: authorized.daemonBootEpoch, executionGeneration: authorized.executionGeneration, + capabilityRef: authorized.capabilityRef, toolName: authorized.canonicalToolName, input: routedProposal.toolInput, inputHash: authorized.inputHash, @@ -852,6 +855,10 @@ function startOmiToolsRelay(): Promise { precedingAssistantText: authorized.precedingAssistantText, runMode: authorized.runMode, chatMode: authorized.chatMode, + ...(authorized.canonicalToolName === "render_chat_blocks" + && authorized.chatFirstControlGeneration !== null + ? { chatFirstControlGeneration: authorized.chatFirstControlGeneration } + : {}), }); } } catch { @@ -1081,6 +1088,15 @@ function buildMcpServers( if (context?.screenContext === true) { omiToolsEnv.push({ name: "OMI_SCREEN_CONTEXT", value: "true" }); } + // Omit both variables in legacy mode. This keeps the capability-off child + // environment (and therefore its tools/list bytes) exactly unchanged. + if (context?.chatFirstUi === true && context.surfaceKind === "main_chat") { + omiToolsEnv.push({ name: "OMI_CHAT_FIRST_UI", value: "true" }); + omiToolsEnv.push({ name: "OMI_SURFACE_KIND", value: "main_chat" }); + if (context.chatFirstControlGeneration !== undefined && context.chatFirstControlGeneration !== null) { + omiToolsEnv.push({ name: "OMI_CHAT_FIRST_CONTROL_GENERATION", value: String(context.chatFirstControlGeneration) }); + } + } omiToolsEnv.push({ name: "OMI_EXECUTION_ROLE", value: context?.executionRole === "leaf" ? "leaf" : "coordinator", @@ -1637,6 +1653,19 @@ async function main(): Promise { } } const selectedProfile = creationProfile ?? preference; + const chatFirstCapability = resolve.chatFirstCapability; + if (chatFirstCapability !== undefined) { + if ( + typeof chatFirstCapability.chatFirstUi !== "boolean" + || !Number.isSafeInteger(chatFirstCapability.controlGeneration) + || chatFirstCapability.controlGeneration < 0 + ) { + throw new Error("Invalid chat-first capability projection"); + } + if (resolve.surfaceKind !== "main_chat" && chatFirstCapability.chatFirstUi) { + throw new Error("Chat-first capability may only be projected to main_chat"); + } + } const resolved = kernel.resolveSurfaceSession({ ownerId, surfaceRef: { @@ -1650,6 +1679,7 @@ async function main(): Promise { defaultCwd: selectedProfile.workingDirectory, executionRole: executionRoleForSurface(resolve), title: resolve.title ?? null, + chatFirstCapability, }); const profile = kernel.sessionExecutionProfile(resolved.agentSessionId, ownerId); send({ @@ -1930,6 +1960,7 @@ async function main(): Promise { manifestDigest: authorized.manifestDigest, daemonBootEpoch: authorized.daemonBootEpoch, executionGeneration: authorized.executionGeneration, + capabilityRef: authorized.capabilityRef, toolName: authorized.canonicalToolName, input: routed.toolInput, inputHash: authorized.inputHash, @@ -1942,6 +1973,10 @@ async function main(): Promise { precedingAssistantText: authorized.precedingAssistantText, runMode: authorized.runMode, chatMode: authorized.chatMode, + ...(authorized.canonicalToolName === "render_chat_blocks" + && authorized.chatFirstControlGeneration !== null + ? { chatFirstControlGeneration: authorized.chatFirstControlGeneration } + : {}), ...(routed.recoveredFromDelegation ? { policyRecovery: "permission_delegation_to_native" as const } : {}), @@ -2321,6 +2356,87 @@ async function main(): Promise { break; } + case "append_chat_first_blocks": { + const request = msg as AppendChatFirstBlocksMessage; + try { + const ownerId = resolveActiveOwner(request.ownerId); + if (!Array.isArray(request.blocks) || request.blocks.length < 1 || request.blocks.length > 8) { + throw new Error("Chat-first append requires one to eight blocks"); + } + if (!Number.isSafeInteger(request.controlGeneration) || request.controlGeneration < 0) { + throw new Error("Chat-first append requires a valid control generation"); + } + const capability = kernel.assertLiveRunToolCapability({ + capabilityRef: request.capabilityRef, + activeOwnerId: ownerId, + }); + if ( + capability.ownerId !== ownerId + || capability.sessionId !== request.sessionId + || capability.runId !== request.runId + || capability.attemptId !== request.attemptId + || capability.surfaceKind !== "main_chat" + || capability.chatFirstUi !== true + || capability.chatFirstControlGeneration !== request.controlGeneration + || !capability.allowedToolNames.includes("render_chat_blocks") + ) { + throw new Error("Chat-first append capability does not match the producing run"); + } + const turn = appendChatFirstBlocksToProducingTurn(store, { + ownerId, + sessionId: request.sessionId, + runId: request.runId, + attemptId: request.attemptId, + blocks: request.blocks as ConversationContentBlock[], + }); + const range = listJournalTurns(store, { + ownerId, + conversationId: turn.conversationId, + afterTurnSeq: Math.max(0, turn.turnSeq - 1), + limit: 1, + }); + send({ + type: "journal_operation_result", + protocolVersion: request.protocolVersion, + requestId: request.requestId, + clientId: request.clientId, + operation: "append_chat_first_blocks", + conversationId: turn.conversationId, + surfaceKind: "main_chat", + externalRefKind: capability.externalRefKind ?? "", + externalRefId: capability.externalRefId ?? "", + turn: journalTurnProjection(turn), + turns: [], + clearedCount: 0, + highWaterTurnSeq: range.highWaterTurnSeq, + generationBaseTurnSeq: range.generationBaseTurnSeq, + conversationGeneration: range.generation, + }); + send({ + type: "journal_turn_changed", + ownerId, + conversationGeneration: range.generation, + generationBaseTurnSeq: range.generationBaseTurnSeq, + surfaceKind: "main_chat", + externalRefKind: capability.externalRefKind ?? "", + externalRefId: capability.externalRefId ?? "", + turn: journalTurnProjection(turn), + }); + pumpJournalOutbox(); + } catch (error) { + const envelope = runtimeErrorEnvelope(error); + send({ + type: "error", + protocolVersion: request.protocolVersion, + requestId: request.requestId, + clientId: request.clientId, + message: envelope.message, + failure: envelope.failure, + }); + } + break; + } + case "journal_terminalize_turn": { const request = msg as JournalTerminalizeTurnMessage; try { diff --git a/desktop/macos/agent/src/omi-tools-stdio.ts b/desktop/macos/agent/src/omi-tools-stdio.ts index 21c57b71038..ecfdb27b8af 100644 --- a/desktop/macos/agent/src/omi-tools-stdio.ts +++ b/desktop/macos/agent/src/omi-tools-stdio.ts @@ -154,7 +154,16 @@ async function requestSwiftTool( const isOnboarding = process.env.OMI_ONBOARDING === "true"; const hasScreenContext = process.env.OMI_SCREEN_CONTEXT === "true"; const executionRole = process.env.OMI_EXECUTION_ROLE === "leaf" ? "leaf" : "coordinator"; -const projectionContext = { onboarding: isOnboarding, screenContext: hasScreenContext, executionRole } as const; +const chatFirstUi = process.env.OMI_CHAT_FIRST_UI === "true" && process.env.OMI_SURFACE_KIND === "main_chat"; +const controlGeneration = Number(process.env.OMI_CHAT_FIRST_CONTROL_GENERATION); +const projectionContext = { + onboarding: isOnboarding, + screenContext: hasScreenContext, + executionRole, + surfaceKind: process.env.OMI_SURFACE_KIND, + chatFirstUi, + controlGeneration: Number.isSafeInteger(controlGeneration) && controlGeneration >= 0 ? controlGeneration : null, +} as const; // Tool order is owned by the canonical manifest projection. const ADVERTISED_TOOLS = toolsForAdapter("omi-tools-stdio", projectionContext); diff --git a/desktop/macos/agent/src/protocol.ts b/desktop/macos/agent/src/protocol.ts index 4f4fc7d3800..a2b6860f3a0 100644 --- a/desktop/macos/agent/src/protocol.ts +++ b/desktop/macos/agent/src/protocol.ts @@ -4,7 +4,11 @@ // === Swift → Bridge (stdin) === export const PROTOCOL_VERSION = 2 as const; -export const RUNTIME_CAPABILITIES = ["journal_import_remote_turn", "runtime_adapter_availability"] as const; +export const RUNTIME_CAPABILITIES = [ + "journal_import_remote_turn", + "runtime_adapter_availability", + "chat_first_capability_projection", +] as const; export type ProtocolVersion = typeof PROTOCOL_VERSION; export interface ProtocolEnvelope { @@ -163,6 +167,14 @@ export interface ResolveSurfaceSessionMessage extends ProtocolEnvelope { modelProfile: string | null; workingDirectory: string; }; + /** + * Ephemeral server-derived capability, accepted only for the main Chat + * surface. It is never persisted to the kernel journal or preferences. + */ + chatFirstCapability?: { + chatFirstUi: boolean; + controlGeneration: number; + }; } export interface MigrateSessionExecutionProfileMessage extends ProtocolEnvelope { @@ -358,6 +370,23 @@ export interface JournalClearTurnsMessage extends ProtocolEnvelope { expectedGeneration: number; } +/** + * Privileged local-only append for server-validated chat-first blocks. The + * capability and producing run/attempt bind this mutation to the assistant + * turn that invoked `render_chat_blocks`; Swift cannot select an arbitrary + * journal turn. + */ +export interface AppendChatFirstBlocksMessage extends ProtocolEnvelope { + type: "append_chat_first_blocks"; + ownerId: string; + sessionId: string; + runId: string; + attemptId: string; + capabilityRef: string; + controlGeneration: number; + blocks: unknown[]; +} + export interface EnsureAgentSpawnJournalMessage extends ProtocolEnvelope { type: "ensure_agent_spawn_journal"; ownerId: string; @@ -444,6 +473,7 @@ export type InboundMessage = | JournalTerminalizeTurnMessage | JournalListTurnsMessage | JournalClearTurnsMessage + | AppendChatFirstBlocksMessage | EnsureAgentSpawnJournalMessage | JournalBackendSyncResultMessage | JournalBackendDeleteResultMessage @@ -516,6 +546,7 @@ export interface AuthorizedToolExecutionMessage extends OutboundEnvelope { manifestDigest: string; daemonBootEpoch: string; executionGeneration: number; + capabilityRef: string; toolName: string; input: Record; inputHash: string; @@ -528,6 +559,8 @@ export interface AuthorizedToolExecutionMessage extends OutboundEnvelope { precedingAssistantText: string | null; runMode: "ask" | "act"; chatMode: string | null; + /** Present only for the server-authorized Main Chat structured-block tool. */ + chatFirstControlGeneration?: number; /** Bounded policy recovery telemetry; absent for ordinary authorized calls. */ policyRecovery?: "permission_delegation_to_native"; } @@ -805,6 +838,8 @@ export interface ContextSnapshotProjection { manifestVersion: number; manifestDigest: string; allowedToolNames: string[]; + chatFirstUi: boolean; + chatFirstControlGeneration: number | null; }; } @@ -867,7 +902,7 @@ export interface AgentSpawnJournalEnsuredMessage extends OutboundEnvelope { export interface JournalOperationResultMessage extends OutboundEnvelope { type: "journal_operation_result"; - operation: "record" | "record_exchange" | "import_remote" | "update" | "list" | "clear"; + operation: "record" | "record_exchange" | "import_remote" | "update" | "list" | "clear" | "append_chat_first_blocks"; conversationId: string; surfaceKind: string; externalRefKind: string; diff --git a/desktop/macos/agent/src/runtime/chat-first-capability.ts b/desktop/macos/agent/src/runtime/chat-first-capability.ts new file mode 100644 index 00000000000..64b93a96a2f --- /dev/null +++ b/desktop/macos/agent/src/runtime/chat-first-capability.ts @@ -0,0 +1,40 @@ +/** + * The chat-first rollout is sampled by Swift once per app session from the + * server-owned workflow-control response. This module deliberately contains + * no persistence: process restart, owner replacement, an absent projection, + * and every non-main surface are all capability-off. + */ +export interface ChatFirstCapabilityProjection { + chatFirstUi: boolean; + controlGeneration: number; +} + +export interface ChatFirstProjectionContext { + surfaceKind?: string; + chatFirstUi?: boolean; + controlGeneration?: number | null; +} + +export interface EffectiveChatFirstCapability { + chatFirstUi: boolean; + controlGeneration: number | null; +} + +export function effectiveChatFirstCapability( + projection: ChatFirstProjectionContext | undefined, +): EffectiveChatFirstCapability { + const generation = projection?.controlGeneration; + const enabled = projection?.chatFirstUi === true + && projection?.surfaceKind === "main_chat" + && Number.isSafeInteger(generation) + && (generation ?? -1) >= 0; + return { + chatFirstUi: enabled, + controlGeneration: enabled ? generation! : null, + }; +} + +/** One predicate shared by snapshots, run admission, and the MCP child. */ +export function isChatFirstMainChat(projection: ChatFirstProjectionContext | undefined): boolean { + return effectiveChatFirstCapability(projection).chatFirstUi; +} diff --git a/desktop/macos/agent/src/runtime/context-snapshot.ts b/desktop/macos/agent/src/runtime/context-snapshot.ts index 0161ff14f6f..7637457795a 100644 --- a/desktop/macos/agent/src/runtime/context-snapshot.ts +++ b/desktop/macos/agent/src/runtime/context-snapshot.ts @@ -13,6 +13,10 @@ import { } from "./omi-tool-manifest.js"; import { readSessionExecutionProfile } from "./session-execution-profile.js"; import { stableJsonStringify } from "./kernel-support.js"; +import { + effectiveChatFirstCapability, + type ChatFirstCapabilityProjection, +} from "./chat-first-capability.js"; import type { AgentExecutionRole, AgentStore } from "./types.js"; const ACTIVE_RUN_STATUSES = [ @@ -62,6 +66,7 @@ export interface ContextSourceUpdateInput { capturedAtMs: number; expiresAtMs?: number | null; payload: Record; + chatFirstCapability?: ChatFirstCapabilityProjection; } export interface ContextSourceUpdateResult { @@ -143,7 +148,14 @@ export function updateContextSource( } return { changed: metadataChanged, - snapshot: buildContextSnapshot(store, input.sessionId, input.ownerId, nowMs, projectionSurface), + snapshot: buildContextSnapshot( + store, + input.sessionId, + input.ownerId, + nowMs, + projectionSurface, + input.chatFirstCapability, + ), }; } @@ -175,7 +187,14 @@ export function updateContextSource( ); return { changed: true, - snapshot: buildContextSnapshot(store, input.sessionId, input.ownerId, nowMs, projectionSurface), + snapshot: buildContextSnapshot( + store, + input.sessionId, + input.ownerId, + nowMs, + projectionSurface, + input.chatFirstCapability, + ), }; }); } @@ -186,6 +205,7 @@ export function buildContextSnapshot( ownerId: string, nowMs = Date.now(), requestedSurfaceKind?: string, + chatFirstCapability?: ChatFirstCapabilityProjection, ): ContextSnapshotProjection { const session = assertOwnedSession(store, sessionId, ownerId); const surfaceKind = projectionSurfaceKind(store, sessionId, ownerId, String(session.surface_kind), requestedSurfaceKind); @@ -349,6 +369,7 @@ export function buildContextSnapshot( baseMaterial, nowMs, surfaceKind, + chatFirstCapability, }); } @@ -383,6 +404,7 @@ export function inheritContextSnapshotForSession( }, nowMs, surfaceKind: String(session.surface_kind), + chatFirstCapability: undefined, }); } @@ -398,6 +420,7 @@ function projectContextSnapshot( baseMaterial: Pick; nowMs: number; surfaceKind: string; + chatFirstCapability?: ChatFirstCapabilityProjection; }, ): ContextSnapshotProjection { const profile = readSessionExecutionProfile(store, input.sessionId); @@ -405,18 +428,26 @@ function projectContextSnapshot( const screenContext = input.baseMaterial.sourceOutcomes.some( (source) => source.source === "screen" && source.outcome === "available", ); - const availability = buildToolAvailabilitySnapshot(adapterId, { + const chatFirst = effectiveChatFirstCapability({ + surfaceKind: input.surfaceKind, + chatFirstUi: input.chatFirstCapability?.chatFirstUi, + controlGeneration: input.chatFirstCapability?.controlGeneration, + }); + const projectionContext = { executionRole: profile.executionRole, screenContext, - }); + surfaceKind: input.surfaceKind, + chatFirstUi: chatFirst.chatFirstUi, + controlGeneration: chatFirst.controlGeneration, + } as const; + const availability = buildToolAvailabilitySnapshot(adapterId, projectionContext); const capabilities = { executionRole: profile.executionRole, manifestVersion: availability.manifestVersion, manifestDigest: availability.manifestDigest, - allowedToolNames: toolsForAdapter(adapterId, { - executionRole: profile.executionRole, - screenContext, - }).map((tool) => tool.name).sort(), + allowedToolNames: toolsForAdapter(adapterId, projectionContext).map((tool) => tool.name).sort(), + chatFirstUi: chatFirst.chatFirstUi, + chatFirstControlGeneration: chatFirst.controlGeneration, }; const capabilityVersion = hash(stableJsonStringify(capabilities)); const contextPlan = buildConversationContextPlan({ diff --git a/desktop/macos/agent/src/runtime/conversation-journal.ts b/desktop/macos/agent/src/runtime/conversation-journal.ts index 40910fe8955..9eb720db869 100644 --- a/desktop/macos/agent/src/runtime/conversation-journal.ts +++ b/desktop/macos/agent/src/runtime/conversation-journal.ts @@ -94,6 +94,20 @@ export interface UpdateJournalTurnInput { nowMs?: number; } +/** + * Kernel-only admission for server-validated chat-first blocks. The caller has + * already checked the live run capability; this helper binds that exact + * main-Chat run/attempt to its one streaming assistant placeholder and appends + * the canonical blocks in the same SQLite transaction. + */ +export interface AppendChatFirstBlocksInput { + ownerId: string; + sessionId: string; + runId: string; + attemptId: string; + blocks: readonly ConversationContentBlock[]; +} + export interface TerminalizeJournalTurnInput { ownerId: string; conversationId: string; @@ -567,6 +581,80 @@ export function updateJournalTurn(store: AgentStore, input: UpdateJournalTurnInp }); } +export function appendChatFirstBlocksToProducingTurn( + store: AgentStore, + input: AppendChatFirstBlocksInput, +): ConversationTurn { + if (input.blocks.length < 1 || input.blocks.length > 8) { + throw new Error("Chat-first append requires one to eight blocks"); + } + return store.withTransaction(() => { + const activeAttempt = store.getOptionalRow( + `SELECT 1 + FROM run_attempts a + JOIN runs r ON r.run_id = a.run_id + JOIN sessions s ON s.session_id = r.session_id + WHERE a.attempt_id = ? + AND a.run_id = ? + AND s.owner_id = ? + AND s.session_id = ? + AND r.status IN ('queued', 'starting', 'running', 'waiting_input', 'waiting_approval') + AND a.status IN ('queued', 'starting', 'running', 'waiting_input', 'waiting_approval') + AND a.attempt_no = ( + SELECT MAX(latest.attempt_no) FROM run_attempts latest WHERE latest.run_id = r.run_id + )`, + [input.attemptId, input.runId, input.ownerId, input.sessionId], + ); + if (!activeAttempt) { + throw new Error("Chat-first append requires the current owner-bound run attempt"); + } + const bound = store.allRows( + `SELECT conversation_id, turn_id + FROM conversation_turns + WHERE producing_run_id = ? + AND producing_attempt_id = ? + AND role = 'assistant' + AND surface_kind = 'main_chat' + AND status = 'streaming'`, + [input.runId, input.attemptId], + ); + if (bound.length > 1) { + throw new Error("Chat-first append found multiple producing assistant journal turns"); + } + const producing = bound[0] ?? (() => { + // The placeholder is journaled before the runtime supplies a run/attempt. + // Pin it only when the current owner/session has exactly one live main + // Chat assistant target; user text and display order are never selectors. + const pending = store.allRows( + `SELECT DISTINCT ct.conversation_id, ct.turn_id + FROM surface_conversations sc + JOIN conversation_turns ct ON ct.conversation_id = sc.conversation_id + WHERE sc.owner_id = ? + AND sc.agent_session_id = ? + AND sc.surface_kind = 'main_chat' + AND ct.role = 'assistant' + AND ct.surface_kind = 'main_chat' + AND ct.status = 'streaming' + AND ct.producing_run_id IS NULL + AND ct.producing_attempt_id IS NULL`, + [input.ownerId, input.sessionId], + ); + if (pending.length !== 1) { + throw new Error("Chat-first append requires exactly one live producing assistant journal turn"); + } + return pending[0]!; + })(); + return updateJournalTurn(store, { + ownerId: input.ownerId, + conversationId: String(producing.conversation_id), + turnId: String(producing.turn_id), + producingRunId: input.runId, + producingAttemptId: input.attemptId, + appendContentBlocks: input.blocks, + }); + }); +} + export function validateProducingJournalTurnAdmission( store: AgentStore, input: ProducingJournalTurnAdmissionInput, @@ -2390,6 +2478,37 @@ function validateContentBlocks(blocks: readonly ConversationContentBlock[]): Con nonEmpty(block.type, "content block type"); if (ids.has(id)) throw new Error(`Duplicate content block ID ${id}`); ids.add(id); + if (block.type === "questionCard") { + nonEmpty(block.questionId, "question ID"); + if (block.text.length === 0 || block.text.length > 300) throw new Error("Question card text is out of bounds"); + nonEmpty(block.subject.id, "question subject ID"); + if (!["task", "goal", "capture"].includes(block.subject.kind)) throw new Error("Question subject kind is invalid"); + if (block.options.length < 1 || block.options.length > 4) throw new Error("Question card option count is out of bounds"); + const optionIds = new Set(); + let defers = 0; + for (const option of block.options) { + nonEmpty(option.optionId, "question option ID"); + if (optionIds.has(option.optionId)) throw new Error("Question option IDs must be unique"); + optionIds.add(option.optionId); + if (option.label.length === 0 || option.label.length > 80) throw new Error("Question option label is out of bounds"); + if (option.preparedAnswer.length === 0 || option.preparedAnswer.length > 500) { + throw new Error("Question option prepared answer is out of bounds"); + } + if (option.defer === true) defers += 1; + } + if (defers > 1) throw new Error("Question card may contain at most one defer option"); + } else if (block.type === "taskCard") { + nonEmpty(block.taskId, "task ID"); + } else if (block.type === "goalLink") { + nonEmpty(block.goalId, "goal ID"); + if (block.summary.length === 0 || block.summary.length > 200) throw new Error("Goal summary is out of bounds"); + } else if (block.type === "captureLink") { + nonEmpty(block.conversationId, "conversation ID"); + if (block.summary.length === 0 || block.summary.length > 200) throw new Error("Capture summary is out of bounds"); + if (block.momentTimestampMs !== undefined && (!Number.isSafeInteger(block.momentTimestampMs) || block.momentTimestampMs < 0)) { + throw new Error("Capture moment timestamp is invalid"); + } + } return structuredClone(block); }); } diff --git a/desktop/macos/agent/src/runtime/jsonl-transport.ts b/desktop/macos/agent/src/runtime/jsonl-transport.ts index 5f7cbb5fe1f..85014572e55 100644 --- a/desktop/macos/agent/src/runtime/jsonl-transport.ts +++ b/desktop/macos/agent/src/runtime/jsonl-transport.ts @@ -38,6 +38,9 @@ export interface McpServerBuildContext { includeSwiftBackedTools?: boolean; screenContext?: boolean; executionRole?: "coordinator" | "leaf"; + /** Server-authoritative projection admitted into this exact run snapshot. */ + chatFirstUi?: boolean; + chatFirstControlGeneration?: number | null; } export type McpServerBuilder = ( @@ -465,6 +468,12 @@ export class JsonlTransport { sessionId, adapterId: profile.adapterId, executionRole, + surfaceKind, + screenContext: snapshot.sourceOutcomes.some( + (source) => source.source === "screen" && source.outcome === "available", + ), + chatFirstUi: snapshot.capabilities.chatFirstUi === true, + chatFirstControlGeneration: snapshot.capabilities.chatFirstControlGeneration, }), maxAttempts: this.maxRecoverableRetries > 0 ? this.maxRecoverableRetries + 1 : undefined, recoverAfterError: this.recoverAfterError(profile.adapterId), diff --git a/desktop/macos/agent/src/runtime/kernel-core.ts b/desktop/macos/agent/src/runtime/kernel-core.ts index 3b112469a6a..8aed4f2bfa3 100644 --- a/desktop/macos/agent/src/runtime/kernel-core.ts +++ b/desktop/macos/agent/src/runtime/kernel-core.ts @@ -241,6 +241,10 @@ export class KernelCore { return decision; } + assertLiveRunToolCapability(input: { capabilityRef: string; activeOwnerId: string }) { + return this.toolCapabilities.assertLiveCapability(input.capabilityRef, input.activeOwnerId); + } + markRunToolInvocationDispatched(invocation: AuthorizedRunToolInvocation): void { this.toolCapabilities.markInvocationDispatched(invocation); } diff --git a/desktop/macos/agent/src/runtime/kernel-sessions.ts b/desktop/macos/agent/src/runtime/kernel-sessions.ts index f1fa452d3a6..beedc158775 100644 --- a/desktop/macos/agent/src/runtime/kernel-sessions.ts +++ b/desktop/macos/agent/src/runtime/kernel-sessions.ts @@ -137,6 +137,7 @@ import { type ContextSourceUpdateResult, } from "./context-snapshot.js"; import type { ContextSnapshotProjection } from "../protocol.js"; +import type { ChatFirstCapabilityProjection } from "./chat-first-capability.js"; import { ensureAgentSpawnJournal, type EnsureAgentSpawnJournalInput, @@ -144,6 +145,13 @@ import { } from "./agent-spawn-journal.js"; export class KernelSessions extends KernelArtifacts { + /** Process-local only: never back this with SQLite or a user preference. */ + private readonly chatFirstCapabilities = new Map(); + + private chatFirstCapability(sessionId: string, ownerId: string, surfaceKind?: string): ChatFirstCapabilityProjection | undefined { + if (surfaceKind !== "main_chat") return undefined; + return this.chatFirstCapabilities.get(`${ownerId}:${sessionId}`); + } ownedSession(sessionId: string, ownerId: string): AgentSession { const session = this.readSession(sessionId); this.assertSessionOwner(session, ownerId); @@ -165,7 +173,14 @@ export class KernelSessions extends KernelArtifacts { } contextSnapshot(sessionId: string, ownerId: string, surfaceKind?: string): ContextSnapshotProjection { - return buildContextSnapshot(this.store, sessionId, ownerId, Date.now(), surfaceKind); + return buildContextSnapshot( + this.store, + sessionId, + ownerId, + Date.now(), + surfaceKind, + this.chatFirstCapability(sessionId, ownerId, surfaceKind), + ); } contextSnapshotForExactSurface( @@ -177,17 +192,22 @@ export class KernelSessions extends KernelArtifacts { WHERE owner_id = ? AND surface_kind = ? AND external_ref_kind = ? AND external_ref_id = ?`, [ownerId, surface.surfaceKind, surface.externalRefKind, surface.externalRefId], ); + const sessionId = String(mapping.agent_session_id); return buildContextSnapshot( this.store, - String(mapping.agent_session_id), + sessionId, ownerId, Date.now(), surface.surfaceKind, + this.chatFirstCapability(sessionId, ownerId, surface.surfaceKind), ); } updateContextSource(input: ContextSourceUpdateInput): ContextSourceUpdateResult { - return updateContextSource(this.store, input); + return updateContextSource(this.store, { + ...input, + chatFirstCapability: this.chatFirstCapability(input.sessionId, input.ownerId, input.surfaceKind), + }); } ensureAgentSpawnJournal(input: EnsureAgentSpawnJournalInput): EnsureAgentSpawnJournalResult { @@ -284,8 +304,28 @@ export class KernelSessions extends KernelArtifacts { adapterBindings: this.readBindingsForSession(session.sessionId), })); } - resolveSurfaceSession(input: ResolveSurfaceSessionInput): ResolveSurfaceSessionResult { - return resolveSurfaceSession(this.store, input, () => Date.now()); + resolveSurfaceSession(input: ResolveSurfaceSessionInput & { chatFirstCapability?: ChatFirstCapabilityProjection }): ResolveSurfaceSessionResult { + const capability = input.chatFirstCapability; + const { chatFirstCapability: _ignored, ...sessionInput } = input; + const resolved = resolveSurfaceSession(this.store, sessionInput, () => Date.now()); + if (input.surfaceRef.surfaceKind !== "main_chat") return resolved; + if (capability && (!Number.isSafeInteger(capability.controlGeneration) || capability.controlGeneration < 0)) { + throw new Error("chat-first capability requires a non-negative control generation"); + } + const key = `${input.ownerId}:${resolved.agentSessionId}`; + const previous = this.chatFirstCapabilities.get(key); + const sampled: ChatFirstCapabilityProjection = capability?.chatFirstUi === true + ? capability + : { chatFirstUi: false, controlGeneration: capability?.controlGeneration ?? 0 }; + if ( + previous + && previous.chatFirstUi + && (previous.chatFirstUi !== sampled.chatFirstUi || previous.controlGeneration !== sampled.controlGeneration) + ) { + throw new Error("chat-first capability is immutable for the runtime session"); + } + if (!previous) this.chatFirstCapabilities.set(key, Object.freeze({ ...sampled })); + return resolved; } importLegacyMainChatSessions( @@ -295,6 +335,9 @@ export class KernelSessions extends KernelArtifacts { } clearOwnerState(ownerId: string): { invalidatedBindingIds: string[] } { + for (const key of this.chatFirstCapabilities.keys()) { + if (key.startsWith(`${ownerId}:`)) this.chatFirstCapabilities.delete(key); + } return clearOwnerSurfaceState(this.store, ownerId, () => Date.now()); } diff --git a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts index 178d6048424..e2bd4d440e6 100644 --- a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts +++ b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { isChatFirstMainChat } from "./chat-first-capability.js"; import { agentControlCapabilityManifest, @@ -101,6 +102,9 @@ export interface OmiToolProjectionContext { onboarding?: boolean; screenContext?: boolean; executionRole?: "coordinator" | "leaf"; + surfaceKind?: string; + chatFirstUi?: boolean; + controlGeneration?: number | null; } export interface OmiToolAvailabilitySnapshot { @@ -1536,6 +1540,56 @@ export const omiToolManifest: OmiToolManifestEntry[] = [ ...swiftToolManifest.slice(4), ] satisfies OmiToolManifestEntry[]; +/** + * This is intentionally not part of `omiToolManifest`: capability-off callers + * must retain the historical order, digest, and raw tools/list bytes. + */ +export const chatFirstToolManifest: OmiToolManifestEntry[] = [ + { + name: "render_chat_blocks", + label: "Render Chat Blocks", + description: "Render validated question, task, goal, and Omi-capture blocks on the producing main Chat turn.", + promptSnippet: "render_chat_blocks - Add validated structured cards to this main Chat response", + promptGuidelines: [ + "Use only for a compact actionable question, task, goal, or Omi-device capture reference.", + "Never invent entity identifiers or URLs; the server validates every requested reference.", + ], + latency: "fast network", + inputSchema: schema({ + blocks: { + type: "array", + description: "1-8 declarative chat blocks validated by the Omi backend.", + items: { type: "object" }, + }, + }, ["blocks"]), + mcpInputSchema: schema({ + blocks: { + type: "array", + description: "1-8 declarative chat blocks validated by the Omi backend.", + items: { type: "object" }, + }, + }, ["blocks"]), + annotations: localWrite, + timeoutClass: "normal", + executor: { kind: "swiftTool" }, + surfaces: ["desktop_chat"], + capabilityDoc: doc("Render Chat Blocks", "Add validated structured cards to the current main Chat response.", [ + "Only available to the server-enabled chat-first main Chat cohort.", + ]), + intendedForAgents: true, + runtimePreconditions: [ + "Requires a server-authoritative chat-first capability on the current main Chat run.", + "Every entity reference is revalidated by the backend before journal admission.", + ], + adapters: stdioOnly(), + }, +] satisfies OmiToolManifestEntry[]; + +export const allOmiToolManifest: OmiToolManifestEntry[] = [ + ...omiToolManifest, + ...chatFirstToolManifest, +] satisfies OmiToolManifestEntry[]; + function canonicalManifestJson(value: unknown): string { if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(canonicalManifestJson).join(",")}]`; @@ -1552,6 +1606,10 @@ export const OMI_TOOL_MANIFEST_DIGEST = `sha256:${createHash("sha256") .update(canonicalManifestJson(omiToolManifest)) .digest("hex")}` as const; +export const OMI_CHAT_FIRST_TOOL_MANIFEST_DIGEST = `sha256:${createHash("sha256") + .update(canonicalManifestJson(allOmiToolManifest)) + .digest("hex")}` as const; + export function isToolAvailableForContext( availability: OmiToolAdapterAvailability | undefined, context: OmiToolProjectionContext = {}, @@ -1569,7 +1627,12 @@ export function toolsForAdapter( adapterId: OmiToolAdapterId, context: OmiToolProjectionContext = {}, ): OmiToolManifestEntry[] { - return omiToolManifest.filter((tool) => isToolAvailableForContext(tool.adapters[adapterId], context)); + const base = omiToolManifest.filter((tool) => isToolAvailableForContext(tool.adapters[adapterId], context)); + if (!isChatFirstMainChat(context)) return base; + return [ + ...base, + ...chatFirstToolManifest.filter((tool) => isToolAvailableForContext(tool.adapters[adapterId], context)), + ]; } export function toolNamesForAdapter( @@ -1591,7 +1654,7 @@ export function mcpToolDefinitionsForAdapter( } export function toolManifestEntry(name: string): OmiToolManifestEntry | undefined { - return omiToolManifest.find((tool) => tool.name === name || tool.aliases?.includes(name)); + return allOmiToolManifest.find((tool) => tool.name === name || tool.aliases?.includes(name)); } export function normalizeOmiToolName( @@ -1602,7 +1665,7 @@ export function normalizeOmiToolName( const dotMatch = /^omi-tools\.(.+)$/.exec(name); const unprefixed = mcpMatch?.[1] ?? dotMatch?.[1] ?? name; - for (const tool of omiToolManifest) { + for (const tool of allOmiToolManifest) { const availability = tool.adapters[adapterId]; const adapterName = availability?.adapterName ?? tool.name; const aliases = new Set([...(tool.aliases ?? []), ...(availability?.aliases ?? [])]); @@ -1623,8 +1686,10 @@ export function buildToolAvailabilitySnapshot( const advertised = toolsForAdapter(adapterId, context); const aliases: Record = {}; const disabled: Array<{ name: string; reason: string }> = []; + // Keep the legacy availability snapshot byte-for-byte stable while off. + const manifestForSnapshot = isChatFirstMainChat(context) ? allOmiToolManifest : omiToolManifest; - for (const tool of omiToolManifest) { + for (const tool of manifestForSnapshot) { const availability = tool.adapters[adapterId]; if (isToolAvailableForContext(availability, context)) { for (const alias of [...(tool.aliases ?? []), ...(availability?.aliases ?? [])]) { @@ -1643,7 +1708,9 @@ export function buildToolAvailabilitySnapshot( return { manifestVersion: OMI_TOOL_MANIFEST_VERSION, - manifestDigest: OMI_TOOL_MANIFEST_DIGEST, + manifestDigest: isChatFirstMainChat(context) + ? OMI_CHAT_FIRST_TOOL_MANIFEST_DIGEST + : OMI_TOOL_MANIFEST_DIGEST, adapterId, context, advertisedToolCount: advertised.length, diff --git a/desktop/macos/agent/src/runtime/run-tool-capability.ts b/desktop/macos/agent/src/runtime/run-tool-capability.ts index 574abbb6061..cc91089f30a 100644 --- a/desktop/macos/agent/src/runtime/run-tool-capability.ts +++ b/desktop/macos/agent/src/runtime/run-tool-capability.ts @@ -80,6 +80,8 @@ export interface RunToolCapability { manifestVersion: number; manifestDigest: string; allowedToolNames: readonly string[]; + chatFirstUi: boolean; + chatFirstControlGeneration: number | null; daemonBootEpoch: string; executionGeneration: number; registeredAtMs: number; @@ -130,6 +132,8 @@ export interface AuthorizedRunToolInvocation { precedingAssistantText: string | null; runMode: RunMode; chatMode: string | null; + chatFirstUi: boolean; + chatFirstControlGeneration: number | null; canonicalToolName: string; tool: OmiToolManifestEntry; } @@ -270,6 +274,9 @@ export class RunToolCapabilityBroker { const projectionContext = { executionRole: persisted.profile.executionRole, screenContext: persisted.screenContext, + surfaceKind: persisted.surfaceKind, + chatFirstUi: persisted.chatFirstUi, + controlGeneration: persisted.chatFirstControlGeneration, }; const snapshot = buildToolAvailabilitySnapshot(adapterProjection, projectionContext); const allowedToolNames = toolsForAdapter(adapterProjection, projectionContext) @@ -295,6 +302,8 @@ export class RunToolCapabilityBroker { manifestVersion: snapshot.manifestVersion, manifestDigest: snapshot.manifestDigest, allowedToolNames: Object.freeze(allowedToolNames), + chatFirstUi: persisted.chatFirstUi, + chatFirstControlGeneration: persisted.chatFirstControlGeneration, daemonBootEpoch: this.daemonBootEpoch, executionGeneration: ++this.executionGeneration, registeredAtMs: this.nowMs(), @@ -431,6 +440,8 @@ export class RunToolCapabilityBroker { precedingAssistantText: capability.precedingAssistantText, runMode: capability.runMode, chatMode: capability.chatMode, + chatFirstUi: capability.chatFirstUi, + chatFirstControlGeneration: capability.chatFirstControlGeneration, canonicalToolName: tool.name, tool, }; @@ -580,6 +591,18 @@ export class RunToolCapabilityBroker { return state && !state.revoked ? state.capability : undefined; } + /** Verify a live capability without consuming its one-use tool invocation. */ + assertLiveCapability(capabilityRef: string, activeOwnerId: string): RunToolCapability { + const state = this.states.get(capabilityRef); + if (!state) this.reject("capability_missing", "Unknown run tool capability"); + if (state.revoked) { + const code = rejectCodeForRevocation(state.revocationReason ?? "explicit"); + this.reject(code, "Run tool capability has been revoked"); + } + this.assertLiveCapabilityAuthority(state, activeOwnerId); + return state.capability; + } + private revokeRunCapabilities( runId: string, reason: RunToolCapabilityRevocationReason, @@ -673,6 +696,8 @@ export class RunToolCapabilityBroker { runMode: RunMode; chatMode: string | null; screenContext: boolean; + chatFirstUi: boolean; + chatFirstControlGeneration: number | null; } { const row = this.store.getRow( `SELECT s.*, r.session_id AS authoritative_session_id, r.status AS authoritative_run_status, @@ -705,6 +730,18 @@ export class RunToolCapabilityBroker { && !Array.isArray(metadata.externalSurface) ? metadata.externalSurface as Record : null; + const admitted = runInput.admittedContextSnapshot + && typeof runInput.admittedContextSnapshot === "object" + && !Array.isArray(runInput.admittedContextSnapshot) + ? runInput.admittedContextSnapshot as Record + : {}; + const admittedCapabilities = admitted.capabilities + && typeof admitted.capabilities === "object" + && !Array.isArray(admitted.capabilities) + ? admitted.capabilities as Record + : {}; + const chatFirstUi = admittedCapabilities.chatFirstUi === true && text(row.surface_kind) === "main_chat"; + const controlGeneration = Number(admittedCapabilities.chatFirstControlGeneration); return { ownerId: text(row.owner_id), sessionId, @@ -720,6 +757,10 @@ export class RunToolCapabilityBroker { runMode: text(row.mode) === "act" ? "act" : "ask", chatMode: typeof metadata.chatMode === "string" ? metadata.chatMode : null, screenContext: admittedScreenContext(runInput), + chatFirstUi, + chatFirstControlGeneration: chatFirstUi && Number.isSafeInteger(controlGeneration) && controlGeneration >= 0 + ? controlGeneration + : null, }; } diff --git a/desktop/macos/agent/src/runtime/types.ts b/desktop/macos/agent/src/runtime/types.ts index ad633f2e079..ea9ef7f1331 100644 --- a/desktop/macos/agent/src/runtime/types.ts +++ b/desktop/macos/agent/src/runtime/types.ts @@ -105,6 +105,17 @@ export type ConversationContentBlock = } | { type: "thinking"; id: string; text: string } | { type: "discoveryCard"; id: string; title: string; summary: string; fullText: string } + | { + type: "questionCard"; + id: string; + questionId: string; + text: string; + subject: { kind: "task" | "goal" | "capture"; id: string }; + options: Array<{ optionId: string; label: string; preparedAnswer: string; defer?: boolean }>; + } + | { type: "taskCard"; id: string; taskId: string } + | { type: "goalLink"; id: string; goalId: string; summary: string } + | { type: "captureLink"; id: string; conversationId: string; momentTimestampMs?: number; summary: string } | { type: "agentSpawn"; id: string; diff --git a/desktop/macos/agent/tests/conversation-journal.test.ts b/desktop/macos/agent/tests/conversation-journal.test.ts index 6ffa6538704..476707cd828 100644 --- a/desktop/macos/agent/tests/conversation-journal.test.ts +++ b/desktop/macos/agent/tests/conversation-journal.test.ts @@ -8,6 +8,7 @@ import { ackBackendTurnOutbox, ackBackendTurnOutboxWithWakes, applyBackendReconcilePage, + appendChatFirstBlocksToProducingTurn, beginBackendReconcile, beginBackendReconcilesForOwner, clearJournalConversation, @@ -40,6 +41,115 @@ afterEach(() => { }); describe("kernel conversation journal", () => { + it("atomically binds the one current main-chat placeholder and replays validated chat-first blocks", () => { + const fixture = newSurface("main_chat", "chat", "chat-first-append"); + const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-append"); + recordStreamingAssistantPlaceholder(fixture, "turn-chat-first-placeholder"); + const blocks: ConversationContentBlock[] = [{ + type: "taskCard", + id: "cfb-task-1", + taskId: "task-1", + }]; + + const appended = appendChatFirstBlocksToProducingTurn(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + blocks, + }); + const replayed = appendChatFirstBlocksToProducingTurn(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + blocks, + }); + + expect(appended).toMatchObject({ + turnId: "turn-chat-first-placeholder", + producingRunId: run.runId, + producingAttemptId: attempt.attemptId, + contentBlocks: blocks, + }); + expect(replayed).toEqual(appended); + expect(listJournalTurns(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + }).turns.at(-1)).toMatchObject({ contentBlocks: blocks }); + fixture.store.close(); + }); + + it("rejects wrong, non-main, stale, multiple, and missing chat-first targets without mutating journal rows", () => { + const block: ConversationContentBlock = { type: "taskCard", id: "cfb-rejected", taskId: "task-1" }; + const cases: Array<{ + name: string; + arrange: () => { fixture: SurfaceFixture; input: { ownerId: string; sessionId: string; runId: string; attemptId: string } }; + error: RegExp; + }> = [ + { + name: "wrong owner", + arrange: () => { + const fixture = newSurface("main_chat", "chat", "chat-first-wrong"); + const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-wrong"); + recordStreamingAssistantPlaceholder(fixture, "turn-wrong-owner"); + return { fixture, input: { ownerId: "other-owner", sessionId: fixture.sessionId, runId: run.runId, attemptId: attempt.attemptId } }; + }, + error: /current owner-bound run attempt/i, + }, + { + name: "non-main surface", + arrange: () => { + const fixture = newSurface("realtime_voice", "chat", "chat-first-non-main"); + const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-non-main"); + recordStreamingAssistantPlaceholder(fixture, "turn-non-main"); + return { fixture, input: { ownerId: fixture.ownerId, sessionId: fixture.sessionId, runId: run.runId, attemptId: attempt.attemptId } }; + }, + error: /exactly one live producing assistant/i, + }, + { + name: "superseded attempt", + arrange: () => { + const fixture = newSurface("main_chat", "chat", "chat-first-stale"); + const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-stale"); + fixture.store.execute("UPDATE run_attempts SET status = 'succeeded' WHERE attempt_id = ?", [attempt.attemptId]); + recordStreamingAssistantPlaceholder(fixture, "turn-stale-attempt"); + return { fixture, input: { ownerId: fixture.ownerId, sessionId: fixture.sessionId, runId: run.runId, attemptId: attempt.attemptId } }; + }, + error: /current owner-bound run attempt/i, + }, + { + name: "multiple placeholders", + arrange: () => { + const fixture = newSurface("main_chat", "chat", "chat-first-multiple"); + const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-multiple"); + recordStreamingAssistantPlaceholder(fixture, "turn-multiple-one"); + recordStreamingAssistantPlaceholder(fixture, "turn-multiple-two"); + return { fixture, input: { ownerId: fixture.ownerId, sessionId: fixture.sessionId, runId: run.runId, attemptId: attempt.attemptId } }; + }, + error: /exactly one live producing assistant/i, + }, + { + name: "missing placeholder", + arrange: () => { + const fixture = newSurface("main_chat", "chat", "chat-first-missing"); + const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-missing"); + return { fixture, input: { ownerId: fixture.ownerId, sessionId: fixture.sessionId, runId: run.runId, attemptId: attempt.attemptId } }; + }, + error: /exactly one live producing assistant/i, + }, + ]; + + for (const testCase of cases) { + const { fixture, input } = testCase.arrange(); + const before = journalStorageSnapshot(fixture.store); + expect(() => appendChatFirstBlocksToProducingTurn(fixture.store, { ...input, blocks: [block] })) + .toThrow(testCase.error); + expect(journalStorageSnapshot(fixture.store)).toEqual(before); + fixture.store.close(); + } + }); + it("projects shared chat revisions through the requesting binding with owner-fenced wakes", () => { const fixture = newSurface("main_chat", "chat", "default"); const realtimeSession = fixture.store.insertSession({ @@ -2475,6 +2585,7 @@ interface SurfaceFixture { ownerId: string; sessionId: string; conversationId: string; + surfaceKind: string; } function newSurface(surfaceKind: string, externalRefKind: string, externalRefId: string): SurfaceFixture { @@ -2501,7 +2612,7 @@ function insertSurface( createdAtMs: 1, lastActiveAtMs: 1, }); - return { store, ownerId, sessionId: session.sessionId, conversationId }; + return { store, ownerId, sessionId: session.sessionId, conversationId, surfaceKind }; } function recordCompletedTextTurn( @@ -2524,6 +2635,41 @@ function recordCompletedTextTurn( }); } +function insertActiveRunAttempt(fixture: SurfaceFixture, suffix: string) { + const run = fixture.store.insertRun({ + sessionId: fixture.sessionId, + runId: `run-${suffix}`, + clientId: "chat-first-test", + requestId: suffix, + status: "running", + mode: "act", + }); + const attempt = fixture.store.insertAttempt({ + attemptId: `att-${suffix}`, + runId: run.runId, + attemptNo: 1, + status: "running", + adapterId: "fake", + adapterInstanceId: `fake:${suffix}`, + }); + return { run, attempt }; +} + +function recordStreamingAssistantPlaceholder(fixture: SurfaceFixture, turnId: string): void { + recordJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId, + role: "assistant", + surfaceKind: fixture.surfaceKind, + origin: "typed_chat", + status: "streaming", + content: "", + contentBlocks: [], + createdAtMs: 10, + }); +} + function journalStorageSnapshot(store: SqliteAgentStore) { return { turns: store.allRows( diff --git a/desktop/macos/agent/tests/omi-tool-manifest.test.ts b/desktop/macos/agent/tests/omi-tool-manifest.test.ts index 16f22ad7263..1b5f97e5f63 100644 --- a/desktop/macos/agent/tests/omi-tool-manifest.test.ts +++ b/desktop/macos/agent/tests/omi-tool-manifest.test.ts @@ -157,6 +157,36 @@ describe("omi tool manifest", () => { expect(executeSql?.inputSchema.required).toEqual(["query"]); }); + it("keeps the capability-off stdio manifest byte-stable and never leaks the chat-first tool", () => { + const legacyBytes = JSON.stringify(mcpToolDefinitionsForAdapter("omi-tools-stdio")); + const capabilityOffBytes = JSON.stringify(mcpToolDefinitionsForAdapter("omi-tools-stdio", { + surfaceKind: "main_chat", chatFirstUi: false, controlGeneration: 7, + })); + const nonMainBytes = JSON.stringify(mcpToolDefinitionsForAdapter("omi-tools-stdio", { + surfaceKind: "floating_chat", chatFirstUi: true, controlGeneration: 7, + })); + + expect(capabilityOffBytes).toBe(legacyBytes); + expect(nonMainBytes).toBe(legacyBytes); + expect(capabilityOffBytes).not.toContain("render_chat_blocks"); + }); + + it("exposes render_chat_blocks only to the authorized main-chat stdio projection", () => { + const enabled = mcpToolDefinitionsForAdapter("omi-tools-stdio", { + surfaceKind: "main_chat", chatFirstUi: true, controlGeneration: 7, + }); + const snapshot = buildToolAvailabilitySnapshot("omi-tools-stdio", { + surfaceKind: "main_chat", chatFirstUi: true, controlGeneration: 7, + }); + + expect(enabled.map((tool) => tool.name)).toContain("render_chat_blocks"); + expect(snapshot.advertisedToolNames).toContain("render_chat_blocks"); + expect(snapshot.manifestDigest).not.toBe(buildToolAvailabilitySnapshot("omi-tools-stdio").manifestDigest); + expect(toolNamesForAdapter("pi-mono", { + surfaceKind: "main_chat", chatFirstUi: true, controlGeneration: 7, + })).not.toContain("render_chat_blocks"); + }); + it("keeps schemas expressive enough for nested onboarding tools", () => { const saveKnowledgeGraph = toolsForAdapter("omi-tools-stdio", { onboarding: true }).find( (tool) => tool.name === "save_knowledge_graph", diff --git a/desktop/macos/agent/tests/surface-session.test.ts b/desktop/macos/agent/tests/surface-session.test.ts index 866f82c3bf7..ce3caeb84ef 100644 --- a/desktop/macos/agent/tests/surface-session.test.ts +++ b/desktop/macos/agent/tests/surface-session.test.ts @@ -446,4 +446,43 @@ describe("surface_conversations", () => { expect(store.allRows("SELECT * FROM surface_conversations")).toHaveLength(1); }); + it("samples chat-first capability once for main Chat and keeps every other surface off", () => { + const kernel = new AgentRuntimeKernel({ store, registry: new AdapterRegistry() }); + const mainSurface = { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "chat-first" }; + const main = kernel.resolveSurfaceSession({ + ownerId: "owner-a", + surfaceRef: mainSurface, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 7 }, + }); + const mainSnapshot = kernel.contextSnapshot(main.agentSessionId, "owner-a", "main_chat"); + + expect(mainSnapshot.capabilities.chatFirstUi).toBe(true); + expect(mainSnapshot.capabilities.chatFirstControlGeneration).toBe(7); + + const floating = kernel.resolveSurfaceSession({ + ownerId: "owner-a", + surfaceRef: { surfaceKind: "floating_chat", externalRefKind: "chat", externalRefId: "chat-first" }, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 7 }, + }); + const floatingSnapshot = kernel.contextSnapshot(floating.agentSessionId, "owner-a", "floating_chat"); + expect(floatingSnapshot.capabilities.chatFirstUi).toBe(false); + expect(floatingSnapshot.capabilities.chatFirstControlGeneration).toBeNull(); + }); + + it("cannot turn chat-first on after the main-chat session was sampled off", () => { + const kernel = new AgentRuntimeKernel({ store, registry: new AdapterRegistry() }); + const surfaceRef = { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "sampled-off" }; + const resolved = kernel.resolveSurfaceSession({ ownerId: "owner-a", surfaceRef, defaultAdapterId: "acp" }); + kernel.resolveSurfaceSession({ + ownerId: "owner-a", + surfaceRef, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 7 }, + }); + + expect(kernel.contextSnapshot(resolved.agentSessionId, "owner-a", "main_chat").capabilities.chatFirstUi).toBe(false); + }); + }); From 9dd421878765b1c5c3d6b66a62095f5f545b4cff Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 04:11:51 -0400 Subject: [PATCH 03/28] fix(chat): preserve admitted chat-first capability Keep the generation-fenced main-Chat capability when an accepted run inherits its context snapshot, while reapplying the destination surface gate so non-main sessions remain off.\n\nVerification:\n- 55 focused agent capability/context/history/manifest tests\n- desktop agent TypeScript build\n- fresh Sol regression review and diff check --- .../agent/src/runtime/context-snapshot.ts | 25 ++- .../chat-first-capability-projection.test.ts | 149 ++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 desktop/macos/agent/tests/chat-first-capability-projection.test.ts diff --git a/desktop/macos/agent/src/runtime/context-snapshot.ts b/desktop/macos/agent/src/runtime/context-snapshot.ts index 7637457795a..52028d46cc7 100644 --- a/desktop/macos/agent/src/runtime/context-snapshot.ts +++ b/desktop/macos/agent/src/runtime/context-snapshot.ts @@ -404,10 +404,33 @@ export function inheritContextSnapshotForSession( }, nowMs, surfaceKind: String(session.surface_kind), - chatFirstCapability: undefined, + // The admitted snapshot is the immutable, generation-fenced authority for + // this logical run. Re-project its effective main-Chat capability instead + // of silently rebuilding the child snapshot with the extension disabled. + // projectContextSnapshot still applies the destination surface gate, so a + // delegated, PTT, or other non-main session cannot inherit the tools. + chatFirstCapability: chatFirstCapabilityFromAdmittedSnapshot(admitted), }); } +function chatFirstCapabilityFromAdmittedSnapshot( + admitted: ContextSnapshotProjection, +): ChatFirstCapabilityProjection | undefined { + const { chatFirstUi, chatFirstControlGeneration } = admitted.capabilities; + if ( + chatFirstUi !== true + || chatFirstControlGeneration === null + || !Number.isSafeInteger(chatFirstControlGeneration) + || chatFirstControlGeneration < 0 + ) { + return undefined; + } + return { + chatFirstUi: true, + controlGeneration: chatFirstControlGeneration, + }; +} + function projectContextSnapshot( store: AgentStore, input: { diff --git a/desktop/macos/agent/tests/chat-first-capability-projection.test.ts b/desktop/macos/agent/tests/chat-first-capability-projection.test.ts new file mode 100644 index 00000000000..e7edf9427b3 --- /dev/null +++ b/desktop/macos/agent/tests/chat-first-capability-projection.test.ts @@ -0,0 +1,149 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { mcpToolDefinitionsForAdapter } from "../src/runtime/omi-tool-manifest.js"; +import { RunToolCapabilityRejectedError } from "../src/runtime/run-tool-capability.js"; +import { createKernelHarness, waitUntil } from "./kernel-fakes.js"; + +const roots: string[] = []; +const CHAT_FIRST_DYNAMIC_TOOLS = ["render_chat_blocks", "search_chat_history"] as const; + +afterEach(() => { + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +describe("chat-first admitted capability projection", () => { + it("preserves the enabled main-Chat generation through run admission for both dynamic tools", async () => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "chat-first-main" }, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 19 }, + }); + const admittedContextSnapshot = kernel.contextSnapshot(resolved.agentSessionId, "owner", "main_chat"); + expect(admittedContextSnapshot.capabilities).toMatchObject({ + chatFirstUi: true, + chatFirstControlGeneration: 19, + }); + + adapter.deferResult(); + const runPromise = kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "chat-first-main", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "chat-first-client", + requestId: "chat-first-request", + prompt: "Use a rich Chat card.", + cwd: "/tmp/chat-first-projection", + admittedContextSnapshot, + }); + await waitUntil(() => adapter.executed.length === 1); + + const capabilityRef = adapter.executed[0]!.toolCapabilityRef; + for (const toolName of CHAT_FIRST_DYNAMIC_TOOLS) { + const authorized = kernel.authorizeRelayedRunToolInvocation({ + capabilityRef, + invocationId: `enabled-${toolName}`, + toolName, + toolInput: {}, + activeOwnerId: "owner", + }); + expect(authorized).toMatchObject({ + canonicalToolName: toolName, + surfaceKind: "main_chat", + chatFirstUi: true, + chatFirstControlGeneration: 19, + }); + } + + adapter.resolveDeferred(); + await runPromise; + store.close(); + }); + + it.each([ + ["capability-off main Chat", "main_chat", { chatFirstUi: false, controlGeneration: 19 }], + ["enabled non-main surface", "floating_chat", { chatFirstUi: true, controlGeneration: 19 }], + ] as const)("keeps dynamic tools absent and un-authorizable for %s", async (_label, surfaceKind, capability) => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind, externalRefKind: "chat", externalRefId: `projection-${surfaceKind}` }, + defaultAdapterId: "acp", + chatFirstCapability: capability, + }); + const admittedContextSnapshot = kernel.contextSnapshot(resolved.agentSessionId, "owner", surfaceKind); + expect(admittedContextSnapshot.capabilities).toMatchObject({ + chatFirstUi: false, + chatFirstControlGeneration: null, + }); + expect(admittedContextSnapshot.capabilities.allowedToolNames).not.toEqual( + expect.arrayContaining(CHAT_FIRST_DYNAMIC_TOOLS), + ); + + adapter.deferResult(); + const runPromise = kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind, + externalRefKind: "chat", + externalRefId: `projection-${surfaceKind}`, + defaultAdapterId: "acp", + adapterId: "acp", + clientId: `projection-client-${surfaceKind}`, + requestId: `projection-request-${surfaceKind}`, + prompt: "Use a rich Chat card.", + cwd: "/tmp/chat-first-projection", + admittedContextSnapshot, + }); + await waitUntil(() => adapter.executed.length === 1); + + for (const toolName of CHAT_FIRST_DYNAMIC_TOOLS) { + expectToolNotAllowed(() => kernel.authorizeRelayedRunToolInvocation({ + capabilityRef: adapter.executed[0]!.toolCapabilityRef, + invocationId: `${surfaceKind}-${toolName}`, + toolName, + toolInput: {}, + activeOwnerId: "owner", + })); + } + + adapter.resolveDeferred(); + await runPromise; + store.close(); + }); + + it("keeps capability-off and non-main MCP tools/list bytes equal to the legacy projection", () => { + const legacy = JSON.stringify(mcpToolDefinitionsForAdapter("omi-tools-stdio")); + for (const projection of [ + { surfaceKind: "main_chat", chatFirstUi: false, controlGeneration: 19 }, + { surfaceKind: "floating_chat", chatFirstUi: true, controlGeneration: 19 }, + ]) { + expect(JSON.stringify(mcpToolDefinitionsForAdapter("omi-tools-stdio", projection))).toBe(legacy); + } + }); +}); + +function expectToolNotAllowed(work: () => unknown): void { + try { + work(); + throw new Error("Expected a run-tool capability rejection"); + } catch (error) { + expect(error).toBeInstanceOf(RunToolCapabilityRejectedError); + expect((error as RunToolCapabilityRejectedError).code).toBe("tool_not_allowed"); + } +} + +function newDatabasePath(): string { + const root = mkdtempSync(join(tmpdir(), "omi-chat-first-projection-")); + roots.push(root); + return join(root, "agent.sqlite"); +} From e7bdca20941683e063c4d309e93cf4a86b1862b3 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 04:22:07 -0400 Subject: [PATCH 04/28] feat(chat): search the local journal on demand Adds the capability-gated main-Chat search_chat_history tool. The stdio child relays only after normal authorization; the parent kernel searches the caller's current-generation journal with strict ownership, date, excerpt, and 500-turn bounds.\n\nVerification:\n- 65 focused journal/search/manifest tests\n- desktop agent TypeScript build and tool-surface generator check\n- fresh Sol review: 69 focused tests and tool-surface contract suite\n- shared full agent harness deferred until concurrent T06 Swift compile completes --- .../Generated/GeneratedToolExecutors.swift | 2 +- desktop/macos/agent/src/index.ts | 47 +++++ desktop/macos/agent/src/omi-tools-stdio.ts | 12 ++ .../agent/src/runtime/conversation-journal.ts | 120 +++++++++++ .../macos/agent/src/runtime/kernel-core.ts | 79 +++++++ .../agent/src/runtime/omi-tool-manifest.ts | 45 ++++ .../agent/tests/chat-history-search.test.ts | 193 ++++++++++++++++++ .../agent/tests/conversation-journal.test.ts | 101 +++++++++ .../agent/tests/omi-tool-manifest.test.ts | 5 +- 9 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 desktop/macos/agent/tests/chat-history-search.test.ts diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift index 55041a55f60..4a0aeb8cbb1 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift @@ -43,7 +43,7 @@ enum GeneratedSwiftToolExecutor: String { enum GeneratedToolExecutors { static let manifestVersion = 1 static let manifestDigest = "sha256:bddf77bb08d251aade514f1406e51abd1f51d1753a26bfbb0cf5a5c4a2f05458" - static let chatFirstManifestDigest = "sha256:7e390dbb46f41b48198a4cb24df578c98f6d0934cc31c4a348dcd26e70e5ce0f" + static let chatFirstManifestDigest = "sha256:aeefd2baddfdd17a1afe0beff7fb3f43445220337a790448d0f26d32dba345d3" static let aliasToCanonical: [String: GeneratedSwiftTool] = [ "search_screen_history": .semanticSearch, diff --git a/desktop/macos/agent/src/index.ts b/desktop/macos/agent/src/index.ts index e7ba5c328e8..54af60b8e30 100644 --- a/desktop/macos/agent/src/index.ts +++ b/desktop/macos/agent/src/index.ts @@ -791,6 +791,53 @@ function startOmiToolsRelay(): Promise { continue; } + if (authorized.canonicalToolName === "search_chat_history") { + void (async () => { + let result: string; + let outcome: "succeeded" | "failed" = "succeeded"; + try { + if (!runtimeKernel) throw new Error("Agent runtime kernel is not ready"); + runtimeKernel.markRunToolInvocationDispatched(authorized); + const search = runtimeKernel.searchAuthorizedChatHistory({ + invocation: authorized, + toolInput: routedProposal.toolInput, + activeOwnerId: () => currentOwnerId, + }); + result = JSON.stringify(search); + } catch { + outcome = "failed"; + // Search results and journal details are transcript data. + // Keep relay diagnostics shape-only even on malformed input. + result = relayError("chat_history_search_failed", "Chat history search could not be completed"); + } + const finalizedResult = finalizeRelayResult(msg.callId, result, authorized, outcome); + const finalizedOutcome = controlToolInvocationOutcome(finalizedResult); + try { + runtimeKernel?.completeRunToolInvocation({ + invocationId: authorized.invocationId, + ownerId: authorized.ownerId, + sessionId: authorized.sessionId, + runId: authorized.runId, + attemptId: authorized.attemptId, + profileGeneration: authorized.profileGeneration, + manifestVersion: authorized.manifestVersion, + manifestDigest: authorized.manifestDigest, + daemonBootEpoch: authorized.daemonBootEpoch, + executionGeneration: authorized.executionGeneration, + inputHash: authorized.inputHash, + capabilityRef: authorized.capabilityRef, + activeOwnerId: currentOwnerId, + outcome: finalizedOutcome, + result: finalizedResult, + }); + } catch (error) { + logErr(`Failed to complete chat-history invocation ${authorized.invocationId}: ${error}`); + } + writeFinalizedRelayToolResult(client, msg.callId, finalizedResult); + })(); + continue; + } + const callId = msg.callId; const pendingKey = toolCallPendingKey({ invocationId, diff --git a/desktop/macos/agent/src/omi-tools-stdio.ts b/desktop/macos/agent/src/omi-tools-stdio.ts index ecfdb27b8af..259ce373a0d 100644 --- a/desktop/macos/agent/src/omi-tools-stdio.ts +++ b/desktop/macos/agent/src/omi-tools-stdio.ts @@ -340,6 +340,18 @@ async function handleJsonRpc( }, }); } + } else if (toolName === "search_chat_history") { + // This remains a relay request, not a child-process SQLite read. The + // parent kernel rechecks the run capability then scopes the search to + // the caller's current main-Chat journal generation. + const result = await requestSwiftTool(toolName, args); + if (!isNotification) { + send({ + jsonrpc: "2.0", + id, + result: { content: [{ type: "text", text: result }] }, + }); + } } else if (isAgentControlToolName(toolName)) { // Runtime control tools are handled by the Node parent/kernel. They // still travel over the relay so MCP clients use the same tool path. diff --git a/desktop/macos/agent/src/runtime/conversation-journal.ts b/desktop/macos/agent/src/runtime/conversation-journal.ts index 9eb720db869..82232c37be7 100644 --- a/desktop/macos/agent/src/runtime/conversation-journal.ts +++ b/desktop/macos/agent/src/runtime/conversation-journal.ts @@ -242,6 +242,31 @@ export interface JournalTurnRange { turns: ConversationTurn[]; } +/** + * The model may recover bounded context from its own main-Chat journal without + * widening the prompt window or granting a child process SQLite access. + * Results intentionally contain only replay-safe transcript fields. + */ +export interface ChatHistorySearchMatch { + timestamp: string; + role: ConversationTurnRole; + excerpt: string; +} + +export interface SearchJournalConversationInput { + ownerId: string; + conversationId: string; + query: string; + startDate?: string; + endDate?: string; + limit?: number; +} + +const MAX_CHAT_HISTORY_SEARCH_SCAN = 500; +const DEFAULT_CHAT_HISTORY_SEARCH_LIMIT = 10; +const MAX_CHAT_HISTORY_SEARCH_LIMIT = 20; +const MAX_CHAT_HISTORY_SEARCH_EXCERPT = 320; + export interface JournalTurnChangedWake { ownerId: string; conversationGeneration: number; @@ -1177,6 +1202,101 @@ export function listJournalTurns( }; } +/** + * Search only the current journal generation, newest first. This is deliberately + * not an FTS endpoint: it bounds local work to the most recent transcript + * window and never exposes metadata, outbox rows, or a different conversation. + */ +export function searchJournalConversation( + store: AgentStore, + input: SearchJournalConversationInput, +): ChatHistorySearchMatch[] { + assertConversationOwner(store, input.conversationId, input.ownerId); + const query = requiredChatHistorySearchQuery(input.query); + const startAtMs = parseChatHistorySearchDate(input.startDate, "start_date"); + const endAtMs = parseChatHistorySearchDate(input.endDate, "end_date"); + if (startAtMs !== null && endAtMs !== null && startAtMs > endAtMs) { + throw new Error("search_chat_history start_date must not be after end_date"); + } + const limit = boundedChatHistorySearchLimit(input.limit); + store.execute( + `INSERT INTO conversation_journal_state( + conversation_id, generation, high_water_turn_seq, updated_at_ms + ) VALUES (?, 1, 0, ?) + ON CONFLICT(conversation_id) DO NOTHING`, + [input.conversationId, Date.now()], + ); + const state = requireJournalState(store, input.conversationId); + const rows = store.allRows( + `SELECT turn_json + FROM conversation_turn_revisions + WHERE conversation_id = ? AND generation = ? + ORDER BY turn_seq DESC + LIMIT ?`, + [input.conversationId, state.generation, MAX_CHAT_HISTORY_SEARCH_SCAN], + ); + const matches: ChatHistorySearchMatch[] = []; + for (const row of rows) { + const turn = JSON.parse(String(row.turn_json)) as ConversationTurn; + if ((startAtMs !== null && turn.createdAtMs < startAtMs) || (endAtMs !== null && turn.createdAtMs > endAtMs)) { + continue; + } + const excerpt = chatHistorySearchExcerpt(turn.content, query); + if (!excerpt) continue; + matches.push({ + timestamp: new Date(turn.createdAtMs).toISOString(), + role: turn.role, + excerpt, + }); + if (matches.length >= limit) break; + } + return matches; +} + +function requiredChatHistorySearchQuery(value: string): string { + const query = value.trim(); + if (!query) throw new Error("search_chat_history query must not be empty"); + if (query.length > 512) throw new Error("search_chat_history query is too long"); + return query; +} + +function parseChatHistorySearchDate(value: string | undefined, field: "start_date" | "end_date"): number | null { + if (value === undefined) return null; + const normalized = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(normalized)) { + throw new Error(`search_chat_history ${field} must be an ISO timestamp`); + } + const parsed = Date.parse(normalized); + if (!Number.isFinite(parsed)) throw new Error(`search_chat_history ${field} must be an ISO timestamp`); + return parsed; +} + +function boundedChatHistorySearchLimit(value: number | undefined): number { + if (value === undefined) return DEFAULT_CHAT_HISTORY_SEARCH_LIMIT; + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error("search_chat_history limit must be a positive integer"); + } + return Math.min(value, MAX_CHAT_HISTORY_SEARCH_LIMIT); +} + +function chatHistorySearchExcerpt(content: string, query: string): string | null { + const normalizedContent = content.trim(); + if (!normalizedContent) return null; + const lowerContent = normalizedContent.toLocaleLowerCase(); + const lowerQuery = query.toLocaleLowerCase(); + let matchIndex = lowerContent.indexOf(lowerQuery); + if (matchIndex < 0) { + const keywords = lowerQuery.split(/\s+/).filter(Boolean); + if (keywords.length === 0 || !keywords.every((keyword) => lowerContent.includes(keyword))) return null; + matchIndex = lowerContent.indexOf(keywords[0]!); + } + const start = Math.max(0, matchIndex - Math.floor(MAX_CHAT_HISTORY_SEARCH_EXCERPT / 3)); + const end = Math.min(normalizedContent.length, start + MAX_CHAT_HISTORY_SEARCH_EXCERPT); + const prefix = start > 0 ? "…" : ""; + const suffix = end < normalizedContent.length ? "…" : ""; + return `${prefix}${normalizedContent.slice(start, end).trim()}${suffix}`; +} + export function clearJournalConversation( store: AgentStore, input: { ownerId: string; conversationId: string; expectedGeneration: number; nowMs?: number }, diff --git a/desktop/macos/agent/src/runtime/kernel-core.ts b/desktop/macos/agent/src/runtime/kernel-core.ts index 8aed4f2bfa3..8cc178fcd15 100644 --- a/desktop/macos/agent/src/runtime/kernel-core.ts +++ b/desktop/macos/agent/src/runtime/kernel-core.ts @@ -17,6 +17,7 @@ import { type ResolveSurfaceSessionInput, } from "./surface-session.js"; import { + conversationIdForOwnedSurfaceSession, conversationIdForSession, } from "./conversation-turns.js"; import { @@ -28,6 +29,7 @@ import { import { repairPersistedAgentSpawnJournals } from "./agent-spawn-journal.js"; import { bindProducingJournalTurn, + searchJournalConversation, validateProducingJournalTurnAdmission, } from "./conversation-journal.js"; import type { @@ -245,6 +247,58 @@ export class KernelCore { return this.toolCapabilities.assertLiveCapability(input.capabilityRef, input.activeOwnerId); } + /** + * Parent-kernel dispatch for the chat-first local history tool. The stdio + * child only relays its request; this method requires the already-admitted + * one-use invocation before it can read the caller's journal. + */ + searchAuthorizedChatHistory(input: { + invocation: AuthorizedRunToolInvocation; + toolInput: Record; + activeOwnerId: () => string; + }) { + const { invocation } = input; + if ( + invocation.canonicalToolName !== "search_chat_history" + || invocation.surfaceKind !== "main_chat" + || invocation.chatFirstUi !== true + || !Number.isSafeInteger(invocation.chatFirstControlGeneration) + || invocation.tool.executor.kind !== "nodeTool" + ) { + throw new Error("search_chat_history requires an enabled main-Chat tool capability"); + } + const toolInput = chatHistorySearchToolInput(input.toolInput); + const lease = this.acquireRunToolExecutionLease(invocation, input.activeOwnerId); + try { + lease.assertCurrentAuthority(); + const session = this.readSession(invocation.sessionId); + this.assertSessionOwner(session, invocation.ownerId); + if (session.surfaceKind !== "main_chat") { + throw new Error("search_chat_history requires the caller's main Chat session"); + } + if (invocation.externalRefKind !== "chat" || !invocation.externalRefId) { + throw new Error("search_chat_history requires the caller's canonical Chat reference"); + } + const conversationId = conversationIdForOwnedSurfaceSession(this.store, { + ownerId: invocation.ownerId, + sessionId: session.sessionId, + surfaceKind: "main_chat", + externalRefKind: invocation.externalRefKind, + externalRefId: invocation.externalRefId, + }); + if (!conversationId) throw new Error("search_chat_history requires an exact canonical Chat conversation"); + const matches = searchJournalConversation(this.store, { + ownerId: invocation.ownerId, + conversationId, + ...toolInput, + }); + lease.assertCurrentAuthority(); + return { matches }; + } finally { + lease.release(); + } + } + markRunToolInvocationDispatched(invocation: AuthorizedRunToolInvocation): void { this.toolCapabilities.markInvocationDispatched(invocation); } @@ -2643,6 +2697,31 @@ function requiredExternalIdentity(value: string, field: string): string { return normalized; } +function chatHistorySearchToolInput(input: Record): { + query: string; + startDate?: string; + endDate?: string; + limit?: number; +} { + if (typeof input.query !== "string") { + throw new Error("search_chat_history requires a query string"); + } + const readOptionalString = (value: unknown, field: string): string | undefined => { + if (value === undefined) return undefined; + if (typeof value !== "string") throw new Error(`search_chat_history ${field} must be a string`); + return value; + }; + if (input.limit !== undefined && (typeof input.limit !== "number" || !Number.isSafeInteger(input.limit))) { + throw new Error("search_chat_history limit must be an integer"); + } + return { + query: input.query, + startDate: readOptionalString(input.start_date, "start_date"), + endDate: readOptionalString(input.end_date, "end_date"), + ...(typeof input.limit === "number" ? { limit: input.limit } : {}), + }; +} + function stableExternalSpawnPillId(invocationId: string): string { const digest = stableHash(`external-spawn:${invocationId}`).replace(/^sha256:/, "").slice(0, 32); return `${digest.slice(0, 8)}-${digest.slice(8, 12)}-${digest.slice(12, 16)}-${digest.slice(16, 20)}-${digest.slice(20)}`; diff --git a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts index e2bd4d440e6..0f79a515b27 100644 --- a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts +++ b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts @@ -1583,6 +1583,51 @@ export const chatFirstToolManifest: OmiToolManifestEntry[] = [ ], adapters: stdioOnly(), }, + { + name: "search_chat_history", + label: "Search Chat History", + description: "Search a bounded window of the current main Chat journal for an older decision.", + promptSnippet: "search_chat_history - Search the current Chat's older journaled turns", + promptGuidelines: [ + "Use only when an older Chat decision is outside the retained recent context.", + "Search terms and optional ISO date bounds are scoped to this one main Chat transcript.", + ], + latency: "fast local", + inputSchema: schema({ + query: { + type: "string", + description: "Required keyword or phrase to search in this Chat's local journal.", + }, + start_date: { + type: "string", + description: "Optional inclusive ISO timestamp lower bound.", + }, + end_date: { + type: "string", + description: "Optional inclusive ISO timestamp upper bound.", + }, + limit: { + type: "integer", + minimum: 1, + maximum: 20, + description: "Optional result count; defaults to 10 and is capped at 20.", + }, + }, ["query"]), + annotations: readOnlyLocal, + timeoutClass: "normal", + executor: { kind: "nodeTool" }, + surfaces: ["desktop_chat"], + capabilityDoc: doc("Search Chat History", "Recover a bounded older decision from the current Chat transcript.", [ + "Only available to the server-enabled chat-first main Chat cohort.", + "The parent kernel searches only the caller-owned current journal generation.", + ]), + intendedForAgents: true, + runtimePreconditions: [ + "Requires a server-authoritative chat-first capability on the current main Chat run.", + "Search is authorized by the parent kernel before it reads local journal state.", + ], + adapters: stdioOnly(), + }, ] satisfies OmiToolManifestEntry[]; export const allOmiToolManifest: OmiToolManifestEntry[] = [ diff --git a/desktop/macos/agent/tests/chat-history-search.test.ts b/desktop/macos/agent/tests/chat-history-search.test.ts new file mode 100644 index 00000000000..8c98f6c3bbc --- /dev/null +++ b/desktop/macos/agent/tests/chat-history-search.test.ts @@ -0,0 +1,193 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { recordJournalTurn } from "../src/runtime/conversation-journal.js"; +import { toolManifestEntry } from "../src/runtime/omi-tool-manifest.js"; +import { RunToolCapabilityRejectedError } from "../src/runtime/run-tool-capability.js"; +import { createKernelHarness, waitUntil } from "./kernel-fakes.js"; + +const roots: string[] = []; + +afterEach(() => { + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +describe("chat-first history search dispatch", () => { + it("requires the enabled main-Chat run capability before the parent kernel searches its journal", async () => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "history-search" }, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 9 }, + }); + recordJournalTurn(store, { + ownerId: "owner", + conversationId: resolved.conversationId, + turnId: "old-decision", + role: "assistant", + surfaceKind: "main_chat", + origin: "typed_chat", + status: "completed", + content: "We agreed to keep the launch intentionally small.", + contentBlocks: [], + createdAtMs: 1_000, + }); + // A single session can retain multiple legacy surface aliases. The + // capability's external chat reference, not whichever alias was touched + // most recently, must select the searchable transcript. + store.insertSurfaceConversation({ + ownerId: "owner", + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "other-history-alias", + conversationId: "other-history-conversation", + agentSessionId: resolved.agentSessionId, + createdAtMs: 2_000, + lastActiveAtMs: 2_000, + }); + recordJournalTurn(store, { + ownerId: "owner", + conversationId: "other-history-conversation", + turnId: "other-decision", + role: "assistant", + surfaceKind: "main_chat", + origin: "typed_chat", + status: "completed", + content: "A different alias must never replace the caller's transcript.", + contentBlocks: [], + createdAtMs: 2_001, + }); + + adapter.deferResult(); + const runPromise = kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "history-search", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "history-client", + requestId: "history-request", + prompt: "What did we decide before?", + cwd: "/tmp/history-search", + admittedContextSnapshot: kernel.contextSnapshot(resolved.agentSessionId, "owner", "main_chat"), + }); + await waitUntil(() => adapter.executed.length === 1); + const capabilityRef = adapter.executed[0]!.toolCapabilityRef; + + expectCapabilityCode(() => kernel.authorizeRelayedRunToolInvocation({ + capabilityRef, + invocationId: "history-off-manifest", + toolName: "search_chat_history", + toolInput: { query: "launch intentionally" }, + activeOwnerId: "different-owner", + }), "owner_mismatch"); + expect(Number(store.getRow("SELECT COUNT(*) AS count FROM tool_invocation_ledger").count)).toBe(0); + + const authorized = kernel.authorizeRelayedRunToolInvocation({ + capabilityRef, + invocationId: "history-authorized", + toolName: "search_chat_history", + toolInput: { query: "launch intentionally" }, + activeOwnerId: "owner", + }); + expect(authorized).toMatchObject({ + canonicalToolName: "search_chat_history", + surfaceKind: "main_chat", + chatFirstUi: true, + chatFirstControlGeneration: 9, + }); + + kernel.markRunToolInvocationDispatched(authorized); + const result = kernel.searchAuthorizedChatHistory({ + invocation: authorized, + toolInput: { query: "launch intentionally" }, + activeOwnerId: () => "owner", + }); + expect(result).toEqual({ + matches: [{ + timestamp: new Date(1_000).toISOString(), + role: "assistant", + excerpt: "We agreed to keep the launch intentionally small.", + }], + }); + kernel.completeRunToolInvocation({ + invocationId: authorized.invocationId, + ownerId: authorized.ownerId, + sessionId: authorized.sessionId, + runId: authorized.runId, + attemptId: authorized.attemptId, + profileGeneration: authorized.profileGeneration, + manifestVersion: authorized.manifestVersion, + manifestDigest: authorized.manifestDigest, + daemonBootEpoch: authorized.daemonBootEpoch, + executionGeneration: authorized.executionGeneration, + inputHash: authorized.inputHash, + capabilityRef: authorized.capabilityRef, + activeOwnerId: "owner", + outcome: "succeeded", + result: JSON.stringify(result), + }); + adapter.resolveDeferred(); + await runPromise; + store.close(); + }); + + it("cannot authorize history search for the legacy capability-off manifest", async () => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "legacy-history" }, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: false, controlGeneration: 9 }, + }); + adapter.deferResult(); + const runPromise = kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "legacy-history", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "legacy-history-client", + requestId: "legacy-history-request", + prompt: "Can you look up an older choice?", + cwd: "/tmp/legacy-history-search", + admittedContextSnapshot: kernel.contextSnapshot(resolved.agentSessionId, "owner", "main_chat"), + }); + await waitUntil(() => adapter.executed.length === 1); + expectCapabilityCode(() => kernel.authorizeRelayedRunToolInvocation({ + capabilityRef: adapter.executed[0]!.toolCapabilityRef, + invocationId: "legacy-history-attempt", + toolName: "search_chat_history", + toolInput: { query: "older choice" }, + activeOwnerId: "owner", + }), "tool_not_allowed"); + expect(toolManifestEntry("search_chat_history")?.executor.kind).toBe("nodeTool"); + adapter.resolveDeferred(); + await runPromise; + store.close(); + }); +}); + +function expectCapabilityCode(work: () => unknown, code: string): void { + try { + work(); + throw new Error("Expected a run-tool capability rejection"); + } catch (error) { + expect(error).toBeInstanceOf(RunToolCapabilityRejectedError); + expect((error as RunToolCapabilityRejectedError).code).toBe(code); + } +} + +function newDatabasePath(): string { + const root = mkdtempSync(join(tmpdir(), "omi-chat-history-search-")); + roots.push(root); + return join(root, "agent.sqlite"); +} diff --git a/desktop/macos/agent/tests/conversation-journal.test.ts b/desktop/macos/agent/tests/conversation-journal.test.ts index 476707cd828..0c9daa331b4 100644 --- a/desktop/macos/agent/tests/conversation-journal.test.ts +++ b/desktop/macos/agent/tests/conversation-journal.test.ts @@ -25,6 +25,7 @@ import { migrateJournalConversation, recordJournalExchange, recordJournalTurn, + searchJournalConversation, settleClearedBackendTurnClaim, assertPublicJournalUpdatePolicy, terminalizeJournalTurn, @@ -150,6 +151,106 @@ describe("kernel conversation journal", () => { } }); + it("searches the current journal generation with date bounds, excerpts, and a capped result count", () => { + const fixture = newSurface("main_chat", "chat", "history-search"); + recordCompletedTextTurn(fixture, "search-old", "Decision: keep the ambient notes private.", 1_000); + recordCompletedTextTurn(fixture, "search-outside-range", "Decision: keep the ambient notes private.", 2_000); + for (let index = 0; index < 24; index += 1) { + recordCompletedTextTurn(fixture, `search-limit-${index}`, `Decision ${index}: keep the ambient notes private.`, 3_000 + index); + } + + const dateFiltered = searchJournalConversation(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + query: "ambient notes", + startDate: new Date(1_000).toISOString(), + endDate: new Date(1_000).toISOString(), + }); + expect(dateFiltered).toEqual([{ + timestamp: new Date(1_000).toISOString(), + role: "assistant", + excerpt: "Decision: keep the ambient notes private.", + }]); + + const capped = searchJournalConversation(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + query: "ambient notes", + limit: 999, + }); + expect(capped).toHaveLength(20); + expect(capped[0]?.timestamp).toBe(new Date(3_023).toISOString()); + expect(capped.every((match) => match.excerpt.length <= 322)).toBe(true); + expect(searchJournalConversation(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + query: "no matching journal text", + })).toEqual([]); + fixture.store.close(); + }); + + it("keeps search owner-fenced and excludes a cleared journal generation", () => { + const fixture = newSurface("main_chat", "chat", "history-generation"); + recordCompletedTextTurn(fixture, "search-cleared", "A private historic decision.", 1_000); + const other = insertSurface(fixture.store, "main_chat", "chat", "history-other", "other-owner"); + recordJournalTurn(fixture.store, { + ownerId: other.ownerId, + conversationId: other.conversationId, + turnId: "search-other-owner", + role: "assistant", + surfaceKind: "main_chat", + origin: "typed_chat", + status: "completed", + content: "A private historic decision.", + contentBlocks: [], + createdAtMs: 1_001, + }); + + expect(() => searchJournalConversation(fixture.store, { + ownerId: fixture.ownerId, + conversationId: other.conversationId, + query: "private historic", + })).toThrow(/outside owner scope/i); + const cleared = clearJournalConversation(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + expectedGeneration: 1, + nowMs: 2_000, + }); + recordCompletedTextTurn(fixture, "search-current", "A current private decision.", 2_001); + expect(searchJournalConversation(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + query: "historic decision", + })).toEqual([]); + expect(searchJournalConversation(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + query: "current private", + })).toEqual([{ + timestamp: new Date(2_001).toISOString(), + role: "assistant", + excerpt: "A current private decision.", + }]); + expect(cleared.generation).toBe(2); + fixture.store.close(); + }); + + it("never scans past the newest 500 current-generation journal turns", () => { + const fixture = newSurface("main_chat", "chat", "history-bounded"); + recordCompletedTextTurn(fixture, "search-outside-window", "needle only in the oldest turn", 1_000); + for (let index = 0; index < 500; index += 1) { + recordCompletedTextTurn(fixture, `search-window-${index}`, `Recent non-match ${index}`, 2_000 + index); + } + + expect(searchJournalConversation(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + query: "needle only", + })).toEqual([]); + fixture.store.close(); + }); + it("projects shared chat revisions through the requesting binding with owner-fenced wakes", () => { const fixture = newSurface("main_chat", "chat", "default"); const realtimeSession = fixture.store.insertSession({ diff --git a/desktop/macos/agent/tests/omi-tool-manifest.test.ts b/desktop/macos/agent/tests/omi-tool-manifest.test.ts index 1b5f97e5f63..b411859ec19 100644 --- a/desktop/macos/agent/tests/omi-tool-manifest.test.ts +++ b/desktop/macos/agent/tests/omi-tool-manifest.test.ts @@ -169,9 +169,10 @@ describe("omi tool manifest", () => { expect(capabilityOffBytes).toBe(legacyBytes); expect(nonMainBytes).toBe(legacyBytes); expect(capabilityOffBytes).not.toContain("render_chat_blocks"); + expect(capabilityOffBytes).not.toContain("search_chat_history"); }); - it("exposes render_chat_blocks only to the authorized main-chat stdio projection", () => { + it("exposes chat-first tools only to the authorized main-chat stdio projection", () => { const enabled = mcpToolDefinitionsForAdapter("omi-tools-stdio", { surfaceKind: "main_chat", chatFirstUi: true, controlGeneration: 7, }); @@ -180,7 +181,9 @@ describe("omi tool manifest", () => { }); expect(enabled.map((tool) => tool.name)).toContain("render_chat_blocks"); + expect(enabled.map((tool) => tool.name)).toContain("search_chat_history"); expect(snapshot.advertisedToolNames).toContain("render_chat_blocks"); + expect(snapshot.advertisedToolNames).toContain("search_chat_history"); expect(snapshot.manifestDigest).not.toBe(buildToolAvailabilitySnapshot("omi-tools-stdio").manifestDigest); expect(toolNamesForAdapter("pi-mono", { surfaceKind: "main_chat", chatFirstUi: true, controlGeneration: 7, From c739b1675c23689ac450b6aa4c058913bf517d31 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 04:39:10 -0400 Subject: [PATCH 05/28] feat(chat): add cohort-gated proactive intent foundation Adds fail-closed server-owned proactive intent, deferral, budget, receipt, and materialization-fetch contracts. It reserves budget before any judge call, emits no feature work for capability-off users, and never writes a chat transcript.\n\nVerification:\n- 35 focused T04 backend/API/generator tests (plus 11 generator tests)\n- app-client OpenAPI export, TS/Swift generation, and compatibility checks\n- Black and git diff checks\n- fresh Sol review-and-fix gate\n\nDeferred: source-owner wake hooks, real grounded judge binding, kernel materialization, and UI rendering. --- backend/database/chat_first_intents.py | 543 ++++++++++++++ backend/models/chat_first.py | 116 ++- backend/models/proactive_budget.py | 102 +++ backend/routers/chat_first.py | 190 ++++- backend/scripts/export_openapi.py | 1 + .../unit/test_chat_first_proactive_engine.py | 203 ++++++ .../unit/test_chat_first_proactive_intents.py | 415 +++++++++++ .../unit/test_chat_first_proactive_router.py | 169 +++++ backend/utils/metrics.py | 6 + .../chat_first_eligibility.py | 46 ++ .../task_intelligence/proactive_engine.py | 284 ++++++++ .../Sources/Generated/OmiApi.generated.swift | 54 +- .../src/renderer/src/lib/omiApi.generated.ts | 159 +++- docs/api-reference/app-client-openapi.json | 678 +++++++++++++++++- .../lib/services/omi-api/omiApi.generated.ts | 159 +++- web/app/src/lib/omiApi.generated.ts | 159 +++- .../src/lib/omiApi.generated.ts | 159 +++- 17 files changed, 3414 insertions(+), 29 deletions(-) create mode 100644 backend/database/chat_first_intents.py create mode 100644 backend/models/proactive_budget.py create mode 100644 backend/tests/unit/test_chat_first_proactive_engine.py create mode 100644 backend/tests/unit/test_chat_first_proactive_intents.py create mode 100644 backend/tests/unit/test_chat_first_proactive_router.py create mode 100644 backend/utils/task_intelligence/chat_first_eligibility.py create mode 100644 backend/utils/task_intelligence/proactive_engine.py diff --git a/backend/database/chat_first_intents.py b/backend/database/chat_first_intents.py new file mode 100644 index 00000000000..2858ba035d5 --- /dev/null +++ b/backend/database/chat_first_intents.py @@ -0,0 +1,543 @@ +"""Durable Chat-first proactive intent state, separate from the chat journal.""" + +import hashlib +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Iterable, cast + +from google.cloud import firestore + +from database._client import get_firestore_client +from models.chat_first import ( + ChatFirstBlockSpec, + ChatFirstSubject, + DeferralReceipt, + ProactiveBudgetState, + ProactiveDeferral, + ProactiveIntent, + ProactiveIntentSource, + QuestionCardSpec, +) +from models.proactive_budget import account_materialization, budget_allows, normalized_budget_state, reserve_budget +from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode + +INTENTS_COLLECTION = 'chat_first_proactive_intents' +DEFERRALS_COLLECTION = 'chat_first_deferrals' +STATE_COLLECTION = 'chat_first_proactive_state' +BUDGET_DOCUMENT = 'budget' +_DEFERRAL_DUE_AFTER = timedelta(hours=24) + + +class ChatFirstIntentStoreError(RuntimeError): + """Base class for closed intent-store failures.""" + + +class ChatFirstIntentGenerationMismatch(ChatFirstIntentStoreError): + pass + + +class ChatFirstIntentConflictError(ChatFirstIntentStoreError): + pass + + +class ProactiveBudgetExhausted(ChatFirstIntentStoreError): + pass + + +class ProactiveIntentNotReady(ChatFirstIntentStoreError): + pass + + +@dataclass(frozen=True) +class AgentJudgmentAdmission: + """The one durable admission result that may precede a judge call. + + A newly acquired reservation is the cost gate for a single judge call. An + already-pending reservation deliberately is *not* another admission: two + concurrent post-commit wakes for the same continuity key must not spend two + model calls while racing to create one intent. + """ + + existing_intent: ProactiveIntent | None + newly_reserved: bool + + +def _db(firestore_client: Any = None) -> Any: + return firestore_client or get_firestore_client() + + +def _user_ref(uid: str, *, firestore_client: Any = None): + return _db(firestore_client).collection('users').document(uid) + + +def _control_ref(uid: str, *, firestore_client: Any = None): + return _user_ref(uid, firestore_client=firestore_client).collection('task_intelligence_control').document('state') + + +def _intent_ref(uid: str, intent_id: str, *, firestore_client: Any = None): + return _user_ref(uid, firestore_client=firestore_client).collection(INTENTS_COLLECTION).document(intent_id) + + +def _deferral_ref(uid: str, deferral_id: str, *, firestore_client: Any = None): + return _user_ref(uid, firestore_client=firestore_client).collection(DEFERRALS_COLLECTION).document(deferral_id) + + +def _budget_ref(uid: str, *, firestore_client: Any = None): + return _user_ref(uid, firestore_client=firestore_client).collection(STATE_COLLECTION).document(BUDGET_DOCUMENT) + + +def _stable_id(prefix: str, *parts: object) -> str: + raw = '\x1f'.join(str(part) for part in parts).encode('utf-8') + return f'{prefix}_{hashlib.sha256(raw).hexdigest()[:32]}' + + +def _snapshot_dict(snapshot: Any) -> dict[str, Any]: + payload = snapshot.to_dict() + return cast(dict[str, Any], payload) if isinstance(payload, dict) else {} + + +def _require_control(snapshot: Any, account_generation: int) -> None: + control = TaskWorkflowControl() + if snapshot.exists: + control = TaskWorkflowControl.model_validate(_snapshot_dict(snapshot)) + if ( + control.workflow_mode != TaskWorkflowMode.read + or control.account_generation != account_generation + or not control.chat_first_ui_enabled + ): + raise ChatFirstIntentGenerationMismatch('chat-first capability changed') + + +def _budget_from_snapshot(snapshot: Any, *, account_generation: int, now: datetime) -> ProactiveBudgetState: + if not snapshot.exists: + return ProactiveBudgetState(account_generation=account_generation) + state = ProactiveBudgetState.model_validate(_snapshot_dict(snapshot)) + if state.account_generation != account_generation: + return ProactiveBudgetState(account_generation=account_generation) + return normalized_budget_state(state, now=now) + + +def _require_current_control(uid: str, *, account_generation: int, firestore_client: Any) -> None: + """Fence read-only entry points before they inspect feature-specific rows.""" + + _require_control(_control_ref(uid, firestore_client=firestore_client).get(), account_generation) + + +def _intent_payload(intent: ProactiveIntent) -> dict[str, Any]: + return intent.model_dump(mode='python') + + +def get_budget_state( + uid: str, + *, + account_generation: int, + now: datetime, + firestore_client: Any = None, +) -> ProactiveBudgetState: + """Read bounded accounting only after the caller passed cohort eligibility.""" + + client = _db(firestore_client) + _require_current_control(uid, account_generation=account_generation, firestore_client=client) + snapshot = _budget_ref(uid, firestore_client=client).get() + return _budget_from_snapshot(snapshot, account_generation=account_generation, now=now) + + +def admit_agent_judgment( + uid: str, + *, + continuity_key: str, + subject: ChatFirstSubject, + account_generation: int, + now: datetime, + firestore_client: Any = None, +) -> AgentJudgmentAdmission: + """Atomically reserve one agent-tier evaluation before any provider call. + + This is intentionally separate from ``create_intent``. It makes the + budget a genuine model-cost gate under concurrent wakes while allowing a + declined or failed judgment to release its reservation without consuming a + materialized turn. + """ + + client = _db(firestore_client) + intent_id = _stable_id('cfi', uid, account_generation, 'agent_judgment', continuity_key) + intent_ref = _intent_ref(uid, intent_id, firestore_client=client) + budget_ref = _budget_ref(uid, firestore_client=client) + transaction = client.transaction() + + @firestore.transactional + def apply(write_transaction: Any) -> AgentJudgmentAdmission: + control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) + _require_control(control_snapshot, account_generation) + existing_snapshot = intent_ref.get(transaction=write_transaction) + if existing_snapshot.exists: + existing = ProactiveIntent.model_validate(_snapshot_dict(existing_snapshot)) + if ( + existing.account_generation != account_generation + or existing.source != 'agent_judgment' + or existing.continuity_key != continuity_key + or existing.subject != subject + ): + raise ChatFirstIntentConflictError('agent judgment continuity key was reused') + return AgentJudgmentAdmission(existing_intent=existing, newly_reserved=False) + + budget_snapshot = budget_ref.get(transaction=write_transaction) + budget = _budget_from_snapshot(budget_snapshot, account_generation=account_generation, now=now) + if any(reservation.intent_id == intent_id for reservation in budget.reservations): + return AgentJudgmentAdmission(existing_intent=None, newly_reserved=False) + try: + reserved = reserve_budget(budget, intent_id=intent_id, now=now) + except ValueError as exc: + raise ProactiveBudgetExhausted('proactive turn budget exhausted') from exc + write_transaction.set(budget_ref, reserved.model_dump(mode='python')) + return AgentJudgmentAdmission(existing_intent=None, newly_reserved=True) + + return apply(transaction) + + +def release_agent_judgment_admission( + uid: str, + *, + continuity_key: str, + account_generation: int, + now: datetime, + firestore_client: Any = None, +) -> None: + """Release an unused pre-judge reservation without touching an intent. + + An existing intent owns its reservation until the local kernel receipt. A + retry after a provider failure or empty selection therefore remains safe + and idempotent. + """ + + client = _db(firestore_client) + intent_id = _stable_id('cfi', uid, account_generation, 'agent_judgment', continuity_key) + intent_ref = _intent_ref(uid, intent_id, firestore_client=client) + budget_ref = _budget_ref(uid, firestore_client=client) + transaction = client.transaction() + + @firestore.transactional + def apply(write_transaction: Any) -> None: + control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) + _require_control(control_snapshot, account_generation) + intent_snapshot = intent_ref.get(transaction=write_transaction) + budget_snapshot = budget_ref.get(transaction=write_transaction) + if intent_snapshot.exists: + return + budget = _budget_from_snapshot(budget_snapshot, account_generation=account_generation, now=now) + reservations = [reservation for reservation in budget.reservations if reservation.intent_id != intent_id] + if len(reservations) == len(budget.reservations): + return + write_transaction.set( + budget_ref, budget.model_copy(update={'reservations': reservations}).model_dump(mode='python') + ) + + apply(transaction) + + +def create_intent( + uid: str, + *, + source: ProactiveIntentSource, + continuity_key: str, + subject: ChatFirstSubject | None, + blocks: list[ChatFirstBlockSpec], + account_generation: int, + now: datetime, + firestore_client: Any = None, +) -> tuple[ProactiveIntent, bool]: + """Idempotently persist an intent and atomically reserve agent-turn budget.""" + + client = _db(firestore_client) + intent_id = _stable_id('cfi', uid, account_generation, source, continuity_key) + intent = ProactiveIntent( + intent_id=intent_id, + continuity_key=continuity_key, + account_generation=account_generation, + source=source, + subject=subject, + blocks=blocks, + created_at=now, + ) + intent_ref = _intent_ref(uid, intent_id, firestore_client=client) + budget_ref = _budget_ref(uid, firestore_client=client) + transaction = client.transaction() + + @firestore.transactional + def apply(write_transaction: Any) -> tuple[ProactiveIntent, bool]: + control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) + _require_control(control_snapshot, account_generation) + existing_snapshot = intent_ref.get(transaction=write_transaction) + budget_snapshot = ( + budget_ref.get(transaction=write_transaction) + if intent.consumes_turn_budget and not existing_snapshot.exists + else None + ) + if existing_snapshot.exists: + existing = ProactiveIntent.model_validate(_snapshot_dict(existing_snapshot)) + if ( + existing.account_generation != account_generation + or existing.source != source + or existing.continuity_key != continuity_key + or existing.subject != subject + or existing.blocks != blocks + ): + raise ChatFirstIntentConflictError('intent continuity key was reused with different content') + return existing, False + + reserved: ProactiveBudgetState | None = None + if intent.consumes_turn_budget: + assert budget_snapshot is not None + budget = _budget_from_snapshot(budget_snapshot, account_generation=account_generation, now=now) + try: + reserved = reserve_budget(budget, intent_id=intent_id, now=now) + except ValueError as exc: + raise ProactiveBudgetExhausted('proactive turn budget exhausted') from exc + write_transaction.set(intent_ref, _intent_payload(intent)) + if reserved is not None: + write_transaction.set(budget_ref, reserved.model_dump(mode='python')) + return intent, True + + return apply(transaction) + + +def fetch_ready_intents( + uid: str, + *, + account_generation: int, + limit: int = 8, + firestore_client: Any = None, +) -> list[ProactiveIntent]: + """Return ready intents only; this never changes delivery or writes Chat.""" + + client = _db(firestore_client) + _require_current_control(uid, account_generation=account_generation, firestore_client=client) + collection = _user_ref(uid, firestore_client=client).collection(INTENTS_COLLECTION) + ready: list[ProactiveIntent] = [] + for snapshot in collection.stream(): + payload = _snapshot_dict(snapshot) + if payload.get('account_generation') != account_generation or payload.get('delivery_state') != 'ready': + continue + ready.append(ProactiveIntent.model_validate(payload)) + ready.sort(key=lambda intent: (intent.created_at, intent.intent_id)) + return ready[:limit] + + +def acknowledge_materialization( + uid: str, + *, + intent_id: str, + receipt_id: str, + account_generation: int, + now: datetime, + firestore_client: Any = None, +) -> ProactiveIntent: + """Accept a local-kernel receipt and atomically account for an agent turn.""" + + client = _db(firestore_client) + intent_ref = _intent_ref(uid, intent_id, firestore_client=client) + budget_ref = _budget_ref(uid, firestore_client=client) + transaction = client.transaction() + + @firestore.transactional + def apply(write_transaction: Any) -> ProactiveIntent: + control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) + _require_control(control_snapshot, account_generation) + intent_snapshot = intent_ref.get(transaction=write_transaction) + if not intent_snapshot.exists: + raise ProactiveIntentNotReady('proactive intent is not ready') + intent = ProactiveIntent.model_validate(_snapshot_dict(intent_snapshot)) + budget_snapshot = budget_ref.get(transaction=write_transaction) if intent.consumes_turn_budget else None + if intent.account_generation != account_generation: + raise ChatFirstIntentGenerationMismatch('intent account generation changed') + if intent.delivery_state == 'delivered': + if intent.materialization_receipt_id != receipt_id: + raise ChatFirstIntentConflictError('intent was already acknowledged by a different receipt') + return intent + if intent.delivery_state != 'ready': + raise ProactiveIntentNotReady('proactive intent is not ready') + + delivered = intent.model_copy( + update={ + 'delivery_state': 'delivered', + 'delivered_at': now, + 'materialization_receipt_id': receipt_id, + } + ) + if intent.consumes_turn_budget: + assert budget_snapshot is not None + budget = _budget_from_snapshot(budget_snapshot, account_generation=account_generation, now=now) + accounted = account_materialization(budget, intent_id=intent_id, now=now) + write_transaction.set(budget_ref, accounted.model_dump(mode='python')) + write_transaction.set(intent_ref, _intent_payload(delivered)) + return delivered + + return apply(transaction) + + +def record_deferral( + uid: str, + *, + continuity_key: str, + subject: ChatFirstSubject, + question: QuestionCardSpec, + account_generation: int, + now: datetime, + firestore_client: Any = None, +) -> tuple[DeferralReceipt, bool]: + """Accept the kernel's idempotent deferral outbox record.""" + + client = _db(firestore_client) + deferral_id = _stable_id('cfd', uid, account_generation, continuity_key) + deferral = ProactiveDeferral( + deferral_id=deferral_id, + continuity_key=continuity_key, + account_generation=account_generation, + subject=subject, + question=question, + created_at=now, + due_at=now + _DEFERRAL_DUE_AFTER, + ) + ref = _deferral_ref(uid, deferral_id, firestore_client=client) + transaction = client.transaction() + + @firestore.transactional + def apply(write_transaction: Any) -> tuple[DeferralReceipt, bool]: + control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) + _require_control(control_snapshot, account_generation) + existing_snapshot = ref.get(transaction=write_transaction) + if existing_snapshot.exists: + existing = ProactiveDeferral.model_validate(_snapshot_dict(existing_snapshot)) + if ( + existing.account_generation != account_generation + or existing.continuity_key != continuity_key + or existing.subject != subject + or existing.question != question + ): + raise ChatFirstIntentConflictError('deferral continuity key was reused with different content') + return ( + DeferralReceipt(deferral_id=existing.deferral_id, due_at=existing.due_at, state=existing.state), + False, + ) + write_transaction.set(ref, deferral.model_dump(mode='python')) + return DeferralReceipt(deferral_id=deferral_id, due_at=deferral.due_at, state='pending'), True + + return apply(transaction) + + +def release_due_deferrals( + uid: str, + *, + account_generation: int, + now: datetime, + subject: ChatFirstSubject | None = None, + firestore_client: Any = None, +) -> list[ProactiveIntent]: + """Release due or meaningful-subject-change deferrals exactly once, verbatim.""" + + client = _db(firestore_client) + _require_current_control(uid, account_generation=account_generation, firestore_client=client) + collection = _user_ref(uid, firestore_client=client).collection(DEFERRALS_COLLECTION) + candidates: list[ProactiveDeferral] = [] + for snapshot in collection.stream(): + payload = _snapshot_dict(snapshot) + if payload.get('account_generation') != account_generation or payload.get('state') != 'pending': + continue + deferred = ProactiveDeferral.model_validate(payload) + if subject is not None: + if deferred.subject != subject: + continue + elif deferred.due_at > now: + continue + candidates.append(deferred) + + released: list[ProactiveIntent] = [] + for deferred in candidates[:32]: + intent = _release_deferral_transaction( + uid, + deferred, + account_generation=account_generation, + now=now, + firestore_client=client, + ) + if intent is not None: + released.append(intent) + return released + + +def _release_deferral_transaction( + uid: str, + deferred: ProactiveDeferral, + *, + account_generation: int, + now: datetime, + firestore_client: Any, +) -> ProactiveIntent | None: + intent_id = _stable_id('cfi', uid, account_generation, 'deferral_reraise', deferred.continuity_key) + intent = ProactiveIntent( + intent_id=intent_id, + continuity_key=deferred.continuity_key, + account_generation=account_generation, + source='deferral_reraise', + subject=deferred.subject, + blocks=[deferred.question], + created_at=now, + ) + deferral_ref = _deferral_ref(uid, deferred.deferral_id, firestore_client=firestore_client) + intent_ref = _intent_ref(uid, intent_id, firestore_client=firestore_client) + transaction = firestore_client.transaction() + + @firestore.transactional + def apply(write_transaction: Any) -> ProactiveIntent | None: + control_snapshot = _control_ref(uid, firestore_client=firestore_client).get(transaction=write_transaction) + _require_control(control_snapshot, account_generation) + deferral_snapshot = deferral_ref.get(transaction=write_transaction) + intent_snapshot = intent_ref.get(transaction=write_transaction) + if not deferral_snapshot.exists: + return None + current = ProactiveDeferral.model_validate(_snapshot_dict(deferral_snapshot)) + if current.account_generation != account_generation or current.state != 'pending': + return None + if intent_snapshot.exists: + existing = ProactiveIntent.model_validate(_snapshot_dict(intent_snapshot)) + if existing.source != 'deferral_reraise' or existing.continuity_key != current.continuity_key: + raise ChatFirstIntentConflictError('deferral intent collision') + released = current.model_copy(update={'state': 'released', 'released_intent_id': existing.intent_id}) + write_transaction.set(deferral_ref, released.model_dump(mode='python')) + return existing + released = current.model_copy(update={'state': 'released', 'released_intent_id': intent_id}) + write_transaction.set(intent_ref, _intent_payload(intent)) + write_transaction.set(deferral_ref, released.model_dump(mode='python')) + return intent + + return apply(transaction) + + +def iter_ready_intent_ids( + intents: Iterable[ProactiveIntent], +) -> list[str]: + """Small content-free helper for shape-only call-site accounting.""" + + return [intent.intent_id for intent in intents] + + +__all__ = [ + 'AgentJudgmentAdmission', + 'BUDGET_DOCUMENT', + 'ChatFirstIntentConflictError', + 'ChatFirstIntentGenerationMismatch', + 'ChatFirstIntentStoreError', + 'DEFERRALS_COLLECTION', + 'INTENTS_COLLECTION', + 'ProactiveBudgetExhausted', + 'ProactiveIntentNotReady', + 'acknowledge_materialization', + 'admit_agent_judgment', + 'create_intent', + 'fetch_ready_intents', + 'get_budget_state', + 'iter_ready_intent_ids', + 'release_agent_judgment_admission', + 'record_deferral', + 'release_due_deferrals', +] diff --git a/backend/models/chat_first.py b/backend/models/chat_first.py index f56f71c02bc..11bd4f7fdb4 100644 --- a/backend/models/chat_first.py +++ b/backend/models/chat_first.py @@ -1,5 +1,6 @@ -"""Strict, content-free contracts for chat-first structured-block admission.""" +"""Strict contracts for chat-first structured-block and proactive-intent admission.""" +from datetime import datetime from hashlib import sha256 from typing import Annotated, Literal, Union @@ -86,6 +87,110 @@ class ChatFirstBlockValidationReceipt(_StrictModel): blocks: list[dict[str, object]] = Field(default_factory=list) +ProactiveIntentSource = Literal['daily_opener', 'capture_arrival', 'deferral_reraise', 'agent_judgment'] +ProactiveIntentDeliveryState = Literal['ready', 'delivered'] + + +class ProactiveIntent(_StrictModel): + """A server-side instruction, not a Chat transcript row. + + The local desktop kernel is the sole writer of the visible assistant turn. + This record remains ready until that kernel has committed and acknowledged + its stable ``intent_id``. + """ + + intent_id: StableId + continuity_key: StableId + account_generation: int = Field(ge=0) + source: ProactiveIntentSource + subject: ChatFirstSubject | None = None + blocks: list[ChatFirstBlockSpec] = Field(min_length=1, max_length=8) + delivery_state: ProactiveIntentDeliveryState = 'ready' + created_at: datetime + delivered_at: datetime | None = None + materialization_receipt_id: StableId | None = None + + @property + def consumes_turn_budget(self) -> bool: + return self.source == 'agent_judgment' + + +class ProactiveBudgetReservation(_StrictModel): + intent_id: StableId + expires_at: datetime + + +class ProactiveBudgetState(_StrictModel): + """Private server accounting for proactive agent turns only.""" + + account_generation: int = Field(ge=0) + materialized_at: list[datetime] = Field(default_factory=list, max_length=64) + reservations: list[ProactiveBudgetReservation] = Field(default_factory=list, max_length=16) + + +class ProactiveMaterializationReceipt(_StrictModel): + """Content-free receipt emitted only after the local journal commits.""" + + intent_id: StableId + receipt_id: StableId + + +class MaterializePromptsRequest(_StrictModel): + source_surface: Literal['main_chat'] + control_generation: int = Field(ge=0) + owner_fence: StableId + window_foreground: bool = False + receipts: list[ProactiveMaterializationReceipt] = Field(default_factory=list, max_length=16) + + @model_validator(mode='after') + def validate_unique_receipts(self): + intent_ids = [receipt.intent_id for receipt in self.receipts] + if len(intent_ids) != len(set(intent_ids)): + raise ValueError('materialization receipt intent IDs must be unique') + return self + + +class MaterializePromptsResponse(_StrictModel): + intents: list[ProactiveIntent] = Field(default_factory=list) + + +class DeferralCreateRequest(_StrictModel): + """The idempotent server receiver for the kernel-owned deferral outbox.""" + + source_surface: Literal['main_chat'] + control_generation: int = Field(ge=0) + owner_fence: StableId + continuity_key: StableId + subject: ChatFirstSubject + question: QuestionCardSpec + + @model_validator(mode='after') + def require_question_subject_match(self): + if self.question.subject != self.subject: + raise ValueError('deferral question subject must match the deferred subject') + return self + + +class ProactiveDeferral(_StrictModel): + """Durable, server-side record delivered by the kernel's deferral outbox.""" + + deferral_id: StableId + continuity_key: StableId + account_generation: int = Field(ge=0) + subject: ChatFirstSubject + question: QuestionCardSpec + created_at: datetime + due_at: datetime + state: Literal['pending', 'released'] = 'pending' + released_intent_id: StableId | None = None + + +class DeferralReceipt(_StrictModel): + deferral_id: StableId + due_at: datetime + state: Literal['pending', 'released'] + + def stable_block_id(*, uid: str, generation: int, block: ChatFirstBlockSpec) -> str: """Generate an opaque, retry-stable block ID without exposing block text.""" @@ -100,7 +205,16 @@ def stable_block_id(*, uid: str, generation: int, block: ChatFirstBlockSpec) -> 'ChatFirstBlockValidationReceipt', 'ChatFirstBlockValidationRequest', 'ChatFirstSubject', + 'DeferralCreateRequest', + 'DeferralReceipt', 'GoalLinkSpec', + 'MaterializePromptsRequest', + 'MaterializePromptsResponse', + 'ProactiveBudgetReservation', + 'ProactiveBudgetState', + 'ProactiveDeferral', + 'ProactiveIntent', + 'ProactiveMaterializationReceipt', 'QuestionCardSpec', 'QuestionOption', 'TaskCardSpec', diff --git a/backend/models/proactive_budget.py b/backend/models/proactive_budget.py new file mode 100644 index 00000000000..afd8dc12398 --- /dev/null +++ b/backend/models/proactive_budget.py @@ -0,0 +1,102 @@ +"""Pure budget arithmetic for Chat-first agent-initiated turns.""" + +from datetime import datetime, timedelta, timezone + +from models.chat_first import ProactiveBudgetReservation, ProactiveBudgetState + +DAILY_PROACTIVE_TURN_LIMIT = 10 +ROLLING_30_MINUTE_PROACTIVE_TURN_LIMIT = 2 +_RESERVATION_TTL = timedelta(days=1) +_RECENT_WINDOW = timedelta(minutes=30) + + +def normalized_budget_state(state: ProactiveBudgetState, *, now: datetime) -> ProactiveBudgetState: + """Drop expired reservations and stale accounting outside the bounded horizon.""" + + now_utc = _as_utc(now) + oldest_materialization = now_utc - timedelta(days=2) + return ProactiveBudgetState( + account_generation=state.account_generation, + materialized_at=[ + materialized_at + for materialized_at in state.materialized_at + if _as_utc(materialized_at) >= oldest_materialization + ], + reservations=[reservation for reservation in state.reservations if _as_utc(reservation.expires_at) > now_utc], + ) + + +def budget_allows(state: ProactiveBudgetState, *, now: datetime) -> bool: + """Return whether one additional agent judgment may be evaluated. + + Outstanding reservations count against both limits. This prevents a burst of + judged-but-not-yet-materialized intents from bypassing the mechanical cost + gate, while receipts remain the only moment that records a consumed turn. + """ + + normalized = normalized_budget_state(state, now=now) + now_utc = _as_utc(now) + daily_turns = sum(1 for value in normalized.materialized_at if _as_utc(value).date() == now_utc.date()) + recent_turns = sum(1 for value in normalized.materialized_at if _as_utc(value) > now_utc - _RECENT_WINDOW) + reserved_turns = len(normalized.reservations) + return ( + daily_turns + reserved_turns < DAILY_PROACTIVE_TURN_LIMIT + and recent_turns + reserved_turns < ROLLING_30_MINUTE_PROACTIVE_TURN_LIMIT + ) + + +def reserve_budget( + state: ProactiveBudgetState, + *, + intent_id: str, + now: datetime, +) -> ProactiveBudgetState: + """Reserve one budget slot for an agent-tier intent in the creating transaction.""" + + normalized = normalized_budget_state(state, now=now) + if any(reservation.intent_id == intent_id for reservation in normalized.reservations): + return normalized + if not budget_allows(normalized, now=now): + raise ValueError('proactive turn budget exhausted') + return normalized.model_copy( + update={ + 'reservations': [ + *normalized.reservations, + ProactiveBudgetReservation(intent_id=intent_id, expires_at=_as_utc(now) + _RESERVATION_TTL), + ] + } + ) + + +def account_materialization( + state: ProactiveBudgetState, + *, + intent_id: str, + now: datetime, +) -> ProactiveBudgetState: + """Convert a reservation into a consumed turn after a kernel receipt.""" + + normalized = normalized_budget_state(state, now=now) + if any(reservation.intent_id == intent_id for reservation in normalized.reservations): + reservations = [reservation for reservation in normalized.reservations if reservation.intent_id != intent_id] + else: + # A receipt may arrive after a bounded reservation expires. It still + # represents a real materialized turn and must be counted once. + reservations = normalized.reservations + return normalized.model_copy( + update={'reservations': reservations, 'materialized_at': [*normalized.materialized_at, now]} + ) + + +def _as_utc(value: datetime) -> datetime: + return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + + +__all__ = [ + 'DAILY_PROACTIVE_TURN_LIMIT', + 'ROLLING_30_MINUTE_PROACTIVE_TURN_LIMIT', + 'account_materialization', + 'budget_allows', + 'normalized_budget_state', + 'reserve_budget', +] diff --git a/backend/routers/chat_first.py b/backend/routers/chat_first.py index 40a349d41b7..c97f10e3a1e 100644 --- a/backend/routers/chat_first.py +++ b/backend/routers/chat_first.py @@ -6,10 +6,14 @@ from typing import Annotated, Any -from fastapi import APIRouter, Body, Depends +from datetime import datetime, timezone +import logging + +from fastapi import APIRouter, Body, Depends, HTTPException, status from pydantic import ValidationError import database.action_items as action_items_db +import database.chat_first_intents as chat_first_intents_db import database.conversations as conversations_db import database.goals as goals_db import database.task_intelligence_control as task_control_db @@ -18,15 +22,90 @@ ChatFirstBlockSpec, ChatFirstBlockValidationReceipt, ChatFirstBlockValidationRequest, + ChatFirstSubject, + DeferralCreateRequest, + DeferralReceipt, GoalLinkSpec, + MaterializePromptsRequest, + MaterializePromptsResponse, QuestionCardSpec, TaskCardSpec, stable_block_id, ) +from utils.metrics import CHAT_FIRST_PROACTIVE_TOTAL from utils.other import endpoints as auth -from utils.task_intelligence.rollout import resolve_chat_first_ui, resolve_task_intelligence_for_user +from utils.task_intelligence.chat_first_eligibility import resolve_chat_first_eligibility +from utils.task_intelligence.proactive_engine import persist_daily_opener_intent +from utils.task_intelligence.rollout import resolve_task_intelligence_for_user router = APIRouter() +logger = logging.getLogger(__name__) + + +def _eligibility(uid: str): + """Resolve Chat-first authority through the shared fail-closed boundary. + + Providers are passed explicitly so this route keeps its narrow unit-test + seams; other feature ingress uses the utility's production defaults. + """ + + return resolve_chat_first_eligibility( + uid, + load_control=task_control_db.get_task_workflow_control, + resolve_rollout=resolve_task_intelligence_for_user, + ) + + +def _require_materialization_capability(uid: str, *, owner_fence: str, control_generation: int): + """Reject stale or off desktop ingress before reading any proactive state.""" + + if owner_fence != uid: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Not found') + eligibility = _eligibility(uid) + if not eligibility.enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Not found') + if eligibility.account_generation != control_generation: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='account generation mismatch') + return eligibility + + +def _daily_opener_blocks(uid: str) -> tuple[list, ChatFirstSubject | None]: + """Build the closed deterministic opener from canonical task/goal facts.""" + + focused_goal = goals_db.get_user_goal(uid) + if not focused_goal: + return [], None + goal_id = focused_goal.get('id') or focused_goal.get('goal_id') + if not isinstance(goal_id, str) or not goal_id: + return [], None + title = focused_goal.get('title') + summary = title if isinstance(title, str) and title.strip() else 'Today’s focus' + blocks = [GoalLinkSpec(type='goalLink', goal_id=goal_id, summary=summary[:200])] + for task in action_items_db.get_action_items(uid, completed=False, limit=3): + task_id = task.get('id') + if isinstance(task_id, str) and task_id: + blocks.append(TaskCardSpec(type='taskCard', task_id=task_id)) + return blocks, ChatFirstSubject(kind='goal', id=goal_id) + + +def _maybe_persist_daily_opener(uid: str, *, control_generation: int, now: datetime) -> None: + """Best-effort lazy opener preparation; a failure never breaks Chat fetch.""" + + try: + blocks, subject = _daily_opener_blocks(uid) + if not blocks: + return + persist_daily_opener_intent( + uid, + blocks=blocks, + subject=subject, + expected_generation=control_generation, + now=now, + eligibility_resolver=_eligibility, + ) + except Exception as exc: + # No product content enters this log. A later foreground request may retry. + logger.warning('chat_first_daily_opener_prepare_failed uid=%s error=%s', uid, type(exc).__name__) def _entity_available(uid: str, block: ChatFirstBlockSpec) -> bool: @@ -72,26 +151,16 @@ def validate_chat_first_blocks( if request.owner_fence != uid: return ChatFirstBlockValidationReceipt(accepted=False, code='capability_unavailable') - try: - control = task_control_db.get_task_workflow_control(uid) - rollout = resolve_task_intelligence_for_user( - uid=uid, - workflow_mode=control.workflow_mode, - account_generation=control.account_generation, - ) - enabled = resolve_chat_first_ui(rollout, control.chat_first_ui_enabled) - except Exception: + eligibility = _eligibility(uid) + if not eligibility.enabled: return ChatFirstBlockValidationReceipt(accepted=False, code='capability_unavailable') - - if not enabled: - return ChatFirstBlockValidationReceipt(accepted=False, code='capability_unavailable') - if control.account_generation != request.control_generation: + if eligibility.account_generation != request.control_generation: return ChatFirstBlockValidationReceipt(accepted=False, code='generation_mismatch') if not all(_entity_available(uid, block) for block in request.blocks): return ChatFirstBlockValidationReceipt(accepted=False, code='entity_unavailable') block_ids = [ - stable_block_id(uid=uid, generation=control.account_generation, block=block) for block in request.blocks + stable_block_id(uid=uid, generation=request.control_generation, block=block) for block in request.blocks ] if len(block_ids) != len(set(block_ids)): return ChatFirstBlockValidationReceipt(accepted=False, code='invalid_request') @@ -104,3 +173,92 @@ def validate_chat_first_blocks( for block_id, block in zip(block_ids, request.blocks) ], ) + + +@router.post( + '/v1/chat/materialize-prompts', + response_model=MaterializePromptsResponse, + tags=['chat-first'], +) +def materialize_prompts( + request: MaterializePromptsRequest, + uid: str = Depends(auth.get_current_user_uid), +) -> MaterializePromptsResponse: + """Fetch ready intents and accept kernel receipts; never writes a Chat row.""" + + _require_materialization_capability( + uid, + owner_fence=request.owner_fence, + control_generation=request.control_generation, + ) + now = datetime.now(timezone.utc) + for receipt in request.receipts: + try: + chat_first_intents_db.acknowledge_materialization( + uid, + intent_id=receipt.intent_id, + receipt_id=receipt.receipt_id, + account_generation=request.control_generation, + now=now, + ) + except chat_first_intents_db.ChatFirstIntentGenerationMismatch as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='account generation mismatch') from exc + except ( + chat_first_intents_db.ChatFirstIntentConflictError, + chat_first_intents_db.ProactiveIntentNotReady, + ) as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='invalid materialization receipt') from exc + CHAT_FIRST_PROACTIVE_TOTAL.labels(event='kernel_receipt', source='materialization').inc() + + try: + released = chat_first_intents_db.release_due_deferrals( + uid, + account_generation=request.control_generation, + now=now, + ) + for _intent in released: + CHAT_FIRST_PROACTIVE_TOTAL.labels(event='deferral_released', source='deferral_reraise').inc() + if request.window_foreground: + _maybe_persist_daily_opener(uid, control_generation=request.control_generation, now=now) + intents = chat_first_intents_db.fetch_ready_intents( + uid, + account_generation=request.control_generation, + ) + except chat_first_intents_db.ChatFirstIntentGenerationMismatch as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='account generation mismatch') from exc + CHAT_FIRST_PROACTIVE_TOTAL.labels(event='fetch', source='materialization').inc() + return MaterializePromptsResponse(intents=intents) + + +@router.post( + '/v1/chat/deferrals', + response_model=DeferralReceipt, + tags=['chat-first'], +) +def record_chat_deferral( + request: DeferralCreateRequest, + uid: str = Depends(auth.get_current_user_uid), +) -> DeferralReceipt: + """Receive one idempotent kernel-outbox deferral without touching Chat state.""" + + _require_materialization_capability( + uid, + owner_fence=request.owner_fence, + control_generation=request.control_generation, + ) + try: + receipt, created = chat_first_intents_db.record_deferral( + uid, + continuity_key=request.continuity_key, + subject=request.subject, + question=request.question, + account_generation=request.control_generation, + now=datetime.now(timezone.utc), + ) + except chat_first_intents_db.ChatFirstIntentGenerationMismatch as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='account generation mismatch') from exc + except chat_first_intents_db.ChatFirstIntentConflictError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='deferral continuity conflict') from exc + if created: + CHAT_FIRST_PROACTIVE_TOTAL.labels(event='deferral_recorded', source='deferral_reraise').inc() + return receipt diff --git a/backend/scripts/export_openapi.py b/backend/scripts/export_openapi.py index c65548fb2d6..f19dee296cb 100644 --- a/backend/scripts/export_openapi.py +++ b/backend/scripts/export_openapi.py @@ -61,6 +61,7 @@ '/v1/apps', '/v1/calendar', '/v1/candidates', + '/v1/chat', '/v1/conversations', '/v1/dev', '/v1/fair-use', diff --git a/backend/tests/unit/test_chat_first_proactive_engine.py b/backend/tests/unit/test_chat_first_proactive_engine.py new file mode 100644 index 00000000000..3fc43b20c37 --- /dev/null +++ b/backend/tests/unit/test_chat_first_proactive_engine.py @@ -0,0 +1,203 @@ +"""Failure-isolated, content-free proactive-judgment contracts.""" + +from datetime import datetime, timezone + +import utils.task_intelligence.proactive_engine as engine +from models.chat_first import ( + ChatFirstSubject, + QuestionCardSpec, + QuestionOption, +) +from utils.task_intelligence.chat_first_eligibility import ChatFirstEligibility + +NOW = datetime(2026, 7, 15, 12, tzinfo=timezone.utc) +SUBJECT = ChatFirstSubject(kind='goal', id='goal-1') + + +class _Judge: + model_version = 'fixture.v1' + + def __init__(self, selection): + self.selection = selection + self.calls = 0 + + def judge(self, candidates): + self.calls += 1 + return self.selection + + +def _trigger(): + return engine.ProactiveWakeTrigger(kind='goal_changed', subject=SUBJECT, continuity_key='goal-1-complete') + + +def _question(): + return QuestionCardSpec( + type='questionCard', + question_id='question-1', + text='What should happen next?', + subject=SUBJECT, + options=[QuestionOption(option_id='yes', label='Yes', prepared_answer='Yes')], + ) + + +def test_capability_off_wake_has_zero_feature_store_provider_and_metric_work(monkeypatch): + monkeypatch.setattr( + engine.intent_db, + 'release_due_deferrals', + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('feature store must not run')), + ) + monkeypatch.setattr( + engine.intent_db, + 'get_budget_state', + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('feature store must not run')), + ) + monkeypatch.setattr(engine, '_meter', lambda *args: (_ for _ in ()).throw(AssertionError('metric must not run'))) + judge = _Judge(engine.ProactiveSelection(blocks=[_question()])) + + result = engine.wake_after_commit( + 'user-1', + _trigger(), + judge=judge, + now=NOW, + eligibility_resolver=lambda _uid: ChatFirstEligibility(enabled=False), + ) + + assert result.outcome == 'disabled' + assert judge.calls == 0 + + +def test_capability_off_deterministic_capture_has_zero_feature_store_and_metric_work(monkeypatch): + monkeypatch.setattr( + engine.intent_db, + 'create_intent', + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('feature store must not run')), + ) + monkeypatch.setattr(engine, '_meter', lambda *args: (_ for _ in ()).throw(AssertionError('metric must not run'))) + + result = engine.persist_capture_arrival_intent( + 'user-1', + conversation_id='capture-1', + summary='New Omi capture', + now=NOW, + eligibility_resolver=lambda _uid: ChatFirstEligibility(enabled=False), + ) + + assert result is None + + +def test_capture_arrival_is_failure_isolated_and_bounds_the_persisted_summary(monkeypatch): + created = [] + monkeypatch.setattr( + engine.intent_db, + 'create_intent', + lambda *args, **kwargs: created.append(kwargs) or (_ for _ in ()).throw(TimeoutError('store unavailable')), + ) + + result = engine.persist_capture_arrival_intent( + 'user-1', + conversation_id='capture-1', + summary='x' * 400, + now=NOW, + eligibility_resolver=lambda _uid: ChatFirstEligibility(enabled=True, account_generation=7), + ) + + assert result is None + assert created[0]['blocks'][0].summary == 'x' * 200 + + +def test_exhausted_budget_short_circuits_before_judge(monkeypatch): + monkeypatch.setattr(engine.intent_db, 'release_due_deferrals', lambda *args, **kwargs: []) + monkeypatch.setattr( + engine.intent_db, + 'admit_agent_judgment', + lambda *args, **kwargs: (_ for _ in ()).throw(engine.intent_db.ProactiveBudgetExhausted()), + ) + monkeypatch.setattr( + engine.intent_db, + 'create_intent', + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('intent must not be created')), + ) + judge = _Judge(engine.ProactiveSelection(blocks=[_question()])) + + result = engine.wake_after_commit( + 'user-1', + _trigger(), + judge=judge, + now=NOW, + eligibility_resolver=lambda _uid: ChatFirstEligibility(enabled=True, account_generation=7), + ) + + assert result.outcome == 'budget_exhausted' + assert judge.calls == 0 + + +def test_empty_judgment_declines_without_consuming_or_creating(monkeypatch): + monkeypatch.setattr(engine.intent_db, 'release_due_deferrals', lambda *args, **kwargs: []) + monkeypatch.setattr( + engine.intent_db, + 'admit_agent_judgment', + lambda *args, **kwargs: engine.intent_db.AgentJudgmentAdmission(existing_intent=None, newly_reserved=True), + ) + monkeypatch.setattr( + engine.intent_db, + 'create_intent', + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('decline must not create an intent')), + ) + released = [] + monkeypatch.setattr( + engine.intent_db, + 'release_agent_judgment_admission', + lambda *args, **kwargs: released.append(kwargs), + ) + judge = _Judge(None) + + result = engine.wake_after_commit( + 'user-1', + _trigger(), + judge=judge, + now=NOW, + eligibility_resolver=lambda _uid: ChatFirstEligibility(enabled=True, account_generation=7), + ) + + assert result.outcome == 'declined' + assert judge.calls == 1 + assert released == [{'continuity_key': 'goal-1-complete', 'account_generation': 7, 'now': NOW}] + + +def test_agent_admission_happens_before_the_judge_and_duplicate_wake_stays_quiet(monkeypatch): + events = [] + monkeypatch.setattr(engine.intent_db, 'release_due_deferrals', lambda *args, **kwargs: []) + monkeypatch.setattr( + engine.intent_db, + 'admit_agent_judgment', + lambda *args, **kwargs: events.append('admit') + or engine.intent_db.AgentJudgmentAdmission(existing_intent=None, newly_reserved=False), + ) + monkeypatch.setattr( + engine.intent_db, + 'release_agent_judgment_admission', + lambda *args, **kwargs: events.append('release'), + ) + judge = _Judge(engine.ProactiveSelection(blocks=[_question()])) + + result = engine.wake_after_commit( + 'user-1', + _trigger(), + judge=judge, + now=NOW, + eligibility_resolver=lambda _uid: ChatFirstEligibility(enabled=True, account_generation=7), + ) + + assert result.outcome == 'already_pending' + assert events == ['admit'] + assert judge.calls == 0 + + +def test_post_commit_wake_isolates_provider_or_store_failure(monkeypatch): + monkeypatch.setattr( + engine, 'wake_after_commit', lambda *args, **kwargs: (_ for _ in ()).throw(TimeoutError('timeout')) + ) + + result = engine.run_post_commit_wake('user-1', _trigger()) + + assert result.outcome == 'declined' diff --git a/backend/tests/unit/test_chat_first_proactive_intents.py b/backend/tests/unit/test_chat_first_proactive_intents.py new file mode 100644 index 00000000000..26645af9306 --- /dev/null +++ b/backend/tests/unit/test_chat_first_proactive_intents.py @@ -0,0 +1,415 @@ +"""Hermetic contracts for server-only Chat-first proactive intent state.""" + +from copy import deepcopy +from datetime import datetime, timedelta, timezone + +import pytest + +import database.chat_first_intents as intents_db +from models.chat_first import CaptureLinkSpec, ChatFirstSubject, QuestionCardSpec, QuestionOption +from models.chat_first import ProactiveBudgetState +from models.proactive_budget import budget_allows +from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode + +NOW = datetime(2026, 7, 15, 12, tzinfo=timezone.utc) +UID = 'user-1' +GENERATION = 7 + + +class _Snapshot: + def __init__(self, database, path): + self._database = database + self._path = path + self.exists = path in database.rows + + def to_dict(self): + return deepcopy(self._database.rows.get(self._path)) + + +class _Document: + def __init__(self, database, path): + self._database = database + self._path = path + + @property + def id(self): + return self._path[-1] + + def collection(self, name): + return _Collection(self._database, (*self._path, name)) + + def get(self, transaction=None): + if transaction is not None: + transaction.read() + return _Snapshot(self._database, self._path) + + def set(self, payload): + self._database.rows[self._path] = deepcopy(payload) + + +class _Collection: + def __init__(self, database, path): + self._database = database + self._path = path + + def document(self, identifier): + return _Document(self._database, (*self._path, identifier)) + + def stream(self): + child_length = len(self._path) + 1 + return [ + _Snapshot(self._database, path) + for path in sorted(self._database.rows) + if path[: len(self._path)] == self._path and len(path) == child_length + ] + + +class _Transaction: + def __init__(self): + self._wrote = False + + def read(self): + if self._wrote: + raise AssertionError('Firestore transactions must finish reads before writes') + + def set(self, ref, payload): + self._wrote = True + ref.set(payload) + + +class _Firestore: + def __init__(self): + self.rows = {} + + def collection(self, name): + return _Collection(self, (name,)) + + def transaction(self): + return _Transaction() + + +@pytest.fixture +def firestore(monkeypatch): + monkeypatch.setattr( + intents_db.firestore, 'transactional', lambda function: lambda transaction: function(transaction) + ) + fake = _Firestore() + fake.rows[('users', UID, 'task_intelligence_control', 'state')] = TaskWorkflowControl( + workflow_mode=TaskWorkflowMode.read, + account_generation=GENERATION, + chat_first_ui_enabled=True, + ).persisted_payload() + return fake + + +def _question(subject: ChatFirstSubject | None = None) -> QuestionCardSpec: + return QuestionCardSpec( + type='questionCard', + question_id='question-1', + text='What should happen next?', + subject=subject or ChatFirstSubject(kind='goal', id='goal-1'), + options=[QuestionOption(option_id='yes', label='Yes', prepared_answer='Yes')], + ) + + +def test_agent_intent_reserves_then_receipt_accounts_one_turn_idempotently(firestore): + question = _question() + intent, created = intents_db.create_intent( + UID, + source='agent_judgment', + continuity_key='goal-1-complete', + subject=question.subject, + blocks=[question], + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + retried, created_on_retry = intents_db.create_intent( + UID, + source='agent_judgment', + continuity_key='goal-1-complete', + subject=question.subject, + blocks=[question], + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + assert created is True + assert created_on_retry is False + assert retried.intent_id == intent.intent_id + assert ( + len( + intents_db.get_budget_state( + UID, account_generation=GENERATION, now=NOW, firestore_client=firestore + ).reservations + ) + == 1 + ) + + delivered = intents_db.acknowledge_materialization( + UID, + intent_id=intent.intent_id, + receipt_id='kernel-receipt-1', + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + replayed = intents_db.acknowledge_materialization( + UID, + intent_id=intent.intent_id, + receipt_id='kernel-receipt-1', + account_generation=GENERATION, + now=NOW + timedelta(seconds=1), + firestore_client=firestore, + ) + budget = intents_db.get_budget_state(UID, account_generation=GENERATION, now=NOW, firestore_client=firestore) + + assert delivered.delivery_state == 'delivered' + assert replayed == delivered + assert budget.reservations == [] + assert budget.materialized_at == [NOW] + + +def test_budget_gate_counts_reservations_before_a_provider_call(firestore): + question = _question() + for continuity_key in ('first', 'second'): + intents_db.create_intent( + UID, + source='agent_judgment', + continuity_key=continuity_key, + subject=question.subject, + blocks=[question], + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + budget = intents_db.get_budget_state(UID, account_generation=GENERATION, now=NOW, firestore_client=firestore) + + assert budget_allows(budget, now=NOW) is False + + +def test_agent_judgment_admission_is_single_writer_and_decline_releases_its_slot(firestore): + first = intents_db.admit_agent_judgment( + UID, + continuity_key='first', + subject=ChatFirstSubject(kind='goal', id='goal-1'), + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + duplicate = intents_db.admit_agent_judgment( + UID, + continuity_key='first', + subject=ChatFirstSubject(kind='goal', id='goal-1'), + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + second = intents_db.admit_agent_judgment( + UID, + continuity_key='second', + subject=ChatFirstSubject(kind='goal', id='goal-2'), + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + assert first.newly_reserved is True + assert duplicate.newly_reserved is False + assert duplicate.existing_intent is None + assert second.newly_reserved is True + with pytest.raises(intents_db.ProactiveBudgetExhausted): + intents_db.admit_agent_judgment( + UID, + continuity_key='third', + subject=ChatFirstSubject(kind='goal', id='goal-3'), + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + intents_db.release_agent_judgment_admission( + UID, + continuity_key='first', + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + retry = intents_db.admit_agent_judgment( + UID, + continuity_key='third', + subject=ChatFirstSubject(kind='goal', id='goal-3'), + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + assert retry.newly_reserved is True + + +def test_pre_admitted_agent_judgment_reuses_its_reservation_when_the_intent_is_persisted(firestore): + question = _question() + admission = intents_db.admit_agent_judgment( + UID, + continuity_key='goal-1-complete', + subject=question.subject, + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + intent, created = intents_db.create_intent( + UID, + source='agent_judgment', + continuity_key='goal-1-complete', + subject=question.subject, + blocks=[question], + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + assert admission.newly_reserved is True + assert created is True + assert intent.delivery_state == 'ready' + assert ( + len( + intents_db.get_budget_state( + UID, account_generation=GENERATION, now=NOW, firestore_client=firestore + ).reservations + ) + == 1 + ) + + +def test_budget_has_explicit_rolling_and_utc_day_boundaries(): + rolling = ProactiveBudgetState(account_generation=GENERATION, materialized_at=[NOW, NOW]) + daily = ProactiveBudgetState(account_generation=GENERATION, materialized_at=[NOW] * 10) + + assert budget_allows(rolling, now=NOW + timedelta(minutes=29, seconds=59)) is False + assert budget_allows(rolling, now=NOW + timedelta(minutes=30)) is True + assert budget_allows(daily, now=NOW + timedelta(hours=1)) is False + assert budget_allows(daily, now=NOW + timedelta(days=1)) is True + + +def test_capture_arrival_retry_creates_one_deterministic_receipt_intent(firestore): + blocks = [CaptureLinkSpec(type='captureLink', conversation_id='capture-1', summary='New Omi capture')] + first, created = intents_db.create_intent( + UID, + source='capture_arrival', + continuity_key='capture:capture-1', + subject=ChatFirstSubject(kind='capture', id='capture-1'), + blocks=blocks, + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + retry, created_on_retry = intents_db.create_intent( + UID, + source='capture_arrival', + continuity_key='capture:capture-1', + subject=ChatFirstSubject(kind='capture', id='capture-1'), + blocks=blocks, + account_generation=GENERATION, + now=NOW + timedelta(minutes=1), + firestore_client=firestore, + ) + + assert created is True + assert created_on_retry is False + assert retry.intent_id == first.intent_id + assert retry.source == 'capture_arrival' + + +def test_deferral_releases_once_verbatim_when_due_or_subject_changes(firestore): + question = _question() + receipt, created = intents_db.record_deferral( + UID, + continuity_key='defer-goal-1', + subject=question.subject, + question=question, + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + assert created is True + assert receipt.state == 'pending' + assert ( + intents_db.release_due_deferrals( + UID, + account_generation=GENERATION, + now=NOW + timedelta(hours=23, minutes=59), + firestore_client=firestore, + ) + == [] + ) + + due = intents_db.release_due_deferrals( + UID, + account_generation=GENERATION, + now=NOW + timedelta(hours=24), + firestore_client=firestore, + ) + replay = intents_db.release_due_deferrals( + UID, + account_generation=GENERATION, + now=NOW + timedelta(hours=25), + firestore_client=firestore, + ) + + assert len(due) == 1 + assert due[0].source == 'deferral_reraise' + assert due[0].blocks == [question] + assert replay == [] + + task_subject = ChatFirstSubject(kind='task', id='task-1') + task_question = _question(task_subject) + intents_db.record_deferral( + UID, + continuity_key='defer-task-1', + subject=task_subject, + question=task_question, + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + subject_change = intents_db.release_due_deferrals( + UID, + account_generation=GENERATION, + now=NOW + timedelta(minutes=1), + subject=task_subject, + firestore_client=firestore, + ) + + assert len(subject_change) == 1 + assert subject_change[0].blocks == [task_question] + + +def test_off_or_stale_control_rejects_intent_before_feature_records_are_read(firestore): + firestore.rows[('users', UID, 'task_intelligence_control', 'state')] = TaskWorkflowControl( + workflow_mode=TaskWorkflowMode.read, + account_generation=GENERATION, + chat_first_ui_enabled=False, + ).persisted_payload() + question = _question() + + with pytest.raises(intents_db.ChatFirstIntentGenerationMismatch): + intents_db.create_intent( + UID, + source='agent_judgment', + continuity_key='off', + subject=question.subject, + blocks=[question], + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + assert not any(INTENTS_COLLECTION in path or DEFERRALS_COLLECTION in path for path in firestore.rows) + + +INTENTS_COLLECTION = intents_db.INTENTS_COLLECTION +DEFERRALS_COLLECTION = intents_db.DEFERRALS_COLLECTION diff --git a/backend/tests/unit/test_chat_first_proactive_router.py b/backend/tests/unit/test_chat_first_proactive_router.py new file mode 100644 index 00000000000..d3ad48062a1 --- /dev/null +++ b/backend/tests/unit/test_chat_first_proactive_router.py @@ -0,0 +1,169 @@ +"""API contracts for the journal-owned materialization fetch/ack boundary.""" + +from datetime import datetime, timezone +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from models.chat_first import ChatFirstSubject, ProactiveIntent, QuestionCardSpec, QuestionOption +from models.task_intelligence import TaskWorkflowControl +import routers.chat_first as chat_first_router + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(chat_first_router.router) + app.dependency_overrides[chat_first_router.auth.get_current_user_uid] = lambda: 'user-1' + return TestClient(app) + + +def _request(*, generation: int = 7, owner_fence: str = 'user-1', receipts=None) -> dict: + return { + 'source_surface': 'main_chat', + 'control_generation': generation, + 'owner_fence': owner_fence, + 'window_foreground': False, + 'receipts': receipts or [], + } + + +def _enable_chat_first(monkeypatch, *, generation: int = 7) -> None: + monkeypatch.setattr( + chat_first_router.task_control_db, + 'get_task_workflow_control', + lambda uid: TaskWorkflowControl( + workflow_mode='read', account_generation=generation, chat_first_ui_enabled=True + ), + ) + monkeypatch.setattr( + chat_first_router, + 'resolve_task_intelligence_for_user', + lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), + ) + + +def _question() -> QuestionCardSpec: + return QuestionCardSpec( + type='questionCard', + question_id='question-1', + text='What should happen next?', + subject=ChatFirstSubject(kind='goal', id='goal-1'), + options=[QuestionOption(option_id='yes', label='Yes', prepared_answer='Yes')], + ) + + +def test_materialize_capability_off_does_zero_feature_store_or_metric_work(monkeypatch): + monkeypatch.setattr( + chat_first_router.task_control_db, + 'get_task_workflow_control', + lambda uid: TaskWorkflowControl(workflow_mode='read', account_generation=7, chat_first_ui_enabled=False), + ) + monkeypatch.setattr( + chat_first_router, + 'resolve_task_intelligence_for_user', + lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), + ) + for name in ('acknowledge_materialization', 'release_due_deferrals', 'fetch_ready_intents'): + monkeypatch.setattr( + chat_first_router.chat_first_intents_db, + name, + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError(f'{name} must not run')), + ) + monkeypatch.setattr( + chat_first_router, + 'CHAT_FIRST_PROACTIVE_TOTAL', + SimpleNamespace(labels=lambda **kwargs: (_ for _ in ()).throw(AssertionError('metric must not run'))), + ) + + response = _client().post('/v1/chat/materialize-prompts', json=_request()) + + assert response.status_code == 404 + assert response.json() == {'detail': 'Not found'} + + +def test_materialize_rejects_wrong_owner_or_generation_before_feature_store_reads(monkeypatch): + _enable_chat_first(monkeypatch) + monkeypatch.setattr( + chat_first_router.chat_first_intents_db, + 'fetch_ready_intents', + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('feature store must not run')), + ) + + wrong_owner = _client().post('/v1/chat/materialize-prompts', json=_request(owner_fence='another-user')) + stale = _client().post('/v1/chat/materialize-prompts', json=_request(generation=6)) + + assert wrong_owner.status_code == 404 + assert stale.status_code == 409 + + +def test_materialize_returns_ready_intents_and_acknowledges_only_kernel_receipts(monkeypatch): + _enable_chat_first(monkeypatch) + intent = ProactiveIntent( + intent_id='intent-1', + continuity_key='goal-1-complete', + account_generation=7, + source='agent_judgment', + subject=ChatFirstSubject(kind='goal', id='goal-1'), + blocks=[_question()], + created_at=datetime(2026, 7, 15, tzinfo=timezone.utc), + ) + acknowledgements = [] + monkeypatch.setattr( + chat_first_router.chat_first_intents_db, + 'acknowledge_materialization', + lambda *args, **kwargs: acknowledgements.append(kwargs) or intent, + ) + monkeypatch.setattr(chat_first_router.chat_first_intents_db, 'release_due_deferrals', lambda *args, **kwargs: []) + monkeypatch.setattr( + chat_first_router.chat_first_intents_db, 'fetch_ready_intents', lambda *args, **kwargs: [intent] + ) + + response = _client().post( + '/v1/chat/materialize-prompts', + json=_request(receipts=[{'intent_id': 'intent-1', 'receipt_id': 'kernel-receipt-1'}]), + ) + + assert response.status_code == 200 + assert len(acknowledgements) == 1 + assert acknowledgements[0]['intent_id'] == 'intent-1' + assert acknowledgements[0]['receipt_id'] == 'kernel-receipt-1' + assert acknowledgements[0]['account_generation'] == 7 + assert response.json()['intents'][0]['intent_id'] == 'intent-1' + assert response.json()['intents'][0]['delivery_state'] == 'ready' + + +def test_deferral_receiver_is_capability_gated_before_its_store(monkeypatch): + monkeypatch.setattr( + chat_first_router.task_control_db, + 'get_task_workflow_control', + lambda uid: TaskWorkflowControl(workflow_mode='read', account_generation=7, chat_first_ui_enabled=False), + ) + monkeypatch.setattr( + chat_first_router, + 'resolve_task_intelligence_for_user', + lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), + ) + monkeypatch.setattr( + chat_first_router.chat_first_intents_db, + 'record_deferral', + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('deferral store must not run')), + ) + monkeypatch.setattr( + chat_first_router, + 'CHAT_FIRST_PROACTIVE_TOTAL', + SimpleNamespace(labels=lambda **kwargs: (_ for _ in ()).throw(AssertionError('metric must not run'))), + ) + question = _question() + request = { + 'source_surface': 'main_chat', + 'control_generation': 7, + 'owner_fence': 'user-1', + 'continuity_key': 'defer-goal-1', + 'subject': question.subject.model_dump(), + 'question': question.model_dump(), + } + + response = _client().post('/v1/chat/deferrals', json=request) + + assert response.status_code == 404 diff --git a/backend/utils/metrics.py b/backend/utils/metrics.py index 4239eb24286..97fa9ffbbf9 100644 --- a/backend/utils/metrics.py +++ b/backend/utils/metrics.py @@ -222,6 +222,12 @@ ['event', 'subject_kind', 'code'], ) +CHAT_FIRST_PROACTIVE_TOTAL = Counter( + 'chat_first_proactive_total', + 'Chat-first proactive engine activity with no user content', + ['event', 'source'], +) + AUTH_FLOW_EVENTS = Counter( 'auth_flow_events_total', 'Auth flow events by provider, stage, outcome, and sanitized failure class', diff --git a/backend/utils/task_intelligence/chat_first_eligibility.py b/backend/utils/task_intelligence/chat_first_eligibility.py new file mode 100644 index 00000000000..aed32dfb3c1 --- /dev/null +++ b/backend/utils/task_intelligence/chat_first_eligibility.py @@ -0,0 +1,46 @@ +"""Fail-closed, reusable server authority for the Chat-first cohort.""" + +from dataclasses import dataclass +from typing import Callable + +import database.task_intelligence_control as task_control_db +from models.task_intelligence import TaskWorkflowControl +from utils.task_intelligence.rollout import resolve_chat_first_ui, resolve_task_intelligence_for_user + + +@dataclass(frozen=True) +class ChatFirstEligibility: + """Fresh server-side capability resolution for one authenticated account.""" + + enabled: bool + account_generation: int | None = None + + +def resolve_chat_first_eligibility( + uid: str, + *, + load_control: Callable[[str], TaskWorkflowControl] = task_control_db.get_task_workflow_control, + resolve_rollout: Callable[..., object] = resolve_task_intelligence_for_user, +) -> ChatFirstEligibility: + """Resolve the generation-bound cohort capability without fallback state. + + This is intentionally the only reusable server authority for chat-first + ingress. Callers must invoke it before touching feature-specific stores, + metrics, or providers. Any control or canonical-memory failure fails closed. + """ + + try: + control = load_control(uid) + rollout = resolve_rollout( + uid=uid, + workflow_mode=control.workflow_mode, + account_generation=control.account_generation, + ) + if not resolve_chat_first_ui(rollout, control.chat_first_ui_enabled): + return ChatFirstEligibility(enabled=False) + return ChatFirstEligibility(enabled=True, account_generation=control.account_generation) + except Exception: + return ChatFirstEligibility(enabled=False) + + +__all__ = ['ChatFirstEligibility', 'resolve_chat_first_eligibility'] diff --git a/backend/utils/task_intelligence/proactive_engine.py b/backend/utils/task_intelligence/proactive_engine.py new file mode 100644 index 00000000000..fca3f47aaf9 --- /dev/null +++ b/backend/utils/task_intelligence/proactive_engine.py @@ -0,0 +1,284 @@ +"""Post-commit, fail-closed Chat-first proactive-intent orchestration.""" + +import logging +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Callable, Literal, Protocol + +import database.chat_first_intents as intent_db +from models.chat_first import CaptureLinkSpec, ChatFirstBlockSpec, ChatFirstSubject, ProactiveIntent +from utils.metrics import CHAT_FIRST_PROACTIVE_TOTAL +from utils.task_intelligence.chat_first_eligibility import ChatFirstEligibility, resolve_chat_first_eligibility + +logger = logging.getLogger(__name__) + +WakeTriggerKind = Literal['task_changed', 'goal_changed', 'capture_finalized', 'deferral_due'] + + +@dataclass(frozen=True) +class ProactiveWakeTrigger: + """Content-free post-commit trigger. It cannot be a Chat transcript event.""" + + kind: WakeTriggerKind + subject: ChatFirstSubject + continuity_key: str + + +@dataclass(frozen=True) +class ProactiveCandidate: + """Deterministic shortlist member passed to an injectable judgment seam.""" + + subject: ChatFirstSubject + trigger_kind: WakeTriggerKind + continuity_key: str + + +@dataclass(frozen=True) +class ProactiveSelection: + """A structured judgment result. Empty remains the default action.""" + + blocks: list[ChatFirstBlockSpec] + + +class ProactiveJudge(Protocol): + model_version: str + + def judge(self, candidates: list[ProactiveCandidate]) -> ProactiveSelection | None: ... + + +class EmptyProactiveJudge: + """Safe default until a production structured judge is intentionally bound.""" + + model_version = 'empty-default.v1' + + def judge(self, candidates: list[ProactiveCandidate]) -> ProactiveSelection | None: + return None + + +@dataclass(frozen=True) +class ProactiveWakeResult: + outcome: Literal['disabled', 'stale', 'budget_exhausted', 'already_pending', 'declined', 'created', 'no_candidate'] + intent: ProactiveIntent | None = None + + +def wake_after_commit( + uid: str, + trigger: ProactiveWakeTrigger, + *, + expected_generation: int | None = None, + judge: ProactiveJudge | None = None, + now: datetime | None = None, + eligibility_resolver: Callable[[str], ChatFirstEligibility] = resolve_chat_first_eligibility, +) -> ProactiveWakeResult: + """Create an optional agent-tier intent without affecting the source mutation. + + This is synchronous by design so the caller can choose the owning background + executor. It never mutates the source task, goal, capture, or any chat row. + The wrapper below isolates all failures after the source transaction commits. + """ + + resolved_now = now or datetime.now(timezone.utc) + eligibility = eligibility_resolver(uid) + if not eligibility.enabled: + return ProactiveWakeResult(outcome='disabled') + if expected_generation is not None and eligibility.account_generation != expected_generation: + return ProactiveWakeResult(outcome='stale') + assert eligibility.account_generation is not None + generation = eligibility.account_generation + _meter('wake', trigger.kind) + + # A due deferral is deterministic. Release it before agent judgment, but + # never recurse into this wake path from the release/receipt operation. + released = intent_db.release_due_deferrals( + uid, + account_generation=generation, + now=resolved_now, + subject=trigger.subject, + ) + for _intent in released: + _meter('deferral_released', 'deferral_reraise') + + if trigger.kind not in {'task_changed', 'goal_changed'}: + return ProactiveWakeResult(outcome='no_candidate') + + try: + admission = intent_db.admit_agent_judgment( + uid, + continuity_key=trigger.continuity_key, + subject=trigger.subject, + account_generation=generation, + now=resolved_now, + ) + except intent_db.ProactiveBudgetExhausted: + # The admission transaction is intentionally before the provider call. + _meter('budget_short_circuit', 'agent_judgment') + return ProactiveWakeResult(outcome='budget_exhausted') + if admission.existing_intent is not None: + return ProactiveWakeResult(outcome='created', intent=admission.existing_intent) + if not admission.newly_reserved: + return ProactiveWakeResult(outcome='already_pending') + + admission_resolved = False + try: + candidates = _deterministic_shortlist(trigger) + if not candidates: + return ProactiveWakeResult(outcome='no_candidate') + + resolved_judge = judge or EmptyProactiveJudge() + _meter('judgment_called', 'agent_judgment') + selection = resolved_judge.judge(candidates) + if ( + selection is None + or not selection.blocks + or not any(block.type == 'questionCard' for block in selection.blocks) + ): + _meter('judgment_declined', 'agent_judgment') + return ProactiveWakeResult(outcome='declined') + + intent, created = intent_db.create_intent( + uid, + source='agent_judgment', + continuity_key=trigger.continuity_key, + subject=trigger.subject, + blocks=selection.blocks, + account_generation=generation, + now=resolved_now, + ) + admission_resolved = True + if created: + _meter('intent_created', 'agent_judgment') + return ProactiveWakeResult(outcome='created', intent=intent) + finally: + if not admission_resolved: + try: + intent_db.release_agent_judgment_admission( + uid, + continuity_key=trigger.continuity_key, + account_generation=generation, + now=resolved_now, + ) + except Exception as exc: + # The source mutation has already committed. Preserve its result; + # control-generation rollover also makes the old reservation inert. + logger.warning('chat_first_proactive_admission_release_failed uid=%s error=%s', uid, type(exc).__name__) + + +def run_post_commit_wake( + uid: str, + trigger: ProactiveWakeTrigger, + **kwargs, +) -> ProactiveWakeResult: + """Failure-isolated convenience seam for background mutation owners.""" + + try: + return wake_after_commit(uid, trigger, **kwargs) + except Exception as exc: + logger.warning('chat_first_proactive_wake_failed uid=%s error=%s', uid, type(exc).__name__) + _meter('wake_failed', trigger.kind) + return ProactiveWakeResult(outcome='declined') + + +def persist_capture_arrival_intent( + uid: str, + *, + conversation_id: str, + summary: str, + expected_generation: int | None = None, + now: datetime | None = None, + eligibility_resolver: Callable[[str], ChatFirstEligibility] = resolve_chat_first_eligibility, +) -> ProactiveIntent | None: + """Persist the deterministic capture receipt without calling an LLM. + + Capture finalization has already committed by the time this hook runs. A + malformed title or unavailable intent store therefore must not turn a + successful capture into a failed source operation. + """ + + resolved_now = now or datetime.now(timezone.utc) + try: + eligibility = eligibility_resolver(uid) + if not eligibility.enabled or ( + expected_generation is not None and eligibility.account_generation != expected_generation + ): + return None + assert eligibility.account_generation is not None + bounded_summary = summary.strip()[:200] + if not bounded_summary: + return None + intent, created = intent_db.create_intent( + uid, + source='capture_arrival', + continuity_key=f'capture:{conversation_id}', + subject=ChatFirstSubject(kind='capture', id=conversation_id), + blocks=[CaptureLinkSpec(type='captureLink', conversation_id=conversation_id, summary=bounded_summary)], + account_generation=eligibility.account_generation, + now=resolved_now, + ) + if created: + _meter('intent_created', 'capture_arrival') + return intent + except Exception as exc: + logger.warning('chat_first_capture_arrival_intent_failed uid=%s error=%s', uid, type(exc).__name__) + return None + + +def persist_daily_opener_intent( + uid: str, + *, + blocks: list[ChatFirstBlockSpec], + subject: ChatFirstSubject | None, + expected_generation: int | None = None, + now: datetime | None = None, + eligibility_resolver: Callable[[str], ChatFirstEligibility] = resolve_chat_first_eligibility, +) -> ProactiveIntent | None: + """Persist the once-per-UTC-day deterministic opener supplied by the caller.""" + + resolved_now = now or datetime.now(timezone.utc) + eligibility = eligibility_resolver(uid) + if not eligibility.enabled or ( + expected_generation is not None and eligibility.account_generation != expected_generation + ): + return None + assert eligibility.account_generation is not None + intent, created = intent_db.create_intent( + uid, + source='daily_opener', + continuity_key=f'daily:{resolved_now.date().isoformat()}', + subject=subject, + blocks=blocks, + account_generation=eligibility.account_generation, + now=resolved_now, + ) + if created: + _meter('intent_created', 'daily_opener') + return intent + + +def _deterministic_shortlist(trigger: ProactiveWakeTrigger) -> list[ProactiveCandidate]: + return [ + ProactiveCandidate( + subject=trigger.subject, + trigger_kind=trigger.kind, + continuity_key=trigger.continuity_key, + ) + ] + + +def _meter(event: str, source: str) -> None: + """Emit only bounded shape labels; never content, prompts, or subject IDs.""" + + CHAT_FIRST_PROACTIVE_TOTAL.labels(event=event, source=source).inc() + + +__all__ = [ + 'EmptyProactiveJudge', + 'ProactiveCandidate', + 'ProactiveJudge', + 'ProactiveSelection', + 'ProactiveWakeResult', + 'ProactiveWakeTrigger', + 'persist_capture_arrival_intent', + 'persist_daily_opener_intent', + 'run_post_commit_wake', + 'wake_after_commit', +] diff --git a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift index 5a9b90c708c..aa18cb114b6 100644 --- a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift +++ b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift @@ -6713,6 +6713,58 @@ public enum OmiAPI { return try JSONDecoder().decode(CandidateResolutionReceipt.self, from: data) } + public static func recordChatDeferralV1ChatDeferralsPost(client: OmiApiClient, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil, body: OmiAnyCodable) async throws -> OmiAnyCodable { + let _path = "/v1/chat/deferrals" + guard var components = URLComponents(string: client.baseURL + _path) else { + throw OmiApiError.invalidURL + } + guard let url = components.url else { throw OmiApiError.invalidURL } + var req = URLRequest(url: url) + req.httpMethod = "POST" + for (name, value) in client.headers { req.setValue(value, forHTTPHeaderField: name) } + if let token = client.token { + req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") + } + if let authorization { req.setValue(String(authorization), forHTTPHeaderField: "authorization") } + if let xAppPlatform { req.setValue(String(xAppPlatform), forHTTPHeaderField: "X-App-Platform") } + if let xDeviceIdHash { req.setValue(String(xDeviceIdHash), forHTTPHeaderField: "X-Device-Id-Hash") } + if let xAppVersion { req.setValue(String(xAppVersion), forHTTPHeaderField: "X-App-Version") } + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(body) + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw OmiApiError.invalidURL } + guard (200..<300).contains(http.statusCode) else { + throw OmiApiError.httpError(status: http.statusCode, data: data) + } + return try JSONDecoder().decode(OmiAnyCodable.self, from: data) + } + + public static func materializePromptsV1ChatMaterializePromptsPost(client: OmiApiClient, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil, body: OmiAnyCodable) async throws -> OmiAnyCodable { + let _path = "/v1/chat/materialize-prompts" + guard var components = URLComponents(string: client.baseURL + _path) else { + throw OmiApiError.invalidURL + } + guard let url = components.url else { throw OmiApiError.invalidURL } + var req = URLRequest(url: url) + req.httpMethod = "POST" + for (name, value) in client.headers { req.setValue(value, forHTTPHeaderField: name) } + if let token = client.token { + req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") + } + if let authorization { req.setValue(String(authorization), forHTTPHeaderField: "authorization") } + if let xAppPlatform { req.setValue(String(xAppPlatform), forHTTPHeaderField: "X-App-Platform") } + if let xDeviceIdHash { req.setValue(String(xDeviceIdHash), forHTTPHeaderField: "X-Device-Id-Hash") } + if let xAppVersion { req.setValue(String(xAppVersion), forHTTPHeaderField: "X-App-Version") } + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(body) + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw OmiApiError.invalidURL } + guard (200..<300).contains(http.statusCode) else { + throw OmiApiError.httpError(status: http.statusCode, data: data) + } + return try JSONDecoder().decode(OmiAnyCodable.self, from: data) + } + public static func getConversationsV1ConversationsGet(client: OmiApiClient, limit: Int? = nil, offset: Int? = nil, statuses: String? = nil, includeDiscarded: Bool? = nil, sources: String? = nil, startDate: String? = nil, endDate: String? = nil, folderId: String? = nil, starred: Bool? = nil, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> [Conversation] { let _path = "/v1/conversations" guard var components = URLComponents(string: client.baseURL + _path) else { @@ -14266,5 +14318,5 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } - // Total: 380 Swift client methods generated. + // Total: 381 Swift client methods generated. } diff --git a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts index f16eb437408..e8f709aee1f 100644 --- a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts +++ b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts @@ -857,6 +857,13 @@ export type CandidateStatus = "pending" | "accepted" | "rejected" | "expired"; export type CandidateSubjectKind = "task" | "workstream"; +export interface CaptureLinkSpec { + conversation_id: string; + moment_timestamp_ms?: number | null; + summary: string; + type: "captureLink"; +} + export type CategoryEnum = "personal" | "education" | "health" | "finance" | "legal" | "philosophy" | "spiritual" | "science" | "entrepreneurship" | "parenting" | "romantic" | "travel" | "inspiration" | "technology" | "business" | "social" | "work" | "sports" | "politics" | "literature" | "history" | "architecture" | "music" | "weather" | "news" | "entertainment" | "psychology" | "real" | "design" | "family" | "economics" | "environment" | "other"; export interface ChartData { @@ -878,6 +885,11 @@ export interface ChartDataset { label: string; } +export interface ChatFirstSubject { + id: string; + kind: "task" | "goal" | "capture"; +} + export interface ChatMessageCountResponse { count: number; } @@ -1320,6 +1332,21 @@ export interface DefaultTaskIntegrationResponse { default_app: string | null; } +export interface DeferralCreateRequest { + continuity_key: string; + control_generation: number; + owner_fence: string; + question: QuestionCardSpec; + source_surface: "main_chat"; + subject: ChatFirstSubject; +} + +export interface DeferralReceipt { + deferral_id: string; + due_at: string; + state: "pending" | "released"; +} + export interface DeleteAccountRequest { reason?: string | null; reason_details?: string | null; @@ -1812,6 +1839,12 @@ export interface GoalLifecycleRequest { status: GoalStatus; } +export interface GoalLinkSpec { + goal_id: string; + summary: string; + type: "goalLink"; +} + export interface GoalMetric { current: number; max?: number | null; @@ -2011,6 +2044,18 @@ export interface LlmUsageResponse { top_features?: Array; } +export interface MaterializePromptsRequest { + control_generation: number; + owner_fence: string; + receipts?: Array; + source_surface: "main_chat"; + window_foreground?: boolean; +} + +export interface MaterializePromptsResponse { + intents?: Array; +} + export interface McpAddServerResponse { app_id: string; auth_url?: string | null; @@ -2537,6 +2582,24 @@ export interface PrivateCloudSyncResponse { private_cloud_sync_enabled: boolean; } +export interface ProactiveIntent { + account_generation: number; + blocks: Array; + continuity_key: string; + created_at: string; + delivered_at?: string | null; + delivery_state?: "ready" | "delivered"; + intent_id: string; + materialization_receipt_id?: string | null; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + subject?: ChatFirstSubject | null; +} + +export interface ProactiveMaterializationReceipt { + intent_id: string; + receipt_id: string; +} + export interface ProactiveNotification { scopes: Array; } @@ -2572,6 +2635,21 @@ export interface PublicFairUseCaseStatusResponse { updated_at?: string | null; } +export interface QuestionCardSpec { + options: Array; + question_id: string; + subject: ChatFirstSubject; + text: string; + type: "questionCard"; +} + +export interface QuestionOption { + defer?: boolean; + label: string; + option_id: string; + prepared_answer: string; +} + export interface RateMessageRequest { rating?: number | null; } @@ -3078,6 +3156,11 @@ export interface TaskCancelCandidate { export type TaskCandidate = TaskCreateCandidate | TaskUpdateCandidate | TaskCompleteCandidate | TaskCancelCandidate | TaskSupersedeCandidate; +export interface TaskCardSpec { + task_id: string; + type: "taskCard"; +} + export interface TaskChangePayload { description?: string | null; due_at?: string | null; @@ -3797,10 +3880,12 @@ export interface OmiApiSchemas { "CandidateResolutionRequest": CandidateResolutionRequest; "CandidateStatus": CandidateStatus; "CandidateSubjectKind": CandidateSubjectKind; + "CaptureLinkSpec": CaptureLinkSpec; "CategoryEnum": CategoryEnum; "ChartData": ChartData; "ChartDataPoint": ChartDataPoint; "ChartDataset": ChartDataset; + "ChatFirstSubject": ChatFirstSubject; "ChatMessageCountResponse": ChatMessageCountResponse; "ChatQuotaUnit": ChatQuotaUnit; "ChatRatingResponse": ChatRatingResponse; @@ -3864,6 +3949,8 @@ export interface OmiApiSchemas { "DecisionRecord": DecisionRecord; "DefaultTaskIntegrationRequest": DefaultTaskIntegrationRequest; "DefaultTaskIntegrationResponse": DefaultTaskIntegrationResponse; + "DeferralCreateRequest": DeferralCreateRequest; + "DeferralReceipt": DeferralReceipt; "DeleteAccountRequest": DeleteAccountRequest; "DeleteActionItemRequest": DeleteActionItemRequest; "DeleteImportJobResponse": DeleteImportJobResponse; @@ -3927,6 +4014,7 @@ export interface OmiApiSchemas { "GoalFocusRequest": GoalFocusRequest; "GoalHistoryEntryResponse": GoalHistoryEntryResponse; "GoalLifecycleRequest": GoalLifecycleRequest; + "GoalLinkSpec": GoalLinkSpec; "GoalMetric": GoalMetric; "GoalOriginWorkIntent": GoalOriginWorkIntent; "GoalProgressEvent": GoalProgressEvent; @@ -3957,6 +4045,8 @@ export interface OmiApiSchemas { "LlmUsageFeatureResponse": LlmUsageFeatureResponse; "LlmUsageRecordResponse": LlmUsageRecordResponse; "LlmUsageResponse": LlmUsageResponse; + "MaterializePromptsRequest": MaterializePromptsRequest; + "MaterializePromptsResponse": MaterializePromptsResponse; "McpAddServerResponse": McpAddServerResponse; "McpApiKey": McpApiKey; "McpApiKeyCreate": McpApiKeyCreate; @@ -4032,12 +4122,16 @@ export interface OmiApiSchemas { "PluginResult": PluginResult; "PricingOption": PricingOption; "PrivateCloudSyncResponse": PrivateCloudSyncResponse; + "ProactiveIntent": ProactiveIntent; + "ProactiveMaterializationReceipt": ProactiveMaterializationReceipt; "ProactiveNotification": ProactiveNotification; "ProcessConversationRequest": ProcessConversationRequest; "ProgressExtractRequest": ProgressExtractRequest; "ProgressExtractResponse": ProgressExtractResponse; "ProgressExtractUpdateResponse": ProgressExtractUpdateResponse; "PublicFairUseCaseStatusResponse": PublicFairUseCaseStatusResponse; + "QuestionCardSpec": QuestionCardSpec; + "QuestionOption": QuestionOption; "RateMessageRequest": RateMessageRequest; "RebuildResponse": RebuildResponse; "Recommendation": Recommendation; @@ -4105,6 +4199,7 @@ export interface OmiApiSchemas { "TaskAssistantSettings": TaskAssistantSettings; "TaskCancelCandidate": TaskCancelCandidate; "TaskCandidate": TaskCandidate; + "TaskCardSpec": TaskCardSpec; "TaskChangePayload": TaskChangePayload; "TaskCompleteCandidate": TaskCompleteCandidate; "TaskCreateCandidate": TaskCreateCandidate; @@ -5082,6 +5177,26 @@ export interface OmiApiPaths { }; }; }; + "/v1/chat/deferrals": { + post: { + operationId: "record_chat_deferral_v1_chat_deferrals_post"; + responses: { + "200": DeferralReceipt; + "401": void; + "422": HTTPValidationError; + }; + }; + }; + "/v1/chat/materialize-prompts": { + post: { + operationId: "materialize_prompts_v1_chat_materialize_prompts_post"; + responses: { + "200": MaterializePromptsResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/conversations": { get: { operationId: "get_conversations_v1_conversations_get"; @@ -9785,6 +9900,48 @@ export async function reject_candidate_v1_candidates__candidate_id__reject_post( return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function record_chat_deferral_v1_chat_deferrals_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: DeferralCreateRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/chat/deferrals`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + +export async function materialize_prompts_v1_chat_materialize_prompts_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: MaterializePromptsRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/chat/materialize-prompts`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, sources?: string | null, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/conversations`; @@ -15500,4 +15657,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 380 client methods generated. +// Total: 381 client methods generated. diff --git a/docs/api-reference/app-client-openapi.json b/docs/api-reference/app-client-openapi.json index 9e858037231..0c766e5dd18 100644 --- a/docs/api-reference/app-client-openapi.json +++ b/docs/api-reference/app-client-openapi.json @@ -5543,6 +5543,48 @@ "title": "CandidateSubjectKind", "type": "string" }, + "CaptureLinkSpec": { + "additionalProperties": false, + "properties": { + "conversation_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Conversation Id", + "type": "string" + }, + "moment_timestamp_ms": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Moment Timestamp Ms" + }, + "summary": { + "maxLength": 200, + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "type": { + "const": "captureLink", + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "conversation_id", + "summary" + ], + "title": "CaptureLinkSpec", + "type": "object" + }, "CategoryEnum": { "enum": [ "personal", @@ -5684,6 +5726,33 @@ "title": "ChartDataset", "type": "object" }, + "ChatFirstSubject": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Id", + "type": "string" + }, + "kind": { + "enum": [ + "task", + "goal", + "capture" + ], + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "title": "ChatFirstSubject", + "type": "object" + }, "ChatMessageCountResponse": { "properties": { "count": { @@ -8427,6 +8496,84 @@ "title": "DefaultTaskIntegrationResponse", "type": "object" }, + "DeferralCreateRequest": { + "additionalProperties": false, + "description": "The idempotent server receiver for the kernel-owned deferral outbox.", + "properties": { + "continuity_key": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Continuity Key", + "type": "string" + }, + "control_generation": { + "minimum": 0.0, + "title": "Control Generation", + "type": "integer" + }, + "owner_fence": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Owner Fence", + "type": "string" + }, + "question": { + "$ref": "#/components/schemas/QuestionCardSpec" + }, + "source_surface": { + "const": "main_chat", + "title": "Source Surface", + "type": "string" + }, + "subject": { + "$ref": "#/components/schemas/ChatFirstSubject" + } + }, + "required": [ + "source_surface", + "control_generation", + "owner_fence", + "continuity_key", + "subject", + "question" + ], + "title": "DeferralCreateRequest", + "type": "object" + }, + "DeferralReceipt": { + "additionalProperties": false, + "properties": { + "deferral_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Deferral Id", + "type": "string" + }, + "due_at": { + "format": "date-time", + "title": "Due At", + "type": "string" + }, + "state": { + "enum": [ + "pending", + "released" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "deferral_id", + "due_at", + "state" + ], + "title": "DeferralReceipt", + "type": "object" + }, "DeleteAccountRequest": { "properties": { "reason": { @@ -11270,6 +11417,36 @@ "title": "GoalLifecycleRequest", "type": "object" }, + "GoalLinkSpec": { + "additionalProperties": false, + "properties": { + "goal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Goal Id", + "type": "string" + }, + "summary": { + "maxLength": 200, + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "type": { + "const": "goalLink", + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "goal_id", + "summary" + ], + "title": "GoalLinkSpec", + "type": "object" + }, "GoalMetric": { "additionalProperties": false, "properties": { @@ -12384,6 +12561,62 @@ "title": "LlmUsageResponse", "type": "object" }, + "MaterializePromptsRequest": { + "additionalProperties": false, + "properties": { + "control_generation": { + "minimum": 0.0, + "title": "Control Generation", + "type": "integer" + }, + "owner_fence": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Owner Fence", + "type": "string" + }, + "receipts": { + "items": { + "$ref": "#/components/schemas/ProactiveMaterializationReceipt" + }, + "maxItems": 16, + "title": "Receipts", + "type": "array" + }, + "source_surface": { + "const": "main_chat", + "title": "Source Surface", + "type": "string" + }, + "window_foreground": { + "default": false, + "title": "Window Foreground", + "type": "boolean" + } + }, + "required": [ + "source_surface", + "control_generation", + "owner_fence" + ], + "title": "MaterializePromptsRequest", + "type": "object" + }, + "MaterializePromptsResponse": { + "additionalProperties": false, + "properties": { + "intents": { + "items": { + "$ref": "#/components/schemas/ProactiveIntent" + }, + "title": "Intents", + "type": "array" + } + }, + "title": "MaterializePromptsResponse", + "type": "object" + }, "McpAddServerResponse": { "properties": { "app_id": { @@ -15410,20 +15643,172 @@ "title", "price_string" ], - "title": "PricingOption", + "title": "PricingOption", + "type": "object" + }, + "PrivateCloudSyncResponse": { + "properties": { + "private_cloud_sync_enabled": { + "title": "Private Cloud Sync Enabled", + "type": "boolean" + } + }, + "required": [ + "private_cloud_sync_enabled" + ], + "title": "PrivateCloudSyncResponse", + "type": "object" + }, + "ProactiveIntent": { + "additionalProperties": false, + "description": "A server-side instruction, not a Chat transcript row.\n\nThe local desktop kernel is the sole writer of the visible assistant turn.\nThis record remains ready until that kernel has committed and acknowledged\nits stable ``intent_id``.", + "properties": { + "account_generation": { + "minimum": 0.0, + "title": "Account Generation", + "type": "integer" + }, + "blocks": { + "items": { + "discriminator": { + "mapping": { + "captureLink": "#/components/schemas/CaptureLinkSpec", + "goalLink": "#/components/schemas/GoalLinkSpec", + "questionCard": "#/components/schemas/QuestionCardSpec", + "taskCard": "#/components/schemas/TaskCardSpec" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/QuestionCardSpec" + }, + { + "$ref": "#/components/schemas/TaskCardSpec" + }, + { + "$ref": "#/components/schemas/GoalLinkSpec" + }, + { + "$ref": "#/components/schemas/CaptureLinkSpec" + } + ] + }, + "maxItems": 8, + "minItems": 1, + "title": "Blocks", + "type": "array" + }, + "continuity_key": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Continuity Key", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "delivered_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Delivered At" + }, + "delivery_state": { + "default": "ready", + "enum": [ + "ready", + "delivered" + ], + "title": "Delivery State", + "type": "string" + }, + "intent_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Intent Id", + "type": "string" + }, + "materialization_receipt_id": { + "anyOf": [ + { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Materialization Receipt Id" + }, + "source": { + "enum": [ + "daily_opener", + "capture_arrival", + "deferral_reraise", + "agent_judgment" + ], + "title": "Source", + "type": "string" + }, + "subject": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatFirstSubject" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "intent_id", + "continuity_key", + "account_generation", + "source", + "blocks", + "created_at" + ], + "title": "ProactiveIntent", "type": "object" }, - "PrivateCloudSyncResponse": { + "ProactiveMaterializationReceipt": { + "additionalProperties": false, + "description": "Content-free receipt emitted only after the local journal commits.", "properties": { - "private_cloud_sync_enabled": { - "title": "Private Cloud Sync Enabled", - "type": "boolean" + "intent_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Intent Id", + "type": "string" + }, + "receipt_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Receipt Id", + "type": "string" } }, "required": [ - "private_cloud_sync_enabled" + "intent_id", + "receipt_id" ], - "title": "PrivateCloudSyncResponse", + "title": "ProactiveMaterializationReceipt", "type": "object" }, "ProactiveNotification": { @@ -15623,6 +16008,86 @@ "title": "PublicFairUseCaseStatusResponse", "type": "object" }, + "QuestionCardSpec": { + "additionalProperties": false, + "properties": { + "options": { + "items": { + "$ref": "#/components/schemas/QuestionOption" + }, + "maxItems": 4, + "minItems": 1, + "title": "Options", + "type": "array" + }, + "question_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Question Id", + "type": "string" + }, + "subject": { + "$ref": "#/components/schemas/ChatFirstSubject" + }, + "text": { + "maxLength": 300, + "minLength": 1, + "title": "Text", + "type": "string" + }, + "type": { + "const": "questionCard", + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "question_id", + "text", + "subject", + "options" + ], + "title": "QuestionCardSpec", + "type": "object" + }, + "QuestionOption": { + "additionalProperties": false, + "properties": { + "defer": { + "default": false, + "title": "Defer", + "type": "boolean" + }, + "label": { + "maxLength": 80, + "minLength": 1, + "title": "Label", + "type": "string" + }, + "option_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Option Id", + "type": "string" + }, + "prepared_answer": { + "maxLength": 500, + "minLength": 1, + "title": "Prepared Answer", + "type": "string" + } + }, + "required": [ + "option_id", + "label", + "prepared_answer" + ], + "title": "QuestionOption", + "type": "object" + }, "RateMessageRequest": { "properties": { "rating": { @@ -18652,6 +19117,29 @@ } ] }, + "TaskCardSpec": { + "additionalProperties": false, + "properties": { + "task_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Task Id", + "type": "string" + }, + "type": { + "const": "taskCard", + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "task_id" + ], + "title": "TaskCardSpec", + "type": "object" + }, "TaskChangePayload": { "additionalProperties": false, "properties": { @@ -30198,6 +30686,182 @@ ] } }, + "/v1/chat/deferrals": { + "post": { + "description": "Receive one idempotent kernel-outbox deferral without touching Chat state.", + "operationId": "record_chat_deferral_v1_chat_deferrals_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "title": "Authorization", + "type": "string" + } + }, + { + "in": "header", + "name": "X-App-Platform", + "required": false, + "schema": { + "title": "X-App-Platform", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Device-Id-Hash", + "required": false, + "schema": { + "title": "X-Device-Id-Hash", + "type": "string" + } + }, + { + "in": "header", + "name": "X-App-Version", + "required": false, + "schema": { + "title": "X-App-Version", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeferralCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeferralReceipt" + } + } + }, + "description": "Successful Response" + }, + "401": { + "$ref": "#/components/responses/Error401" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "firebaseBearer": [] + } + ], + "summary": "Record Chat Deferral", + "tags": [ + "chat-first" + ] + } + }, + "/v1/chat/materialize-prompts": { + "post": { + "description": "Fetch ready intents and accept kernel receipts; never writes a Chat row.", + "operationId": "materialize_prompts_v1_chat_materialize_prompts_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "title": "Authorization", + "type": "string" + } + }, + { + "in": "header", + "name": "X-App-Platform", + "required": false, + "schema": { + "title": "X-App-Platform", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Device-Id-Hash", + "required": false, + "schema": { + "title": "X-Device-Id-Hash", + "type": "string" + } + }, + { + "in": "header", + "name": "X-App-Version", + "required": false, + "schema": { + "title": "X-App-Version", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaterializePromptsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaterializePromptsResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "$ref": "#/components/responses/Error401" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "firebaseBearer": [] + } + ], + "summary": "Materialize Prompts", + "tags": [ + "chat-first" + ] + } + }, "/v1/conversations": { "get": { "description": "List responses may omit detail-only fields such as transcript_segments. Clients should treat omitted transcript_segments as unknown/not loaded, not as an empty transcript.", diff --git a/web/admin/lib/services/omi-api/omiApi.generated.ts b/web/admin/lib/services/omi-api/omiApi.generated.ts index f16eb437408..e8f709aee1f 100644 --- a/web/admin/lib/services/omi-api/omiApi.generated.ts +++ b/web/admin/lib/services/omi-api/omiApi.generated.ts @@ -857,6 +857,13 @@ export type CandidateStatus = "pending" | "accepted" | "rejected" | "expired"; export type CandidateSubjectKind = "task" | "workstream"; +export interface CaptureLinkSpec { + conversation_id: string; + moment_timestamp_ms?: number | null; + summary: string; + type: "captureLink"; +} + export type CategoryEnum = "personal" | "education" | "health" | "finance" | "legal" | "philosophy" | "spiritual" | "science" | "entrepreneurship" | "parenting" | "romantic" | "travel" | "inspiration" | "technology" | "business" | "social" | "work" | "sports" | "politics" | "literature" | "history" | "architecture" | "music" | "weather" | "news" | "entertainment" | "psychology" | "real" | "design" | "family" | "economics" | "environment" | "other"; export interface ChartData { @@ -878,6 +885,11 @@ export interface ChartDataset { label: string; } +export interface ChatFirstSubject { + id: string; + kind: "task" | "goal" | "capture"; +} + export interface ChatMessageCountResponse { count: number; } @@ -1320,6 +1332,21 @@ export interface DefaultTaskIntegrationResponse { default_app: string | null; } +export interface DeferralCreateRequest { + continuity_key: string; + control_generation: number; + owner_fence: string; + question: QuestionCardSpec; + source_surface: "main_chat"; + subject: ChatFirstSubject; +} + +export interface DeferralReceipt { + deferral_id: string; + due_at: string; + state: "pending" | "released"; +} + export interface DeleteAccountRequest { reason?: string | null; reason_details?: string | null; @@ -1812,6 +1839,12 @@ export interface GoalLifecycleRequest { status: GoalStatus; } +export interface GoalLinkSpec { + goal_id: string; + summary: string; + type: "goalLink"; +} + export interface GoalMetric { current: number; max?: number | null; @@ -2011,6 +2044,18 @@ export interface LlmUsageResponse { top_features?: Array; } +export interface MaterializePromptsRequest { + control_generation: number; + owner_fence: string; + receipts?: Array; + source_surface: "main_chat"; + window_foreground?: boolean; +} + +export interface MaterializePromptsResponse { + intents?: Array; +} + export interface McpAddServerResponse { app_id: string; auth_url?: string | null; @@ -2537,6 +2582,24 @@ export interface PrivateCloudSyncResponse { private_cloud_sync_enabled: boolean; } +export interface ProactiveIntent { + account_generation: number; + blocks: Array; + continuity_key: string; + created_at: string; + delivered_at?: string | null; + delivery_state?: "ready" | "delivered"; + intent_id: string; + materialization_receipt_id?: string | null; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + subject?: ChatFirstSubject | null; +} + +export interface ProactiveMaterializationReceipt { + intent_id: string; + receipt_id: string; +} + export interface ProactiveNotification { scopes: Array; } @@ -2572,6 +2635,21 @@ export interface PublicFairUseCaseStatusResponse { updated_at?: string | null; } +export interface QuestionCardSpec { + options: Array; + question_id: string; + subject: ChatFirstSubject; + text: string; + type: "questionCard"; +} + +export interface QuestionOption { + defer?: boolean; + label: string; + option_id: string; + prepared_answer: string; +} + export interface RateMessageRequest { rating?: number | null; } @@ -3078,6 +3156,11 @@ export interface TaskCancelCandidate { export type TaskCandidate = TaskCreateCandidate | TaskUpdateCandidate | TaskCompleteCandidate | TaskCancelCandidate | TaskSupersedeCandidate; +export interface TaskCardSpec { + task_id: string; + type: "taskCard"; +} + export interface TaskChangePayload { description?: string | null; due_at?: string | null; @@ -3797,10 +3880,12 @@ export interface OmiApiSchemas { "CandidateResolutionRequest": CandidateResolutionRequest; "CandidateStatus": CandidateStatus; "CandidateSubjectKind": CandidateSubjectKind; + "CaptureLinkSpec": CaptureLinkSpec; "CategoryEnum": CategoryEnum; "ChartData": ChartData; "ChartDataPoint": ChartDataPoint; "ChartDataset": ChartDataset; + "ChatFirstSubject": ChatFirstSubject; "ChatMessageCountResponse": ChatMessageCountResponse; "ChatQuotaUnit": ChatQuotaUnit; "ChatRatingResponse": ChatRatingResponse; @@ -3864,6 +3949,8 @@ export interface OmiApiSchemas { "DecisionRecord": DecisionRecord; "DefaultTaskIntegrationRequest": DefaultTaskIntegrationRequest; "DefaultTaskIntegrationResponse": DefaultTaskIntegrationResponse; + "DeferralCreateRequest": DeferralCreateRequest; + "DeferralReceipt": DeferralReceipt; "DeleteAccountRequest": DeleteAccountRequest; "DeleteActionItemRequest": DeleteActionItemRequest; "DeleteImportJobResponse": DeleteImportJobResponse; @@ -3927,6 +4014,7 @@ export interface OmiApiSchemas { "GoalFocusRequest": GoalFocusRequest; "GoalHistoryEntryResponse": GoalHistoryEntryResponse; "GoalLifecycleRequest": GoalLifecycleRequest; + "GoalLinkSpec": GoalLinkSpec; "GoalMetric": GoalMetric; "GoalOriginWorkIntent": GoalOriginWorkIntent; "GoalProgressEvent": GoalProgressEvent; @@ -3957,6 +4045,8 @@ export interface OmiApiSchemas { "LlmUsageFeatureResponse": LlmUsageFeatureResponse; "LlmUsageRecordResponse": LlmUsageRecordResponse; "LlmUsageResponse": LlmUsageResponse; + "MaterializePromptsRequest": MaterializePromptsRequest; + "MaterializePromptsResponse": MaterializePromptsResponse; "McpAddServerResponse": McpAddServerResponse; "McpApiKey": McpApiKey; "McpApiKeyCreate": McpApiKeyCreate; @@ -4032,12 +4122,16 @@ export interface OmiApiSchemas { "PluginResult": PluginResult; "PricingOption": PricingOption; "PrivateCloudSyncResponse": PrivateCloudSyncResponse; + "ProactiveIntent": ProactiveIntent; + "ProactiveMaterializationReceipt": ProactiveMaterializationReceipt; "ProactiveNotification": ProactiveNotification; "ProcessConversationRequest": ProcessConversationRequest; "ProgressExtractRequest": ProgressExtractRequest; "ProgressExtractResponse": ProgressExtractResponse; "ProgressExtractUpdateResponse": ProgressExtractUpdateResponse; "PublicFairUseCaseStatusResponse": PublicFairUseCaseStatusResponse; + "QuestionCardSpec": QuestionCardSpec; + "QuestionOption": QuestionOption; "RateMessageRequest": RateMessageRequest; "RebuildResponse": RebuildResponse; "Recommendation": Recommendation; @@ -4105,6 +4199,7 @@ export interface OmiApiSchemas { "TaskAssistantSettings": TaskAssistantSettings; "TaskCancelCandidate": TaskCancelCandidate; "TaskCandidate": TaskCandidate; + "TaskCardSpec": TaskCardSpec; "TaskChangePayload": TaskChangePayload; "TaskCompleteCandidate": TaskCompleteCandidate; "TaskCreateCandidate": TaskCreateCandidate; @@ -5082,6 +5177,26 @@ export interface OmiApiPaths { }; }; }; + "/v1/chat/deferrals": { + post: { + operationId: "record_chat_deferral_v1_chat_deferrals_post"; + responses: { + "200": DeferralReceipt; + "401": void; + "422": HTTPValidationError; + }; + }; + }; + "/v1/chat/materialize-prompts": { + post: { + operationId: "materialize_prompts_v1_chat_materialize_prompts_post"; + responses: { + "200": MaterializePromptsResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/conversations": { get: { operationId: "get_conversations_v1_conversations_get"; @@ -9785,6 +9900,48 @@ export async function reject_candidate_v1_candidates__candidate_id__reject_post( return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function record_chat_deferral_v1_chat_deferrals_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: DeferralCreateRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/chat/deferrals`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + +export async function materialize_prompts_v1_chat_materialize_prompts_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: MaterializePromptsRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/chat/materialize-prompts`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, sources?: string | null, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/conversations`; @@ -15500,4 +15657,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 380 client methods generated. +// Total: 381 client methods generated. diff --git a/web/app/src/lib/omiApi.generated.ts b/web/app/src/lib/omiApi.generated.ts index f16eb437408..e8f709aee1f 100644 --- a/web/app/src/lib/omiApi.generated.ts +++ b/web/app/src/lib/omiApi.generated.ts @@ -857,6 +857,13 @@ export type CandidateStatus = "pending" | "accepted" | "rejected" | "expired"; export type CandidateSubjectKind = "task" | "workstream"; +export interface CaptureLinkSpec { + conversation_id: string; + moment_timestamp_ms?: number | null; + summary: string; + type: "captureLink"; +} + export type CategoryEnum = "personal" | "education" | "health" | "finance" | "legal" | "philosophy" | "spiritual" | "science" | "entrepreneurship" | "parenting" | "romantic" | "travel" | "inspiration" | "technology" | "business" | "social" | "work" | "sports" | "politics" | "literature" | "history" | "architecture" | "music" | "weather" | "news" | "entertainment" | "psychology" | "real" | "design" | "family" | "economics" | "environment" | "other"; export interface ChartData { @@ -878,6 +885,11 @@ export interface ChartDataset { label: string; } +export interface ChatFirstSubject { + id: string; + kind: "task" | "goal" | "capture"; +} + export interface ChatMessageCountResponse { count: number; } @@ -1320,6 +1332,21 @@ export interface DefaultTaskIntegrationResponse { default_app: string | null; } +export interface DeferralCreateRequest { + continuity_key: string; + control_generation: number; + owner_fence: string; + question: QuestionCardSpec; + source_surface: "main_chat"; + subject: ChatFirstSubject; +} + +export interface DeferralReceipt { + deferral_id: string; + due_at: string; + state: "pending" | "released"; +} + export interface DeleteAccountRequest { reason?: string | null; reason_details?: string | null; @@ -1812,6 +1839,12 @@ export interface GoalLifecycleRequest { status: GoalStatus; } +export interface GoalLinkSpec { + goal_id: string; + summary: string; + type: "goalLink"; +} + export interface GoalMetric { current: number; max?: number | null; @@ -2011,6 +2044,18 @@ export interface LlmUsageResponse { top_features?: Array; } +export interface MaterializePromptsRequest { + control_generation: number; + owner_fence: string; + receipts?: Array; + source_surface: "main_chat"; + window_foreground?: boolean; +} + +export interface MaterializePromptsResponse { + intents?: Array; +} + export interface McpAddServerResponse { app_id: string; auth_url?: string | null; @@ -2537,6 +2582,24 @@ export interface PrivateCloudSyncResponse { private_cloud_sync_enabled: boolean; } +export interface ProactiveIntent { + account_generation: number; + blocks: Array; + continuity_key: string; + created_at: string; + delivered_at?: string | null; + delivery_state?: "ready" | "delivered"; + intent_id: string; + materialization_receipt_id?: string | null; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + subject?: ChatFirstSubject | null; +} + +export interface ProactiveMaterializationReceipt { + intent_id: string; + receipt_id: string; +} + export interface ProactiveNotification { scopes: Array; } @@ -2572,6 +2635,21 @@ export interface PublicFairUseCaseStatusResponse { updated_at?: string | null; } +export interface QuestionCardSpec { + options: Array; + question_id: string; + subject: ChatFirstSubject; + text: string; + type: "questionCard"; +} + +export interface QuestionOption { + defer?: boolean; + label: string; + option_id: string; + prepared_answer: string; +} + export interface RateMessageRequest { rating?: number | null; } @@ -3078,6 +3156,11 @@ export interface TaskCancelCandidate { export type TaskCandidate = TaskCreateCandidate | TaskUpdateCandidate | TaskCompleteCandidate | TaskCancelCandidate | TaskSupersedeCandidate; +export interface TaskCardSpec { + task_id: string; + type: "taskCard"; +} + export interface TaskChangePayload { description?: string | null; due_at?: string | null; @@ -3797,10 +3880,12 @@ export interface OmiApiSchemas { "CandidateResolutionRequest": CandidateResolutionRequest; "CandidateStatus": CandidateStatus; "CandidateSubjectKind": CandidateSubjectKind; + "CaptureLinkSpec": CaptureLinkSpec; "CategoryEnum": CategoryEnum; "ChartData": ChartData; "ChartDataPoint": ChartDataPoint; "ChartDataset": ChartDataset; + "ChatFirstSubject": ChatFirstSubject; "ChatMessageCountResponse": ChatMessageCountResponse; "ChatQuotaUnit": ChatQuotaUnit; "ChatRatingResponse": ChatRatingResponse; @@ -3864,6 +3949,8 @@ export interface OmiApiSchemas { "DecisionRecord": DecisionRecord; "DefaultTaskIntegrationRequest": DefaultTaskIntegrationRequest; "DefaultTaskIntegrationResponse": DefaultTaskIntegrationResponse; + "DeferralCreateRequest": DeferralCreateRequest; + "DeferralReceipt": DeferralReceipt; "DeleteAccountRequest": DeleteAccountRequest; "DeleteActionItemRequest": DeleteActionItemRequest; "DeleteImportJobResponse": DeleteImportJobResponse; @@ -3927,6 +4014,7 @@ export interface OmiApiSchemas { "GoalFocusRequest": GoalFocusRequest; "GoalHistoryEntryResponse": GoalHistoryEntryResponse; "GoalLifecycleRequest": GoalLifecycleRequest; + "GoalLinkSpec": GoalLinkSpec; "GoalMetric": GoalMetric; "GoalOriginWorkIntent": GoalOriginWorkIntent; "GoalProgressEvent": GoalProgressEvent; @@ -3957,6 +4045,8 @@ export interface OmiApiSchemas { "LlmUsageFeatureResponse": LlmUsageFeatureResponse; "LlmUsageRecordResponse": LlmUsageRecordResponse; "LlmUsageResponse": LlmUsageResponse; + "MaterializePromptsRequest": MaterializePromptsRequest; + "MaterializePromptsResponse": MaterializePromptsResponse; "McpAddServerResponse": McpAddServerResponse; "McpApiKey": McpApiKey; "McpApiKeyCreate": McpApiKeyCreate; @@ -4032,12 +4122,16 @@ export interface OmiApiSchemas { "PluginResult": PluginResult; "PricingOption": PricingOption; "PrivateCloudSyncResponse": PrivateCloudSyncResponse; + "ProactiveIntent": ProactiveIntent; + "ProactiveMaterializationReceipt": ProactiveMaterializationReceipt; "ProactiveNotification": ProactiveNotification; "ProcessConversationRequest": ProcessConversationRequest; "ProgressExtractRequest": ProgressExtractRequest; "ProgressExtractResponse": ProgressExtractResponse; "ProgressExtractUpdateResponse": ProgressExtractUpdateResponse; "PublicFairUseCaseStatusResponse": PublicFairUseCaseStatusResponse; + "QuestionCardSpec": QuestionCardSpec; + "QuestionOption": QuestionOption; "RateMessageRequest": RateMessageRequest; "RebuildResponse": RebuildResponse; "Recommendation": Recommendation; @@ -4105,6 +4199,7 @@ export interface OmiApiSchemas { "TaskAssistantSettings": TaskAssistantSettings; "TaskCancelCandidate": TaskCancelCandidate; "TaskCandidate": TaskCandidate; + "TaskCardSpec": TaskCardSpec; "TaskChangePayload": TaskChangePayload; "TaskCompleteCandidate": TaskCompleteCandidate; "TaskCreateCandidate": TaskCreateCandidate; @@ -5082,6 +5177,26 @@ export interface OmiApiPaths { }; }; }; + "/v1/chat/deferrals": { + post: { + operationId: "record_chat_deferral_v1_chat_deferrals_post"; + responses: { + "200": DeferralReceipt; + "401": void; + "422": HTTPValidationError; + }; + }; + }; + "/v1/chat/materialize-prompts": { + post: { + operationId: "materialize_prompts_v1_chat_materialize_prompts_post"; + responses: { + "200": MaterializePromptsResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/conversations": { get: { operationId: "get_conversations_v1_conversations_get"; @@ -9785,6 +9900,48 @@ export async function reject_candidate_v1_candidates__candidate_id__reject_post( return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function record_chat_deferral_v1_chat_deferrals_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: DeferralCreateRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/chat/deferrals`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + +export async function materialize_prompts_v1_chat_materialize_prompts_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: MaterializePromptsRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/chat/materialize-prompts`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, sources?: string | null, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/conversations`; @@ -15500,4 +15657,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 380 client methods generated. +// Total: 381 client methods generated. diff --git a/web/personas-open-source/src/lib/omiApi.generated.ts b/web/personas-open-source/src/lib/omiApi.generated.ts index f16eb437408..e8f709aee1f 100644 --- a/web/personas-open-source/src/lib/omiApi.generated.ts +++ b/web/personas-open-source/src/lib/omiApi.generated.ts @@ -857,6 +857,13 @@ export type CandidateStatus = "pending" | "accepted" | "rejected" | "expired"; export type CandidateSubjectKind = "task" | "workstream"; +export interface CaptureLinkSpec { + conversation_id: string; + moment_timestamp_ms?: number | null; + summary: string; + type: "captureLink"; +} + export type CategoryEnum = "personal" | "education" | "health" | "finance" | "legal" | "philosophy" | "spiritual" | "science" | "entrepreneurship" | "parenting" | "romantic" | "travel" | "inspiration" | "technology" | "business" | "social" | "work" | "sports" | "politics" | "literature" | "history" | "architecture" | "music" | "weather" | "news" | "entertainment" | "psychology" | "real" | "design" | "family" | "economics" | "environment" | "other"; export interface ChartData { @@ -878,6 +885,11 @@ export interface ChartDataset { label: string; } +export interface ChatFirstSubject { + id: string; + kind: "task" | "goal" | "capture"; +} + export interface ChatMessageCountResponse { count: number; } @@ -1320,6 +1332,21 @@ export interface DefaultTaskIntegrationResponse { default_app: string | null; } +export interface DeferralCreateRequest { + continuity_key: string; + control_generation: number; + owner_fence: string; + question: QuestionCardSpec; + source_surface: "main_chat"; + subject: ChatFirstSubject; +} + +export interface DeferralReceipt { + deferral_id: string; + due_at: string; + state: "pending" | "released"; +} + export interface DeleteAccountRequest { reason?: string | null; reason_details?: string | null; @@ -1812,6 +1839,12 @@ export interface GoalLifecycleRequest { status: GoalStatus; } +export interface GoalLinkSpec { + goal_id: string; + summary: string; + type: "goalLink"; +} + export interface GoalMetric { current: number; max?: number | null; @@ -2011,6 +2044,18 @@ export interface LlmUsageResponse { top_features?: Array; } +export interface MaterializePromptsRequest { + control_generation: number; + owner_fence: string; + receipts?: Array; + source_surface: "main_chat"; + window_foreground?: boolean; +} + +export interface MaterializePromptsResponse { + intents?: Array; +} + export interface McpAddServerResponse { app_id: string; auth_url?: string | null; @@ -2537,6 +2582,24 @@ export interface PrivateCloudSyncResponse { private_cloud_sync_enabled: boolean; } +export interface ProactiveIntent { + account_generation: number; + blocks: Array; + continuity_key: string; + created_at: string; + delivered_at?: string | null; + delivery_state?: "ready" | "delivered"; + intent_id: string; + materialization_receipt_id?: string | null; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + subject?: ChatFirstSubject | null; +} + +export interface ProactiveMaterializationReceipt { + intent_id: string; + receipt_id: string; +} + export interface ProactiveNotification { scopes: Array; } @@ -2572,6 +2635,21 @@ export interface PublicFairUseCaseStatusResponse { updated_at?: string | null; } +export interface QuestionCardSpec { + options: Array; + question_id: string; + subject: ChatFirstSubject; + text: string; + type: "questionCard"; +} + +export interface QuestionOption { + defer?: boolean; + label: string; + option_id: string; + prepared_answer: string; +} + export interface RateMessageRequest { rating?: number | null; } @@ -3078,6 +3156,11 @@ export interface TaskCancelCandidate { export type TaskCandidate = TaskCreateCandidate | TaskUpdateCandidate | TaskCompleteCandidate | TaskCancelCandidate | TaskSupersedeCandidate; +export interface TaskCardSpec { + task_id: string; + type: "taskCard"; +} + export interface TaskChangePayload { description?: string | null; due_at?: string | null; @@ -3797,10 +3880,12 @@ export interface OmiApiSchemas { "CandidateResolutionRequest": CandidateResolutionRequest; "CandidateStatus": CandidateStatus; "CandidateSubjectKind": CandidateSubjectKind; + "CaptureLinkSpec": CaptureLinkSpec; "CategoryEnum": CategoryEnum; "ChartData": ChartData; "ChartDataPoint": ChartDataPoint; "ChartDataset": ChartDataset; + "ChatFirstSubject": ChatFirstSubject; "ChatMessageCountResponse": ChatMessageCountResponse; "ChatQuotaUnit": ChatQuotaUnit; "ChatRatingResponse": ChatRatingResponse; @@ -3864,6 +3949,8 @@ export interface OmiApiSchemas { "DecisionRecord": DecisionRecord; "DefaultTaskIntegrationRequest": DefaultTaskIntegrationRequest; "DefaultTaskIntegrationResponse": DefaultTaskIntegrationResponse; + "DeferralCreateRequest": DeferralCreateRequest; + "DeferralReceipt": DeferralReceipt; "DeleteAccountRequest": DeleteAccountRequest; "DeleteActionItemRequest": DeleteActionItemRequest; "DeleteImportJobResponse": DeleteImportJobResponse; @@ -3927,6 +4014,7 @@ export interface OmiApiSchemas { "GoalFocusRequest": GoalFocusRequest; "GoalHistoryEntryResponse": GoalHistoryEntryResponse; "GoalLifecycleRequest": GoalLifecycleRequest; + "GoalLinkSpec": GoalLinkSpec; "GoalMetric": GoalMetric; "GoalOriginWorkIntent": GoalOriginWorkIntent; "GoalProgressEvent": GoalProgressEvent; @@ -3957,6 +4045,8 @@ export interface OmiApiSchemas { "LlmUsageFeatureResponse": LlmUsageFeatureResponse; "LlmUsageRecordResponse": LlmUsageRecordResponse; "LlmUsageResponse": LlmUsageResponse; + "MaterializePromptsRequest": MaterializePromptsRequest; + "MaterializePromptsResponse": MaterializePromptsResponse; "McpAddServerResponse": McpAddServerResponse; "McpApiKey": McpApiKey; "McpApiKeyCreate": McpApiKeyCreate; @@ -4032,12 +4122,16 @@ export interface OmiApiSchemas { "PluginResult": PluginResult; "PricingOption": PricingOption; "PrivateCloudSyncResponse": PrivateCloudSyncResponse; + "ProactiveIntent": ProactiveIntent; + "ProactiveMaterializationReceipt": ProactiveMaterializationReceipt; "ProactiveNotification": ProactiveNotification; "ProcessConversationRequest": ProcessConversationRequest; "ProgressExtractRequest": ProgressExtractRequest; "ProgressExtractResponse": ProgressExtractResponse; "ProgressExtractUpdateResponse": ProgressExtractUpdateResponse; "PublicFairUseCaseStatusResponse": PublicFairUseCaseStatusResponse; + "QuestionCardSpec": QuestionCardSpec; + "QuestionOption": QuestionOption; "RateMessageRequest": RateMessageRequest; "RebuildResponse": RebuildResponse; "Recommendation": Recommendation; @@ -4105,6 +4199,7 @@ export interface OmiApiSchemas { "TaskAssistantSettings": TaskAssistantSettings; "TaskCancelCandidate": TaskCancelCandidate; "TaskCandidate": TaskCandidate; + "TaskCardSpec": TaskCardSpec; "TaskChangePayload": TaskChangePayload; "TaskCompleteCandidate": TaskCompleteCandidate; "TaskCreateCandidate": TaskCreateCandidate; @@ -5082,6 +5177,26 @@ export interface OmiApiPaths { }; }; }; + "/v1/chat/deferrals": { + post: { + operationId: "record_chat_deferral_v1_chat_deferrals_post"; + responses: { + "200": DeferralReceipt; + "401": void; + "422": HTTPValidationError; + }; + }; + }; + "/v1/chat/materialize-prompts": { + post: { + operationId: "materialize_prompts_v1_chat_materialize_prompts_post"; + responses: { + "200": MaterializePromptsResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/conversations": { get: { operationId: "get_conversations_v1_conversations_get"; @@ -9785,6 +9900,48 @@ export async function reject_candidate_v1_candidates__candidate_id__reject_post( return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function record_chat_deferral_v1_chat_deferrals_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: DeferralCreateRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/chat/deferrals`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + +export async function materialize_prompts_v1_chat_materialize_prompts_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: MaterializePromptsRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/chat/materialize-prompts`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_conversations_v1_conversations_get(query: { limit?: number, offset?: number, statuses?: string | null, include_discarded?: boolean, sources?: string | null, start_date?: string | null, end_date?: string | null, folder_id?: string | null, starred?: boolean | null }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/conversations`; @@ -15500,4 +15657,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 380 client methods generated. +// Total: 381 client methods generated. From 11052c9e97d91a65a401a2b403a2e6c68f7c2a92 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 12:20:50 -0400 Subject: [PATCH 06/28] feat(desktop): add the cohort-gated chat-first shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a server-sampled, fail-closed parallel shell with Chat as home, typed primary/More navigation, and capability projection before chat warmup. More→Dashboard routes to the one Chat rather than embedding an inline second composer; legacy shell behavior is preserved.\n\nVerification:\n- ChatFirstShell XCTest suite (including stable goals automation route)\n- Swift debug build and desktop test-quality check\n- agent-logic Swift gate: 438 focused lifecycle/state tests\n- agent runtime/node harness: 104 + 104 tests\n- named omi-chat-first-t06 bundle: signed/launched, healthy runtime, capability-off legacy shell and legacy Chat/Tasks/Conversations routes verified\n- fresh Sol review-and-fix gate\n\nEnabled live shell was not exercised because the server-authoritative dev control returned off for the seeded account; no local override was used. --- .../Chat/ChatFirstCapabilityProjection.swift | 1 + .../Sources/DesktopAutomationBridge.swift | 11 + .../MainWindow/ChatFirst/ChatFirstRoute.swift | 321 ++++++++++++++ .../MainWindow/ChatFirst/ChatFirstShell.swift | 309 +++++++++++++ .../Sources/MainWindow/DesktopHomeView.swift | 415 +++++++++++++----- .../MainWindow/Pages/DashboardPage.swift | 36 +- .../Sources/Providers/ChatProvider.swift | 49 ++- .../Desktop/Tests/ChatFirstShellTests.swift | 124 ++++++ .../agent/src/runtime/conversation-turns.ts | 34 ++ .../unreleased/20260715-chat-first-shell.json | 3 + desktop/macos/scripts/omi-ctl | 2 +- 11 files changed, 1185 insertions(+), 120 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift create mode 100644 desktop/macos/Desktop/Tests/ChatFirstShellTests.swift create mode 100644 desktop/macos/changelog/unreleased/20260715-chat-first-shell.json diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift index 44cedc78ef3..9da5f57b95a 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift @@ -9,6 +9,7 @@ struct ChatFirstCapabilityProjection: Equatable, Sendable { init?(control: OmiAPI.TaskWorkflowControl) { guard control.chatFirstUi == true, + control.workflowMode == .read, let generation = control.accountGeneration, generation >= 0 else { return nil } diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift index 5668e953a7a..351834bdde2 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift @@ -124,6 +124,13 @@ struct DesktopAutomationSnapshot: Codable, Sendable { var usesLegacyHomeDesign: Bool /// Redesigned Home stage mode: `hub`, `chat`, or `connect`. Nil when legacy home or not on Dashboard. var homeMode: String? + /// `loading`, `legacy`, or `chat_first`; never a local rollout preference. + var shellVariant: String? + /// Stable typed route for the cohort shell. Nil for the legacy shell. + var chatFirstRoute: String? + /// Shape-only focus telemetry for route acknowledgement; entity IDs stay local. + var pendingFocusKind: String? + var acknowledgedFocusKind: String? var showsPrimarySidebar: Bool var isSidebarCollapsed: Bool var hasCompletedOnboarding: Bool @@ -431,6 +438,10 @@ final class DesktopAutomationStateStore { highlightedSettingId: nil, usesLegacyHomeDesign: false, homeMode: nil, + shellVariant: nil, + chatFirstRoute: nil, + pendingFocusKind: nil, + acknowledgedFocusKind: nil, showsPrimarySidebar: false, isSidebarCollapsed: true, hasCompletedOnboarding: false, diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift new file mode 100644 index 00000000000..63991681f09 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift @@ -0,0 +1,321 @@ +import Foundation +import Combine + +/// Chat-first navigation deliberately does not reuse the legacy sidebar's raw +/// integer values. The legacy adapter below is the only compatibility boundary +/// while both shells are live. +enum ChatFirstRoute: Hashable, Codable, Sendable { + case chat + case conversations + case tasks + case goals + case memories + case more(ChatFirstMorePage) + + var stableName: String { + switch self { + case .chat: return "chat" + case .conversations: return "conversations" + case .tasks: return "tasks" + case .goals: return "goals" + case .memories: return "memories" + case .more(let page): return "more.\(page.stableName)" + } + } + + var title: String { + switch self { + case .chat: return "Chat" + case .conversations: return "Conversations" + case .tasks: return "Tasks" + case .goals: return "Goals" + case .memories: return "Memories" + case .more(let page): return page.title + } + } + + var isPrimaryDestination: Bool { + switch self { + case .chat, .conversations, .tasks, .goals, .memories: return true + case .more: return false + } + } + + static let primaryDestinations: [ChatFirstRoute] = [ + .chat, .conversations, .tasks, .goals, .memories, + ] + + /// Automation reuses the stable names of primary destinations. Legacy-only + /// locations deliberately return nil here and continue through the legacy + /// sidebar adapter in `DesktopHomeView`. + static func primaryAutomationDestination(named target: String) -> ChatFirstRoute? { + let normalized = target.lowercased().replacingOccurrences(of: "-", with: "_") + switch normalized { + case "chat": return .chat + case "conversations": return .conversations + case "tasks": return .tasks + case "goals": return .goals + case "memories": return .memories + default: return nil + } + } +} + +enum ChatFirstMorePage: String, CaseIterable, Codable, Hashable, Sendable { + case dashboard + case focus + case insight + case rewind + case apps + case permissions + case help + case settings + + var stableName: String { rawValue } + + var title: String { + switch self { + case .dashboard: return "Dashboard" + case .focus: return "Focus" + case .insight: return "Insights" + case .rewind: return "Rewind" + case .apps: return "Apps" + case .permissions: return "Permissions" + case .help: return "Help from Founder" + case .settings: return "Settings" + } + } + + var systemImage: String { + switch self { + case .dashboard: return "house.fill" + case .focus: return "eye.fill" + case .insight: return "lightbulb.fill" + case .rewind: return "clock.arrow.circlepath" + case .apps: return "puzzlepiece.fill" + case .permissions: return "exclamationmark.triangle.fill" + case .help: return "bubble.left.fill" + case .settings: return "gearshape.fill" + } + } +} + +enum ChatFirstPendingFocus: Equatable, Sendable { + case task(id: String) + case goal(id: String) + case capture(id: String, momentTs: TimeInterval?) + case memory(id: String) + + var route: ChatFirstRoute { + switch self { + case .task: return .tasks + case .goal: return .goals + case .capture: return .conversations + case .memory: return .memories + } + } + + var stableName: String { + switch self { + case .task: return "task" + case .goal: return "goal" + case .capture: return "capture" + case .memory: return "memory" + } + } +} + +private struct ChatFirstPersistedNavigation: Codable, Equatable { + var route: ChatFirstRoute + var isSidebarCollapsed: Bool +} + +/// Root-owned navigation and focus state for the cohort-only shell. The only +/// persisted values are route and collapse preference; a focus request is a +/// transient deep-link contract and must be acknowledged by the destination +/// only after that entity is visible. +@MainActor +final class ChatFirstShellNavigation: ObservableObject { + static let storageKey = "chatFirstShell.windowNavigation.v1" + + @Published private(set) var route: ChatFirstRoute + @Published private(set) var pendingFocus: ChatFirstPendingFocus? + @Published private(set) var lastAcknowledgedFocusKind: String? + @Published private(set) var isSidebarCollapsed: Bool + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + if let data = defaults.data(forKey: Self.storageKey), + let persisted = try? JSONDecoder().decode(ChatFirstPersistedNavigation.self, from: data) + { + route = persisted.route + isSidebarCollapsed = persisted.isSidebarCollapsed + } else { + route = .chat + isSidebarCollapsed = false + } + pendingFocus = nil + lastAcknowledgedFocusKind = nil + } + + func selectPrimary(_ destination: ChatFirstRoute) { + guard destination.isPrimaryDestination else { return } + route = destination + pendingFocus = nil + persistNavigation() + } + + func selectMore(_ page: ChatFirstMorePage) { + route = .more(page) + pendingFocus = nil + persistNavigation() + } + + /// Used by a typed rich-Chat link. Unlike direct navigation it carries the + /// focus until the destination calls `acknowledgeFocus` after visible load. + func open(focus: ChatFirstPendingFocus) { + route = focus.route + pendingFocus = focus + persistNavigation() + } + + @discardableResult + func acknowledgeFocus(_ focus: ChatFirstPendingFocus) -> Bool { + guard route == focus.route, pendingFocus == focus else { return false } + pendingFocus = nil + lastAcknowledgedFocusKind = focus.stableName + return true + } + + func toggleSidebar() { + isSidebarCollapsed.toggle() + persistNavigation() + } + + func setSidebarCollapsed(_ isCollapsed: Bool) { + guard isSidebarCollapsed != isCollapsed else { return } + isSidebarCollapsed = isCollapsed + persistNavigation() + } + + /// Compatibility boundary for existing automation names and page callbacks. + /// No Chat-first route is represented by a legacy raw index internally. + func selectLegacyDestination(_ item: SidebarNavItem) { + switch item { + case .dashboard: selectMore(.dashboard) + case .conversations: selectPrimary(.conversations) + case .chat: selectPrimary(.chat) + case .memories: selectPrimary(.memories) + case .tasks: selectPrimary(.tasks) + case .focus: selectMore(.focus) + case .insight: selectMore(.insight) + case .rewind: selectMore(.rewind) + case .apps: selectMore(.apps) + case .settings: selectMore(.settings) + case .permissions: selectMore(.permissions) + case .help: selectMore(.help) + } + } + + private func persistNavigation() { + let persisted = ChatFirstPersistedNavigation(route: route, isSidebarCollapsed: isSidebarCollapsed) + defaults.set(try? JSONEncoder().encode(persisted), forKey: Self.storageKey) + } +} + +/// An immutable per-root sampling result. A failed, missing, stale, or +/// owner-mismatched control response resolves to legacy. Once resolved for an +/// owner it never live-swaps; owner replacement fails closed for this launch. +enum ChatFirstShellVariant: Equatable { + case unresolved + case legacy + case chatFirst(ChatFirstCapabilityProjection) + + var projection: ChatFirstCapabilityProjection? { + guard case .chatFirst(let projection) = self else { return nil } + return projection + } + + var stableName: String { + switch self { + case .unresolved: return "loading" + case .legacy: return "legacy" + case .chatFirst: return "chat_first" + } + } +} + +struct ChatFirstShellCapabilitySample: Equatable { + private(set) var variant: ChatFirstShellVariant = .unresolved + private(set) var sampledOwnerID: String? + + mutating func resolve( + control: OmiAPI.TaskWorkflowControl?, + requestedOwnerID: String?, + ownerIsStillCurrent: Bool + ) { + guard case .unresolved = variant else { return } + guard let ownerID = requestedOwnerID, !ownerID.isEmpty, ownerIsStillCurrent else { + variant = .legacy + return + } + sampledOwnerID = ownerID + if let control, let projection = ChatFirstCapabilityProjection(control: control) { + variant = .chatFirst(projection) + } else { + variant = .legacy + } + } + + mutating func ownerDidChange(to ownerID: String?) { + guard let sampledOwnerID else { return } + guard sampledOwnerID == ownerID else { + variant = .legacy + return + } + } + + mutating func failClosed() { + variant = .legacy + } +} + +/// The provider owns this tiny bridge handoff. It contains no persistence and +/// only projects an enabled sample to the exact main-Chat surface and owner. +struct ChatFirstMainChatProjectionGate: Equatable { + private var ownerID: String? + private var sample: ChatFirstCapabilityProjection? + private var mainChatWasResolved = false + + mutating func configure(sample: ChatFirstCapabilityProjection?, ownerID: String?) -> Bool { + guard let ownerID, !ownerID.isEmpty else { return false } + if mainChatWasResolved { + return self.ownerID == ownerID && self.sample == sample + } + if let configuredOwner = self.ownerID { + return configuredOwner == ownerID && self.sample == sample + } + self.ownerID = ownerID + self.sample = sample + return true + } + + func isConfigured(for ownerID: String?) -> Bool { + self.ownerID == ownerID && ownerID != nil + } + + func capability( + for surface: AgentSurfaceReference, + ownerID: String? + ) -> ChatFirstCapabilityProjection? { + guard surface.surfaceKind == "main_chat", self.ownerID == ownerID else { return nil } + return sample + } + + mutating func markResolved(surface: AgentSurfaceReference, ownerID: String?) { + guard surface.surfaceKind == "main_chat", self.ownerID == ownerID else { return } + mainChatWasResolved = true + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift new file mode 100644 index 00000000000..246f0183257 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift @@ -0,0 +1,309 @@ +import SwiftUI +import OmiTheme + +/// Cohort-only main-window shell. It shares the existing data owners with the +/// legacy shell but owns no second chat state, task state, or navigation index. +struct ChatFirstShell: View { + @ObservedObject var navigation: ChatFirstShellNavigation + @ObservedObject var appState: AppState + let viewModelContainer: ViewModelContainer + @Binding var selectedSettingsSection: SettingsContentView.SettingsSection + @Binding var highlightedSettingID: String? + + @State private var selectedConversation: ServerConversation? + + var body: some View { + HStack(spacing: 0) { + ChatFirstSidebar(navigation: navigation) + + ZStack { + RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous) + .fill(OmiColors.backgroundPrimary) + .overlay( + RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous) + .stroke(OmiColors.border.opacity(0.3), lineWidth: 1) + ) + + destination + .clipShape(RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous)) + } + .padding(OmiSpacing.md) + } + .background(OmiColors.backgroundPrimary) + .environmentObject(navigation) + .onExitCommand { + guard navigation.route != .chat else { return } + OmiMotion.withGated(.easeOut(duration: 0.12)) { + navigation.selectPrimary(.chat) + } + } + } + + @ViewBuilder + private var destination: some View { + switch navigation.route { + case .chat: + ChatPage( + appProvider: viewModelContainer.appProvider, + chatProvider: viewModelContainer.chatProvider + ) + .accessibilityIdentifier("chat-first-route-chat") + case .conversations: + ConversationsPage(appState: appState, selectedConversation: $selectedConversation) + .accessibilityIdentifier("chat-first-route-conversations") + case .tasks: + TasksPage( + viewModel: viewModelContainer.tasksViewModel, + chatCoordinator: viewModelContainer.taskChatCoordinator, + chatProvider: viewModelContainer.chatProvider + ) + .accessibilityIdentifier("chat-first-route-tasks") + case .goals: + ChatFirstDeferredDestination( + title: "Goals", + message: "Your canonical goals will appear here." + ) + .accessibilityIdentifier("chat-first-route-goals") + case .memories: + MemoriesPage( + viewModel: viewModelContainer.memoriesViewModel, + graphViewModel: viewModelContainer.memoryGraphViewModel + ) + .accessibilityIdentifier("chat-first-route-memories") + case .more(let page): + moreDestination(page) + .accessibilityIdentifier("chat-first-route-more-\(page.stableName)") + } + } + + @ViewBuilder + private func moreDestination(_ page: ChatFirstMorePage) -> some View { + switch page { + case .dashboard: + DashboardPage( + viewModel: viewModelContainer.dashboardViewModel, + homeStatusStore: viewModelContainer.homeStatusStore, + appState: appState, + appProvider: viewModelContainer.appProvider, + chatProvider: viewModelContainer.chatProvider, + memoriesViewModel: viewModelContainer.memoriesViewModel, + taskChatCoordinator: viewModelContainer.taskChatCoordinator, + onOpenPrimaryChat: { + navigation.selectPrimary(.chat) + }, + selectedIndex: legacySelectionBinding + ) + case .focus: + FocusPage() + case .insight: + InsightPage() + case .rewind: + RewindPage(appState: appState) + case .apps: + AppsPage( + appProvider: viewModelContainer.appProvider, + appState: appState, + connectorStatusStore: viewModelContainer.homeStatusStore.connectorStatusStore, + handlesAutomationPresentations: viewModelContainer.isInitialLoadComplete + ) + case .permissions: + PermissionsPage(appState: appState) + case .help: + HelpPage() + case .settings: + SettingsPage( + appState: appState, + selectedSection: $selectedSettingsSection, + highlightedSettingId: $highlightedSettingID, + chatProvider: viewModelContainer.chatProvider + ) + } + } + + /// Existing Dashboard callbacks still speak in legacy sidebar items. Keep + /// that compatibility at this one boundary while the cohort shell itself is + /// entirely route-typed. + private var legacySelectionBinding: Binding { + Binding( + get: { legacySidebarItem(for: navigation.route).rawValue }, + set: { rawValue in + guard let item = SidebarNavItem(rawValue: rawValue) else { return } + navigation.selectLegacyDestination(item) + } + ) + } + + private func legacySidebarItem(for route: ChatFirstRoute) -> SidebarNavItem { + switch route { + case .chat: return .chat + case .conversations: return .conversations + case .tasks: return .tasks + case .memories: return .memories + case .goals: return .dashboard + case .more(let page): + switch page { + case .dashboard: return .dashboard + case .focus: return .focus + case .insight: return .insight + case .rewind: return .rewind + case .apps: return .apps + case .permissions: return .permissions + case .help: return .help + case .settings: return .settings + } + } + } +} + +private struct ChatFirstSidebar: View { + @ObservedObject var navigation: ChatFirstShellNavigation + + private let expandedWidth: CGFloat = 228 + private let collapsedWidth: CGFloat = 68 + + private var width: CGFloat { + navigation.isSidebarCollapsed ? collapsedWidth : expandedWidth + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: OmiSpacing.sm) { + Image(systemName: "circle.fill") + .scaledFont(size: OmiType.subheading, weight: .semibold) + .foregroundStyle(OmiColors.textPrimary) + .accessibilityHidden(true) + + if !navigation.isSidebarCollapsed { + Text("Omi") + .scaledFont(size: OmiType.heading, weight: .bold) + .foregroundStyle(OmiColors.textPrimary) + } + + Spacer(minLength: 0) + + Button { + navigation.toggleSidebar() + } label: { + Image(systemName: navigation.isSidebarCollapsed ? "sidebar.right" : "sidebar.left") + .scaledFont(size: OmiType.body, weight: .medium) + .frame(width: 30, height: 30) + } + .buttonStyle(.plain) + .foregroundStyle(OmiColors.textSecondary) + .accessibilityLabel(navigation.isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar") + .accessibilityIdentifier("chat-first-sidebar-collapse") + } + .padding(.horizontal, navigation.isSidebarCollapsed ? OmiSpacing.md : OmiSpacing.lg) + .padding(.top, OmiSpacing.lg) + .padding(.bottom, OmiSpacing.xl) + + VStack(alignment: .leading, spacing: OmiSpacing.xs) { + ForEach(ChatFirstRoute.primaryDestinations, id: \.self) { route in + primaryItem(route) + } + } + .padding(.horizontal, OmiSpacing.sm) + + Spacer(minLength: OmiSpacing.lg) + + Menu { + ForEach(ChatFirstMorePage.allCases, id: \.self) { page in + Button { + navigation.selectMore(page) + } label: { + Label(page.title, systemImage: page.systemImage) + } + .accessibilityIdentifier("chat-first-more-\(page.stableName)") + } + } label: { + sidebarLabel( + title: "More", + icon: "ellipsis.circle", + selected: !navigation.route.isPrimaryDestination + ) + } + .menuStyle(.borderlessButton) + .accessibilityLabel("More destinations") + .accessibilityIdentifier("chat-first-sidebar-more") + .padding(.horizontal, OmiSpacing.sm) + .padding(.bottom, OmiSpacing.lg) + } + .frame(width: width) + .frame(maxHeight: .infinity) + .background(OmiColors.backgroundSecondary.opacity(0.84)) + .animation(OmiMotion.gated(.easeOut(duration: 0.16)), value: navigation.isSidebarCollapsed) + } + + @ViewBuilder + private func primaryItem(_ route: ChatFirstRoute) -> some View { + Button { + navigation.selectPrimary(route) + } label: { + sidebarLabel( + title: route.title, + icon: icon(for: route), + selected: navigation.route == route + ) + } + .buttonStyle(.plain) + .accessibilityLabel(route.title) + .accessibilityIdentifier("chat-first-sidebar-\(route.stableName)") + } + + private func sidebarLabel(title: String, icon: String, selected: Bool) -> some View { + HStack(spacing: OmiSpacing.md) { + Image(systemName: icon) + .scaledFont(size: OmiType.body, weight: .medium) + .frame(width: 20) + .accessibilityHidden(true) + + if !navigation.isSidebarCollapsed { + Text(title) + .scaledFont(size: OmiType.body, weight: selected ? .semibold : .regular) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + .foregroundStyle(selected ? OmiColors.textPrimary : OmiColors.textSecondary) + .padding(.horizontal, navigation.isSidebarCollapsed ? OmiSpacing.md : OmiSpacing.sm) + .frame(height: 42) + .background( + RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) + .fill(selected ? OmiColors.backgroundTertiary.opacity(0.9) : Color.clear) + ) + .contentShape(RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous)) + } + + private func icon(for route: ChatFirstRoute) -> String { + switch route { + case .chat: return "bubble.left.and.bubble.right.fill" + case .conversations: return "text.bubble.fill" + case .tasks: return "checklist" + case .goals: return "target" + case .memories: return "brain" + case .more: return "ellipsis.circle" + } + } +} + +private struct ChatFirstDeferredDestination: View { + let title: String + let message: String + + var body: some View { + VStack(spacing: OmiSpacing.md) { + Image(systemName: "target") + .scaledFont(size: 36, weight: .medium) + .foregroundStyle(OmiColors.textTertiary) + Text(title) + .scaledFont(size: OmiType.title, weight: .bold) + .foregroundStyle(OmiColors.textPrimary) + Text(message) + .scaledFont(size: OmiType.body) + .foregroundStyle(OmiColors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(OmiColors.backgroundPrimary) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 18a075b1d0f..4a03259b3e0 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -22,6 +22,9 @@ struct DesktopHomeView: View { @EnvironmentObject private var appState: AppState @StateObject private var viewModelContainer = ViewModelContainer() + /// The cohort shell owns typed navigation at the root, never through legacy + /// sidebar indices. It persists only route/collapse state, not enrollment. + @StateObject private var chatFirstNavigation = ChatFirstShellNavigation() @ObservedObject private var authState = AuthState.shared @ObservedObject private var apiKeyService = APIKeyService.shared @ObservedObject private var updatePolicyManager = DesktopUpdatePolicyManager.shared @@ -55,6 +58,7 @@ struct DesktopHomeView: View { @State private var initialFileIndexingBackfill = DelayedFileIndexingBackfillState() @State private var automationPresentationReadinessGate = DesktopAutomationPresentationReadinessGate() + @State private var chatFirstCapabilitySample = ChatFirstShellCapabilitySample() // Pre-loaded hero logo to avoid NSImage init crashes during SwiftUI body evaluation private static let heroLogoImage: NSImage? = { @@ -114,8 +118,16 @@ struct DesktopHomeView: View { log("DesktopHomeView: Showing OnboardingView (signed in, not onboarded)") } } + } else if case .unresolved = chatFirstCapabilitySample.variant { + // Do not flash the legacy shell while the server-authoritative cohort + // sample is in flight. No Main Chat resolution or startup warmup runs + // until this settles to an immutable session choice. + ChatFirstCapabilityLoadingView() + .task(id: RuntimeOwnerIdentity.currentOwnerId() ?? "missing-owner") { + await resolveChatFirstCapabilityIfNeeded() + } } else { - // State 3: Signed in and onboarded - show main content + // State 3: Signed in and onboarded with a fixed shell choice. ZStack { // After onboarding completes, navigate to Tasks page Color.clear @@ -123,8 +135,7 @@ struct DesktopHomeView: View { .onAppear { if UserDefaults.standard.bool(forKey: "onboardingJustCompleted") { UserDefaults.standard.removeObject(forKey: "onboardingJustCompleted") - log("DesktopHomeView: Onboarding just completed — navigating to Dashboard") - selectedIndex = SidebarNavItem.dashboard.rawValue + navigateAfterOnboarding() } } mainContent @@ -140,7 +151,7 @@ struct DesktopHomeView: View { // "Account & Plan" page — scroll straight to the plan card. highlightedSettingId = "planusage.current" OmiMotion.withGated(Self.pageNavigationAnimation) { - selectedIndex = SidebarNavItem.settings.rawValue + navigateToLegacyDestination(.settings) } }, onDismiss: { @@ -150,7 +161,7 @@ struct DesktopHomeView: View { appState.showUsageLimitPopup = false selectedSettingsSection = .advanced OmiMotion.withGated(Self.pageNavigationAnimation) { - selectedIndex = SidebarNavItem.settings.rawValue + navigateToLegacyDestination(.settings) } } ) @@ -306,6 +317,7 @@ struct DesktopHomeView: View { log( "DesktopHomeView: userDidSignOut — resetting hasCompletedOnboarding and stopping transcription" ) + chatFirstCapabilitySample.ownerDidChange(to: nil) resetSessionScopedStartupWarmups(preserveCrispReadState: false) appState.conversationRepository.reset() appState.folders = [] @@ -459,6 +471,12 @@ struct DesktopHomeView: View { .onChange(of: authState.isSignedIn) { _, _ in reportAutomationState() } .onChange(of: authState.isRestoringAuth) { _, _ in reportAutomationState() } .onChange(of: appState.hasCompletedOnboarding) { _, _ in reportAutomationState() } + .onReceive(NotificationCenter.default.publisher(for: .runtimeOwnerDidChange)) { _ in + chatFirstCapabilitySample.ownerDidChange(to: RuntimeOwnerIdentity.currentOwnerId()) + // The provider's owner-bound gate rejects the previous sample for this + // owner; no replacement sample is persisted or inferred locally. + reportAutomationState() + } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in enforceMainWindowMinimumSize() reportAutomationState() @@ -557,6 +575,7 @@ struct DesktopHomeView: View { /// Redirect to conversations if current page isn't visible at the current tier level private func redirectIfPageHidden() { + guard !usesChatFirstShell else { return } // Tier 0 or tier 6+ shows everything — no redirect needed guard currentTierLevel > 0 && currentTierLevel < 6 else { return } // Don't redirect from settings/permissions/help pages @@ -585,7 +604,7 @@ struct DesktopHomeView: View { } private var showsPrimarySidebar: Bool { - useLegacyHomeDesign && !hideSidebar + !usesChatFirstShell && useLegacyHomeDesign && !hideSidebar } private var currentAppStateLabel: String { @@ -604,19 +623,27 @@ struct DesktopHomeView: View { }) let onDashboard = selectedIndex == SidebarNavItem.dashboard.rawValue let priorHomeMode = DesktopAutomationStateStore.shared.current().homeMode + let chatFirstRoute = usesChatFirstShell ? chatFirstNavigation.route : nil let snapshot = DesktopAutomationSnapshot( bridgeEnabled: true, bridgePort: DesktopAutomationLaunchOptions.port, bundleIdentifier: Bundle.main.bundleIdentifier ?? "unknown", appState: currentAppStateLabel, - selectedTab: SidebarNavItem(rawValue: selectedIndex)?.title, - selectedTabIndex: selectedIndex, - selectedSettingsSection: isInSettings ? selectedSettingsSection.rawValue : nil, + selectedTab: chatFirstRoute?.title ?? SidebarNavItem(rawValue: selectedIndex)?.title, + selectedTabIndex: usesChatFirstShell ? nil : selectedIndex, + selectedSettingsSection: usesChatFirstShell + ? (chatFirstRoute == .more(.settings) ? selectedSettingsSection.rawValue : nil) + : (isInSettings ? selectedSettingsSection.rawValue : nil), highlightedSettingId: highlightedSettingId, - usesLegacyHomeDesign: useLegacyHomeDesign, - homeMode: onDashboard && !useLegacyHomeDesign ? (priorHomeMode ?? "hub") : nil, + usesLegacyHomeDesign: !usesChatFirstShell && useLegacyHomeDesign, + homeMode: !usesChatFirstShell && onDashboard && !useLegacyHomeDesign ? (priorHomeMode ?? "hub") : nil, + shellVariant: chatFirstCapabilitySample.variant.stableName, + chatFirstRoute: chatFirstRoute?.stableName, + pendingFocusKind: chatFirstNavigation.pendingFocus?.stableName, + acknowledgedFocusKind: chatFirstNavigation.lastAcknowledgedFocusKind, showsPrimarySidebar: showsPrimarySidebar, - isSidebarCollapsed: isSidebarCollapsed, + isSidebarCollapsed: usesChatFirstShell + ? chatFirstNavigation.isSidebarCollapsed : isSidebarCollapsed, hasCompletedOnboarding: appState.hasCompletedOnboarding, isSignedIn: authState.isSignedIn, isRestoringAuth: authState.isRestoringAuth, @@ -662,8 +689,12 @@ struct DesktopHomeView: View { } highlightedSettingId = settingId - if let item = resolvedAutomationTarget(target) { - selectedIndex = item.rawValue + if usesChatFirstShell, + let route = ChatFirstRoute.primaryAutomationDestination(named: target) + { + chatFirstNavigation.selectPrimary(route) + } else if let item = resolvedAutomationTarget(target) { + navigateToLegacyDestination(item) } reportAutomationState() @@ -676,7 +707,7 @@ struct DesktopHomeView: View { if let window = NSApp.windows.first(where: { $0.title.lowercased().hasPrefix("omi") }) { window.makeKeyAndOrderFront(nil) } - selectedIndex = SidebarNavItem.apps.rawValue + navigateToLegacyDestination(.apps) reportAutomationState() } @@ -871,7 +902,238 @@ struct DesktopHomeView: View { index == SidebarNavItem.memories.rawValue } + private var usesChatFirstShell: Bool { + if case .chatFirst = chatFirstCapabilitySample.variant { return true } + return false + } + + private func updateStoreActivityForCurrentShell() { + guard usesChatFirstShell else { + updateStoreActivity(for: selectedIndex) + return + } + viewModelContainer.tasksStore.isActive = + chatFirstNavigation.route == .tasks || chatFirstNavigation.route == .more(.dashboard) + viewModelContainer.memoriesViewModel.isActive = chatFirstNavigation.route == .memories + } + + /// One fresh server read decides both the shell and the local runtime + /// projection. A failed response, missing owner, stale auth snapshot, or + /// owner change resolves legacy; there is no cached local enablement. + private func resolveChatFirstCapabilityIfNeeded() async { + guard case .unresolved = chatFirstCapabilitySample.variant else { return } + guard let ownerID = RuntimeOwnerIdentity.currentOwnerId(), + let authorization = RuntimeOwnerIdentity.captureAuthorizationSnapshot(expectedOwnerID: ownerID) + else { + chatFirstCapabilitySample.resolve( + control: nil, + requestedOwnerID: nil, + ownerIsStillCurrent: false + ) + _ = viewModelContainer.chatProvider.configureChatFirstMainChatCapability(nil) + reportAutomationState() + return + } + + do { + let control = try await APIClient.shared.getCandidateWorkflowControl( + expectedOwnerId: ownerID, + authorizationSnapshot: authorization + ) + let current = RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) + && RuntimeOwnerIdentity.currentOwnerId() == ownerID + chatFirstCapabilitySample.resolve( + control: control, + requestedOwnerID: ownerID, + ownerIsStillCurrent: current + ) + } catch { + let current = RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) + && RuntimeOwnerIdentity.currentOwnerId() == ownerID + chatFirstCapabilitySample.resolve( + control: nil, + requestedOwnerID: ownerID, + ownerIsStillCurrent: current + ) + log("DesktopHomeView: chat-first control unavailable; using legacy shell") + } + + let projectionConfigured = viewModelContainer.chatProvider.configureChatFirstMainChatCapability( + chatFirstCapabilitySample.variant.projection + ) + if !projectionConfigured { + // A pre-existing Main Chat session cannot be retroactively upgraded with + // dynamic tools. Keep this launch on the byte-equivalent legacy path. + chatFirstCapabilitySample.failClosed() + log("DesktopHomeView: chat-first projection handoff rejected; using legacy shell") + } + reportAutomationState() + } + + private func navigateAfterOnboarding() { + if usesChatFirstShell { + chatFirstNavigation.selectPrimary(.chat) + log("DesktopHomeView: Onboarding just completed — opening Chat") + } else { + selectedIndex = SidebarNavItem.dashboard.rawValue + log("DesktopHomeView: Onboarding just completed — navigating to Dashboard") + } + } + + /// Existing menu, keyboard, and automation callers retain their legacy + /// names. This is the sole root adapter between those callers and typed + /// cohort navigation. + private func navigateToLegacyDestination(_ item: SidebarNavItem) { + if usesChatFirstShell { + chatFirstNavigation.selectLegacyDestination(item) + } else { + selectedIndex = item.rawValue + } + } + private var mainContent: some View { + mainContentWithLifecycle( + mainContentWithNotifications( + mainContentWithOverlays(shellContent) + ) + ) + } + + /// Keep the type checker from attempting to infer every shell, overlay, and + /// event subscription in one expression. The functions deliberately retain + /// the existing modifier order; they are only compile-time seams. + private func mainContentWithOverlays(_ content: Content) -> some View { + content + .overlay { + // Goal completion celebration overlay + GoalCelebrationView() + } + .overlay { + if showTryAskingPopup { + let suggestions = PostOnboardingPromptSuggestions.suggestions() + if !suggestions.isEmpty { + TryAskingPopupView( + suggestions: suggestions, + onAsk: { suggestion in + showTryAskingPopup = false + PostOnboardingPromptSuggestions.shouldShowPopup = false + FloatingControlBarManager.shared.openAIInputWithQuery(suggestion) + }, + onDismiss: { + showTryAskingPopup = false + PostOnboardingPromptSuggestions.shouldShowPopup = false + PostOnboardingPromptSuggestions.isDismissed = true + } + ) + } + } + } + } + + private func mainContentWithNotifications(_ content: Content) -> some View { + content + .onReceive(NotificationCenter.default.publisher(for: .showTryAskingPopup)) { _ in + showTryAskingPopup = true + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToRewindSettings)) { _ in + selectedSettingsSection = .rewind + navigateToLegacyDestination(.settings) + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToDeviceSettings)) { _ in + if let url = URL(string: "https://www.omi.me") { + NSWorkspace.shared.open(url) + } + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToTaskSettings)) { _ in + selectedSettingsSection = .advanced + navigateToLegacyDestination(.settings) + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToFloatingBarSettings)) { _ in + selectedSettingsSection = .floatingBar + navigateToLegacyDestination(.settings) + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToAIChatSettings)) { _ in + selectedSettingsSection = .advanced + navigateToLegacyDestination(.settings) + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToRewind)) { _ in + log("DesktopHomeView: Received navigateToRewind notification") + navigateToLegacyDestination(.rewind) + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToRewindNotes)) { _ in + navigateToLegacyDestination(.rewind) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: .expandRewindTranscript, object: nil) + } + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToChat)) { _ in + if usesChatFirstShell { + chatFirstNavigation.selectPrimary(.chat) + } else { + // Legacy Home owns the historic Chat notification contract. + selectedIndex = SidebarNavItem.dashboard.rawValue + } + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToTasks)) { _ in + navigateToLegacyDestination(.tasks) + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToSidebarItem)) { notification in + if let rawValue = notification.userInfo?["rawValue"] as? Int, + let item = SidebarNavItem(rawValue: rawValue) + { + navigateToLegacyDestination(item) + } + } + } + + private func mainContentWithLifecycle(_ content: Content) -> some View { + content + .onChange(of: selectedIndex) { oldValue, newValue in + if newValue == SidebarNavItem.settings.rawValue + && oldValue != SidebarNavItem.settings.rawValue + { + previousIndexBeforeSettings = oldValue + } + updateStoreActivity(for: newValue) + } + .onChange(of: chatFirstNavigation.route) { _, _ in + updateStoreActivityForCurrentShell() + reportAutomationState() + } + .onChange(of: chatFirstNavigation.isSidebarCollapsed) { _, _ in reportAutomationState() } + .onChange(of: useLegacyHomeDesign) { _, newValue in + OmiMotion.withGated(.easeInOut(duration: 0.2)) { + isSidebarCollapsed = !newValue + } + } + .onAppear { + if case .legacy = chatFirstCapabilitySample.variant { + isSidebarCollapsed = !useLegacyHomeDesign + } + updateStoreActivityForCurrentShell() + restorePreChatWindowWidth() + } + } + + /// Keep the legacy HStack out of the chat-first branch's SwiftUI generic + /// expression. The runtime choice is already immutable for this app session; + /// this is only an erased rendering boundary, not a second state owner. + private var shellContent: AnyView { + if case .chatFirst = chatFirstCapabilitySample.variant { + return AnyView( + ChatFirstShell( + navigation: chatFirstNavigation, + appState: appState, + viewModelContainer: viewModelContainer, + selectedSettingsSection: $selectedSettingsSection, + highlightedSettingID: $highlightedSettingId + ) + ) + } + return AnyView(legacyMainContent) + } + + private var legacyMainContent: some View { HStack(spacing: 0) { // Sidebar slot: settings sidebar overlays main sidebar // IMPORTANT: SidebarView is kept alive (but hidden) when in settings to prevent @@ -977,109 +1239,16 @@ struct DesktopHomeView: View { } .padding(OmiSpacing.md) } - .overlay { - // Goal completion celebration overlay - GoalCelebrationView() - } - .overlay { - if showTryAskingPopup { - let suggestions = PostOnboardingPromptSuggestions.suggestions() - if !suggestions.isEmpty { - TryAskingPopupView( - suggestions: suggestions, - onAsk: { suggestion in - showTryAskingPopup = false - PostOnboardingPromptSuggestions.shouldShowPopup = false - FloatingControlBarManager.shared.openAIInputWithQuery(suggestion) - }, - onDismiss: { - showTryAskingPopup = false - PostOnboardingPromptSuggestions.shouldShowPopup = false - PostOnboardingPromptSuggestions.isDismissed = true - } - ) - } - } - } - .onReceive(NotificationCenter.default.publisher(for: .showTryAskingPopup)) { _ in - showTryAskingPopup = true - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToRewindSettings)) { _ in - // Set the section directly and navigate to settings - selectedSettingsSection = .rewind - selectedIndex = SidebarNavItem.settings.rawValue - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToDeviceSettings)) { _ in - if let url = URL(string: "https://www.omi.me") { - NSWorkspace.shared.open(url) - } - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToTaskSettings)) { _ in - // Navigate to settings > advanced > task assistant subsection - selectedSettingsSection = .advanced - selectedIndex = SidebarNavItem.settings.rawValue - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToFloatingBarSettings)) { _ in - selectedSettingsSection = .floatingBar - selectedIndex = SidebarNavItem.settings.rawValue - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToAIChatSettings)) { _ in - selectedSettingsSection = .advanced - selectedIndex = SidebarNavItem.settings.rawValue - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToRewind)) { _ in - // Navigate to Rewind page (index 6) - triggered by global hotkey Cmd+Option+R - log( - "DesktopHomeView: Received navigateToRewind notification, navigating to Rewind (index \(SidebarNavItem.rewind.rawValue))" - ) - selectedIndex = SidebarNavItem.rewind.rawValue - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToRewindNotes)) { _ in - selectedIndex = SidebarNavItem.rewind.rawValue - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - NotificationCenter.default.post(name: .expandRewindTranscript, object: nil) - } - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToChat)) { _ in - // Chat now lives on the Dashboard page. - selectedIndex = SidebarNavItem.dashboard.rawValue - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToTasks)) { _ in - selectedIndex = SidebarNavItem.tasks.rawValue - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToSidebarItem)) { notification in - if let rawValue = notification.userInfo?["rawValue"] as? Int, - let item = SidebarNavItem(rawValue: rawValue) - { - selectedIndex = item.rawValue - } - } - .onChange(of: selectedIndex) { oldValue, newValue in - // Track the previous index when navigating to settings - if newValue == SidebarNavItem.settings.rawValue - && oldValue != SidebarNavItem.settings.rawValue - { - previousIndexBeforeSettings = oldValue - } - // Only auto-refresh stores when their pages are visible - updateStoreActivity(for: newValue) - } - .onChange(of: useLegacyHomeDesign) { _, newValue in - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isSidebarCollapsed = !newValue - } - } - .onAppear { - isSidebarCollapsed = !useLegacyHomeDesign - updateStoreActivity(for: selectedIndex) - // Restore window width if the user quit with task chat panel open. - // The chat panel is never open on startup (showChatPanel defaults to false), - // but macOS restores the expanded window frame from the previous session. - restorePreChatWindowWidth() - } } private func navigateHomeOnEscapeIfNeeded() { + if usesChatFirstShell { + guard chatFirstNavigation.route != .chat else { return } + OmiMotion.withGated(Self.pageNavigationAnimation) { + chatFirstNavigation.selectPrimary(.chat) + } + return + } guard !useLegacyHomeDesign else { return } guard let item = SidebarNavItem(rawValue: selectedIndex) else { return } guard [.conversations, .memories, .tasks, .rewind].contains(item) else { return } @@ -1089,6 +1258,22 @@ struct DesktopHomeView: View { } } +private struct ChatFirstCapabilityLoadingView: View { + var body: some View { + VStack(spacing: OmiSpacing.md) { + ProgressView() + .controlSize(.small) + Text("Preparing Omi…") + .scaledFont(size: OmiType.body, weight: .medium) + .foregroundStyle(OmiColors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(OmiColors.backgroundPrimary) + .accessibilityElement(children: .combine) + .accessibilityLabel("Preparing Omi") + } +} + private struct PageChromeBar: View { let onHome: () -> Void diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 8ae46cecb0c..5233cb18d37 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -220,6 +220,10 @@ struct DashboardPage: View { @ObservedObject var chatProvider: ChatProvider @ObservedObject var memoriesViewModel: MemoriesViewModel var taskChatCoordinator: TaskChatCoordinator? = nil + /// The cohort shell reuses dashboard content under More, but Chat itself + /// has one primary home. Legacy callers leave this nil and retain their + /// inline Home chat exactly as before. + var onOpenPrimaryChat: (() -> Void)? = nil @ObservedObject private var deviceProvider = DeviceProvider.shared @StateObject private var intelligenceStore = DashboardIntelligenceStore() @Binding var selectedIndex: Int @@ -247,6 +251,10 @@ struct DashboardPage: View { @State private var homeMode: HomeStageMode = .hub @FocusState private var homeAskFieldFocused: Bool + private var routesChatToPrimaryShell: Bool { + onOpenPrimaryChat != nil + } + private var selectedApp: OmiApp? { guard let appId = chatProvider.selectedAppId else { return nil } return appProvider.chatApps.first { $0.id == appId } @@ -365,7 +373,7 @@ struct DashboardPage: View { private var homeSurface: some View { Group { - if useLegacyHomeDesign { + if useLegacyHomeDesign && !routesChatToPrimaryShell { legacyHome } else { redesignedHome @@ -1093,6 +1101,10 @@ struct DashboardPage: View { } private func openHomeChat(focusInput: Bool = true) { + if let onOpenPrimaryChat { + onOpenPrimaryChat() + return + } guard homeMode != .chat else { return } OmiMotion.withGated(Self.homeStageAnimation) { homeMode = .chat @@ -1135,6 +1147,17 @@ struct DashboardPage: View { // Text is required — ChatProvider.sendMessage no-ops on empty text, so // an attachment-only "send" would silently drop the turn. guard !text.isEmpty else { return } + if let onOpenPrimaryChat { + onOpenPrimaryChat() + guard !chatProvider.isSending else { return } + AnalyticsManager.shared.chatMessageSent( + messageLength: text.count, + hasSelectedAppContext: selectedApp != nil, + source: "home_ask_bar" + ) + Task { await chatProvider.sendMainDraft(draft) } + return + } openHomeChat(focusInput: false) AnalyticsManager.shared.chatMessageSent( messageLength: text.count, @@ -1149,6 +1172,17 @@ struct DashboardPage: View { } private func askHomeSuggestion(_ suggestion: String) { + if let onOpenPrimaryChat { + onOpenPrimaryChat() + guard !chatProvider.isSending else { return } + AnalyticsManager.shared.chatMessageSent( + messageLength: suggestion.count, + hasSelectedAppContext: selectedApp != nil, + source: "home_suggested_question" + ) + Task { await chatProvider.sendMessage(suggestion) } + return + } openHomeChat(focusInput: false) AnalyticsManager.shared.chatMessageSent( messageLength: suggestion.count, diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index f3909dbfad6..edebd0c1dd8 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -1217,6 +1217,10 @@ class ChatProvider: ObservableObject { private var journalOwnerByMessageID: [String: String] = [:] private var journalTerminalTargets = ChatTerminalTargetRegistry() private var agentBridgeStarted = false + /// The root shell supplies one server-authoritative sample before this + /// provider resolves Main Chat. This is process-local only: a different + /// owner, a failed sample, and every non-main surface receive no extension. + private var chatFirstMainChatProjectionGate = ChatFirstMainChatProjectionGate() private let bridgeReadinessSingleFlight = AgentRuntimeStartupSingleFlight() /// Tracks the harness mode the bridge is actually running (NOT the @AppStorage preference). @@ -1573,6 +1577,16 @@ class ChatProvider: ObservableObject { _ = await ensureBridgeStarted() } + /// Configures the immutable main-Chat capability handoff at root-shell + /// construction. A nil sample is explicit capability-off and is never + /// persisted or inferred from local state. + @discardableResult + func configureChatFirstMainChatCapability( + _ sample: ChatFirstCapabilityProjection? + ) -> Bool { + chatFirstMainChatProjectionGate.configure(sample: sample, ownerID: runtimeOwnerId) + } + /// Drop a cached agent surface so the next query recreates it with fresh prompt context. func invalidateAgentSurface(surface: AgentSurfaceReference) async { guard agentBridgeStarted else { return } @@ -1664,9 +1678,16 @@ class ChatProvider: ObservableObject { modelProfile: usesNativeModelChoice ? nil : ModelQoS.Claude.chat, workingDirectory: effectiveAgentWorkingDirectory() ) - let warmSurfaces: [AgentSurfaceReference] = [.mainChat(chatId: nil), .floatingChat()] + // Onboarding can start the shared runtime before the root shell has + // sampled workflow control. Do not resolve Main Chat in that interval: + // its first kernel resolution must carry the root's true or explicit + // capability-off sample. Floating remains capability-off. + var warmSurfaces: [AgentSurfaceReference] = [.floatingChat()] + if chatFirstMainChatProjectionGate.isConfigured(for: runtimeOwnerId) { + warmSurfaces.insert(.mainChat(chatId: nil), at: 0) + } for surface in warmSurfaces { - let session = try await resolvedAgentClient().resolveSurfaceSession(surface) + let session = try await resolveAgentSurfaceSession(surface) await resolvedAgentClient().warmupSession(session) } agentBridgeStarted = true @@ -1771,7 +1792,7 @@ class ChatProvider: ObservableObject { throw BridgeError.agentError("Unknown AI runtime mode: \(requestedHarness)") } let usesNativeModelChoice = requestedHarness == "hermes" || requestedHarness == "openclaw" - return try await resolvedAgentClient().resolveSurfaceSession( + return try await resolveAgentSurfaceSession( surface, creationProfile: AgentSessionCreationProfile( adapterId: requestedAdapter, @@ -1782,6 +1803,28 @@ class ChatProvider: ObservableObject { ) } + /// The only ChatProvider path that resolves a runtime surface. It carries + /// the root's immutable projection to the local kernel for Main Chat and + /// deliberately omits it for floating, onboarding, task, and all other + /// surfaces. A failed/off/owner-mismatched sample maps to the base tools. + private func resolveAgentSurfaceSession( + _ surface: AgentSurfaceReference, + creationProfile: AgentSessionCreationProfile? = nil + ) async throws -> AgentSurfaceSession { + let ownerID = runtimeOwnerId + let projection = chatFirstMainChatProjectionGate.capability( + for: surface, + ownerID: ownerID + ) + let session = try await resolvedAgentClient().resolveSurfaceSession( + surface, + creationProfile: creationProfile, + chatFirstCapability: projection + ) + chatFirstMainChatProjectionGate.markResolved(surface: surface, ownerID: ownerID) + return session + } + private func prepareKernelQueryContext( surface: AgentSurfaceReference, systemPromptStyle: ChatSystemPromptStyle, diff --git a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift new file mode 100644 index 00000000000..95f7705eaf6 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift @@ -0,0 +1,124 @@ +import XCTest + +@testable import Omi_Computer + +@MainActor +final class ChatFirstShellTests: XCTestCase { + private func enabledControl(generation: Int = 7) -> OmiAPI.TaskWorkflowControl { + OmiAPI.TaskWorkflowControl( + accountGeneration: generation, + chatFirstUi: true, + workflowMode: .read + ) + } + + func testSuccessfulSampleSelectsChatFirstAndCannotLiveSwap() throws { + var sample = ChatFirstShellCapabilitySample() + sample.resolve( + control: enabledControl(), + requestedOwnerID: "owner-a", + ownerIsStillCurrent: true + ) + + XCTAssertEqual(sample.variant.projection?.controlGeneration, 7) + XCTAssertEqual(sample.variant.stableName, "chat_first") + + sample.resolve( + control: OmiAPI.TaskWorkflowControl(accountGeneration: 8, chatFirstUi: false, workflowMode: .off), + requestedOwnerID: "owner-a", + ownerIsStillCurrent: true + ) + XCTAssertEqual(sample.variant.projection?.controlGeneration, 7) + } + + func testMissingStaleAndOwnerChangedSamplesFailClosed() { + var missing = ChatFirstShellCapabilitySample() + missing.resolve(control: nil, requestedOwnerID: "owner-a", ownerIsStillCurrent: true) + XCTAssertEqual(missing.variant.stableName, "legacy") + + var stale = ChatFirstShellCapabilitySample() + stale.resolve(control: enabledControl(), requestedOwnerID: "owner-a", ownerIsStillCurrent: false) + XCTAssertEqual(stale.variant.stableName, "legacy") + + var ownerChanged = ChatFirstShellCapabilitySample() + ownerChanged.resolve(control: enabledControl(), requestedOwnerID: "owner-a", ownerIsStillCurrent: true) + ownerChanged.ownerDidChange(to: "owner-b") + XCTAssertEqual(ownerChanged.variant.stableName, "legacy") + } + + func testNavigationPersistsOnlyRouteAndCollapseAndRetainsFocusUntilAcknowledged() { + let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { UserDefaults.standard.removePersistentDomain(forName: suiteName) } + + let navigation = ChatFirstShellNavigation(defaults: defaults) + XCTAssertEqual(navigation.route, .chat) + XCTAssertNil(navigation.pendingFocus) + + let focus = ChatFirstPendingFocus.capture(id: "capture-1", momentTs: 42) + navigation.open(focus: focus) + navigation.toggleSidebar() + XCTAssertEqual(navigation.route, .conversations) + XCTAssertEqual(navigation.pendingFocus, focus) + XCTAssertFalse(navigation.acknowledgeFocus(.task(id: "task-1"))) + XCTAssertEqual(navigation.pendingFocus, focus) + XCTAssertTrue(navigation.acknowledgeFocus(focus)) + XCTAssertNil(navigation.pendingFocus) + XCTAssertEqual(navigation.lastAcknowledgedFocusKind, "capture") + + let restored = ChatFirstShellNavigation(defaults: defaults) + XCTAssertEqual(restored.route, .conversations) + XCTAssertTrue(restored.isSidebarCollapsed) + XCTAssertNil(restored.pendingFocus) + } + + func testDirectAndLegacyNavigationClearFocusAndMapToTypedRoutes() { + let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { UserDefaults.standard.removePersistentDomain(forName: suiteName) } + + let navigation = ChatFirstShellNavigation(defaults: defaults) + navigation.open(focus: .goal(id: "goal-1")) + navigation.selectPrimary(.tasks) + XCTAssertEqual(navigation.route, .tasks) + XCTAssertNil(navigation.pendingFocus) + + navigation.open(focus: .memory(id: "memory-1")) + navigation.selectLegacyDestination(.settings) + XCTAssertEqual(navigation.route, .more(.settings)) + XCTAssertNil(navigation.pendingFocus) + + navigation.selectLegacyDestination(.chat) + XCTAssertEqual(navigation.route, .chat) + } + + func testPrimaryAutomationRouteIncludesGoalsWithoutRepurposingLegacyPages() { + XCTAssertEqual(ChatFirstRoute.primaryAutomationDestination(named: "goals"), .goals) + XCTAssertEqual(ChatFirstRoute.primaryAutomationDestination(named: "GOALS"), .goals) + XCTAssertNil(ChatFirstRoute.primaryAutomationDestination(named: "dashboard")) + XCTAssertNil(ChatFirstRoute.primaryAutomationDestination(named: "settings")) + } + + func testProjectionGatePassesOnlyEnabledMainChatForSampledOwner() throws { + var gate = ChatFirstMainChatProjectionGate() + XCTAssertFalse(gate.isConfigured(for: "owner-a")) + let enabled = try XCTUnwrap(ChatFirstCapabilityProjection(control: enabledControl(generation: 11))) + XCTAssertTrue(gate.configure(sample: enabled, ownerID: "owner-a")) + XCTAssertTrue(gate.isConfigured(for: "owner-a")) + + let main = AgentSurfaceReference.mainChat(chatId: nil) + XCTAssertEqual(gate.capability(for: main, ownerID: "owner-a"), enabled) + XCTAssertNil(gate.capability(for: .floatingChat(), ownerID: "owner-a")) + XCTAssertNil(gate.capability(for: main, ownerID: "owner-b")) + + gate.markResolved(surface: main, ownerID: "owner-a") + XCTAssertFalse(gate.configure(sample: nil, ownerID: "owner-a")) + XCTAssertEqual(gate.capability(for: main, ownerID: "owner-a"), enabled) + } + + func testProjectionGateKeepsCapabilityOffForFalseSample() { + var gate = ChatFirstMainChatProjectionGate() + XCTAssertTrue(gate.configure(sample: nil, ownerID: "owner-a")) + XCTAssertNil(gate.capability(for: .mainChat(chatId: nil), ownerID: "owner-a")) + } +} diff --git a/desktop/macos/agent/src/runtime/conversation-turns.ts b/desktop/macos/agent/src/runtime/conversation-turns.ts index d8359c383bf..e4b48f30463 100644 --- a/desktop/macos/agent/src/runtime/conversation-turns.ts +++ b/desktop/macos/agent/src/runtime/conversation-turns.ts @@ -17,6 +17,40 @@ export function conversationIdForSession(store: AgentStore, sessionId: string): return row ? String(row.conversation_id) : null; } +/** + * Resolve one caller-owned surface mapping exactly. Capability-scoped reads + * must not pick whichever alias happened to be active most recently for a + * session that participates in more than one surface mapping. + */ +export function conversationIdForOwnedSurfaceSession( + store: AgentStore, + input: { + ownerId: string; + sessionId: string; + surfaceKind: string; + externalRefKind: string; + externalRefId: string; + }, +): string | null { + const row = store.getOptionalRow( + `SELECT conversation_id FROM surface_conversations + WHERE owner_id = ? + AND agent_session_id = ? + AND surface_kind = ? + AND external_ref_kind = ? + AND external_ref_id = ? + LIMIT 1`, + [ + input.ownerId, + input.sessionId, + input.surfaceKind, + input.externalRefKind, + input.externalRefId, + ], + ); + return row ? String(row.conversation_id) : null; +} + /** Shared row codec for the canonical journal reader. This function never writes. */ export function conversationTurnFromRow(row: Record): ConversationTurn { return { diff --git a/desktop/macos/changelog/unreleased/20260715-chat-first-shell.json b/desktop/macos/changelog/unreleased/20260715-chat-first-shell.json new file mode 100644 index 00000000000..c9b3b44cef2 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260715-chat-first-shell.json @@ -0,0 +1,3 @@ +{ + "change": "Added a cohort-gated Chat-first desktop shell with direct navigation to conversations, tasks, goals, and memories" +} diff --git a/desktop/macos/scripts/omi-ctl b/desktop/macos/scripts/omi-ctl index c7f102ea7ad..412b4a32816 100755 --- a/desktop/macos/scripts/omi-ctl +++ b/desktop/macos/scripts/omi-ctl @@ -86,7 +86,7 @@ print(path) done echo "omi-ctl: timed out waiting for app to reach \"main\" state" >&2; exit 1 ;; screens) - echo "dashboard|home conversations chat memories tasks focus insight rewind apps|integrations settings permissions help" ;; + echo "dashboard|home conversations chat memories tasks goals(chat-first) focus insight rewind apps|integrations settings permissions help" ;; *) cat < Date: Wed, 15 Jul 2026 13:13:49 -0400 Subject: [PATCH 07/28] feat(desktop): render chat blocks and capture archive Add the cohort-gated rich main-chat block renderer and an Omi-hardware capture archive with source-safe cache queries, typed discuss-in-chat routing, and honest playback states.\n\nVerification: git diff --check and desktop test-quality static check passed. Runtime Swift builds/tests are intentionally deferred to the final consolidated verification pass at user direction; no queued results are claimed. --- desktop/macos/Desktop/Sources/APIClient.swift | 12 + .../FloatingControlBar/AIResponseView.swift | 4 + .../FloatingControlBarView.swift | 3 + .../Blocks/ChatFirstContentBlockViews.swift | 400 +++++++++++++++++ .../Blocks/ChatFirstRichBlockContext.swift | 16 + .../ChatFirst/CaptureArchivePage.swift | 418 ++++++++++++++++++ .../ChatFirst/CaptureArchiveRepository.swift | 224 ++++++++++ .../ChatFirst/CapturePlayback.swift | 189 ++++++++ .../ChatFirst/CapturePlaybackAPI.swift | 86 ++++ .../MainWindow/ChatFirst/ChatFirstRoute.swift | 33 ++ .../MainWindow/ChatFirst/ChatFirstShell.swift | 13 +- .../MainWindow/Components/ChatBubble.swift | 99 ++++- .../Components/ChatMessagesView.swift | 6 +- .../Sources/MainWindow/Pages/ChatPage.swift | 4 + .../Rewind/Core/TranscriptionStorage.swift | 47 ++ .../APIClient+ConversationModels.swift | 66 ++- .../Desktop/Tests/CaptureArchiveTests.swift | 346 +++++++++++++++ .../Tests/ChatFirstRichBlockTests.swift | 149 +++++++ 18 files changed, 2097 insertions(+), 18 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlayback.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlaybackAPI.swift create mode 100644 desktop/macos/Desktop/Tests/CaptureArchiveTests.swift create mode 100644 desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift diff --git a/desktop/macos/Desktop/Sources/APIClient.swift b/desktop/macos/Desktop/Sources/APIClient.swift index 7ab6735380f..3894cc79202 100644 --- a/desktop/macos/Desktop/Sources/APIClient.swift +++ b/desktop/macos/Desktop/Sources/APIClient.swift @@ -591,6 +591,7 @@ extension APIClient { static func conversationFilterQueryItems( statuses: [ConversationStatus] = [], + sources: [ConversationSource] = [], includeDiscarded: Bool = false, startDate: Date? = nil, endDate: Date? = nil, @@ -606,6 +607,11 @@ extension APIClient { queryItems.append("statuses=\(statusStrings)") } + if !sources.isEmpty { + let sourceStrings = sources.map { $0.rawValue }.joined(separator: ",") + queryItems.append("sources=\(sourceStrings)") + } + if let startDate = startDate { let formatter = ISO8601DateFormatter() queryItems.append("start_date=\(formatter.string(from: startDate))") @@ -632,6 +638,7 @@ extension APIClient { limit: Int = 50, offset: Int = 0, statuses: [ConversationStatus] = [], + sources: [ConversationSource] = [], includeDiscarded: Bool = false, startDate: Date? = nil, endDate: Date? = nil, @@ -644,6 +651,7 @@ extension APIClient { ] queryItems += Self.conversationFilterQueryItems( statuses: statuses, + sources: sources, includeDiscarded: includeDiscarded, startDate: startDate, endDate: endDate, @@ -749,6 +757,7 @@ extension APIClient { static func conversationsCountEndpoint( includeDiscarded: Bool = false, statuses: [ConversationStatus] = [.completed, .processing], + sources: [ConversationSource] = [], startDate: Date? = nil, endDate: Date? = nil, folderId: String? = nil, @@ -756,6 +765,7 @@ extension APIClient { ) -> String { let queryItems = Self.conversationFilterQueryItems( statuses: statuses, + sources: sources, includeDiscarded: includeDiscarded, startDate: startDate, endDate: endDate, @@ -774,6 +784,7 @@ extension APIClient { func getConversationsCount( includeDiscarded: Bool = false, statuses: [ConversationStatus] = [.completed, .processing], + sources: [ConversationSource] = [], startDate: Date? = nil, endDate: Date? = nil, folderId: String? = nil, @@ -782,6 +793,7 @@ extension APIClient { let endpoint = Self.conversationsCountEndpoint( includeDiscarded: includeDiscarded, statuses: statuses, + sources: sources, startDate: startDate, endDate: endDate, folderId: folderId, diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift index ad2acd2ab7d..cd9aabc578f 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift @@ -222,6 +222,10 @@ struct AIResponseView: View { case .discoveryCard(_, let title, let summary, let fullText): DiscoveryCard(title: title, summary: summary, fullText: fullText) .frame(maxWidth: .infinity, alignment: .leading) + // The floating/notch surface never opts into rich chat-first controls. + // Keep journaled blocks inert if an older runtime projects them here. + case .questionCard, .taskCard, .goalLink, .captureLink: + EmptyView() case .agentSpawn( _, let pillId, let sessionId, let runId, let title, let objective, let provider ): diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift index bd43c1269eb..cf86443e4ca 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift @@ -2145,6 +2145,9 @@ private struct AgentMainChatView: View { case .discoveryCard(_, let title, let summary, let fullText): DiscoveryCard(title: title, summary: summary, fullText: fullText) .frame(maxWidth: .infinity, alignment: .leading) + // Rich controls are main-chat-only; floating/notch stays passive. + case .questionCard, .taskCard, .goalLink, .captureLink: + EmptyView() case .agentSpawn( _, let pillId, let sessionId, let runId, let title, let objective, let provider ): diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift new file mode 100644 index 00000000000..a0a08b77f3d --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift @@ -0,0 +1,400 @@ +import SwiftUI +import OmiTheme + +// MARK: - Question card + +/// T07 intentionally renders choices as information-only pills. T08 owns the +/// journal-first selection transaction, tail gating, and option retirement. +struct QuestionCardView: View { + private struct Option: Identifiable { + let id: String + let label: String + + init?(_ dictionary: [String: Any]) { + guard let id = dictionary["optionId"] as? String, + !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let label = dictionary["label"] as? String, + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return nil } + self.id = id + self.label = label + } + } + + let questionID: String + let text: String + let options: [[String: Any]] + + private var validOptions: [Option] { options.compactMap(Option.init) } + + var body: some View { + VStack(alignment: .leading, spacing: OmiSpacing.md) { + Text(text) + .scaledFont(size: OmiType.body, weight: .medium) + .foregroundStyle(OmiColors.textPrimary) + .fixedSize(horizontal: false, vertical: true) + + if !validOptions.isEmpty { + FlowLayout(spacing: OmiSpacing.sm) { + ForEach(validOptions) { option in + Text(option.label) + .scaledFont(size: OmiType.caption, weight: .medium) + .foregroundStyle(OmiColors.textSecondary) + .padding(.horizontal, OmiSpacing.md) + .padding(.vertical, OmiSpacing.sm) + .omiControlSurface( + fill: OmiColors.backgroundPrimary.opacity(0.7), + radius: OmiChrome.chipRadius, + stroke: OmiColors.border.opacity(0.65) + ) + // Options are deliberately not Buttons until T08 installs the + // atomic journal selection operation. + .accessibilityLabel("Suggestion: \(option.label)") + .accessibilityIdentifier("chat-first-question-\(questionID)-option-\(option.id)") + } + } + } + } + .padding(OmiSpacing.md) + .frame(maxWidth: 560, alignment: .leading) + .omiPanel( + fill: OmiColors.backgroundTertiary.opacity(0.88), + radius: OmiChrome.sectionRadius, + stroke: OmiColors.border.opacity(0.55), + shadowOpacity: 0.05, + shadowRadius: 5, + shadowY: 2 + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("chat-first-question-\(questionID)") + } +} + +// MARK: - Task card + +struct TaskCardView: View { + let taskID: String + @ObservedObject private var tasksStore: TasksStore + let navigation: ChatFirstShellNavigation + + @State private var isToggling = false + @State private var showCompletionAcknowledgement = false + + init(taskID: String, tasksStore: TasksStore, navigation: ChatFirstShellNavigation) { + self.taskID = taskID + _tasksStore = ObservedObject(wrappedValue: tasksStore) + self.navigation = navigation + } + + private var task: TaskActionItem? { + tasksStore.tasks.first { $0.id == taskID && $0.deleted != true } + } + + var body: some View { + Group { + if let task { + card(task) + } else { + ChatFirstUnavailableBlockView(entityName: "Task") + } + } + .accessibilityIdentifier("chat-first-task-\(taskID)") + } + + @ViewBuilder + private func card(_ task: TaskActionItem) -> some View { + HStack(alignment: .top, spacing: OmiSpacing.md) { + Button { + toggle(task) + } label: { + ZStack { + Image(systemName: task.completed ? "checkmark.circle.fill" : "circle") + .scaledFont(size: OmiType.subheading, weight: .medium) + .foregroundStyle(task.completed ? OmiColors.success : OmiColors.textTertiary) + + if showCompletionAcknowledgement { + Image(systemName: "checkmark") + .scaledFont(size: OmiType.caption, weight: .bold) + .foregroundStyle(OmiColors.success) + .transition(.scale.combined(with: .opacity)) + } + } + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .disabled(isToggling) + .accessibilityLabel(task.completed ? "Mark \(task.description) incomplete" : "Mark \(task.description) complete") + .accessibilityIdentifier("chat-first-task-\(taskID)-toggle") + + VStack(alignment: .leading, spacing: OmiSpacing.sm) { + Text(task.description) + .scaledFont(size: OmiType.body, weight: .medium) + .foregroundStyle(task.completed ? OmiColors.textTertiary : OmiColors.textPrimary) + .strikethrough(task.completed, color: OmiColors.textTertiary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: OmiSpacing.xs) { + if let goalID = task.goalId, !goalID.isEmpty { + ChatFirstDestinationBadge( + title: "Goal", + systemImage: "target", + accessibilityID: "chat-first-task-\(taskID)-goal-\(goalID)" + ) { + navigation.open(focus: .goal(id: goalID)) + } + } + if let conversationID = task.conversationId, !conversationID.isEmpty { + ChatFirstDestinationBadge( + title: "Capture", + systemImage: "waveform", + accessibilityID: "chat-first-task-\(taskID)-capture-\(conversationID)" + ) { + navigation.open(focus: .capture(id: conversationID, momentTs: nil)) + } + } + } + } + } + .padding(OmiSpacing.md) + .frame(maxWidth: 560, alignment: .leading) + .omiPanel( + fill: OmiColors.backgroundTertiary.opacity(0.88), + radius: OmiChrome.sectionRadius, + stroke: OmiColors.border.opacity(0.55), + shadowOpacity: 0.05, + shadowRadius: 5, + shadowY: 2 + ) + } + + private func toggle(_ task: TaskActionItem) { + guard !isToggling else { return } + let intendedCompletion = !task.completed + isToggling = true + + Task { @MainActor in + await tasksStore.toggleTask(task) + isToggling = false + + // `TasksStore` owns local-first mutation and rollback. Acknowledgement + // is derived only from its reconciled record, never from the tap. + guard ChatFirstTaskCardReconciliation.shouldShowCompletionAcknowledgement( + intendedCompletion: intendedCompletion, + reconciledTask: self.task + ) + else { return } + OmiMotion.withGated(.spring(response: 0.26, dampingFraction: 0.72)) { + showCompletionAcknowledgement = true + } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 550_000_000) + guard !Task.isCancelled else { return } + OmiMotion.withGated(.easeOut(duration: 0.16)) { + showCompletionAcknowledgement = false + } + } + } + } +} + +/// Keeps the card's acknowledgement tied to the owner-safe store's reconciled +/// record. A failed remote mutation leaves the original task in the store, so +/// the view never converts a tap into a false success signal. +enum ChatFirstTaskCardReconciliation { + static func shouldShowCompletionAcknowledgement( + intendedCompletion: Bool, + reconciledTask: TaskActionItem? + ) -> Bool { + intendedCompletion && reconciledTask?.completed == true + } +} + +// MARK: - Goal and capture links + +struct GoalLinkView: View { + let goalID: String + let summary: String + let navigation: ChatFirstShellNavigation + + @State private var isOpening = false + @State private var isUnavailable = false + + var body: some View { + if isUnavailable { + ChatFirstUnavailableBlockView(entityName: "Goal") + } else { + ChatFirstLinkBlockView( + eyebrow: "Goal", + systemImage: "target", + summary: summary, + actionTitle: "Open in Goals", + isOpening: isOpening, + accessibilityID: "chat-first-goal-\(goalID)-open" + ) { + openGoal() + } + } + } + + private func openGoal() { + guard !isOpening else { return } + isOpening = true + Task { @MainActor in + defer { isOpening = false } + do { + // Validate the canonical entity at interaction time. The actual + // destination stays typed and is owned by the shell. + _ = try await APIClient.shared.getCanonicalGoalDetail(goalID: goalID) + navigation.open(focus: .goal(id: goalID)) + } catch { + isUnavailable = true + } + } + } +} + +struct CaptureLinkView: View { + let conversationID: String + let momentTimestampMs: Int? + let summary: String + let navigation: ChatFirstShellNavigation + + @State private var isOpening = false + @State private var isUnavailable = false + + var body: some View { + if isUnavailable { + ChatFirstUnavailableBlockView(entityName: "Conversation") + } else { + ChatFirstLinkBlockView( + eyebrow: "Conversation", + systemImage: "waveform", + summary: summary, + actionTitle: "Open conversation", + isOpening: isOpening, + accessibilityID: "chat-first-capture-\(conversationID)-open" + ) { + openCapture() + } + } + } + + private func openCapture() { + guard !isOpening else { return } + isOpening = true + Task { @MainActor in + defer { isOpening = false } + do { + _ = try await APIClient.shared.getConversation(id: conversationID) + let moment = momentTimestampMs.map { TimeInterval($0) / 1_000 } + navigation.open(focus: .capture(id: conversationID, momentTs: moment)) + } catch { + isUnavailable = true + } + } + } +} + +private struct ChatFirstLinkBlockView: View { + let eyebrow: String + let systemImage: String + let summary: String + let actionTitle: String + let isOpening: Bool + let accessibilityID: String + let action: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: OmiSpacing.sm) { + Label(eyebrow, systemImage: systemImage) + .scaledFont(size: OmiType.caption, weight: .semibold) + .foregroundStyle(OmiColors.textSecondary) + + Text(summary) + .scaledFont(size: OmiType.body, weight: .medium) + .foregroundStyle(OmiColors.textPrimary) + .fixedSize(horizontal: false, vertical: true) + + Button(action: action) { + HStack(spacing: OmiSpacing.xs) { + if isOpening { + ProgressView() + .controlSize(.small) + } + Text(actionTitle) + Image(systemName: "arrow.up.right") + } + .scaledFont(size: OmiType.caption, weight: .semibold) + .foregroundStyle(OmiColors.textPrimary) + .padding(.horizontal, OmiSpacing.sm) + .padding(.vertical, OmiSpacing.xs) + .omiControlSurface( + fill: OmiColors.backgroundPrimary.opacity(0.72), + radius: OmiChrome.chipRadius, + stroke: OmiColors.border.opacity(0.7) + ) + } + .buttonStyle(.plain) + .disabled(isOpening) + .accessibilityLabel(actionTitle) + .accessibilityIdentifier(accessibilityID) + } + .padding(OmiSpacing.md) + .frame(maxWidth: 560, alignment: .leading) + .omiPanel( + fill: OmiColors.backgroundTertiary.opacity(0.88), + radius: OmiChrome.sectionRadius, + stroke: OmiColors.border.opacity(0.55), + shadowOpacity: 0.05, + shadowRadius: 5, + shadowY: 2 + ) + } +} + +private struct ChatFirstDestinationBadge: View { + let title: String + let systemImage: String + let accessibilityID: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .scaledFont(size: OmiType.micro, weight: .medium) + .foregroundStyle(OmiColors.textSecondary) + .padding(.horizontal, OmiSpacing.sm) + .padding(.vertical, OmiSpacing.xxs) + .omiControlSurface( + fill: OmiColors.backgroundPrimary.opacity(0.68), + radius: OmiChrome.chipRadius, + stroke: OmiColors.border.opacity(0.65) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Open \(title)") + .accessibilityIdentifier(accessibilityID) + } +} + +struct ChatFirstUnavailableBlockView: View { + let entityName: String + + var body: some View { + Label("\(entityName) is no longer available", systemImage: "exclamationmark.circle") + .scaledFont(size: OmiType.caption, weight: .medium) + .foregroundStyle(OmiColors.textTertiary) + .padding(OmiSpacing.md) + .frame(maxWidth: 560, alignment: .leading) + .omiPanel( + fill: OmiColors.backgroundTertiary.opacity(0.7), + radius: OmiChrome.sectionRadius, + stroke: OmiColors.border.opacity(0.45), + shadowOpacity: 0, + shadowRadius: 0, + shadowY: 0 + ) + .accessibilityLabel("\(entityName) is no longer available") + .accessibilityIdentifier("chat-first-\(entityName.lowercased())-unavailable") + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift new file mode 100644 index 00000000000..06c2846171a --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Explicit rendering capability for persisted chat-first blocks. The journal +/// is shared by every Chat surface, but rich controls belong exclusively to +/// the enabled main-window shell. Passing this context is therefore a +/// rendering capability, not a second transcript or a user-controlled flag. +@MainActor +struct ChatFirstRichBlockContext { + let navigation: ChatFirstShellNavigation + let tasksStore: TasksStore + + init(navigation: ChatFirstShellNavigation, tasksStore: TasksStore = .shared) { + self.navigation = navigation + self.tasksStore = tasksStore + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift new file mode 100644 index 00000000000..b52049ebc4c --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift @@ -0,0 +1,418 @@ +import Foundation +import SwiftUI +import OmiTheme + +/// Cohort-only device-capture archive. This is intentionally a read-only +/// archive surface: no chat history, composer, search, edit, or delete paths +/// are inherited from the legacy Conversations page. +@MainActor +struct CaptureArchivePage: View { + @ObservedObject var navigation: ChatFirstShellNavigation + let chatProvider: ChatProvider + @StateObject private var repository: CaptureArchiveRepository + @StateObject private var playback: CapturePlaybackController + + init( + navigation: ChatFirstShellNavigation, + chatProvider: ChatProvider + ) { + self.navigation = navigation + self.chatProvider = chatProvider + _repository = StateObject(wrappedValue: CaptureArchiveRepository()) + _playback = StateObject(wrappedValue: CapturePlaybackController()) + } + + var body: some View { + HStack(spacing: 0) { + captureList + .frame(minWidth: 280, idealWidth: 340, maxWidth: 420) + + Divider().overlay(OmiColors.border.opacity(0.45)) + + captureDetail + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(OmiColors.backgroundPrimary) + .task { await repository.loadInitial() } + .task(id: pendingFocusToken) { await resolvePendingFocusIfNeeded() } + .accessibilityIdentifier("chat-first-capture-archive") + } + + private var captureList: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: OmiSpacing.xxs) { + Text("Conversations") + .scaledFont(size: OmiType.title, weight: .bold) + .foregroundStyle(OmiColors.textPrimary) + Text("Omi-device captures") + .scaledFont(size: OmiType.caption) + .foregroundStyle(OmiColors.textSecondary) + } + Spacer() + Button { + Task { await repository.refresh() } + } label: { + Image(systemName: "arrow.clockwise") + .scaledFont(size: OmiType.body, weight: .medium) + } + .buttonStyle(.plain) + .disabled(repository.isLoading) + .accessibilityLabel("Refresh Omi-device captures") + .accessibilityIdentifier("chat-first-capture-refresh") + } + .padding(.horizontal, OmiSpacing.xl) + .padding(.vertical, OmiSpacing.lg) + + if let error = repository.errorMessage { + unavailableState(message: error) + } + + if repository.isLoading && repository.captures.isEmpty { + ProgressView("Loading Omi-device captures") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if repository.captures.isEmpty, repository.errorMessage == nil { + emptyState + } else { + List { + ForEach(repository.captures) { capture in + Button { + Task { await select(capture) } + } label: { + CaptureArchiveRow(capture: capture, isSelected: repository.selectedCapture?.id == capture.id) + } + .buttonStyle(.plain) + .accessibilityLabel(capture.accessibilitySummary) + .accessibilityIdentifier("chat-first-capture-row-\(capture.id)") + .onAppear { + guard capture.id == repository.captures.last?.id else { return } + Task { await repository.loadNextPage() } + } + } + if repository.isLoadingMore { + HStack { Spacer(); ProgressView(); Spacer() } + .accessibilityLabel("Loading more Omi-device captures") + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + .background(OmiColors.backgroundSecondary.opacity(0.6)) + } + + @ViewBuilder + private var captureDetail: some View { + if let capture = repository.selectedCapture { + ScrollView { + VStack(alignment: .leading, spacing: OmiSpacing.xxl) { + detailHeader(capture) + playbackSection(capture) + + if !capture.overview.isEmpty { + detailSection("Summary") { + Text(capture.overview) + .scaledFont(size: OmiType.body) + .foregroundStyle(OmiColors.textSecondary) + .textSelection(.enabled) + } + } + + if !capture.transcriptSegments.isEmpty { + momentsSection(capture) + } + + linkedItemsSection(capture) + } + .padding(OmiSpacing.xxl) + } + .accessibilityIdentifier("chat-first-capture-detail-\(capture.id)") + } else { + VStack(spacing: OmiSpacing.md) { + Image(systemName: "waveform") + .scaledFont(size: 36, weight: .medium) + .foregroundStyle(OmiColors.textTertiary) + Text("Select an Omi-device capture") + .scaledFont(size: OmiType.subheading, weight: .semibold) + .foregroundStyle(OmiColors.textPrimary) + Text("Capture details, audio, and timestamped moments will appear here.") + .scaledFont(size: OmiType.body) + .foregroundStyle(OmiColors.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 340) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func detailHeader(_ capture: ServerConversation) -> some View { + VStack(alignment: .leading, spacing: OmiSpacing.md) { + HStack(alignment: .top, spacing: OmiSpacing.lg) { + VStack(alignment: .leading, spacing: OmiSpacing.xs) { + Text(capture.title) + .scaledFont(size: OmiType.title, weight: .bold) + .foregroundStyle(OmiColors.textPrimary) + .textSelection(.enabled) + Text(capture.detailMetadata) + .scaledFont(size: OmiType.caption) + .foregroundStyle(OmiColors.textSecondary) + } + Spacer() + Button("Discuss in Chat") { + navigation.discuss(.capture(id: capture.id, momentTimestamp: nil), using: chatProvider) + } + .buttonStyle(.borderedProminent) + .tint(OmiColors.textPrimary) + .accessibilityLabel("Discuss this capture in Chat") + .accessibilityIdentifier("chat-first-capture-discuss-\(capture.id)") + } + if let address = capture.geolocation?.address, !address.isEmpty { + Label(address, systemImage: "mappin.and.ellipse") + .scaledFont(size: OmiType.caption) + .foregroundStyle(OmiColors.textSecondary) + } + if !capture.participantLabels.isEmpty { + Label(capture.participantLabels.joined(separator: ", "), systemImage: "person.2") + .scaledFont(size: OmiType.caption) + .foregroundStyle(OmiColors.textSecondary) + } + } + } + + @ViewBuilder + private func playbackSection(_ capture: ServerConversation) -> some View { + detailSection("Playback") { + if playback.isResolving { + HStack(spacing: OmiSpacing.sm) { + ProgressView() + Text("Preparing audio") + .scaledFont(size: OmiType.body) + .foregroundStyle(OmiColors.textSecondary) + } + } else if let resolution = playback.resolution { + HStack(spacing: OmiSpacing.md) { + switch resolution { + case .readyAggregate, .fileFallback: + Button { + playback.playOrPause() + } label: { + Label("Play audio", systemImage: "play.fill") + } + .buttonStyle(.bordered) + .accessibilityLabel("Play capture audio") + .accessibilityIdentifier("chat-first-capture-play") + case .pending, .locked, .unavailable, .noAudio: + Button("Check audio") { + Task { _ = await playback.prepare(for: capture, forceRefresh: true) } + } + .buttonStyle(.bordered) + .disabled(capture.isLocked) + .accessibilityLabel("Check capture audio") + .accessibilityIdentifier("chat-first-capture-check-audio-\(capture.id)") + } + Text(resolution.userFacingMessage) + .scaledFont(size: OmiType.caption) + .foregroundStyle(OmiColors.textSecondary) + } + } else { + Button("Prepare audio") { + Task { _ = await playback.prepare(for: capture) } + } + .buttonStyle(.bordered) + .accessibilityIdentifier("chat-first-capture-prepare-audio") + } + } + } + + private func momentsSection(_ capture: ServerConversation) -> some View { + detailSection("Timestamped moments") { + VStack(alignment: .leading, spacing: OmiSpacing.xs) { + ForEach(Array(capture.transcriptSegments.prefix(12))) { segment in + Button { + Task { _ = await playback.seekToMoment(wallOffset: segment.start) } + } label: { + HStack(alignment: .top, spacing: OmiSpacing.md) { + Text(segment.shortTimestamp) + .scaledFont(size: OmiType.caption, weight: .semibold) + .foregroundStyle(OmiColors.textSecondary) + .frame(width: 60, alignment: .leading) + Text(segment.text) + .scaledFont(size: OmiType.body) + .foregroundStyle(OmiColors.textPrimary) + .lineLimit(2) + Spacer(minLength: 0) + Image(systemName: "play.circle") + .foregroundStyle(OmiColors.textTertiary) + } + .padding(.vertical, OmiSpacing.xs) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canSeekMoment(segment)) + .accessibilityLabel("Seek to \(segment.shortTimestamp): \(segment.text)") + .accessibilityHint(canSeekMoment(segment) ? "Seeks the capture audio" : "Audio is still preparing") + .accessibilityIdentifier("chat-first-capture-moment-\(segment.id)") + } + } + } + } + + private func linkedItemsSection(_ capture: ServerConversation) -> some View { + detailSection("Linked to this capture") { + let taskLinks = capture.structured.actionItems.compactMap(\.targetTaskID) + if taskLinks.isEmpty { + Text("No linked tasks or goals are available for this capture.") + .scaledFont(size: OmiType.body) + .foregroundStyle(OmiColors.textSecondary) + } else { + VStack(alignment: .leading, spacing: OmiSpacing.xs) { + ForEach(taskLinks, id: \.self) { taskID in + Button { + navigation.open(focus: .task(id: taskID)) + } label: { + Label("Open linked task", systemImage: "checklist") + .scaledFont(size: OmiType.body, weight: .medium) + } + .buttonStyle(.bordered) + .accessibilityLabel("Open linked task") + .accessibilityIdentifier("chat-first-capture-task-\(taskID)") + } + } + } + } + } + + private func detailSection( + _ title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: OmiSpacing.sm) { + Text(title) + .scaledFont(size: OmiType.subheading, weight: .semibold) + .foregroundStyle(OmiColors.textPrimary) + content() + } + .padding(OmiSpacing.lg) + .background( + RoundedRectangle(cornerRadius: OmiChrome.controlRadius, style: .continuous) + .fill(OmiColors.backgroundSecondary.opacity(0.72)) + ) + } + + private var emptyState: some View { + VStack(spacing: OmiSpacing.md) { + Image(systemName: "waveform") + .scaledFont(size: 32, weight: .medium) + .foregroundStyle(OmiColors.textTertiary) + Text("No Omi-device captures yet") + .scaledFont(size: OmiType.subheading, weight: .semibold) + .foregroundStyle(OmiColors.textPrimary) + Text("Meetings and moments captured by your Omi device will appear here.") + .scaledFont(size: OmiType.body) + .foregroundStyle(OmiColors.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("chat-first-capture-empty") + } + + private func unavailableState(message: String) -> some View { + HStack(spacing: OmiSpacing.sm) { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(OmiColors.textSecondary) + Text(message) + .scaledFont(size: OmiType.caption) + .foregroundStyle(OmiColors.textSecondary) + Spacer(minLength: 0) + } + .padding(.horizontal, OmiSpacing.lg) + .padding(.vertical, OmiSpacing.sm) + .background(OmiColors.backgroundTertiary.opacity(0.55)) + .accessibilityIdentifier("chat-first-capture-unavailable") + } + + private var pendingFocusToken: String { + guard case .capture(let id, let momentTimestamp) = navigation.pendingFocus else { return "none" } + let moment = momentTimestamp.map { String($0) } ?? "" + return "\(id):\(moment)" + } + + private func select(_ capture: ServerConversation) async { + repository.select(capture) + guard let detail = await repository.loadDetail(id: capture.id) else { return } + _ = await playback.prepare(for: detail) + } + + private func resolvePendingFocusIfNeeded() async { + guard case .capture(let id, let momentTimestamp) = navigation.pendingFocus else { return } + guard let detail = await repository.loadDetail(id: id) else { return } + let resolution = await playback.prepare(for: detail) + if let momentTimestamp { + let didCompleteSeek = await playback.seekToMoment(wallOffset: momentTimestamp) + guard CaptureFocusAcknowledgementPolicy.canAcknowledge( + requestedMoment: momentTimestamp, + resolution: resolution, + didCompleteSeek: didCompleteSeek + ) else { return } + } + _ = navigation.acknowledgeFocus(.capture(id: id, momentTs: momentTimestamp)) + } + + private func canSeekMoment(_ segment: TranscriptSegment) -> Bool { + guard case .readyAggregate(let artifact) = playback.resolution else { return false } + return artifact.artifactOffset(forWallOffset: segment.start) != nil + } +} + +private struct CaptureArchiveRow: View { + let capture: ServerConversation + let isSelected: Bool + + var body: some View { + VStack(alignment: .leading, spacing: OmiSpacing.xs) { + Text(capture.title) + .scaledFont(size: OmiType.body, weight: isSelected ? .semibold : .regular) + .foregroundStyle(OmiColors.textPrimary) + .lineLimit(2) + Text(capture.listMetadata) + .scaledFont(size: OmiType.caption) + .foregroundStyle(OmiColors.textSecondary) + .lineLimit(1) + } + .padding(.vertical, OmiSpacing.xs) + .padding(.horizontal, OmiSpacing.sm) + .background( + RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) + .fill(isSelected ? OmiColors.backgroundTertiary : Color.clear) + ) + } +} + +private extension ServerConversation { + var archiveDisplayDate: Date { startedAt ?? createdAt } + + var listMetadata: String { + "\(archiveDisplayDate.formatted(.relative(presentation: .named))) · \(formattedDuration)" + } + + var detailMetadata: String { + let date = archiveDisplayDate.formatted(date: .abbreviated, time: .shortened) + return "\(date) · \(formattedDuration)" + } + + var participantLabels: [String] { + Array(Set(transcriptSegments.compactMap(\.speaker))).sorted() + } + + var accessibilitySummary: String { + "\(title), \(listMetadata), Omi-device capture" + } +} + +private extension TranscriptSegment { + var shortTimestamp: String { + let totalSeconds = Int(start) + return String(format: "%02d:%02d", totalSeconds / 60, totalSeconds % 60) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift new file mode 100644 index 00000000000..b9e5eb39176 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift @@ -0,0 +1,224 @@ +import Foundation + +/// The capture archive has a single, non-negotiable provenance query. It is +/// intentionally separate from `ConversationListQuery`, whose legacy callers +/// may display mixed desktop, phone, and hardware conversations. +struct CaptureArchiveQuery: Equatable, Sendable { + static let pageSize = 50 + + let offset: Int + let limit: Int + /// These are deliberately constants rather than caller-controlled query + /// knobs. The archive must never be repurposed as a generic conversations + /// list by accidentally changing a page request. + let statuses: [ConversationStatus] = [.completed, .processing] + let source: ConversationSource = .omi + let includeDiscarded = false + + init(offset: Int = 0, limit: Int = CaptureArchiveQuery.pageSize) { + self.offset = offset + self.limit = limit + } +} + +private enum CaptureArchiveRepositoryError: Error { + /// A filtered server/cache response containing another provenance is a + /// contract failure, not an opportunity to client-filter a mixed page. + case receivedNonArchiveCapture +} + +private extension ServerConversation { + var isOmiCaptureArchiveRecord: Bool { + source == .omi && !discarded && (status == .completed || status == .processing) + } +} + +protocol CaptureArchiveRemoteDataSource { + func list(query: CaptureArchiveQuery) async throws -> [ServerConversation] + func count(query: CaptureArchiveQuery) async throws -> Int + func detail(id: String) async throws -> ServerConversation +} + +protocol CaptureArchiveLocalDataSource { + func list(query: CaptureArchiveQuery) async throws -> [ServerConversation] + func count(query: CaptureArchiveQuery) async throws -> Int + func detail(id: String) async throws -> ServerConversation? + func store(_ conversation: ServerConversation) async throws +} + +struct LiveCaptureArchiveRemoteDataSource: CaptureArchiveRemoteDataSource { + func list(query: CaptureArchiveQuery) async throws -> [ServerConversation] { + try await APIClient.shared.getConversations( + limit: query.limit, + offset: query.offset, + statuses: query.statuses, + sources: [query.source], + includeDiscarded: query.includeDiscarded + ) + } + + func count(query: CaptureArchiveQuery) async throws -> Int { + try await APIClient.shared.getConversationsCount( + includeDiscarded: query.includeDiscarded, + statuses: query.statuses, + sources: [query.source] + ) + } + + func detail(id: String) async throws -> ServerConversation { + try await APIClient.shared.getConversation(id: id) + } +} + +struct LiveCaptureArchiveLocalDataSource: CaptureArchiveLocalDataSource { + func list(query: CaptureArchiveQuery) async throws -> [ServerConversation] { + precondition(query.source == .omi && query.includeDiscarded == false) + return try await TranscriptionStorage.shared.getLocalOmiCaptureConversations( + limit: query.limit, + offset: query.offset + ) + } + + func count(query: CaptureArchiveQuery) async throws -> Int { + precondition(query.source == .omi && query.includeDiscarded == false) + return try await TranscriptionStorage.shared.getLocalOmiCaptureConversationsCount() + } + + func detail(id: String) async throws -> ServerConversation? { + try await TranscriptionStorage.shared.getCachedConversation(id: id) + } + + func store(_ conversation: ServerConversation) async throws { + _ = try await TranscriptionStorage.shared.syncServerConversation(conversation) + } +} + +/// Read-only cache/network owner for the cohort-only Omi capture archive. It +/// has no mutation or mixed-source fallback path by design. +@MainActor +final class CaptureArchiveRepository: ObservableObject { + @Published private(set) var captures: [ServerConversation] = [] + @Published private(set) var selectedCapture: ServerConversation? + @Published private(set) var count: Int? + @Published private(set) var isLoading = false + @Published private(set) var isLoadingMore = false + @Published private(set) var errorMessage: String? + + private let remote: any CaptureArchiveRemoteDataSource + private let local: any CaptureArchiveLocalDataSource + private var hasLoaded = false + + init( + remote: any CaptureArchiveRemoteDataSource = LiveCaptureArchiveRemoteDataSource(), + local: any CaptureArchiveLocalDataSource = LiveCaptureArchiveLocalDataSource() + ) { + self.remote = remote + self.local = local + } + + var hasMore: Bool { + guard let count else { return false } + return captures.count < count + } + + func loadInitial(force: Bool = false) async { + guard force || !hasLoaded else { return } + hasLoaded = true + isLoading = true + errorMessage = nil + + let query = CaptureArchiveQuery() + do { + async let cachedRows = local.list(query: query) + async let cachedCount = local.count(query: query) + let (unvalidatedRows, localCount) = try await (cachedRows, cachedCount) + captures = try validatedArchiveRows(unvalidatedRows) + count = localCount + } catch { + // A stale cache must not block the server-authoritative source-scoped + // request. The subsequent failure still surfaces honestly below. + } + + await reloadFirstPage(query: query) + isLoading = false + } + + func refresh() async { + await loadInitial(force: true) + } + + func loadNextPage() async { + guard !isLoadingMore, !isLoading, errorMessage == nil, hasMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + let query = CaptureArchiveQuery(offset: captures.count) + do { + let page = try validatedArchiveRows(await remote.list(query: query)) + for capture in page where !captures.contains(where: { $0.id == capture.id }) { + captures.append(capture) + try? await local.store(capture) + } + } catch { + // Do not retry without the source predicate. The user must choose Refresh. + errorMessage = "Omi-device captures are unavailable. Refresh to try again." + } + } + + func select(_ capture: ServerConversation) { + selectedCapture = capture + } + + /// Detail always revalidates from the source-scoped list's selected capture. + /// It never falls back to a generic list request if the detail read fails. + func loadDetail(id: String) async -> ServerConversation? { + if let cached = try? await local.detail(id: id), cached.isOmiCaptureArchiveRecord { + selectedCapture = cached + } + + do { + let detail = try await remote.detail(id: id) + guard detail.isOmiCaptureArchiveRecord else { + errorMessage = "This capture is no longer available." + return nil + } + selectedCapture = detail + if let index = captures.firstIndex(where: { $0.id == detail.id }) { + captures[index] = detail + } else { + captures.insert(detail, at: 0) + } + try? await local.store(detail) + return detail + } catch { + errorMessage = "This Omi-device capture is unavailable. Refresh to try again." + return nil + } + } + + private func reloadFirstPage(query: CaptureArchiveQuery) async { + do { + async let remoteRows = remote.list(query: query) + async let remoteCount = remote.count(query: query) + let (unvalidatedRows, remoteTotal) = try await (remoteRows, remoteCount) + let rows = try validatedArchiveRows(unvalidatedRows) + captures = rows + count = remoteTotal + errorMessage = nil + for capture in rows { + try? await local.store(capture) + } + } catch { + // Cache rows may remain visible, but the state is never silently healthy: + // archive data is unavailable rather than silently replaced with a mixed list. + errorMessage = "Omi-device captures are unavailable. Refresh to try again." + } + } + + private func validatedArchiveRows(_ rows: [ServerConversation]) throws -> [ServerConversation] { + guard rows.allSatisfy(\.isOmiCaptureArchiveRecord) else { + throw CaptureArchiveRepositoryError.receivedNonArchiveCapture + } + return rows + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlayback.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlayback.swift new file mode 100644 index 00000000000..0408a2e1a0c --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlayback.swift @@ -0,0 +1,189 @@ +import AVFoundation +import Combine +import Foundation + +/// Narrow, page-owned playback boundary for the capture archive. A ready +/// aggregate artifact is the only state that promises exact moment seeking. +protocol CapturePlaybackProviding { + func resolvePlayback(for capture: ServerConversation) async -> CapturePlaybackResolution +} + +enum CapturePlaybackResolution: Equatable { + case readyAggregate(CapturePlaybackArtifact) + case fileFallback(CapturePlaybackFile) + case pending(pollAfterMs: Int?) + case locked + case unavailable + case noAudio + + var userFacingMessage: String { + switch self { + case .readyAggregate: return "Playback ready" + case .fileFallback: return "A single audio part is ready. Timestamped seeking is preparing." + case .pending: return "Audio is preparing. Try again shortly." + case .locked: return "Audio is locked for this capture." + case .unavailable: return "Audio is unavailable for this capture." + case .noAudio: return "No audio is available for this capture." + } + } +} + +struct CapturePlaybackFile: Equatable { + let id: String + let signedURL: URL + let duration: TimeInterval +} + +struct CapturePlaybackArtifact: Equatable { + let signedURL: URL + let duration: TimeInterval + let spans: [CaptureAudioURLSpan] + + /// Converts the source capture's wall-clock offset into the aggregate + /// artifact's media offset. It returns nil across a gap or missing span; + /// callers must not seek a per-file fallback and claim accuracy. + func artifactOffset(forWallOffset wallOffset: TimeInterval) -> TimeInterval? { + guard let span = spans.first(where: { + let end = $0.wallOffset + $0.length + return wallOffset >= $0.wallOffset && wallOffset < end + }) else { + return nil + } + return span.artifactOffset + (wallOffset - span.wallOffset) + } +} + +enum CaptureFocusAcknowledgementPolicy { + /// A capture with no moment is visible as soon as its detail is selected. A + /// moment deep link is acknowledged only after the aggregate seek callback + /// succeeds; pending/fallback/unavailable playback deliberately stays pending. + static func canAcknowledge( + requestedMoment: TimeInterval?, + resolution: CapturePlaybackResolution, + didCompleteSeek: Bool = false + ) -> Bool { + guard requestedMoment != nil else { return true } + guard case .readyAggregate = resolution else { return false } + return didCompleteSeek + } +} + +struct LiveCapturePlaybackProvider: CapturePlaybackProviding { + func resolvePlayback(for capture: ServerConversation) async -> CapturePlaybackResolution { + guard !capture.isLocked else { return .locked } + guard !capture.audioFiles.isEmpty || capture.conversationAudio != nil else { return .noAudio } + + do { + let precache = try await APIClient.shared.precacheCaptureAudio(conversationID: capture.id) + if precache.status == "no_audio" { return .noAudio } + let response = try await APIClient.shared.captureAudioURLs(conversationID: capture.id) + return Self.resolution(from: response) + } catch let APIError.httpError(statusCode, _) where statusCode == 402 { + return .locked + } catch { + // URL endpoints do not distinguish a transient error from an absent + // capture in their stable contract. Never expose an invented URL or + // treat a generic file as exact timestamp playback. + return .unavailable + } + } + + static func resolution(from response: CaptureAudioURLsResponse) -> CapturePlaybackResolution { + if let artifact = response.conversationAudio { + switch artifact.status { + case "cached": + if let signedURL = artifact.signedURL { + return .readyAggregate( + CapturePlaybackArtifact( + signedURL: signedURL, + duration: artifact.duration ?? artifact.capturedDuration ?? 0, + spans: artifact.spans + ) + ) + } + return .unavailable + case "pending": + return .pending(pollAfterMs: response.pollAfterMs) + case "unavailable": + return .unavailable + default: + return .unavailable + } + } + + if let file = response.audioFiles.first(where: { $0.status == "cached" && $0.signedURL != nil }), + let signedURL = file.signedURL + { + return .fileFallback(CapturePlaybackFile(id: file.id, signedURL: signedURL, duration: file.duration)) + } + + if response.audioFiles.contains(where: { $0.status == "pending" }) { + return .pending(pollAfterMs: response.pollAfterMs) + } + if response.audioFiles.isEmpty { return .noAudio } + return .unavailable + } +} + +/// `AVPlayer` lifecycle stays inside the archive. Signed URLs are held only in +/// the player item for the active page and are never persisted or logged. +@MainActor +final class CapturePlaybackController: ObservableObject { + @Published private(set) var resolution: CapturePlaybackResolution? + @Published private(set) var isResolving = false + + private let provider: any CapturePlaybackProviding + private var player: AVPlayer? + private var activeCaptureID: String? + + init(provider: any CapturePlaybackProviding = LiveCapturePlaybackProvider()) { + self.provider = provider + } + + func prepare( + for capture: ServerConversation, + forceRefresh: Bool = false + ) async -> CapturePlaybackResolution { + if !forceRefresh, activeCaptureID == capture.id, let resolution { return resolution } + isResolving = true + defer { isResolving = false } + + let next = await provider.resolvePlayback(for: capture) + activeCaptureID = capture.id + resolution = next + switch next { + case .readyAggregate(let artifact): + player = AVPlayer(url: artifact.signedURL) + case .fileFallback(let file): + player = AVPlayer(url: file.signedURL) + case .pending, .locked, .unavailable, .noAudio: + player = nil + } + return next + } + + func playOrPause() { + guard let player else { return } + if player.timeControlStatus == .playing { + player.pause() + } else { + player.play() + } + } + + /// Returns true only when an aggregate artifact translated the requested + /// wall offset and AVFoundation confirmed the exact seek completed. + func seekToMoment(wallOffset: TimeInterval) async -> Bool { + guard case .readyAggregate(let artifact) = resolution, + let target = artifact.artifactOffset(forWallOffset: wallOffset), + let player + else { return false } + + let time = CMTime(seconds: target, preferredTimescale: 600) + return await withCheckedContinuation { continuation in + player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { finished in + continuation.resume(returning: finished) + } + } + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlaybackAPI.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlaybackAPI.swift new file mode 100644 index 00000000000..5a7ad3e8e7b --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlaybackAPI.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Typed wire contracts for the existing sync-audio endpoints. They live next +/// to the archive rather than extending the generated surface because the +/// current OpenAPI describes these payloads as `OmiAnyCodable`. +struct CaptureAudioPrecacheResponse: Decodable, Equatable { + let status: String + let message: String? + let audioFileCount: Int? + + enum CodingKeys: String, CodingKey { + case status, message + case audioFileCount = "audio_file_count" + } +} + +struct CaptureAudioURLFile: Decodable, Equatable, Identifiable { + let id: String + let status: String + let signedURL: URL? + let contentType: String? + let duration: TimeInterval + + enum CodingKeys: String, CodingKey { + case id, status + case signedURL = "signed_url" + case contentType = "content_type" + case duration + } +} + +struct CaptureAudioURLSpan: Decodable, Equatable { + let fileID: String + let wallOffset: TimeInterval + let artifactOffset: TimeInterval + let length: TimeInterval + + enum CodingKeys: String, CodingKey { + case fileID = "file_id" + case wallOffset = "wall_offset" + case artifactOffset = "artifact_offset" + case length = "len" + } +} + +struct CaptureAudioURLArtifact: Decodable, Equatable { + let status: String + let signedURL: URL? + let contentType: String? + let duration: TimeInterval? + let capturedDuration: TimeInterval? + let spans: [CaptureAudioURLSpan] + + enum CodingKeys: String, CodingKey { + case status + case signedURL = "signed_url" + case contentType = "content_type" + case duration + case capturedDuration = "captured_duration" + case spans + } +} + +struct CaptureAudioURLsResponse: Decodable, Equatable { + let audioFiles: [CaptureAudioURLFile] + let conversationAudio: CaptureAudioURLArtifact? + let pollAfterMs: Int? + + enum CodingKeys: String, CodingKey { + case audioFiles = "audio_files" + case conversationAudio = "conversation_audio" + case pollAfterMs = "poll_after_ms" + } +} + +extension APIClient { + /// Requests backend-side preparation only. Signed URLs are intentionally + /// never logged or persisted by this client. + func precacheCaptureAudio(conversationID: String) async throws -> CaptureAudioPrecacheResponse { + try await post("v1/sync/audio/\(conversationID)/precache") + } + + func captureAudioURLs(conversationID: String) async throws -> CaptureAudioURLsResponse { + try await get("v1/sync/audio/\(conversationID)/urls") + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift index 63991681f09..2cf7c49ee4f 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift @@ -125,6 +125,29 @@ enum ChatFirstPendingFocus: Equatable, Sendable { } } +/// A route-safe, strongly typed origin for the one normal user turn created +/// by a page's "Discuss in Chat" affordance. Pages never construct model +/// prompts from display strings or pass raw URLs into Chat. +enum ChatFirstDiscussionContext: Equatable, Sendable { + case tasks + case goal(id: String) + case capture(id: String, momentTimestamp: TimeInterval?) + + var userMessage: String { + switch self { + case .tasks: + return "Help me review my current tasks." + case .goal(let id): + return "Help me continue working on goal \(id)." + case .capture(let id, let momentTimestamp): + if let momentTimestamp { + return "Discuss Omi capture \(id) at \(Int(momentTimestamp)) seconds." + } + return "Discuss Omi capture \(id)." + } + } +} + private struct ChatFirstPersistedNavigation: Codable, Equatable { var route: ChatFirstRoute var isSidebarCollapsed: Bool @@ -181,6 +204,16 @@ final class ChatFirstShellNavigation: ObservableObject { persistNavigation() } + /// Routes first, then records exactly one ordinary main-Chat user turn. + /// `ChatProvider` remains the single journal owner; navigation stores no + /// transcript copy or separate session identity. + func discuss(_ context: ChatFirstDiscussionContext, using chatProvider: ChatProvider) { + selectPrimary(.chat) + Task { + _ = await chatProvider.sendMessage(context.userMessage) + } + } + @discardableResult func acknowledgeFocus(_ focus: ChatFirstPendingFocus) -> Bool { guard route == focus.route, pendingFocus == focus else { return false } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift index 246f0183257..bd7543613e7 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift @@ -10,8 +10,6 @@ struct ChatFirstShell: View { @Binding var selectedSettingsSection: SettingsContentView.SettingsSection @Binding var highlightedSettingID: String? - @State private var selectedConversation: ServerConversation? - var body: some View { HStack(spacing: 0) { ChatFirstSidebar(navigation: navigation) @@ -45,11 +43,18 @@ struct ChatFirstShell: View { case .chat: ChatPage( appProvider: viewModelContainer.appProvider, - chatProvider: viewModelContainer.chatProvider + chatProvider: viewModelContainer.chatProvider, + chatFirstRichBlockContext: ChatFirstRichBlockContext( + navigation: navigation, + tasksStore: viewModelContainer.tasksStore + ) ) .accessibilityIdentifier("chat-first-route-chat") case .conversations: - ConversationsPage(appState: appState, selectedConversation: $selectedConversation) + CaptureArchivePage( + navigation: navigation, + chatProvider: viewModelContainer.chatProvider + ) .accessibilityIdentifier("chat-first-route-conversations") case .tasks: TasksPage( diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index 1eb8d4382dc..92e700ed258 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -17,6 +17,9 @@ struct ChatBubble: View { var onCancelTurn: (() -> Void)? = nil var onOpenAgent: ((UUID, @escaping (Bool) -> Void) -> Void)? = nil var onOpenAgentRef: ((AgentTimelineRef, @escaping (Bool) -> Void) -> Void)? = nil + /// Nil for all existing Chat surfaces. Rich blocks are transcript data, but + /// only the capability-gated main shell is allowed to turn them into controls. + var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil @State private var isTimestampHovering = false @State private var isExpanded = false @@ -30,7 +33,8 @@ struct ChatBubble: View { onCitationTap: ((Citation) -> Void)? = nil, isDuplicate: Bool = false, onCancelTurn: (() -> Void)? = nil, onOpenAgent: ((UUID, @escaping (Bool) -> Void) -> Void)? = nil, - onOpenAgentRef: ((AgentTimelineRef, @escaping (Bool) -> Void) -> Void)? = nil + onOpenAgentRef: ((AgentTimelineRef, @escaping (Bool) -> Void) -> Void)? = nil, + chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil ) { self.message = message self.app = app @@ -40,6 +44,7 @@ struct ChatBubble: View { self.onCancelTurn = onCancelTurn self.onOpenAgent = onOpenAgent self.onOpenAgentRef = onOpenAgentRef + self.chatFirstRichBlockContext = chatFirstRichBlockContext _lastSubmittedRating = State(initialValue: message.rating) } @@ -85,7 +90,8 @@ struct ChatBubble: View { var body: some View { let groupedBlocks = ContentBlockGroup.visibleChatGroups( message.contentBlocks, - isStreaming: message.isStreaming + isStreaming: message.isStreaming, + richBlockRenderingEnabled: chatFirstRichBlockContext != nil ) HStack(alignment: .top, spacing: OmiSpacing.md) { @@ -276,6 +282,46 @@ struct ChatBubble: View { return AnyView(ThinkingBlock(text: text)) case .discoveryCard(_, let title, let summary, let fullText): return AnyView(DiscoveryCard(title: title, summary: summary, fullText: fullText)) + case .questionCard(_, let questionID, let text, let options, let selectedOptionID): + guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } + return AnyView( + QuestionCardView( + questionID: questionID, + text: text, + options: options, + selectedOptionID: selectedOptionID, + isActionable: false, + onSelect: { _ in } + ) + ) + case .taskCard(_, let taskID): + guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } + return AnyView( + TaskCardView( + taskID: taskID, + tasksStore: chatFirstRichBlockContext.tasksStore, + navigation: chatFirstRichBlockContext.navigation + ) + ) + case .goalLink(_, let goalID, let summary): + guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } + return AnyView( + GoalLinkView( + goalID: goalID, + summary: summary, + navigation: chatFirstRichBlockContext.navigation + ) + ) + case .captureLink(_, let conversationID, let momentTimestampMs, let summary): + guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } + return AnyView( + CaptureLinkView( + conversationID: conversationID, + momentTimestampMs: momentTimestampMs, + summary: summary, + navigation: chatFirstRichBlockContext.navigation + ) + ) case .agentSpawn( _, let pillId, let sessionId, let runId, let title, let objective, let provider ): @@ -857,6 +903,10 @@ enum ContentBlockGroup: Identifiable { case toolCalls(id: String, calls: [ChatContentBlock]) case thinking(id: String, text: String) case discoveryCard(id: String, title: String, summary: String, fullText: String) + case questionCard(id: String, questionID: String, text: String, options: [[String: Any]], selectedOptionID: String?) + case taskCard(id: String, taskID: String) + case goalLink(id: String, goalID: String, summary: String) + case captureLink(id: String, conversationID: String, momentTimestampMs: Int?, summary: String) case agentSpawn( id: String, pillId: UUID?, @@ -883,13 +933,20 @@ enum ContentBlockGroup: Identifiable { case .toolCalls(let id, _): return id case .thinking(let id, _): return id case .discoveryCard(let id, _, _, _): return id + case .questionCard(let id, _, _, _, _): return id + case .taskCard(let id, _): return id + case .goalLink(let id, _, _): return id + case .captureLink(let id, _, _, _): return id case .agentSpawn(let id, _, _, _, _, _, _): return id case .agentCompletion(let id, _, _, _, _, _, _, _): return id } } /// Groups consecutive `.toolCall` blocks together; passes other blocks through - static func group(_ blocks: [ChatContentBlock]) -> [ContentBlockGroup] { + static func group( + _ blocks: [ChatContentBlock], + richBlockRenderingEnabled: Bool = false + ) -> [ContentBlockGroup] { var groups: [ContentBlockGroup] = [] var pendingToolCalls: [ChatContentBlock] = [] @@ -913,11 +970,29 @@ enum ContentBlockGroup: Identifiable { case .discoveryCard(let id, let title, let summary, let fullText): flushToolCalls() groups.append(.discoveryCard(id: id, title: title, summary: summary, fullText: fullText)) - // Chat-first blocks are decoded at the shared journal boundary in T02. - // T07 owns their main-chat visual treatment; suppress them on the legacy - // grouping projection until that renderer lands. - case .questionCard, .taskCard, .goalLink, .captureLink: + case .questionCard(let id, let questionID, let text, _, _, let options, let selectedOptionID): + flushToolCalls() + guard richBlockRenderingEnabled else { continue } + groups.append(.questionCard(id: id, questionID: questionID, text: text, options: options, selectedOptionID: selectedOptionID)) + case .taskCard(let id, let taskID): + flushToolCalls() + guard richBlockRenderingEnabled else { continue } + groups.append(.taskCard(id: id, taskID: taskID)) + case .goalLink(let id, let goalID, let summary): flushToolCalls() + guard richBlockRenderingEnabled else { continue } + groups.append(.goalLink(id: id, goalID: goalID, summary: summary)) + case .captureLink(let id, let conversationID, let momentTimestampMs, let summary): + flushToolCalls() + guard richBlockRenderingEnabled else { continue } + groups.append( + .captureLink( + id: id, + conversationID: conversationID, + momentTimestampMs: momentTimestampMs, + summary: summary + ) + ) case .agentSpawn( let id, let pillId, let sessionId, let runId, let title, let objective, let provider ): @@ -963,7 +1038,11 @@ enum ContentBlockGroup: Identifiable { /// survive. When a structured `.agentSpawn` exists /// for the same pill/run, hide the spawn tool call so the card is the single /// entrypoint (INV-6 structured identity). - static func visibleChatGroups(_ blocks: [ChatContentBlock], isStreaming: Bool) -> [ContentBlockGroup] { + static func visibleChatGroups( + _ blocks: [ChatContentBlock], + isStreaming: Bool, + richBlockRenderingEnabled: Bool = false + ) -> [ContentBlockGroup] { // The display projection turns a persisted spawn into its terminal card. // Both structured forms are therefore authoritative evidence that the // matching raw `spawn_agent` tool row is lifecycle plumbing, not a second @@ -987,11 +1066,11 @@ enum ContentBlockGroup: Identifiable { return trimmedRun.isEmpty ? nil : "run:\(trimmedRun)" } ) - return group(blocks).compactMap { group in + return group(blocks, richBlockRenderingEnabled: richBlockRenderingEnabled).compactMap { group in switch group { case .text(_, let text): return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : group - case .discoveryCard, .agentSpawn, .agentCompletion: + case .discoveryCard, .questionCard, .taskCard, .goalLink, .captureLink, .agentSpawn, .agentCompletion: return group case .thinking: return isStreaming ? group : nil diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift index ca215ee5052..68992ea94d7 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift @@ -67,6 +67,9 @@ struct ChatMessagesView: View { var onOpenAgent: ((UUID, @escaping (Bool) -> Void) -> Void)? = nil /// Opens via structured agent identity (session/run/pill) when available. var onOpenAgentRef: ((AgentTimelineRef, @escaping (Bool) -> Void) -> Void)? = nil + /// Explicitly enables chat-first controls only in the cohort shell's main + /// Chat route. Nil keeps shared transcript projections safe elsewhere. + var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil @ViewBuilder var welcomeContent: () -> WelcomeContent /// IDs of messages that are near-duplicates of an earlier message in the same session. @@ -448,7 +451,8 @@ struct ChatMessagesView: View { isDuplicate: dupeIds.contains(message.id), onCancelTurn: onCancelTurn, onOpenAgent: onOpenAgent, - onOpenAgentRef: onOpenAgentRef + onOpenAgentRef: onOpenAgentRef, + chatFirstRichBlockContext: chatFirstRichBlockContext ) .id(message.id) } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/ChatPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/ChatPage.swift index b7f2c4e3016..702679239f5 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/ChatPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/ChatPage.swift @@ -5,6 +5,9 @@ import SwiftUI struct ChatPage: View { @ObservedObject var appProvider: AppProvider @ObservedObject var chatProvider: ChatProvider + /// Present only for the cohort-gated main-window Chat. Every other caller + /// intentionally keeps journaled chat-first blocks inert. + var chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil @State private var showAppPicker = false @State private var showHistoryPopover = false @State private var selectedCitation: Citation? @@ -388,6 +391,7 @@ struct ChatPage: View { onOpenAgentRef: { ref, completion in FloatingControlBarManager.shared.openAgentChatFromTimeline(ref: ref, completion: completion) }, + chatFirstRichBlockContext: chatFirstRichBlockContext, welcomeContent: { welcomeMessage } ) } diff --git a/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift b/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift index 4974c9bb7f1..71052d10efc 100644 --- a/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift +++ b/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift @@ -1065,6 +1065,35 @@ actor TranscriptionStorage { } } + /// Source-scoped cache read for the cohort-only Omi capture archive. + /// Filtering happens before ordering and limiting so a cached page cannot + /// be filled by another source and then client-filtered. + func getLocalOmiCaptureConversations( + limit: Int = 50, + offset: Int = 0 + ) async throws -> [ServerConversation] { + let db = try await ensureInitialized() + + return try await db.read { database in + let sessions = try TranscriptionSessionRecord + .filter(Column("backendSynced") == true) + .filter(Column("deleted") == false) + .filter(Column("discarded") == false) + .filter(Column("source") == ConversationSource.omi.rawValue) + .filter( + Column("conversationStatus") == LocalConversationStatus.completed.rawValue + || Column("conversationStatus") == LocalConversationStatus.processing.rawValue + ) + .order(Column("startedAt").desc) + .limit(limit, offset: offset) + .fetchAll(database) + + return sessions.compactMap { session in + session.toServerConversation(segments: [], transcriptIncluded: false) + } + } + } + /// Read the richest cached projection for a detail screen. func getCachedConversation(id: String) async throws -> ServerConversation? { guard let session = try await getSessionByBackendId(id), let sessionId = session.id else { @@ -1096,4 +1125,22 @@ actor TranscriptionStorage { return try query.fetchCount(database) } } + + /// Count companion to getLocalOmiCaptureConversations. Keeping the same + /// predicate prevents the archive from reporting a mixed-source cache total. + func getLocalOmiCaptureConversationsCount() async throws -> Int { + let db = try await ensureInitialized() + return try await db.read { database in + try TranscriptionSessionRecord + .filter(Column("backendSynced") == true) + .filter(Column("deleted") == false) + .filter(Column("discarded") == false) + .filter(Column("source") == ConversationSource.omi.rawValue) + .filter( + Column("conversationStatus") == LocalConversationStatus.completed.rawValue + || Column("conversationStatus") == LocalConversationStatus.processing.rawValue + ) + .fetchCount(database) + } + } } diff --git a/desktop/macos/Desktop/Sources/Services/APIClient/APIClient+ConversationModels.swift b/desktop/macos/Desktop/Sources/Services/APIClient/APIClient+ConversationModels.swift index 9f6cbfc05bf..aad8f9d0e7f 100644 --- a/desktop/macos/Desktop/Sources/Services/APIClient/APIClient+ConversationModels.swift +++ b/desktop/macos/Desktop/Sources/Services/APIClient/APIClient+ConversationModels.swift @@ -49,6 +49,45 @@ enum TranscriptPresenceState: Equatable { case includedNonEmpty } +/// Minimal playback metadata projected from the generated conversation DTO. +/// The archive adapter resolves short-lived signed URLs separately, so these +/// values deliberately contain no URL or provider-specific storage path. +struct CaptureAudioFile: Codable, Equatable, Identifiable { + let id: String + let duration: TimeInterval + + init(_ wire: OmiAPI.AudioFile) { + id = wire.id + duration = wire.duration + } +} + +struct CaptureConversationAudioSpan: Codable, Equatable { + let fileID: String + let wallOffset: TimeInterval + let artifactOffset: TimeInterval + let length: TimeInterval + + init(_ wire: OmiAPI.ConversationAudioSpan) { + fileID = wire.fileId + wallOffset = wire.wallOffset + artifactOffset = wire.artifactOffset + length = wire.len + } +} + +struct CaptureConversationAudio: Codable, Equatable { + let duration: TimeInterval + let capturedDuration: TimeInterval + let spans: [CaptureConversationAudioSpan] + + init(_ wire: OmiAPI.ConversationAudio) { + duration = wire.duration + capturedDuration = wire.capturedDuration + spans = (wire.spans ?? []).map(CaptureConversationAudioSpan.init) + } +} + struct ServerConversation: Codable, Identifiable, Equatable { static func == (lhs: ServerConversation, rhs: ServerConversation) -> Bool { lhs.id == rhs.id && lhs.createdAt == rhs.createdAt && lhs.updatedAt == rhs.updatedAt @@ -57,6 +96,8 @@ struct ServerConversation: Codable, Identifiable, Equatable { && lhs.status == rhs.status && lhs.discarded == rhs.discarded && lhs.deleted == rhs.deleted && lhs.isLocked == rhs.isLocked && lhs.starred == rhs.starred && lhs.folderId == rhs.folderId && lhs.source == rhs.source + && lhs.audioFiles == rhs.audioFiles + && lhs.conversationAudio == rhs.conversationAudio && lhs.transcriptSegmentsIncluded == rhs.transcriptSegmentsIncluded } @@ -76,6 +117,12 @@ struct ServerConversation: Codable, Identifiable, Equatable { let appsResults: [AppResponse] let source: ConversationSource? let language: String? + /// Capture playback metadata is server-owned and is intentionally not used + /// by the legacy conversations surface. The chat-first capture archive reads + /// it only after a detail fetch, then resolves signed URLs through its own + /// bounded adapter. + let audioFiles: [CaptureAudioFile] + let conversationAudio: CaptureConversationAudio? let status: ConversationStatus let discarded: Bool @@ -135,6 +182,8 @@ struct ServerConversation: Codable, Identifiable, Equatable { appsResults = (wire.appsResults ?? []).map(AppResponse.init) source = wire.source.map { ConversationSource(rawValue: $0.rawValue) ?? .unknown } language = wire.language + audioFiles = (wire.audioFiles ?? []).map(CaptureAudioFile.init) + conversationAudio = wire.conversationAudio.map(CaptureConversationAudio.init) status = wire.status.map { ConversationStatus(rawValue: $0.rawValue) ?? .completed } ?? .completed discarded = wire.discarded ?? false deleted = false // backend REST Conversation schema does not expose deleted @@ -182,6 +231,8 @@ struct ServerConversation: Codable, Identifiable, Equatable { appsResults: [AppResponse], source: ConversationSource?, language: String?, + audioFiles: [CaptureAudioFile] = [], + conversationAudio: CaptureConversationAudio? = nil, status: ConversationStatus, discarded: Bool, deleted: Bool, @@ -204,6 +255,8 @@ struct ServerConversation: Codable, Identifiable, Equatable { self.appsResults = appsResults self.source = source self.language = language + self.audioFiles = audioFiles + self.conversationAudio = conversationAudio self.status = status self.discarded = discarded self.deleted = deleted @@ -315,7 +368,7 @@ struct Structured: Codable, Equatable { OmiAPI.ActionItem( candidateAction: nil, captureConfidence: nil, captureKind: nil, captureOwner: nil, completed: $0.completed, completedAt: nil, concreteDeliverable: nil, conversationId: nil, createdAt: nil, description_: $0.description, - dueAt: nil, ownershipConfidence: nil, targetTaskId: nil, updatedAt: nil) + dueAt: nil, ownershipConfidence: nil, targetTaskId: $0.targetTaskID, updatedAt: nil) } let eventsWire = events.map { OmiAPI.Event( @@ -360,11 +413,16 @@ struct ActionItem: Codable, Identifiable, Equatable { let description: String let completed: Bool let deleted: Bool + /// Canonical task linkage is optional on legacy captures. When present, the + /// chat-first archive uses this opaque ID for a typed deep link rather than + /// inferring a task from the description. + let targetTaskID: String? - init(description: String, completed: Bool, deleted: Bool) { + init(description: String, completed: Bool, deleted: Bool, targetTaskID: String? = nil) { self.description = description self.completed = completed self.deleted = deleted + self.targetTaskID = targetTaskID } /// Adapter from the generated wire DTO (OmiAPI.ActionItem). `deleted` is a @@ -374,6 +432,7 @@ struct ActionItem: Codable, Identifiable, Equatable { self.description = wire.description_ self.completed = wire.completed ?? false self.deleted = false + self.targetTaskID = wire.targetTaskId } init(from decoder: Decoder) throws { @@ -381,6 +440,7 @@ struct ActionItem: Codable, Identifiable, Equatable { self.description = wire.description_ self.completed = wire.completed ?? false self.deleted = false + self.targetTaskID = wire.targetTaskId } func encode(to encoder: Encoder) throws { @@ -397,7 +457,7 @@ struct ActionItem: Codable, Identifiable, Equatable { description_: description, dueAt: nil, ownershipConfidence: nil, - targetTaskId: nil, + targetTaskId: targetTaskID, updatedAt: nil ) try wire.encode(to: encoder) diff --git a/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift b/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift new file mode 100644 index 00000000000..4a3257311da --- /dev/null +++ b/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift @@ -0,0 +1,346 @@ +import XCTest +@testable import Omi_Computer + +@MainActor +final class CaptureArchiveTests: XCTestCase { + func testArchiveUsesOnlyOmiSourceForRemoteListAndCount() async { + let remote = CaptureArchiveRemoteFake(rows: [archiveCapture(id: "omi-1")], count: 1) + let local = CaptureArchiveLocalFake() + let repository = CaptureArchiveRepository(remote: remote, local: local) + + await repository.loadInitial() + + XCTAssertEqual(remote.listQueries.count, 1) + XCTAssertEqual(remote.countQueries.count, 1) + XCTAssertTrue(remote.listQueries.allSatisfy { $0.source == .omi && !$0.includeDiscarded }) + XCTAssertTrue(remote.countQueries.allSatisfy { $0.source == .omi && !$0.includeDiscarded }) + XCTAssertEqual(remote.listQueries.first?.statuses, [.completed, .processing]) + XCTAssertEqual(repository.captures.map(\.id), ["omi-1"]) + } + + func testArchiveFailureStaysHonestAndNeverRequestsMixedFallback() async { + let remote = CaptureArchiveRemoteFake(error: ArchiveTestError.offline) + let local = CaptureArchiveLocalFake(rows: [archiveCapture(id: "cached-omi")], count: 1) + let repository = CaptureArchiveRepository(remote: remote, local: local) + + await repository.loadInitial() + + XCTAssertEqual(repository.captures.map(\.id), ["cached-omi"]) + XCTAssertNotNil(repository.errorMessage) + XCTAssertEqual(remote.listQueries.count, 1) + XCTAssertEqual(remote.listQueries.first?.source, .omi) + XCTAssertFalse(remote.listQueries.first?.includeDiscarded ?? true) + } + + func testArchiveRejectsMixedServerRowsInsteadOfClientFilteringThem() async { + let remote = CaptureArchiveRemoteFake( + rows: [archiveCapture(id: "omi-1"), archiveCapture(id: "desktop-1", source: .desktop)], + count: 2 + ) + let repository = CaptureArchiveRepository(remote: remote, local: CaptureArchiveLocalFake()) + + await repository.loadInitial() + + XCTAssertTrue(repository.captures.isEmpty) + XCTAssertNotNil(repository.errorMessage) + XCTAssertEqual(remote.listQueries.single?.source, .omi) + XCTAssertEqual(remote.countQueries.single?.source, .omi) + } + + func testArchiveDoesNotSelectANonOmiGenericCacheDetail() async { + let nonOmi = archiveCapture(id: "desktop-1", source: .desktop) + let repository = CaptureArchiveRepository( + remote: CaptureArchiveRemoteFake(error: ArchiveTestError.offline), + local: CaptureArchiveLocalFake(rows: [nonOmi], count: 1) + ) + + let detail = await repository.loadDetail(id: nonOmi.id) + + XCTAssertNil(detail) + XCTAssertNil(repository.selectedCapture) + XCTAssertNotNil(repository.errorMessage) + } + + func testArchivePaginationCarriesOmiQueryAndAdvancesByVisibleRows() async { + let first = archiveCapture(id: "omi-1") + let second = archiveCapture(id: "omi-2") + let remote = CaptureArchiveRemoteFake(rows: [first], count: 2) + remote.pages = [0: [first], 1: [second]] + let repository = CaptureArchiveRepository(remote: remote, local: CaptureArchiveLocalFake()) + + await repository.loadInitial() + await repository.loadNextPage() + + XCTAssertEqual(repository.captures.map(\.id), ["omi-1", "omi-2"]) + XCTAssertEqual(remote.listQueries.map(\.offset), [0, 1]) + XCTAssertTrue(remote.listQueries.allSatisfy { $0.source == .omi && !$0.includeDiscarded }) + } + + func testConversationEndpointIncludesSourceInSharedListAndCountFilters() { + let listFilters = APIClient.conversationFilterQueryItems( + statuses: [.completed, .processing], + sources: [.omi], + includeDiscarded: false + ) + let countEndpoint = APIClient.conversationsCountEndpoint( + includeDiscarded: false, + statuses: [.completed, .processing], + sources: [.omi] + ) + + XCTAssertEqual(listFilters, [ + "include_discarded=false", + "statuses=completed,processing", + "sources=omi", + ]) + XCTAssertTrue(countEndpoint.contains("sources=omi")) + XCTAssertTrue(countEndpoint.contains("include_discarded=false")) + } + + func testPlaybackMapsReadyAggregateAndWallOffsets() { + let response = CaptureAudioURLsResponse( + audioFiles: [], + conversationAudio: CaptureAudioURLArtifact( + status: "cached", + signedURL: URL(string: "https://example.test/capture.mp3"), + contentType: "audio/mpeg", + duration: 40, + capturedDuration: 45, + spans: [ + CaptureAudioURLSpan(fileID: "part-a", wallOffset: 12, artifactOffset: 3, length: 10), + ] + ), + pollAfterMs: nil + ) + + guard case .readyAggregate(let artifact) = LiveCapturePlaybackProvider.resolution(from: response) else { + return XCTFail("Expected aggregate playback artifact") + } + XCTAssertEqual(try XCTUnwrap(artifact.artifactOffset(forWallOffset: 17.5)), 8.5, accuracy: 0.001) + XCTAssertNil(artifact.artifactOffset(forWallOffset: 22)) + } + + func testPlaybackKeepsPendingLockedUnavailableAndFileFallbackHonest() { + let pending = CaptureAudioURLsResponse( + audioFiles: [], + conversationAudio: CaptureAudioURLArtifact( + status: "pending", signedURL: nil, contentType: nil, duration: nil, capturedDuration: nil, spans: [] + ), + pollAfterMs: 3000 + ) + XCTAssertEqual(LiveCapturePlaybackProvider.resolution(from: pending), .pending(pollAfterMs: 3000)) + + let unavailable = CaptureAudioURLsResponse( + audioFiles: [], + conversationAudio: CaptureAudioURLArtifact( + status: "unavailable", signedURL: nil, contentType: nil, duration: nil, capturedDuration: nil, spans: [] + ), + pollAfterMs: nil + ) + XCTAssertEqual(LiveCapturePlaybackProvider.resolution(from: unavailable), .unavailable) + + let fallback = CaptureAudioURLsResponse( + audioFiles: [ + CaptureAudioURLFile( + id: "part-a", status: "cached", signedURL: URL(string: "https://example.test/part-a.mp3"), + contentType: "audio/mpeg", duration: 12 + ), + ], + conversationAudio: nil, + pollAfterMs: nil + ) + guard case .fileFallback = LiveCapturePlaybackProvider.resolution(from: fallback) else { + return XCTFail("Expected per-file fallback") + } + } + + func testPlaybackCanRefreshAPendingCaptureWithoutChangingItsIdentity() async { + let pending = CapturePlaybackResolution.pending(pollAfterMs: 1_000) + let ready = CapturePlaybackResolution.fileFallback(CapturePlaybackFile( + id: "part-a", signedURL: try! XCTUnwrap(URL(string: "https://example.test/part-a.mp3")), duration: 12 + )) + let provider = CapturePlaybackProviderFake(resolutions: [pending, ready]) + let controller = CapturePlaybackController(provider: provider) + let capture = archiveCapture(id: "omi-1") + + XCTAssertEqual(await controller.prepare(for: capture), pending) + XCTAssertEqual(await controller.prepare(for: capture, forceRefresh: true), ready) + XCTAssertEqual(provider.resolveCount, 2) + } + + func testFocusRequiresSuccessfulAggregateSeekBeforeAcknowledgement() { + let artifact = CapturePlaybackArtifact( + signedURL: URL(string: "https://example.test/capture.mp3")!, duration: 20, spans: [] + ) + XCTAssertFalse(CaptureFocusAcknowledgementPolicy.canAcknowledge( + requestedMoment: 3, resolution: .pending(pollAfterMs: 1000) + )) + XCTAssertFalse(CaptureFocusAcknowledgementPolicy.canAcknowledge( + requestedMoment: 3, resolution: .fileFallback(CapturePlaybackFile( + id: "part", signedURL: URL(string: "https://example.test/part.mp3")!, duration: 3 + )), didCompleteSeek: true + )) + XCTAssertFalse(CaptureFocusAcknowledgementPolicy.canAcknowledge( + requestedMoment: 3, resolution: .readyAggregate(artifact), didCompleteSeek: false + )) + XCTAssertTrue(CaptureFocusAcknowledgementPolicy.canAcknowledge( + requestedMoment: 3, resolution: .readyAggregate(artifact), didCompleteSeek: true + )) + } + +} + +final class CaptureArchiveCacheTests: XCTestCase { + private var userID: String! + private var userDirectory: URL? + + override func setUp() async throws { + try await super.setUp() + userID = "capture-archive-cache-test-\(UUID().uuidString)" + await RewindDatabase.shared.close() + await TranscriptionStorage.shared.invalidateCache() + RewindDatabase.currentUserId = userID + await RewindDatabase.shared.configure(userId: userID) + try await RewindDatabase.shared.initialize() + let appSupport = try XCTUnwrap( + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ) + userDirectory = appSupport.appendingPathComponent("Omi", isDirectory: true) + .appendingPathComponent(userID, isDirectory: true) + } + + override func tearDown() async throws { + await RewindDatabase.shared.close() + await TranscriptionStorage.shared.invalidateCache() + RewindDatabase.currentUserId = nil + if let userDirectory { try? FileManager.default.removeItem(at: userDirectory) } + try await super.tearDown() + } + + func testLocalArchiveFiltersOmiAndVisibleStatusesBeforeOrderingAndLimit() async throws { + let oldOmi = archiveCapture( + id: "omi-old", source: .omi, status: .completed, createdAt: Date(timeIntervalSince1970: 100) + ) + let newestDesktop = archiveCapture( + id: "desktop-new", source: .desktop, status: .completed, createdAt: Date(timeIntervalSince1970: 500) + ) + let newestInProgressOmi = archiveCapture( + id: "omi-in-progress", source: .omi, status: .inProgress, createdAt: Date(timeIntervalSince1970: 400) + ) + let processingOmi = archiveCapture( + id: "omi-processing", source: .omi, status: .processing, createdAt: Date(timeIntervalSince1970: 300) + ) + for capture in [oldOmi, newestDesktop, newestInProgressOmi, processingOmi] { + _ = try await TranscriptionStorage.shared.syncServerConversation(capture) + } + + let firstPage = try await TranscriptionStorage.shared.getLocalOmiCaptureConversations(limit: 1) + let allRows = try await TranscriptionStorage.shared.getLocalOmiCaptureConversations(limit: 10) + let count = try await TranscriptionStorage.shared.getLocalOmiCaptureConversationsCount() + + XCTAssertEqual(firstPage.map(\.id), ["omi-processing"]) + XCTAssertEqual(allRows.map(\.id), ["omi-processing", "omi-old"]) + XCTAssertEqual(count, 2) + } +} + +private enum ArchiveTestError: Error { + case offline +} + +private func archiveCapture( + id: String, + source: ConversationSource = .omi, + status: ConversationStatus = .completed, + createdAt: Date = Date(timeIntervalSince1970: 100) +) -> ServerConversation { + ServerConversation( + id: id, + createdAt: createdAt, + updatedAt: createdAt, + startedAt: createdAt, + finishedAt: createdAt.addingTimeInterval(60), + structured: Structured(title: "Capture \(id)", overview: "Summary", emoji: "", category: "other", actionItems: [], events: []), + transcriptSegments: [], + transcriptSegmentsIncluded: false, + geolocation: nil, + photos: [], + appsResults: [], + source: source, + language: "en", + status: status, + discarded: false, + deleted: false, + isLocked: false, + starred: false, + folderId: nil, + inputDeviceName: nil + ) +} + +@MainActor +private final class CaptureArchiveRemoteFake: CaptureArchiveRemoteDataSource { + var listQueries: [CaptureArchiveQuery] = [] + var countQueries: [CaptureArchiveQuery] = [] + var rows: [ServerConversation] + var count: Int + var pages: [Int: [ServerConversation]] = [:] + var error: Error? + + init(rows: [ServerConversation] = [], count: Int = 0, error: Error? = nil) { + self.rows = rows + self.count = count + self.error = error + } + + func list(query: CaptureArchiveQuery) async throws -> [ServerConversation] { + listQueries.append(query) + if let error { throw error } + return pages[query.offset] ?? rows + } + + func count(query: CaptureArchiveQuery) async throws -> Int { + countQueries.append(query) + if let error { throw error } + return count + } + + func detail(id: String) async throws -> ServerConversation { + if let error { throw error } + guard let capture = rows.first(where: { $0.id == id }) else { throw ArchiveTestError.offline } + return capture + } +} + +private final class CaptureArchiveLocalFake: CaptureArchiveLocalDataSource { + var rows: [ServerConversation] + var countValue: Int + + init(rows: [ServerConversation] = [], count: Int = 0) { + self.rows = rows + countValue = count + } + + func list(query: CaptureArchiveQuery) async throws -> [ServerConversation] { rows } + func count(query: CaptureArchiveQuery) async throws -> Int { countValue } + func detail(id: String) async throws -> ServerConversation? { rows.first { $0.id == id } } + func store(_ conversation: ServerConversation) async throws {} +} + +private final class CapturePlaybackProviderFake: CapturePlaybackProviding { + private var resolutions: [CapturePlaybackResolution] + private(set) var resolveCount = 0 + + init(resolutions: [CapturePlaybackResolution]) { + self.resolutions = resolutions + } + + func resolvePlayback(for capture: ServerConversation) async -> CapturePlaybackResolution { + resolveCount += 1 + return resolutions.removeFirst() + } +} + +private extension Array { + var single: Element? { count == 1 ? first : nil } +} diff --git a/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift new file mode 100644 index 00000000000..cafbf0441f3 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift @@ -0,0 +1,149 @@ +import XCTest + +@testable import Omi_Computer + +final class ChatFirstRichBlockTests: XCTestCase { + func testCodecRoundTripsEveryChatFirstBlock() throws { + let blocks: [ChatContentBlock] = [ + .questionCard( + id: "question-card", + questionId: "question-1", + text: "Which goal should we focus on?", + subjectKind: "goal", + subjectId: "goal-1", + options: [[ + "optionId": "focus-goal-1", + "label": "Keep this goal", + "preparedAnswer": "Keep goal 1 as my focus", + "defer": false, + ]] + ), + .taskCard(id: "task-card", taskId: "task-1"), + .goalLink(id: "goal-link", goalId: "goal-1", summary: "Finish the launch plan"), + .captureLink( + id: "capture-link", + conversationId: "capture-1", + momentTimestampMs: 42_000, + summary: "Planning conversation" + ), + ] + + let encoded = try XCTUnwrap(ChatContentBlockCodec.encode(blocks)) + let restored = try XCTUnwrap(ChatContentBlockCodec.decode(encoded)) + XCTAssertEqual(restored.count, blocks.count) + + guard case .questionCard(_, let questionID, let text, let subjectKind, let subjectID, let options) = restored[0] + else { return XCTFail("question card should survive persisted replay") } + XCTAssertEqual(questionID, "question-1") + XCTAssertEqual(text, "Which goal should we focus on?") + XCTAssertEqual(subjectKind, "goal") + XCTAssertEqual(subjectID, "goal-1") + XCTAssertEqual(options.first?["preparedAnswer"] as? String, "Keep goal 1 as my focus") + + guard case .taskCard(_, let taskID) = restored[1] else { + return XCTFail("task card should survive persisted replay") + } + XCTAssertEqual(taskID, "task-1") + + guard case .goalLink(_, let goalID, let goalSummary) = restored[2] else { + return XCTFail("goal link should survive persisted replay") + } + XCTAssertEqual(goalID, "goal-1") + XCTAssertEqual(goalSummary, "Finish the launch plan") + + guard case .captureLink(_, let captureID, let timestamp, let captureSummary) = restored[3] else { + return XCTFail("capture link should survive persisted replay") + } + XCTAssertEqual(captureID, "capture-1") + XCTAssertEqual(timestamp, 42_000) + XCTAssertEqual(captureSummary, "Planning conversation") + } + + func testUnknownPersistedBlockDoesNotDropRecognizedNeighbors() throws { + let restored = ChatContentBlockCodec.decode([ + ["type": "text", "id": "before", "text": "Before"], + ["type": "futureCard", "id": "future", "payload": "new server version"], + ["type": "goalLink", "id": "after", "goalId": "goal-1", "summary": "After"], + ]) + + XCTAssertEqual(restored.count, 2) + guard case .text(let textID, let text) = restored[0] else { + return XCTFail("known text before an unknown block must remain") + } + XCTAssertEqual(textID, "before") + XCTAssertEqual(text, "Before") + guard case .goalLink(_, let goalID, let summary) = restored[1] else { + return XCTFail("known block after an unknown block must remain") + } + XCTAssertEqual(goalID, "goal-1") + XCTAssertEqual(summary, "After") + } + + func testRichRendererSelectionRequiresExplicitChatFirstContext() { + let blocks: [ChatContentBlock] = [ + .questionCard( + id: "question", questionId: "question-1", text: "Question", subjectKind: "goal", subjectId: "goal-1", + options: [] + ), + .taskCard(id: "task", taskId: "task-1"), + .goalLink(id: "goal", goalId: "goal-1", summary: "Goal"), + .captureLink(id: "capture", conversationId: "capture-1", momentTimestampMs: nil, summary: "Capture"), + ] + + XCTAssertTrue( + ContentBlockGroup.visibleChatGroups(blocks, isStreaming: false).isEmpty, + "legacy, floating, task, and onboarding call sites must keep rich blocks inert" + ) + + let enabled = ContentBlockGroup.visibleChatGroups( + blocks, + isStreaming: false, + richBlockRenderingEnabled: true + ) + XCTAssertEqual(enabled.count, 4) + XCTAssertTrue(enabled.contains { if case .questionCard = $0 { return true }; return false }) + XCTAssertTrue(enabled.contains { if case .taskCard = $0 { return true }; return false }) + XCTAssertTrue(enabled.contains { if case .goalLink = $0 { return true }; return false }) + XCTAssertTrue(enabled.contains { if case .captureLink = $0 { return true }; return false }) + } + + func testTaskAcknowledgementRequiresReconciledCompletedRecord() { + let incomplete = task(id: "task-1", completed: false) + let completed = task(id: "task-1", completed: true) + + XCTAssertTrue( + ChatFirstTaskCardReconciliation.shouldShowCompletionAcknowledgement( + intendedCompletion: true, + reconciledTask: completed + ) + ) + XCTAssertFalse( + ChatFirstTaskCardReconciliation.shouldShowCompletionAcknowledgement( + intendedCompletion: true, + reconciledTask: incomplete + ), + "a store rollback must not produce a chat-card success acknowledgement" + ) + XCTAssertFalse( + ChatFirstTaskCardReconciliation.shouldShowCompletionAcknowledgement( + intendedCompletion: false, + reconciledTask: incomplete + ) + ) + XCTAssertFalse( + ChatFirstTaskCardReconciliation.shouldShowCompletionAcknowledgement( + intendedCompletion: true, + reconciledTask: nil + ) + ) + } + + private func task(id: String, completed: Bool) -> TaskActionItem { + TaskActionItem( + id: id, + description: "Draft the plan", + completed: completed, + createdAt: Date(timeIntervalSince1970: 0) + ) + } +} From 35c1d461b71c267085fe035455781979b0503143 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 13:36:11 -0400 Subject: [PATCH 08/28] feat(chat): add question actions and task workspace Make question suggestions durable, one-tap normal chat interactions with tail-only controls, restart recovery, and a separate capability-gated deferral outbox. Add the cohort-only shared-store Tasks workspace with owner-fenced rollback behavior and source-safe capture links.\n\nVerification: static diff checks and independent Sol review/fix gates completed. Runtime builds, test suites, harness, and UI exercise are intentionally deferred to the single final verification pass at user direction. --- .../Desktop/Sources/Chat/AgentBridge.swift | 25 + .../Desktop/Sources/Chat/AgentClient.swift | 18 + .../Sources/Chat/AgentRuntimeProcess.swift | 144 ++++- .../Sources/Chat/ChatContentBlockCodec.swift | 18 +- .../Chat/ChatFirstDeferralOutboxDriver.swift | 159 +++++ .../Sources/Chat/KernelTurnJournal.swift | 3 +- .../FloatingControlBar/AIResponseView.swift | 2 +- .../FloatingControlBarState.swift | 2 +- .../Blocks/ChatFirstContentBlockViews.swift | 51 +- .../Blocks/ChatFirstRichBlockContext.swift | 8 +- .../ChatFirstCaptureLinkPolicy.swift | 17 + .../MainWindow/ChatFirst/ChatFirstShell.swift | 9 +- .../ChatFirst/ChatFirstTasksPage.swift | 550 ++++++++++++++++++ .../MainWindow/Components/ChatBubble.swift | 40 +- .../Sources/Providers/ChatProvider.swift | 276 ++++++++- .../Desktop/Sources/Stores/TasksStore.swift | 210 +++++-- .../ChatFirstDeferralOutboxDriverTests.swift | 56 ++ .../Tests/ChatFirstRichBlockTests.swift | 62 +- .../Tests/ChatFirstTasksPageTests.swift | 79 +++ .../Tests/TasksStoreOwnerBoundaryTests.swift | 163 ++++++ desktop/macos/agent/src/index.ts | 145 +++++ desktop/macos/agent/src/protocol.ts | 52 +- .../agent/src/runtime/conversation-journal.ts | 383 ++++++++++++ .../agent/src/runtime/kernel-sessions.ts | 29 + .../macos/agent/src/runtime/sqlite-store.ts | 55 ++ desktop/macos/agent/src/runtime/types.ts | 5 + .../chat-first-capability-projection.test.ts | 3 + .../agent/tests/conversation-journal.test.ts | 218 +++++++ .../unreleased/20260715-chat-first-tasks.json | 3 + 29 files changed, 2652 insertions(+), 133 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstCaptureLinkPolicy.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift create mode 100644 desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift create mode 100644 desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift create mode 100644 desktop/macos/changelog/unreleased/20260715-chat-first-tasks.json diff --git a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift index b00437c952a..727edcf501c 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift @@ -1362,6 +1362,31 @@ actor AgentBridge { ) } + func recordQuestionInteractionReply( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + questionID: String, + optionID: String, + controlGeneration: Int, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil + ) async throws -> AgentRuntimeProcess.QuestionInteractionReply { + let authorization = try resolveAuthorization( + authorizationSnapshot, + expectedOwnerID: ownerID) + try await start(authorizationSnapshot: authorization) + return try await runtime.recordQuestionInteractionReply( + clientId: clientId, + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + questionID: questionID, + optionID: optionID, + controlGeneration: controlGeneration, + authorizationSnapshot: authorization + ) + } + func updateJournalTurn( surface: AgentSurfaceReference, ownerID: String? = nil, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift index 9f2aea3e122..1cf16259022 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift @@ -241,6 +241,24 @@ enum AgentClient { ) } + func recordQuestionInteractionReply( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + questionID: String, + optionID: String, + controlGeneration: Int + ) async throws -> AgentRuntimeProcess.QuestionInteractionReply { + try await bridge.recordQuestionInteractionReply( + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + questionID: questionID, + optionID: optionID, + controlGeneration: controlGeneration + ) + } + func updateJournalTurn( surface: AgentSurfaceReference, ownerID: String? = nil, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift index e536d1b8745..ceb33ac7bb1 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift @@ -343,6 +343,7 @@ actor AgentRuntimeProcess { case journalBackendSync case journalBackendDelete case journalBackendReconcile + case chatFirstDeferralDelivery case defaultExecutionProfileConfigured case surfaceSessionResolved case sessionExecutionProfileMigrated @@ -403,6 +404,7 @@ actor AgentRuntimeProcess { case "journal_backend_sync": return .journalBackendSync case "journal_backend_delete": return .journalBackendDelete case "journal_backend_reconcile": return .journalBackendReconcile + case "chat_first_deferral_delivery": return .chatFirstDeferralDelivery case "default_execution_profile_configured": return .defaultExecutionProfileConfigured case "surface_session_resolved": return .surfaceSessionResolved case "session_execution_profile_migrated": return .sessionExecutionProfileMigrated @@ -513,6 +515,18 @@ actor AgentRuntimeProcess { let highWaterTurnSeq: Int let conversationGeneration: Int let generationBaseTurnSeq: Int + let accepted: Bool? = nil + let duplicate: Bool? = nil + let continuityKey: String? = nil + } + + struct QuestionInteractionReply: Sendable { + let accepted: Bool + let duplicate: Bool + let continuityKey: String + let parentTurn: KernelJournalTurn? + let userTurn: KernelJournalTurn + let assistantTurn: KernelJournalTurn } typealias JournalTurnChangedHandler = @Sendable (KernelJournalTurn) -> Void @@ -1968,6 +1982,61 @@ actor AgentRuntimeProcess { return turn } + /// The journal derives the stored question payload and only accepts the + /// current main-Chat tail. Swift cannot send an answer string or select an + /// arbitrary parent row through this operation. + func recordQuestionInteractionReply( + clientId: String, + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + questionID: String, + optionID: String, + controlGeneration: Int, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> QuestionInteractionReply { + guard surface.surfaceKind == "main_chat", + controlGeneration >= 0, + !sessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !questionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !optionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw BridgeError.agentError("Invalid question interaction") + } + let result = try await journalOperation( + type: "record_question_interaction_reply", + operation: "record_question_interaction_reply", + clientId: clientId, + surface: surface, + ownerID: ownerID, + payload: [ + "sessionId": sessionID, + "questionId": questionID, + "optionId": optionID, + "controlGeneration": controlGeneration, + ], + authorizationSnapshot: authorizationSnapshot + ) + guard result.accepted == true, + let continuityKey = result.continuityKey, + let userTurn = result.turns.first(where: { $0.role == "user" }), + let assistantTurn = result.turns.first(where: { $0.role == "assistant" }) + else { + throw BridgeError.agentError("Question is no longer actionable") + } + for turn in [result.turn, userTurn, assistantTurn] { + if let turn { recordLifecycleJournalMutation(turn) } + } + return QuestionInteractionReply( + accepted: true, + duplicate: result.duplicate == true, + continuityKey: continuityKey, + parentTurn: result.turn, + userTurn: userTurn, + assistantTurn: assistantTurn + ) + } + private func journalOperation( type: String, operation: String, @@ -3210,6 +3279,9 @@ actor AgentRuntimeProcess { case .journalBackendReconcile: if messageOwnerIsCurrentlyAuthorized(message) { handleJournalBackendReconcile(message) } + case .chatFirstDeferralDelivery: + if messageOwnerIsCurrentlyAuthorized(message) { handleChatFirstDeferralDelivery(message) } + case .defaultExecutionProfileConfigured, .surfaceSessionResolved, .sessionExecutionProfileMigrated, .contextSourceUpdated, .contextSnapshot, .legacyMainChatSessionsImported, @@ -3609,7 +3681,10 @@ actor AgentRuntimeProcess { clearedCount: message.payload["clearedCount"] as? Int ?? 0, highWaterTurnSeq: highWaterTurnSeq, conversationGeneration: conversationGeneration, - generationBaseTurnSeq: generationBaseTurnSeq + generationBaseTurnSeq: generationBaseTurnSeq, + accepted: message.payload["accepted"] as? Bool, + duplicate: message.payload["duplicate"] as? Bool, + continuityKey: message.payload["continuityKey"] as? String )) } @@ -3628,6 +3703,73 @@ actor AgentRuntimeProcess { ) } + private func handleChatFirstDeferralDelivery(_ message: RuntimeMessage) { + guard let request = ChatFirstDeferralDeliveryRequest(payload: message.payload) else { + sendChatFirstDeferralDeliveryResult( + requestId: message.requestId, + clientId: message.clientId, + ownerID: message.payload["ownerId"] as? String, + continuityKey: message.payload["continuityKey"] as? String ?? "", + deliveryGeneration: message.payload["deliveryGeneration"] as? Int ?? 0, + payloadHash: message.payload["payloadHash"] as? String ?? "", + ok: false, + errorCode: "chat_first_deferral_malformed" + ) + return + } + Task { [weak self] in + do { + try await APIClient.shared.recordChatFirstDeferral(request) + await self?.sendChatFirstDeferralDeliveryResult( + requestId: message.requestId, + clientId: message.clientId, + ownerID: request.ownerID, + continuityKey: request.continuityKey, + deliveryGeneration: request.deliveryGeneration, + payloadHash: request.payloadHash, + ok: true, + errorCode: nil + ) + } catch { + await self?.sendChatFirstDeferralDeliveryResult( + requestId: message.requestId, + clientId: message.clientId, + ownerID: request.ownerID, + continuityKey: request.continuityKey, + deliveryGeneration: request.deliveryGeneration, + payloadHash: request.payloadHash, + ok: false, + errorCode: Self.boundedChatFirstDeferralErrorCode(for: error) + ) + } + } + } + + private func sendChatFirstDeferralDeliveryResult( + requestId: String?, + clientId: String?, + ownerID: String?, + continuityKey: String, + deliveryGeneration: Int, + payloadHash: String, + ok: Bool, + errorCode: String? + ) { + var payload: [String: Any] = [ + "type": "chat_first_deferral_delivery_result", + "protocolVersion": 2, + "continuityKey": continuityKey, + "deliveryGeneration": deliveryGeneration, + "payloadHash": payloadHash, + "ok": ok, + ] + if let requestId { payload["requestId"] = requestId } + if let clientId { payload["clientId"] = clientId } + if let ownerID { payload["ownerId"] = ownerID } + if let errorCode { payload["errorCode"] = errorCode } + sendJson(payload) + } + private func handleJournalBackendSync(_ message: RuntimeMessage) { guard let request = KernelJournalBackendSyncDriver.Request(payload: message.payload) else { sendJournalBackendSyncResult( diff --git a/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift b/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift index e3eb385ab56..18d5ff81a4b 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift @@ -85,7 +85,17 @@ enum ChatContentBlockCodec { let subjectId = subject["id"] as? String, let options = dict["options"] as? [[String: Any]] else { continue } - blocks.append(.questionCard(id: id, questionId: questionId, text: text, subjectKind: subjectKind, subjectId: subjectId, options: options)) + blocks.append( + .questionCard( + id: id, + questionId: questionId, + text: text, + subjectKind: subjectKind, + subjectId: subjectId, + options: options, + selectedOptionId: dict["selectedOptionId"] as? String + ) + ) case "taskCard": guard let taskId = dict["taskId"] as? String else { continue } blocks.append(.taskCard(id: id, taskId: taskId)) @@ -210,11 +220,13 @@ enum ChatContentBlockCodec { "summary": summary, "fullText": fullText, ] - case .questionCard(let id, let questionId, let text, let subjectKind, let subjectId, let options): - return [ + case .questionCard(let id, let questionId, let text, let subjectKind, let subjectId, let options, let selectedOptionId): + var dictionary: [String: Any] = [ "type": "questionCard", "id": id, "questionId": questionId, "text": text, "subject": ["kind": subjectKind, "id": subjectId], "options": options, ] + if let selectedOptionId { dictionary["selectedOptionId"] = selectedOptionId } + return dictionary case .taskCard(let id, let taskId): return ["type": "taskCard", "id": id, "taskId": taskId] case .goalLink(let id, let goalId, let summary): diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift new file mode 100644 index 00000000000..3a2efe76a1b --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift @@ -0,0 +1,159 @@ +import Foundation + +/// Physical transport for the T08 kernel-owned deferral outbox. It deliberately +/// has no relationship to desktop message reconciliation: the backend receiver +/// persists task-intelligence state only and never writes a Chat row. +struct ChatFirstDeferralDeliveryRequest: Sendable, Equatable { + struct Subject: Codable, Sendable, Equatable { + let kind: String + let id: String + } + + struct Option: Codable, Sendable, Equatable { + let optionID: String + let label: String + let preparedAnswer: String + let defer: Bool + + enum CodingKeys: String, CodingKey { + case optionID = "option_id" + case label + case preparedAnswer = "prepared_answer" + case defer + } + } + + struct Question: Codable, Sendable, Equatable { + let questionID: String + let text: String + let subject: Subject + let options: [Option] + + enum CodingKeys: String, CodingKey { + case questionID = "question_id" + case text, subject, options + } + } + + let ownerID: String + let continuityKey: String + let controlGeneration: Int + let subject: Subject + let question: Question + let attemptCount: Int + let deliveryGeneration: Int + let payloadHash: String + + init?(payload: [String: Any]) { + guard + let ownerID = payload["ownerId"] as? String, !ownerID.isEmpty, + let continuityKey = payload["continuityKey"] as? String, !continuityKey.isEmpty, + let controlGeneration = payload["controlGeneration"] as? Int, controlGeneration >= 0, + let subjectPayload = payload["subject"] as? [String: Any], + let subjectKind = subjectPayload["kind"] as? String, + ["task", "goal", "capture"].contains(subjectKind), + let subjectID = subjectPayload["id"] as? String, !subjectID.isEmpty, + let questionPayload = payload["question"] as? [String: Any], + let questionID = questionPayload["questionId"] as? String, !questionID.isEmpty, + let questionText = questionPayload["text"] as? String, + !questionText.isEmpty, questionText.count <= 300, + let questionSubject = questionPayload["subject"] as? [String: Any], + let questionSubjectKind = questionSubject["kind"] as? String, + questionSubjectKind == subjectKind, + let questionSubjectID = questionSubject["id"] as? String, + questionSubjectID == subjectID, + let optionsPayload = questionPayload["options"] as? [[String: Any]], + (1...4).contains(optionsPayload.count), + let attemptCount = payload["attemptCount"] as? Int, attemptCount > 0, + let deliveryGeneration = payload["deliveryGeneration"] as? Int, deliveryGeneration > 0, + let payloadHash = payload["payloadHash"] as? String, !payloadHash.isEmpty + else { return nil } + + let options = optionsPayload.compactMap { option -> Option? in + guard + let optionID = option["optionId"] as? String, !optionID.isEmpty, + let label = option["label"] as? String, !label.isEmpty, label.count <= 80, + let preparedAnswer = option["preparedAnswer"] as? String, + !preparedAnswer.isEmpty, preparedAnswer.count <= 500 + else { return nil } + return Option(optionID: optionID, label: label, preparedAnswer: preparedAnswer, defer: option["defer"] as? Bool ?? false) + } + guard options.count == optionsPayload.count, + Set(options.map(\.optionID)).count == options.count, + options.filter(\.defer).count <= 1 + else { return nil } + + self.ownerID = ownerID + self.continuityKey = continuityKey + self.controlGeneration = controlGeneration + self.subject = Subject(kind: subjectKind, id: subjectID) + self.question = Question( + questionID: questionID, + text: questionText, + subject: Subject(kind: questionSubjectKind, id: questionSubjectID), + options: options + ) + self.attemptCount = attemptCount + self.deliveryGeneration = deliveryGeneration + self.payloadHash = payloadHash + } +} + +private struct ChatFirstDeferralCreateBody: Encodable { + let sourceSurface: String = "main_chat" + let controlGeneration: Int + let ownerFence: String + let continuityKey: String + let subject: ChatFirstDeferralDeliveryRequest.Subject + let question: ChatFirstDeferralDeliveryRequest.Question + + enum CodingKeys: String, CodingKey { + case sourceSurface = "source_surface" + case controlGeneration = "control_generation" + case ownerFence = "owner_fence" + case continuityKey = "continuity_key" + case subject, question + } +} + +private struct ChatFirstDeferralReceipt: Decodable { + let deferralID: String + + enum CodingKeys: String, CodingKey { + case deferralID = "deferral_id" + } +} + +extension APIClient { + func recordChatFirstDeferral( + _ request: ChatFirstDeferralDeliveryRequest + ) async throws { + let body = ChatFirstDeferralCreateBody( + controlGeneration: request.controlGeneration, + ownerFence: request.ownerID, + continuityKey: request.continuityKey, + subject: request.subject, + question: request.question + ) + let receipt: ChatFirstDeferralReceipt = try await post( + "v1/chat/deferrals", + body: body, + expectedOwnerId: request.ownerID + ) + guard !receipt.deferralID.isEmpty else { throw APIError.invalidResponse } + } +} + +extension AgentRuntimeProcess { + nonisolated static func boundedChatFirstDeferralErrorCode(for error: Error) -> String { + if let authError = error as? AuthError, case .userChangedDuringRequest = authError { + return "chat_first_deferral_owner_changed" + } + if case let APIError.httpError(statusCode, _) = error { + return [408, 425, 429].contains(statusCode) || (500...599).contains(statusCode) + ? "chat_first_deferral_retryable" + : "chat_first_deferral_4xx" + } + return "chat_first_deferral_failed" + } +} diff --git a/desktop/macos/Desktop/Sources/Chat/KernelTurnJournal.swift b/desktop/macos/Desktop/Sources/Chat/KernelTurnJournal.swift index 6dc5cf4f0cc..1a598d822e0 100644 --- a/desktop/macos/Desktop/Sources/Chat/KernelTurnJournal.swift +++ b/desktop/macos/Desktop/Sources/Chat/KernelTurnJournal.swift @@ -299,7 +299,8 @@ extension KernelJournalTurn { ChatResource.decodeResourcesFromPersistence(resourcesJSON) ), turnOwner: owner, - journalStatus: status + journalStatus: status, + hidesEmptyStreamingPlaceholder: metadata["hiddenUntilOutput"] as? Bool ?? false ) } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift index cd9aabc578f..f7e56074578 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift @@ -123,7 +123,7 @@ struct AIResponseView: View { return ["thinking", id, text].joined(separator: "\u{1E}") case .discoveryCard(let id, let title, let summary, let fullText): return ["discovery", id, title, summary, fullText].joined(separator: "\u{1E}") - case .questionCard(let id, _, _, _, _, _): + case .questionCard(let id, _, _, _, _, _, _): return ["chatFirstQuestion", id].joined(separator: "\u{1E}") case .taskCard(let id, _): return ["chatFirstTask", id].joined(separator: "\u{1E}") diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift index ec9410786f4..8693ef8c401 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift @@ -730,7 +730,7 @@ extension ChatContentBlock { case .toolCall(let id, let name, let status, _, _, _): return "c:\(id):\(name):\(status)" case .thinking(let id, _): return "h:\(id)" case .discoveryCard(let id, _, _, _): return "d:\(id)" - case .questionCard(let id, _, _, _, _, _): return "q:\(id)" + case .questionCard(let id, _, _, _, _, _, _): return "q:\(id)" case .taskCard(let id, _): return "t:\(id)" case .goalLink(let id, _, _): return "g:\(id)" case .captureLink(let id, _, _, _): return "c:\(id)" diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift index a0a08b77f3d..b9da1e24425 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift @@ -3,8 +3,9 @@ import OmiTheme // MARK: - Question card -/// T07 intentionally renders choices as information-only pills. T08 owns the -/// journal-first selection transaction, tail gating, and option retirement. +/// Choices are controls only while the kernel-backed parent is the completed +/// tail of Main Chat. The runtime remains authoritative at selection time; +/// this view's gate simply avoids presenting obsolete choices as actionable. struct QuestionCardView: View { private struct Option: Identifiable { let id: String @@ -24,6 +25,9 @@ struct QuestionCardView: View { let questionID: String let text: String let options: [[String: Any]] + let selectedOptionID: String? + let isActionable: Bool + let onSelect: (String) -> Void private var validOptions: [Option] { options.compactMap(Option.init) } @@ -34,23 +38,29 @@ struct QuestionCardView: View { .foregroundStyle(OmiColors.textPrimary) .fixedSize(horizontal: false, vertical: true) - if !validOptions.isEmpty { + // A completed question remains useful transcript context, but its + // suggestions disappear as soon as an answer exists or another bubble + // has taken the tail. We never leave stale chips that look tappable. + if isActionable, selectedOptionID == nil, !validOptions.isEmpty { FlowLayout(spacing: OmiSpacing.sm) { ForEach(validOptions) { option in - Text(option.label) - .scaledFont(size: OmiType.caption, weight: .medium) - .foregroundStyle(OmiColors.textSecondary) - .padding(.horizontal, OmiSpacing.md) - .padding(.vertical, OmiSpacing.sm) - .omiControlSurface( - fill: OmiColors.backgroundPrimary.opacity(0.7), - radius: OmiChrome.chipRadius, - stroke: OmiColors.border.opacity(0.65) - ) - // Options are deliberately not Buttons until T08 installs the - // atomic journal selection operation. - .accessibilityLabel("Suggestion: \(option.label)") - .accessibilityIdentifier("chat-first-question-\(questionID)-option-\(option.id)") + Button { + onSelect(option.id) + } label: { + Text(option.label) + .scaledFont(size: OmiType.caption, weight: .medium) + .foregroundStyle(OmiColors.textSecondary) + .padding(.horizontal, OmiSpacing.md) + .padding(.vertical, OmiSpacing.sm) + .omiControlSurface( + fill: OmiColors.backgroundPrimary.opacity(0.7), + radius: OmiChrome.chipRadius, + stroke: OmiColors.border.opacity(0.65) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Send suggestion: \(option.label)") + .accessibilityIdentifier("chat-first-question-\(questionID)-option-\(option.id)") } } } @@ -143,7 +153,7 @@ struct TaskCardView: View { navigation.open(focus: .goal(id: goalID)) } } - if let conversationID = task.conversationId, !conversationID.isEmpty { + if let conversationID = ChatFirstCaptureLinkPolicy.captureID(for: task) { ChatFirstDestinationBadge( title: "Capture", systemImage: "waveform", @@ -352,7 +362,10 @@ private struct ChatFirstLinkBlockView: View { } } -private struct ChatFirstDestinationBadge: View { +/// A compact typed destination control shared by rich Chat cards and the +/// cohort-only Tasks page. Its closure is intentionally the only navigation +/// surface: callers supply typed shell focus rather than model text or URLs. +struct ChatFirstDestinationBadge: View { let title: String let systemImage: String let accessibilityID: String diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift index 06c2846171a..0d6cb43c62b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift @@ -8,9 +8,15 @@ import Foundation struct ChatFirstRichBlockContext { let navigation: ChatFirstShellNavigation let tasksStore: TasksStore + let chatProvider: ChatProvider - init(navigation: ChatFirstShellNavigation, tasksStore: TasksStore = .shared) { + init( + navigation: ChatFirstShellNavigation, + tasksStore: TasksStore = .shared, + chatProvider: ChatProvider + ) { self.navigation = navigation self.tasksStore = tasksStore + self.chatProvider = chatProvider } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstCaptureLinkPolicy.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstCaptureLinkPolicy.swift new file mode 100644 index 00000000000..1dcf2d09a9e --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstCaptureLinkPolicy.swift @@ -0,0 +1,17 @@ +import Foundation + +/// The cohort Conversations destination is a strict Omi-device archive, not +/// a general conversation browser. A task can refer to a desktop or phone +/// conversation too, so the link is present only for the one task provenance +/// that is known to resolve inside that archive. Unknown provenance fails +/// closed instead of exposing a misleading destination. +enum ChatFirstCaptureLinkPolicy { + static func captureID(for task: TaskActionItem) -> String? { + guard task.source == "transcription:omi", + let conversationID = task.conversationId + else { return nil } + + let normalized = conversationID.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift index bd7543613e7..fa3e93c70a8 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift @@ -46,7 +46,8 @@ struct ChatFirstShell: View { chatProvider: viewModelContainer.chatProvider, chatFirstRichBlockContext: ChatFirstRichBlockContext( navigation: navigation, - tasksStore: viewModelContainer.tasksStore + tasksStore: viewModelContainer.tasksStore, + chatProvider: viewModelContainer.chatProvider ) ) .accessibilityIdentifier("chat-first-route-chat") @@ -57,9 +58,9 @@ struct ChatFirstShell: View { ) .accessibilityIdentifier("chat-first-route-conversations") case .tasks: - TasksPage( - viewModel: viewModelContainer.tasksViewModel, - chatCoordinator: viewModelContainer.taskChatCoordinator, + ChatFirstTasksPage( + navigation: navigation, + tasksStore: viewModelContainer.tasksStore, chatProvider: viewModelContainer.chatProvider ) .accessibilityIdentifier("chat-first-route-tasks") diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift new file mode 100644 index 00000000000..a70c7ab8338 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift @@ -0,0 +1,550 @@ +import Foundation +import SwiftUI +import OmiTheme + +/// The two deliberate scheduling groups in the cohort Tasks page. This folds +/// the legacy page's Tomorrow, Later, and no-deadline buckets into a single +/// quiet Later section while preserving its rule that overdue work is Today. +enum ChatFirstTaskScheduleGroup: String, CaseIterable, Hashable, Sendable { + case today + case later + + var title: String { + switch self { + case .today: return "Today" + case .later: return "Later" + } + } +} + +struct ChatFirstTaskBadges: Equatable, Sendable { + let goalID: String? + let captureID: String? +} + +struct ChatFirstTaskGoalGroup: Identifiable { + let goalID: String? + let tasks: [TaskActionItem] + + var id: String { goalID.map { "goal:\($0)" } ?? "other" } +} + +/// Pure presentation policy for T10. Keeping date grouping, badge derivation, +/// and the visible-focus predicate separate from the view makes the page +/// replay-safe and keeps tests independent from SwiftUI layout timing. +enum ChatFirstTaskPagePolicy { + static func scheduleGroup( + for task: TaskActionItem, + now: Date = Date(), + calendar: Calendar = .current + ) -> ChatFirstTaskScheduleGroup { + let startOfToday = calendar.startOfDay(for: now) + let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? now + // Matches `TasksViewModel.categoryFor`: every task due before tomorrow — + // including overdue work — belongs in Today. No-date work is Later. + return (task.dueAt ?? .distantFuture) < startOfTomorrow ? .today : .later + } + + static func suggestedDueDate( + for group: ChatFirstTaskScheduleGroup, + now: Date = Date(), + calendar: Calendar = .current + ) -> Date { + let startOfToday = calendar.startOfDay(for: now) + switch group { + case .today: + return calendar.date(bySettingHour: 23, minute: 59, second: 0, of: now) ?? now + case .later: + // This is the legacy page's Later move/create scheduling value. + return calendar.date(byAdding: .day, value: 7, to: startOfToday) ?? now + } + } + + static func badges(for task: TaskActionItem) -> ChatFirstTaskBadges { + ChatFirstTaskBadges( + goalID: normalizedID(task.goalId), + captureID: ChatFirstCaptureLinkPolicy.captureID(for: task) + ) + } + + static func groupedByGoal(_ tasks: [TaskActionItem]) -> [ChatFirstTaskGoalGroup] { + let ordered = tasks.sorted(by: taskSort) + var orderedKeys: [String] = [] + var grouped: [String: [TaskActionItem]] = [:] + var goalIDs: [String: String] = [:] + + for task in ordered { + let goalID = normalizedID(task.goalId) + let key = goalID.map { "goal:\($0)" } ?? "other" + if grouped[key] == nil { + orderedKeys.append(key) + if let goalID { goalIDs[key] = goalID } + } + grouped[key, default: []].append(task) + } + + return orderedKeys.compactMap { key in + guard let tasks = grouped[key] else { return nil } + return ChatFirstTaskGoalGroup(goalID: goalIDs[key], tasks: tasks) + } + } + + static func focusToAcknowledge( + pendingFocus: ChatFirstPendingFocus?, + visibleTaskID: String + ) -> ChatFirstPendingFocus? { + guard case .task(let pendingID) = pendingFocus, pendingID == visibleTaskID else { return nil } + return pendingFocus + } + + private static func normalizedID(_ id: String?) -> String? { + guard let id else { return nil } + let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + + private static func taskSort(_ lhs: TaskActionItem, _ rhs: TaskActionItem) -> Bool { + if lhs.completed != rhs.completed { return !lhs.completed } + let lhsDue = lhs.dueAt ?? .distantFuture + let rhsDue = rhs.dueAt ?? .distantFuture + if lhsDue != rhsDue { return lhsDue < rhsDue } + return lhs.createdAt > rhs.createdAt + } +} + +/// Cohort-only lightweight checklist. It reads and mutates the one shared +/// TasksStore; legacy TasksPage continues to own the legacy-shell UI unchanged. +@MainActor +struct ChatFirstTasksPage: View { + @ObservedObject var navigation: ChatFirstShellNavigation + @ObservedObject var tasksStore: TasksStore + let chatProvider: ChatProvider + + @State private var addDrafts: [ChatFirstTaskScheduleGroup: String] = [:] + @State private var addingGroups: Set = [] + @State private var highlightedTaskID: String? + + init( + navigation: ChatFirstShellNavigation, + tasksStore: TasksStore, + chatProvider: ChatProvider + ) { + self.navigation = navigation + self.tasksStore = tasksStore + self.chatProvider = chatProvider + } + + private var visibleTasks: [TaskActionItem] { + tasksStore.tasks.filter { $0.deleted != true } + } + + private var todayTasks: [TaskActionItem] { + visibleTasks.filter { ChatFirstTaskPagePolicy.scheduleGroup(for: $0) == .today } + } + + private var laterTasks: [TaskActionItem] { + visibleTasks.filter { ChatFirstTaskPagePolicy.scheduleGroup(for: $0) == .later } + } + + private var pendingTaskID: String? { + guard case .task(let id) = navigation.pendingFocus else { return nil } + return id + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + + if let error = tasksStore.error, visibleTasks.isEmpty { + unavailableState(error) + } else if tasksStore.isLoading && visibleTasks.isEmpty { + ProgressView("Loading tasks") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if visibleTasks.isEmpty { + emptyState + } else { + taskList + } + } + .background(OmiColors.backgroundPrimary) + .onAppear { + tasksStore.isActive = true + Task { await tasksStore.loadTasksIfNeeded() } + } + .onDisappear { + tasksStore.isActive = false + } + .accessibilityIdentifier("chat-first-tasks-page") + } + + private var header: some View { + VStack(alignment: .leading, spacing: OmiSpacing.sm) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: OmiSpacing.xxs) { + Text("Tasks") + .scaledFont(size: OmiType.title, weight: .bold) + .foregroundStyle(OmiColors.textPrimary) + Text("A quiet checklist for what is next.") + .scaledFont(size: OmiType.body) + .foregroundStyle(OmiColors.textSecondary) + } + Spacer() + Button { + Task { await tasksStore.loadTasks() } + } label: { + Image(systemName: "arrow.clockwise") + .scaledFont(size: OmiType.body, weight: .medium) + } + .buttonStyle(.plain) + .disabled(tasksStore.isLoading) + .accessibilityLabel("Refresh tasks") + .accessibilityIdentifier("chat-first-tasks-refresh") + } + + Button("Ask Omi about these tasks") { + navigation.discuss(.tasks, using: chatProvider) + } + .buttonStyle(.plain) + .foregroundStyle(OmiColors.textSecondary) + .accessibilityIdentifier("chat-first-tasks-discuss") + + if let error = tasksStore.error, !visibleTasks.isEmpty { + HStack(spacing: OmiSpacing.sm) { + Image(systemName: "exclamationmark.triangle") + .accessibilityHidden(true) + Text("Some task changes could not be confirmed. Refresh to reconcile.") + } + .scaledFont(size: OmiType.caption) + .foregroundStyle(OmiColors.textSecondary) + .padding(.top, OmiSpacing.xs) + } + } + .padding(.horizontal, OmiSpacing.xxl) + .padding(.vertical, OmiSpacing.xl) + } + + private var taskList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: OmiSpacing.xxl) { + scheduleSection(.today, tasks: todayTasks) + scheduleSection(.later, tasks: laterTasks) + } + .padding(.horizontal, OmiSpacing.xxl) + .padding(.bottom, OmiSpacing.xxl) + } + .onAppear { scrollPendingTaskIntoView(proxy) } + .onChange(of: pendingTaskID) { _ in scrollPendingTaskIntoView(proxy) } + .onChange(of: visibleTasks.map(\.id)) { _ in scrollPendingTaskIntoView(proxy) } + } + } + + @ViewBuilder + private func scheduleSection(_ group: ChatFirstTaskScheduleGroup, tasks: [TaskActionItem]) -> some View { + VStack(alignment: .leading, spacing: OmiSpacing.md) { + Text(group.title) + .scaledFont(size: OmiType.subheading, weight: .semibold) + .foregroundStyle(OmiColors.textPrimary) + + ForEach(ChatFirstTaskPagePolicy.groupedByGoal(tasks)) { goalGroup in + VStack(alignment: .leading, spacing: OmiSpacing.xs) { + if let goalID = goalGroup.goalID { + ChatFirstDestinationBadge( + title: "Goal", + systemImage: "target", + accessibilityID: "chat-first-tasks-goal-\(goalID)" + ) { + navigation.open(focus: .goal(id: goalID)) + } + .padding(.bottom, OmiSpacing.xxs) + } + + ForEach(goalGroup.tasks) { task in + ChatFirstTaskRow( + task: task, + scheduleGroup: group, + tasksStore: tasksStore, + navigation: navigation, + isHighlighted: highlightedTaskID == task.id, + onVisible: { taskID in acknowledgeVisibleTaskIfNeeded(taskID) } + ) + .id(task.id) + } + } + } + + ChatFirstTaskAddRow( + group: group, + draft: Binding( + get: { addDrafts[group, default: ""] }, + set: { addDrafts[group] = $0 } + ), + isAdding: addingGroups.contains(group), + onSubmit: { createTask(in: group) } + ) + } + .accessibilityIdentifier("chat-first-tasks-section-\(group.rawValue)") + } + + private var emptyState: some View { + ContentUnavailableView { + Label("No tasks yet", systemImage: "checklist") + } description: { + Text("Talk to Omi when you are ready to make a plan.") + } actions: { + Button("Talk to Omi") { + navigation.discuss(.tasks, using: chatProvider) + } + .accessibilityIdentifier("chat-first-tasks-empty-discuss") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func unavailableState(_ error: String) -> some View { + ContentUnavailableView { + Label("Tasks are unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Refresh") { + Task { await tasksStore.loadTasks() } + } + .accessibilityIdentifier("chat-first-tasks-unavailable-refresh") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func createTask(in group: ChatFirstTaskScheduleGroup) { + guard !addingGroups.contains(group) else { return } + let description = addDrafts[group, default: ""].trimmingCharacters(in: .whitespacesAndNewlines) + guard !description.isEmpty else { return } + + addingGroups.insert(group) + Task { @MainActor in + _ = await tasksStore.createTask( + description: description, + dueAt: ChatFirstTaskPagePolicy.suggestedDueDate(for: group), + priority: nil + ) + addDrafts[group] = "" + addingGroups.remove(group) + } + } + + private func scrollPendingTaskIntoView(_ proxy: ScrollViewProxy) { + guard let taskID = pendingTaskID, + visibleTasks.contains(where: { $0.id == taskID }) + else { return } + withAnimation(OmiMotion.gated(.easeOut(duration: 0.18))) { + proxy.scrollTo(taskID, anchor: .center) + } + } + + private func acknowledgeVisibleTaskIfNeeded(_ taskID: String) { + guard let focus = ChatFirstTaskPagePolicy.focusToAcknowledge( + pendingFocus: navigation.pendingFocus, + visibleTaskID: taskID + ), navigation.acknowledgeFocus(focus) + else { return } + + highlightedTaskID = taskID + Task { @MainActor in + try? await Task.sleep(nanoseconds: 900_000_000) + guard !Task.isCancelled, highlightedTaskID == taskID else { return } + highlightedTaskID = nil + } + } +} + +private struct ChatFirstTaskRow: View { + let task: TaskActionItem + let scheduleGroup: ChatFirstTaskScheduleGroup + @ObservedObject var tasksStore: TasksStore + let navigation: ChatFirstShellNavigation + let isHighlighted: Bool + let onVisible: (String) -> Void + + @State private var isToggling = false + @State private var isSaving = false + @State private var isEditing = false + @State private var titleDraft = "" + @FocusState private var titleIsFocused: Bool + + private var badges: ChatFirstTaskBadges { ChatFirstTaskPagePolicy.badges(for: task) } + private var moveTarget: ChatFirstTaskScheduleGroup { + scheduleGroup == .today ? .later : .today + } + + var body: some View { + HStack(alignment: .top, spacing: OmiSpacing.md) { + Button { + toggle() + } label: { + Image(systemName: task.completed ? "checkmark.circle.fill" : "circle") + .scaledFont(size: OmiType.subheading, weight: .medium) + .foregroundStyle(task.completed ? OmiColors.success : OmiColors.textTertiary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .disabled(isToggling) + .accessibilityLabel(task.completed ? "Mark \(task.description) incomplete" : "Mark \(task.description) complete") + .accessibilityIdentifier("chat-first-tasks-toggle-\(task.id)") + + VStack(alignment: .leading, spacing: OmiSpacing.xs) { + if isEditing { + TextField("Task", text: $titleDraft) + .textFieldStyle(.plain) + .scaledFont(size: OmiType.body, weight: .medium) + .focused($titleIsFocused) + .onSubmit { rename() } + .onExitCommand { cancelRename() } + .accessibilityLabel("Rename \(task.description)") + .accessibilityIdentifier("chat-first-tasks-rename-\(task.id)") + .onAppear { + titleDraft = task.description + titleIsFocused = true + } + } else { + Button { + titleDraft = task.description + isEditing = true + } label: { + Text(task.description) + .scaledFont(size: OmiType.body, weight: .medium) + .foregroundStyle(task.completed ? OmiColors.textTertiary : OmiColors.textPrimary) + .strikethrough(task.completed, color: OmiColors.textTertiary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .buttonStyle(.plain) + .disabled(isSaving) + .onKeyPress(.return) { _ in + titleDraft = task.description + isEditing = true + return .handled + } + .accessibilityLabel("Rename \(task.description)") + .accessibilityIdentifier("chat-first-tasks-title-\(task.id)") + } + + HStack(spacing: OmiSpacing.sm) { + if let captureID = badges.captureID { + ChatFirstDestinationBadge( + title: "Capture", + systemImage: "waveform", + accessibilityID: "chat-first-tasks-capture-\(task.id)-\(captureID)" + ) { + navigation.open(focus: .capture(id: captureID, momentTs: nil)) + } + } + + Button("Move to \(moveTarget.title)") { + move() + } + .buttonStyle(.plain) + .foregroundStyle(OmiColors.textSecondary) + .disabled(isSaving) + .accessibilityIdentifier("chat-first-tasks-move-\(task.id)-\(moveTarget.rawValue)") + } + .scaledFont(size: OmiType.caption) + } + } + .padding(.horizontal, OmiSpacing.md) + .padding(.vertical, OmiSpacing.sm) + .background( + RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) + .fill(isHighlighted ? OmiColors.backgroundTertiary : Color.clear) + ) + .onAppear { onVisible(task.id) } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("chat-first-tasks-row-\(task.id)") + } + + private func toggle() { + guard !isToggling else { return } + isToggling = true + Task { @MainActor in + await tasksStore.toggleTask(task) + isToggling = false + } + } + + private func rename() { + let description = titleDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !description.isEmpty else { + cancelRename() + return + } + guard description != task.description else { + cancelRename() + return + } + isEditing = false + isSaving = true + Task { @MainActor in + _ = await tasksStore.updateTask( + task, + description: description, + remoteFailureBehavior: .rollbackForChatFirst + ) + isSaving = false + } + } + + private func cancelRename() { + titleDraft = task.description + titleIsFocused = false + isEditing = false + } + + private func move() { + guard !isSaving else { return } + isSaving = true + Task { @MainActor in + _ = await tasksStore.updateTask( + task, + dueAt: ChatFirstTaskPagePolicy.suggestedDueDate(for: moveTarget), + remoteFailureBehavior: .rollbackForChatFirst + ) + isSaving = false + } + } +} + +private struct ChatFirstTaskAddRow: View { + let group: ChatFirstTaskScheduleGroup + @Binding var draft: String + let isAdding: Bool + let onSubmit: () -> Void + + @FocusState private var isFocused: Bool + + var body: some View { + HStack(spacing: OmiSpacing.md) { + Image(systemName: "plus") + .scaledFont(size: OmiType.body, weight: .medium) + .foregroundStyle(OmiColors.textTertiary) + .frame(width: 24, height: 24) + .accessibilityHidden(true) + TextField("Add a task", text: $draft) + .textFieldStyle(.plain) + .focused($isFocused) + .onSubmit(onSubmit) + .accessibilityLabel("Add \(group.title) task") + .accessibilityIdentifier("chat-first-tasks-add-\(group.rawValue)") + if !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button("Add", action: onSubmit) + .buttonStyle(.plain) + .foregroundStyle(OmiColors.textSecondary) + .disabled(isAdding) + .accessibilityIdentifier("chat-first-tasks-add-submit-\(group.rawValue)") + } + } + .padding(.horizontal, OmiSpacing.md) + .padding(.vertical, OmiSpacing.sm) + .background( + RoundedRectangle(cornerRadius: OmiChrome.smallControlRadius, style: .continuous) + .stroke(OmiColors.border.opacity(0.45), style: StrokeStyle(lineWidth: 1, dash: [4, 4])) + ) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index 92e700ed258..a17a39cdc55 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -88,13 +88,21 @@ struct ChatBubble: View { } var body: some View { - let groupedBlocks = ContentBlockGroup.visibleChatGroups( - message.contentBlocks, - isStreaming: message.isStreaming, - richBlockRenderingEnabled: chatFirstRichBlockContext != nil - ) + if message.hidesEmptyStreamingPlaceholder, + message.isStreaming, + message.text.isEmpty, + message.contentBlocks.isEmpty + { + EmptyView() + .accessibilityHidden(true) + } else { + let groupedBlocks = ContentBlockGroup.visibleChatGroups( + message.contentBlocks, + isStreaming: message.isStreaming, + richBlockRenderingEnabled: chatFirstRichBlockContext != nil + ) - HStack(alignment: .top, spacing: OmiSpacing.md) { + HStack(alignment: .top, spacing: OmiSpacing.md) { if message.sender == .ai { // App avatar if let app = app { @@ -139,9 +147,10 @@ struct ChatBubble: View { .background(OmiColors.backgroundTertiary) .clipShape(Circle()) } + } + .frame(maxWidth: .infinity, alignment: message.sender == .user ? .trailing : .leading) + .contentShape(Rectangle()) } - .frame(maxWidth: .infinity, alignment: message.sender == .user ? .trailing : .leading) - .contentShape(Rectangle()) } @ViewBuilder @@ -290,8 +299,19 @@ struct ChatBubble: View { text: text, options: options, selectedOptionID: selectedOptionID, - isActionable: false, - onSelect: { _ in } + isActionable: chatFirstRichBlockContext.chatProvider.isQuestionCardActionable( + messageID: message.id, + questionID: questionID, + selectedOptionID: selectedOptionID + ), + onSelect: { optionID in + Task { @MainActor in + await chatFirstRichBlockContext.chatProvider.selectQuestionCardOption( + questionID: questionID, + optionID: optionID + ) + } + } ) ) case .taskCard(_, let taskID): diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index edebd0c1dd8..149e743329b 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -1,4 +1,5 @@ import Combine +import CryptoKit import CoreGraphics @preconcurrency import GRDB import OmiSupport @@ -324,7 +325,15 @@ enum ChatContentBlock: Identifiable { case thinking(id: String, text: String) /// Collapsible card showing a summary with expandable full text (used for AI profile/discovery) case discoveryCard(id: String, title: String, summary: String, fullText: String) - case questionCard(id: String, questionId: String, text: String, subjectKind: String, subjectId: String, options: [[String: Any]]) + case questionCard( + id: String, + questionId: String, + text: String, + subjectKind: String, + subjectId: String, + options: [[String: Any]], + selectedOptionId: String? = nil + ) case taskCard(id: String, taskId: String) case goalLink(id: String, goalId: String, summary: String) case captureLink(id: String, conversationId: String, momentTimestampMs: Int?, summary: String) @@ -354,7 +363,7 @@ enum ChatContentBlock: Identifiable { case .toolCall(let id, _, _, _, _, _): return id case .thinking(let id, _): return id case .discoveryCard(let id, _, _, _): return id - case .questionCard(let id, _, _, _, _, _): return id + case .questionCard(let id, _, _, _, _, _, _): return id case .taskCard(let id, _): return id case .goalLink(let id, _, _): return id case .captureLink(let id, _, _, _): return id @@ -838,13 +847,17 @@ struct ChatMessage: Identifiable { /// Kernel journal lifecycle when this message was projected from a journal /// row. Failed turns get a light visual treatment so they don't look completed. var journalStatus: KernelJournalTurnStatus? + /// A journal-first continuation can reserve its assistant row before the + /// query begins. It stays out of the transcript until real output arrives. + var hidesEmptyStreamingPlaceholder: Bool init( id: String = UUID().uuidString, clientTurnId: String? = nil, text: String, createdAt: Date = Date(), sender: ChatSender, isStreaming: Bool = false, rating: Int? = nil, isSynced: Bool = false, citations: [Citation] = [], contentBlocks: [ChatContentBlock] = [], metadata: MessageMetadata? = nil, notificationContext: String? = nil, notificationScreenshot: Data? = nil, attachments: [ChatAttachment] = [], - resources: [ChatResource] = [], turnOwner: ChatTurnOwner? = nil, journalStatus: KernelJournalTurnStatus? = nil + resources: [ChatResource] = [], turnOwner: ChatTurnOwner? = nil, journalStatus: KernelJournalTurnStatus? = nil, + hidesEmptyStreamingPlaceholder: Bool = false ) { self.id = id self.turnOwner = turnOwner @@ -863,6 +876,62 @@ struct ChatMessage: Identifiable { self.attachments = attachments self.resources = resources self.journalStatus = journalStatus + self.hidesEmptyStreamingPlaceholder = hidesEmptyStreamingPlaceholder + } +} + +/// IDs are the only caller-provided inputs for a suggestion selection. The +/// kernel derives all visible reply content transactionally. +struct ChatQuestionCardSelection: Sendable { + let questionID: String + let optionID: String +} + +/// Receipt for resuming an already-admitted question reply after an app crash. +struct ChatQuestionCardContinuation: Sendable { + let continuityKey: String + let preparedAnswer: String + let userTurnID: String + let assistantTurnID: String + + init?(continuityKey: String, preparedAnswer: String, userTurnID: String, assistantTurnID: String) { + guard !continuityKey.isEmpty, !preparedAnswer.isEmpty, !userTurnID.isEmpty, !assistantTurnID.isEmpty else { + return nil + } + self.continuityKey = continuityKey + self.preparedAnswer = preparedAnswer + self.userTurnID = userTurnID + self.assistantTurnID = assistantTurnID + } + + init?(receipt: AgentRuntimeProcess.QuestionInteractionReply) { + self.init( + continuityKey: receipt.continuityKey, + preparedAnswer: receipt.userTurn.content, + userTurnID: receipt.userTurn.turnId, + assistantTurnID: receipt.assistantTurn.turnId + ) + } + + static func tailResumeCandidate(from messages: [ChatMessage]) -> ChatQuestionCardContinuation? { + guard let assistant = messages.last, + assistant.sender == .ai, + assistant.isStreaming, + assistant.hidesEmptyStreamingPlaceholder, + assistant.text.isEmpty, + assistant.contentBlocks.isEmpty, + let continuityKey = assistant.clientTurnId, + continuityKey.hasPrefix("qri_"), + messages.count >= 2 + else { return nil } + let user = messages[messages.count - 2] + guard user.sender == .user, user.clientTurnId == continuityKey else { return nil } + return ChatQuestionCardContinuation( + continuityKey: continuityKey, + preparedAnswer: user.text, + userTurnID: user.id, + assistantTurnID: assistant.id + ) } } @@ -899,7 +968,7 @@ extension ChatContentBlock { case .discoveryCard(_, let title, _, let fullText): let trimmed = fullText.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? title : "\(title)\n\(trimmed)" - case .questionCard(_, _, let text, _, _, _): + case .questionCard(_, _, let text, _, _, _, _): let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed case .taskCard: @@ -3410,6 +3479,32 @@ class ChatProvider: ObservableObject { if $0.createdAt == $1.createdAt { return $0.id < $1.id } return $0.createdAt < $1.createdAt } + resumeTailQuestionContinuationIfNeeded() + } + + /// Resumes a kernel-admitted suggestion reply only when it remains the + /// current final chat event and the account is still in the gated cohort. + private func resumeTailQuestionContinuationIfNeeded() { + guard !isSending, + let ownerID = runtimeOwnerId, + let continuation = ChatQuestionCardContinuation.tailResumeCandidate(from: messages) + else { return } + let surface = mainChatSurfaceReference() + guard chatFirstMainChatProjectionGate.capability(for: surface, ownerID: ownerID) != nil else { return } + Task { @MainActor [weak self] in + guard let self, + !self.isSending, + self.messages.last?.id == continuation.assistantTurnID, + self.chatFirstMainChatProjectionGate.capability(for: surface, ownerID: ownerID) != nil + else { return } + _ = await self.sendMessage( + "", + surfaceRef: surface, + turnOwner: .mainChat, + clientTurnId: continuation.continuityKey, + questionContinuation: continuation + ) + } } func resetJournalProjection(surface: AgentSurfaceReference) { @@ -3691,6 +3786,53 @@ class ChatProvider: ObservableObject { // MARK: - Send Message + /// Question-card controls are only live on a completed assistant turn at + /// the conversation tail. A later user response retires its choices. + func isQuestionCardActionable( + messageID: String, + questionID: String, + selectedOptionID: String? + ) -> Bool { + guard selectedOptionID == nil, !isSending, let ownerID = runtimeOwnerId else { return false } + let surface = mainChatSurfaceReference() + guard chatFirstMainChatProjectionGate.capability(for: surface, ownerID: ownerID) != nil, + let tail = messages.last, + tail.id == messageID, + tail.sender == .ai, + tail.journalStatus == .completed, + !tail.isStreaming + else { return false } + return tail.contentBlocks.contains { block in + guard case .questionCard(_, let candidateID, _, _, _, _, let candidateSelection) = block else { + return false + } + return candidateID == questionID && candidateSelection == nil + } + } + + /// The click is immediately sent through the normal single-send lock; the + /// kernel owns validation and derives the persisted user reply. + func selectQuestionCardOption(questionID: String, optionID: String) async { + let normalizedQuestionID = questionID.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedOptionID = optionID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedQuestionID.isEmpty, !normalizedOptionID.isEmpty, let ownerID = runtimeOwnerId else { return } + let surface = mainChatSurfaceReference() + guard chatFirstMainChatProjectionGate.capability(for: surface, ownerID: ownerID) != nil else { return } + _ = await sendMessage( + "", + surfaceRef: surface, + turnOwner: .mainChat, + clientTurnId: Self.questionInteractionContinuityKey( + questionID: normalizedQuestionID, + optionID: normalizedOptionID + ), + questionInteraction: ChatQuestionCardSelection( + questionID: normalizedQuestionID, + optionID: normalizedOptionID + ) + ) + } + /// Send a message and get AI response via Claude Agent SDK bridge /// Persists both user and AI messages to backend /// - Parameters: @@ -3707,11 +3849,14 @@ class ChatProvider: ObservableObject { imageData: Data? = nil, turnOwner: ChatTurnOwner = .mainChat, clientTurnId: String = UUID().uuidString, + questionInteraction: ChatQuestionCardSelection? = nil, + questionContinuation: ChatQuestionCardContinuation? = nil, onAccepted: (@MainActor () -> Void)? = nil, onJournalFinalized: (@MainActor (_ accepted: Bool) -> Void)? = nil ) async -> String? { let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedText.isEmpty else { return nil } + guard !trimmedText.isEmpty || questionInteraction != nil || questionContinuation != nil else { return nil } + var effectivePrompt = trimmedText guard let capturedRuntimeOwnerID = runtimeOwnerId else { errorMessage = "Sign in again to continue." return nil @@ -3803,6 +3948,7 @@ class ChatProvider: ObservableObject { telemetryAttempt.finish(stopReason: turnLifecycle.stopReason ?? stopReason(for: sendGen)) clearChatTelemetryState(for: sendGen) releaseSendLock(sendGeneration: sendGen) + return nil } guard let sid = currentSessionId else { @@ -4036,6 +4182,61 @@ class ChatProvider: ObservableObject { usageLimiter.recordQuery() } + var preAdmittedQuestionReply = questionContinuation + if let questionContinuation { + guard questionContinuation.continuityKey == turnAttemptId, + questionContinuation.userTurnID == Self.messageIds(forAttemptId: turnAttemptId).user, + questionContinuation.assistantTurnID == Self.messageIds(forAttemptId: turnAttemptId).assistant + else { + errorMessage = "That suggestion is no longer available." + telemetryAttempt.fail(errorClass: .sessionSetup) + clearChatTelemetryState(for: sendGen) + releaseSendLock(sendGeneration: sendGen) + return nil + } + effectivePrompt = questionContinuation.preparedAnswer + } + if let questionInteraction { + guard resolvedSurface.surfaceKind == "main_chat", + let capability = chatFirstMainChatProjectionGate.capability( + for: resolvedSurface, + ownerID: capturedRuntimeOwnerID + ) + else { + errorMessage = "That suggestion is no longer available." + telemetryAttempt.fail(errorClass: .sessionSetup) + clearChatTelemetryState(for: sendGen) + releaseSendLock(sendGeneration: sendGen) + return nil + } + do { + let receipt = try await resolvedAgentClient().recordQuestionInteractionReply( + surface: resolvedSurface, + ownerID: capturedRuntimeOwnerID, + sessionID: pinnedSession.sessionId, + questionID: questionInteraction.questionID, + optionID: questionInteraction.optionID, + controlGeneration: capability.controlGeneration + ) + guard receipt.continuityKey == turnAttemptId, + receipt.userTurn.turnId == Self.messageIds(forAttemptId: turnAttemptId).user, + receipt.assistantTurn.turnId == Self.messageIds(forAttemptId: turnAttemptId).assistant, + let continuation = ChatQuestionCardContinuation(receipt: receipt) + else { + throw BridgeError.agentError("Question interaction continuity mismatch") + } + effectivePrompt = continuation.preparedAnswer + preAdmittedQuestionReply = continuation + await kernelTurnProjection.refresh(surface: resolvedSurface) + } catch { + errorMessage = "That suggestion is no longer available." + telemetryAttempt.fail(errorClass: .sessionSetup) + clearChatTelemetryState(for: sendGen) + releaseSendLock(sendGeneration: sendGen) + return nil + } + } + // Attempt-derived IDs are the canonical journal identities. Backend // delivery preserves them through the outbox instead of minting a // second writer identity. @@ -4048,7 +4249,7 @@ class ChatProvider: ObservableObject { let userMessage = ChatMessage( id: userMessageId, clientTurnId: turnAttemptId, - text: trimmedText, + text: effectivePrompt, sender: .user, attachments: attachmentsForMessage, turnOwner: turnOwner @@ -4064,17 +4265,23 @@ class ChatProvider: ObservableObject { ) // Both visible halves enter the journal under one SQLite transaction. // If either identity/payload is rejected, neither row can project. - let recordedExchange = await recordStreamingJournalExchange( - surface: resolvedSurface, - ownerID: capturedRuntimeOwnerID, - continuityKey: turnAttemptId, - userMessage: userMessage, - assistantMessage: aiMessage, - origin: journalOrigin, - appId: capturedAppId, - sessionId: capturedSessionId, - messageSource: journalOrigin - ) + let recordedExchange: Bool + if preAdmittedQuestionReply != nil { + // The runtime already wrote the exact pair transactionally. + recordedExchange = true + } else { + recordedExchange = await recordStreamingJournalExchange( + surface: resolvedSurface, + ownerID: capturedRuntimeOwnerID, + continuityKey: turnAttemptId, + userMessage: userMessage, + assistantMessage: aiMessage, + origin: journalOrigin, + appId: capturedAppId, + sessionId: capturedSessionId, + messageSource: journalOrigin + ) + } if recordedExchange { journalOwnerByMessageID[aiMessageId] = capturedRuntimeOwnerID journalTerminalTargets.register( @@ -4117,7 +4324,7 @@ class ChatProvider: ObservableObject { // Track onboarding user-message shape without content. if isOnboarding { AnalyticsManager.shared.onboardingChatMessageDetailed( - role: "user", text: trimmedText, step: "chat" + role: "user", text: effectivePrompt, step: "chat" ) } @@ -4164,7 +4371,7 @@ class ChatProvider: ObservableObject { var screenPayload: [String: Any]? if effectiveImageData == nil, let screenContextReason = ScreenContextAutoIncludePolicy.reason( - userText: trimmedText, + userText: effectivePrompt, systemPromptStyle: systemPromptStyle, turnOwner: turnOwner ) @@ -4466,7 +4673,7 @@ class ChatProvider: ObservableObject { let queryResult: AgentClient.QueryResult do { queryResult = try await resolvedAgentClient().query( - prompt: trimmedText, + prompt: effectivePrompt, session: kernelContext.session, surface: resolvedSurface, mode: chatMode.rawValue, @@ -4758,7 +4965,7 @@ class ChatProvider: ObservableObject { } // Fire-and-forget: check if user's message mentions goal progress - let chatText = trimmedText + let chatText = effectivePrompt Task.detached(priority: .background) { await GoalsAIService.shared.extractProgressFromAllGoals(text: chatText) } @@ -4953,7 +5160,7 @@ class ChatProvider: ObservableObject { } AnalyticsManager.shared.onboardingChatMessageDetailed( role: onboardingRole, - text: trimmedText, + text: effectivePrompt, step: "chat", error: onboardingRole == "error" ? String(describing: error) : nil ) @@ -5002,7 +5209,7 @@ class ChatProvider: ObservableObject { let card = ChatErrorState.from(bridgeError) { currentError = card - lastFailedPrompt = trimmedText + lastFailedPrompt = effectivePrompt errorMessage = nil } else { errorMessage = error.localizedDescription @@ -5059,7 +5266,28 @@ class ChatProvider: ObservableObject { user: String, assistant: String ) { - (user: attemptId, assistant: "\(attemptId)-assistant") + if attemptId.hasPrefix("qri_") { + return ( + user: questionInteractionTurnID(continuityKey: attemptId, role: "user"), + assistant: questionInteractionTurnID(continuityKey: attemptId, role: "assistant") + ) + } + return (user: attemptId, assistant: "\(attemptId)-assistant") + } + + nonisolated static func questionInteractionContinuityKey(questionID: String, optionID: String) -> String { + "qri_\(sha256Prefix("\(questionID)\u{0}\(optionID)", byteCount: 16))" + } + + nonisolated private static func questionInteractionTurnID(continuityKey: String, role: String) -> String { + "turn_\(sha256Prefix("\(continuityKey)\u{0}\(role)", byteCount: 8))" + } + + nonisolated private static func sha256Prefix(_ value: String, byteCount: Int) -> String { + SHA256.hash(data: Data(value.utf8)) + .prefix(byteCount) + .map { String(format: "%02x", $0) } + .joined() } @discardableResult diff --git a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift index aeafb211492..1a7c01c0d09 100644 --- a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift @@ -36,6 +36,29 @@ class TasksStore: ObservableObject { let rollbackLocal: () async throws -> Void } + /// Legacy surfaces deliberately preserve a local edit while offline; the + /// cohort-only inline task controls roll a rejected mutation back instead. + enum TaskUpdateRemoteFailureBehavior: Equatable, Sendable { + case preserveLocalEdit + case rollbackForChatFirst + } + + enum TaskUpdateOutcome: Equatable, Sendable { + case updated + case preservedLocalAfterRemoteFailure + case rolledBackAfterRemoteFailure + case rollbackFailed + case localWriteFailed + case ownerChanged + } + + struct TaskUpdateOperationOverrides { + let updateLocal: (_ ownerID: String) async throws -> TaskActionItem + let updateRemote: (_ ownerID: String) async throws -> TaskActionItem + let syncRemote: (_ task: TaskActionItem, _ ownerID: String) async throws -> Void + let rollbackLocal: () async throws -> Void + } + /// Controllable seams for owner-bound reads and writes. Production callers /// use the defaults; tests suspend individual operations to prove that an /// owner change fences every later cache/UI/defaults publication. @@ -2702,6 +2725,7 @@ class TasksStore: ObservableObject { } } + @discardableResult func updateTask( _ task: TaskActionItem, description: String? = nil, @@ -2709,88 +2733,150 @@ class TasksStore: ObservableObject { clearDueAt: Bool = false, priority: String? = nil, recurrenceRule: String? = nil, - expectedOwnerID: String? = nil - ) async { - guard let lease = captureOwnerLease(expectedOwnerID: expectedOwnerID) else { return } - // Track manual edits: if description is changed, mark as manually edited + expectedOwnerID: String? = nil, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil, + remoteFailureBehavior: TaskUpdateRemoteFailureBehavior = .preserveLocalEdit, + beforeLocalMutation: (() async -> Void)? = nil, + operationOverrides: TaskUpdateOperationOverrides? = nil + ) async -> TaskUpdateOutcome { + guard let lease = captureOwnerLease( + expectedOwnerID: expectedOwnerID, + authorizationSnapshot: authorizationSnapshot + ) else { return .ownerChanged } + if let beforeLocalMutation { await beforeLocalMutation() } + guard isCurrent(lease) else { return .ownerChanged } + var metadata: [String: Any]? = nil if description != nil { metadata = ["manually_edited": true] - // Preserve existing tags in metadata - if !task.tags.isEmpty { - metadata?["tags"] = task.tags - } + if !task.tags.isEmpty { metadata?["tags"] = task.tags } } - // 1. Local-first: update SQLite immediately so auto-refresh reads correct state - var localUpdateSucceeded = true do { - try await ActionItemStorage.shared.updateActionItemFields( - backendId: task.id, - description: description, - dueAt: dueAt, - clearDueAt: clearDueAt, - priority: priority, - metadataBox: ActionItemMetadataBox(metadata), - recurrenceRule: recurrenceRule, - authorization: Self.localMutationAuthorization(snapshot: lease.authorizationSnapshot) - ) + let updatedTask: TaskActionItem? + if let operationOverrides { + updatedTask = try await operationOverrides.updateLocal(lease.ownerID) + } else { + try await ActionItemStorage.shared.updateActionItemFields( + backendId: task.id, + description: description, + dueAt: dueAt, + clearDueAt: clearDueAt, + priority: priority, + metadataBox: ActionItemMetadataBox(metadata), + recurrenceRule: recurrenceRule, + authorization: Self.localMutationAuthorization(snapshot: lease.authorizationSnapshot) + ) + updatedTask = try await ActionItemStorage.shared.getLocalActionItem(byBackendId: task.id) + } + guard isCurrent(lease) else { return .ownerChanged } + if let updatedTask { + replaceTaskInMemory(updatedTask, originalTask: task) + } else if remoteFailureBehavior == .rollbackForChatFirst { + error = "Could not verify the task update locally." + return .localWriteFailed + } } catch { - guard isCurrent(lease) else { return } + guard isCurrent(lease) else { return .ownerChanged } logError("TasksStore: Failed to update task locally", error: error) self.error = error.localizedDescription - localUpdateSucceeded = false + if remoteFailureBehavior == .rollbackForChatFirst { return .localWriteFailed } } - // 2. Read back from SQLite and update in-memory arrays immediately - if localUpdateSucceeded, - let updatedTask = try? await ActionItemStorage.shared.getLocalActionItem(byBackendId: task.id) - { - guard isCurrent(lease) else { return } - if task.completed { - if let index = completedTasks.firstIndex(where: { $0.id == task.id }) { - completedTasks[index] = updatedTask - } + do { + let apiResult: TaskActionItem + if let operationOverrides { + apiResult = try await operationOverrides.updateRemote(lease.ownerID) } else { - if let index = incompleteTasks.firstIndex(where: { $0.id == task.id }) { - incompleteTasks[index] = updatedTask - } + apiResult = try await APIClient.shared.updateActionItem( + id: task.id, + description: description, + dueAt: dueAt, + clearDueAt: clearDueAt, + priority: priority, + metadataBox: ActionItemMetadataBox(metadata), + recurrenceRule: recurrenceRule, + expectedOwnerId: lease.ownerID, + authorizationSnapshot: lease.authorizationSnapshot + ) + } + guard isCurrent(lease) else { return .ownerChanged } + if let operationOverrides { + try await operationOverrides.syncRemote(apiResult, lease.ownerID) + } else { + try await ActionItemStorage.shared.syncTaskActionItems( + [apiResult], + authorization: Self.localMutationAuthorization(snapshot: lease.authorizationSnapshot) + ) + } + guard isCurrent(lease) else { return .ownerChanged } + replaceTaskInMemory(apiResult, originalTask: task) + return .updated + } catch { + guard isCurrent(lease) else { return .ownerChanged } + if remoteFailureBehavior == .preserveLocalEdit { + self.error = error.localizedDescription + logError("TasksStore: Failed to update task on backend (local update preserved)", error: error) + return .preservedLocalAfterRemoteFailure } + let rolledBack = await rollbackTaskUpdateAfterBackendFailure( + task: task, + backendError: error, + expectedOwnerID: lease.ownerID, + authorizationSnapshot: lease.authorizationSnapshot, + rollbackStorage: operationOverrides?.rollbackLocal + ) + if rolledBack { return .rolledBackAfterRemoteFailure } + return isCurrent(lease) ? .rollbackFailed : .ownerChanged } + } - // 3. Call API in background + @discardableResult + func rollbackTaskUpdateAfterBackendFailure( + task: TaskActionItem, + backendError: Error, + expectedOwnerID: String, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil, + rollbackStorage: (() async throws -> Void)? = nil + ) async -> Bool { + guard let lease = captureOwnerLease( + expectedOwnerID: expectedOwnerID, + authorizationSnapshot: authorizationSnapshot + ) else { return false } do { - let apiResult = try await APIClient.shared.updateActionItem( - id: task.id, - description: description, - dueAt: dueAt, - clearDueAt: clearDueAt, - priority: priority, - metadataBox: ActionItemMetadataBox(metadata), - recurrenceRule: recurrenceRule, - expectedOwnerId: lease.ownerID, + if let rollbackStorage { + try await rollbackStorage() + } else { + try await ActionItemStorage.shared.syncTaskActionItems( + [task], + authorization: Self.localMutationAuthorization(snapshot: lease.authorizationSnapshot) + ) + } + guard isCurrent(lease) else { return false } + replaceTaskInMemory(task, originalTask: task) + await loadDashboardTasks( + expectedOwnerID: lease.ownerID, authorizationSnapshot: lease.authorizationSnapshot ) - guard isCurrent(lease) else { return } - // Sync API result to store server-side timestamps - try await ActionItemStorage.shared.syncTaskActionItems( - [apiResult], - authorization: Self.localMutationAuthorization(snapshot: lease.authorizationSnapshot) - ) - guard isCurrent(lease) else { return } - // Keep in-memory arrays aligned with server echo immediately. - if task.completed { - if let index = completedTasks.firstIndex(where: { $0.id == task.id }) { - completedTasks[index] = apiResult - } - } else if let index = incompleteTasks.firstIndex(where: { $0.id == task.id }) { - incompleteTasks[index] = apiResult - } + guard isCurrent(lease) else { return false } + error = backendError.localizedDescription + logError("TasksStore: Failed to update task on backend, reverted Chat-first edit", error: backendError) + return true } catch { - guard isCurrent(lease) else { return } - // Local change persists; next successful sync will reconcile + guard isCurrent(lease) else { return false } self.error = error.localizedDescription - logError("TasksStore: Failed to update task on backend (local update preserved)", error: error) + logError("TasksStore: Failed to roll back Chat-first task update", error: error) + return false + } + } + + private func replaceTaskInMemory(_ updatedTask: TaskActionItem, originalTask: TaskActionItem) { + if originalTask.completed { + if let index = completedTasks.firstIndex(where: { $0.id == originalTask.id }) { + completedTasks[index] = updatedTask + } + } else if let index = incompleteTasks.firstIndex(where: { $0.id == originalTask.id }) { + incompleteTasks[index] = updatedTask } } diff --git a/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift b/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift new file mode 100644 index 00000000000..2aa0d13763c --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift @@ -0,0 +1,56 @@ +import XCTest + +@testable import Omi_Computer + +final class ChatFirstDeferralOutboxDriverTests: XCTestCase { + func testDeferralDeliveryRetriesTransientAndServerFailuresOnly() { + for status in [408, 425, 429, 500, 503] { + XCTAssertEqual( + AgentRuntimeProcess.boundedChatFirstDeferralErrorCode( + for: APIError.httpError(statusCode: status) + ), + "chat_first_deferral_retryable" + ) + } + for status in [400, 401, 403, 422] { + XCTAssertEqual( + AgentRuntimeProcess.boundedChatFirstDeferralErrorCode( + for: APIError.httpError(statusCode: status) + ), + "chat_first_deferral_4xx" + ) + } + } + + func testRestartCandidateRequiresTheExactTailUserAndHiddenAssistantPair() { + let key = "qri_test" + let candidate = ChatQuestionCardContinuation.tailResumeCandidate(from: [ + ChatMessage(id: "parent", text: "Which goal?", sender: .ai), + ChatMessage(id: "user", clientTurnId: key, text: "Keep it", sender: .user), + ChatMessage( + id: "assistant", + clientTurnId: key, + text: "", + sender: .ai, + isStreaming: true, + hidesEmptyStreamingPlaceholder: true + ), + ]) + XCTAssertEqual(candidate?.continuityKey, key) + XCTAssertEqual(candidate?.preparedAnswer, "Keep it") + XCTAssertEqual(candidate?.assistantTurnID, "assistant") + + XCTAssertNil(ChatQuestionCardContinuation.tailResumeCandidate(from: [ + ChatMessage(id: "user", clientTurnId: key, text: "Keep it", sender: .user), + ChatMessage( + id: "assistant", + clientTurnId: key, + text: "", + sender: .ai, + isStreaming: true, + hidesEmptyStreamingPlaceholder: true + ), + ChatMessage(id: "later", text: "A later Omi bubble", sender: .ai), + ])) + } +} diff --git a/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift index cafbf0441f3..302a520600a 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift @@ -32,13 +32,14 @@ final class ChatFirstRichBlockTests: XCTestCase { let restored = try XCTUnwrap(ChatContentBlockCodec.decode(encoded)) XCTAssertEqual(restored.count, blocks.count) - guard case .questionCard(_, let questionID, let text, let subjectKind, let subjectID, let options) = restored[0] + guard case .questionCard(_, let questionID, let text, let subjectKind, let subjectID, let options, let selectedOptionID) = restored[0] else { return XCTFail("question card should survive persisted replay") } XCTAssertEqual(questionID, "question-1") XCTAssertEqual(text, "Which goal should we focus on?") XCTAssertEqual(subjectKind, "goal") XCTAssertEqual(subjectID, "goal-1") XCTAssertEqual(options.first?["preparedAnswer"] as? String, "Keep goal 1 as my focus") + XCTAssertNil(selectedOptionID) guard case .taskCard(_, let taskID) = restored[1] else { return XCTFail("task card should survive persisted replay") @@ -59,6 +60,30 @@ final class ChatFirstRichBlockTests: XCTestCase { XCTAssertEqual(captureSummary, "Planning conversation") } + func testQuestionSelectionReceiptRoundTripsAndRetiresTheOptions() throws { + let selected = ChatContentBlock.questionCard( + id: "question-card", + questionId: "question-1", + text: "Which goal should we focus on?", + subjectKind: "goal", + subjectId: "goal-1", + options: [[ + "optionId": "focus-goal-1", + "label": "Keep this goal", + "preparedAnswer": "Keep goal 1 as my focus", + ]], + selectedOptionId: "focus-goal-1" + ) + + let encoded = try XCTUnwrap(ChatContentBlockCodec.encode([selected])) + let restoredBlocks = try XCTUnwrap(ChatContentBlockCodec.decode(encoded)) + let restored = try XCTUnwrap(restoredBlocks.first) + guard case .questionCard(_, _, _, _, _, _, let selectedOptionID) = restored else { + return XCTFail("question selection receipt should survive replay") + } + XCTAssertEqual(selectedOptionID, "focus-goal-1") + } + func testUnknownPersistedBlockDoesNotDropRecognizedNeighbors() throws { let restored = ChatContentBlockCodec.decode([ ["type": "text", "id": "before", "text": "Before"], @@ -138,12 +163,43 @@ final class ChatFirstRichBlockTests: XCTestCase { ) } - private func task(id: String, completed: Bool) -> TaskActionItem { + func testTaskCaptureLinksFailClosedOutsideTheOmiDeviceArchive() { + let omiCaptureTask = task( + id: "omi-task", + completed: false, + conversationID: "omi-capture", + source: "transcription:omi" + ) + let desktopCaptureTask = task( + id: "desktop-task", + completed: false, + conversationID: "desktop-conversation", + source: "transcription:desktop" + ) + let unknownCaptureTask = task( + id: "unknown-task", + completed: false, + conversationID: "unknown-conversation" + ) + + XCTAssertEqual(ChatFirstCaptureLinkPolicy.captureID(for: omiCaptureTask), "omi-capture") + XCTAssertNil(ChatFirstCaptureLinkPolicy.captureID(for: desktopCaptureTask)) + XCTAssertNil(ChatFirstCaptureLinkPolicy.captureID(for: unknownCaptureTask)) + } + + private func task( + id: String, + completed: Bool, + conversationID: String? = nil, + source: String? = nil + ) -> TaskActionItem { TaskActionItem( id: id, description: "Draft the plan", completed: completed, - createdAt: Date(timeIntervalSince1970: 0) + createdAt: Date(timeIntervalSince1970: 0), + conversationId: conversationID, + source: source ) } } diff --git a/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift b/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift new file mode 100644 index 00000000000..62aa2c464a2 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift @@ -0,0 +1,79 @@ +import XCTest + +@testable import Omi_Computer + +final class ChatFirstTasksPageTests: XCTestCase { + func testScheduleGroupingKeepsOverdueAndTodayWorkTogether() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let now = Date(timeIntervalSince1970: 1_719_115_200) // 2024-06-15 00:00 UTC + let overdue = task(id: "overdue", dueAt: now.addingTimeInterval(-1)) + let today = task(id: "today", dueAt: now.addingTimeInterval(12 * 60 * 60)) + let tomorrow = task(id: "tomorrow", dueAt: now.addingTimeInterval(24 * 60 * 60)) + let unscheduled = task(id: "unscheduled", dueAt: nil) + + XCTAssertEqual(ChatFirstTaskPagePolicy.scheduleGroup(for: overdue, now: now, calendar: calendar), .today) + XCTAssertEqual(ChatFirstTaskPagePolicy.scheduleGroup(for: today, now: now, calendar: calendar), .today) + XCTAssertEqual(ChatFirstTaskPagePolicy.scheduleGroup(for: tomorrow, now: now, calendar: calendar), .later) + XCTAssertEqual(ChatFirstTaskPagePolicy.scheduleGroup(for: unscheduled, now: now, calendar: calendar), .later) + } + + func testGoalGroupingAndBadgesAvoidRepeatedGoalAffordances() { + let first = task( + id: "first", + goalID: "goal-a", + conversationID: "capture-a", + source: "transcription:omi" + ) + let second = task(id: "second", goalID: "goal-a") + let standalone = task(id: "standalone") + let desktopCapture = task( + id: "desktop-capture", + conversationID: "desktop-conversation", + source: "transcription:desktop" + ) + + let groups = ChatFirstTaskPagePolicy.groupedByGoal([first, second, standalone]) + XCTAssertEqual(groups.count, 2) + XCTAssertEqual(Set(groups.first(where: { $0.goalID == "goal-a" })?.tasks.map(\.id) ?? []), Set(["first", "second"])) + XCTAssertEqual(ChatFirstTaskPagePolicy.badges(for: first), .init(goalID: "goal-a", captureID: "capture-a")) + XCTAssertEqual(ChatFirstTaskPagePolicy.badges(for: standalone), .init(goalID: nil, captureID: nil)) + XCTAssertEqual( + ChatFirstTaskPagePolicy.badges(for: desktopCapture), + .init(goalID: nil, captureID: nil), + "only device-originated Omi tasks may deep-link into the strict capture archive" + ) + } + + func testTaskFocusAcknowledgesOnlyTheVisiblePendingTask() { + let requested = ChatFirstPendingFocus.task(id: "task-a") + + XCTAssertEqual( + ChatFirstTaskPagePolicy.focusToAcknowledge(pendingFocus: requested, visibleTaskID: "task-a"), + requested) + XCTAssertNil( + ChatFirstTaskPagePolicy.focusToAcknowledge(pendingFocus: requested, visibleTaskID: "task-b")) + XCTAssertNil( + ChatFirstTaskPagePolicy.focusToAcknowledge( + pendingFocus: .goal(id: "goal-a"), + visibleTaskID: "task-a")) + } + + private func task( + id: String, + dueAt: Date? = nil, + goalID: String? = nil, + conversationID: String? = nil, + source: String? = nil + ) -> TaskActionItem { + TaskActionItem( + id: id, + description: id, + completed: false, + createdAt: Date(timeIntervalSince1970: 0), + dueAt: dueAt, + conversationId: conversationID, + source: source, + goalId: goalID) + } +} diff --git a/desktop/macos/Desktop/Tests/TasksStoreOwnerBoundaryTests.swift b/desktop/macos/Desktop/Tests/TasksStoreOwnerBoundaryTests.swift index ece22508b11..e879cd2858e 100644 --- a/desktop/macos/Desktop/Tests/TasksStoreOwnerBoundaryTests.swift +++ b/desktop/macos/Desktop/Tests/TasksStoreOwnerBoundaryTests.swift @@ -618,6 +618,169 @@ final class TasksStoreOwnerBoundaryTests: XCTestCase { XCTAssertEqual(store.incompleteTasks.map(\.id), [ownerBSentinel.id]) } + @MainActor + func testChatFirstUpdateRollsBackRejectedRenameThroughStoreSeam() async { + let store = TasksStore.shared + await prepareOwnerBoundaryTest(store: store) + + let original = task(id: "owner-a-chat-first-update") + let optimistic = TaskActionItem( + id: original.id, + description: "Renamed task", + completed: false, + createdAt: original.createdAt) + store.incompleteTasks = [original] + let probe = TasksStoreOperationProbe() + + let outcome = await store.updateTask( + original, + description: optimistic.description, + remoteFailureBehavior: .rollbackForChatFirst, + operationOverrides: TasksStore.TaskUpdateOperationOverrides( + updateLocal: { ownerID in + XCTAssertEqual(ownerID, "owner-a") + probe.localWrites += 1 + return optimistic + }, + updateRemote: { ownerID in + XCTAssertEqual(ownerID, "owner-a") + probe.remoteRequests += 1 + throw TasksStoreOwnerBoundaryFailure.backendRejected + }, + syncRemote: { _, _ in probe.remoteSyncs += 1 }, + rollbackLocal: { probe.rollbacks += 1 } + ) + ) + + XCTAssertEqual(outcome, .rolledBackAfterRemoteFailure) + XCTAssertEqual(probe.localWrites, 1) + XCTAssertEqual(probe.remoteRequests, 1) + XCTAssertEqual(probe.remoteSyncs, 0) + XCTAssertEqual(probe.rollbacks, 1) + XCTAssertEqual(store.incompleteTasks, [original]) + } + + @MainActor + func testLegacyUpdatePreservesItsLocalEditAfterRemoteFailure() async { + let store = TasksStore.shared + await prepareOwnerBoundaryTest(store: store) + + let original = task(id: "owner-a-legacy-update") + let optimistic = TaskActionItem( + id: original.id, + description: "Locally renamed task", + completed: false, + createdAt: original.createdAt) + store.incompleteTasks = [original] + let probe = TasksStoreOperationProbe() + + let outcome = await store.updateTask( + original, + description: optimistic.description, + operationOverrides: TasksStore.TaskUpdateOperationOverrides( + updateLocal: { _ in + probe.localWrites += 1 + return optimistic + }, + updateRemote: { _ in + probe.remoteRequests += 1 + throw TasksStoreOwnerBoundaryFailure.backendRejected + }, + syncRemote: { _, _ in probe.remoteSyncs += 1 }, + rollbackLocal: { probe.rollbacks += 1 } + ) + ) + + XCTAssertEqual(outcome, .preservedLocalAfterRemoteFailure) + XCTAssertEqual(probe.localWrites, 1) + XCTAssertEqual(probe.remoteRequests, 1) + XCTAssertEqual(probe.remoteSyncs, 0) + XCTAssertEqual(probe.rollbacks, 0) + XCTAssertEqual(store.incompleteTasks, [optimistic]) + } + + @MainActor + func testChatFirstUpdateCannotRollBackIntoReplacementOwner() async { + let defaults = UserDefaults.standard + let store = TasksStore.shared + await prepareOwnerBoundaryTest(store: store) + + let original = task(id: "owner-a-chat-first-owner-update") + let optimistic = TaskActionItem( + id: original.id, + description: "Renamed task", + completed: false, + createdAt: original.createdAt) + let replacement = task(id: "owner-b-chat-first-sentinel") + store.incompleteTasks = [original] + let gate = TasksStorePauseGate() + let probe = TasksStoreOperationProbe() + + let operation = Task { @MainActor in + await store.updateTask( + original, + description: optimistic.description, + remoteFailureBehavior: .rollbackForChatFirst, + operationOverrides: TasksStore.TaskUpdateOperationOverrides( + updateLocal: { _ in + probe.localWrites += 1 + return optimistic + }, + updateRemote: { _ in + probe.remoteRequests += 1 + await gate.pause() + throw TasksStoreOwnerBoundaryFailure.backendRejected + }, + syncRemote: { _, _ in probe.remoteSyncs += 1 }, + rollbackLocal: { probe.rollbacks += 1 } + ) + ) + } + await gate.waitUntilStarted() + illegallyMutateOwnerDefaults(to: "owner-b", defaults: defaults) + store.incompleteTasks = [replacement] + await gate.release() + let outcome = await operation.value + + XCTAssertEqual(outcome, .ownerChanged) + XCTAssertEqual(probe.localWrites, 1) + XCTAssertEqual(probe.remoteRequests, 1) + XCTAssertEqual(probe.remoteSyncs, 0) + XCTAssertEqual(probe.rollbacks, 0) + XCTAssertEqual(store.incompleteTasks, [replacement]) + XCTAssertNil(store.error) + } + + @MainActor + func testChatFirstUpdateReportsRollbackFailureWithoutPretendingTheOwnerChanged() async { + let store = TasksStore.shared + await prepareOwnerBoundaryTest(store: store) + + let original = task(id: "owner-a-chat-first-rollback-failure") + let optimistic = TaskActionItem( + id: original.id, + description: "Renamed task", + completed: false, + createdAt: original.createdAt) + store.incompleteTasks = [original] + + let outcome = await store.updateTask( + original, + description: optimistic.description, + remoteFailureBehavior: .rollbackForChatFirst, + operationOverrides: TasksStore.TaskUpdateOperationOverrides( + updateLocal: { _ in optimistic }, + updateRemote: { _ in throw TasksStoreOwnerBoundaryFailure.backendRejected }, + syncRemote: { _, _ in }, + rollbackLocal: { throw TasksStoreOwnerBoundaryFailure.backendRejected } + ) + ) + + XCTAssertEqual(outcome, .rollbackFailed) + XCTAssertEqual(store.incompleteTasks, [optimistic]) + XCTAssertEqual(store.error, "backend rejected") + } + @MainActor private func task(id: String, completed: Bool = false) -> TaskActionItem { TaskActionItem( diff --git a/desktop/macos/agent/src/index.ts b/desktop/macos/agent/src/index.ts index 54af60b8e30..4979d0c8d38 100644 --- a/desktop/macos/agent/src/index.ts +++ b/desktop/macos/agent/src/index.ts @@ -59,10 +59,12 @@ import type { JournalListTurnsMessage, JournalClearTurnsMessage, AppendChatFirstBlocksMessage, + RecordQuestionInteractionReplyMessage, EnsureAgentSpawnJournalMessage, JournalBackendSyncResultMessage, JournalBackendDeleteResultMessage, JournalBackendReconcileResultMessage, + ChatFirstDeferralDeliveryResultMessage, RefreshOwnerMessage, RevokeOwnerRuntimeMessage, RefreshTokenMessage, @@ -128,6 +130,7 @@ import { classifyBackendTurnResultDisposition, drainBackendConversationDeleteOutbox, drainBackendTurnOutbox, + drainChatFirstDeferralOutbox, failBackendConversationDeleteOutbox, failBackendReconcile, failBackendTurnOutbox, @@ -136,10 +139,12 @@ import { importRemoteJournalTurn, listJournalTurns, recordJournalExchange, + recordQuestionInteractionReply, recordJournalTurn, settleClearedBackendTurnClaim, assertPublicJournalUpdatePolicy, terminalizeJournalTurn, + settleChatFirstDeferralOutbox, updateJournalTurn, } from "./runtime/conversation-journal.js"; import { DirectControlExecutionBroker } from "./runtime/direct-control-execution.js"; @@ -1559,6 +1564,33 @@ async function main(): Promise { payloadHash: delivery.payloadHash, }); } + // This deliberately remains distinct from backend_turn_outbox: a + // deferral is task-intelligence state, never a second transcript write. + // Do not even claim an outbox row until the server-sampled Main Chat + // capability is present in this process. A fresh capability-off launch + // must leave chat-first background work entirely dormant. + if (kernel.hasChatFirstMainCapability(activeOwnerId)) { + for (const delivery of drainChatFirstDeferralOutbox(store, { ownerId: activeOwnerId, limit: 20 })) { + send({ + type: "chat_first_deferral_delivery", + requestId: `chat-first-deferral:${delivery.continuityKey}:${delivery.deliveryGeneration}`, + clientId: "kernel-chat-first", + ownerId: delivery.ownerId, + continuityKey: delivery.continuityKey, + controlGeneration: delivery.controlGeneration, + subject: delivery.subject, + question: { + questionId: delivery.question.questionId, + text: delivery.question.text, + subject: delivery.question.subject, + options: delivery.question.options, + }, + attemptCount: delivery.attemptCount, + deliveryGeneration: delivery.deliveryGeneration, + payloadHash: delivery.payloadHash, + }); + } + } } catch (error) { logErr(`Journal outbox pump failed: ${error}`); } finally { @@ -2484,6 +2516,93 @@ async function main(): Promise { break; } + case "record_question_interaction_reply": { + const request = msg as RecordQuestionInteractionReplyMessage; + try { + const ownerId = resolveActiveOwner(request.ownerId); + if ( + typeof request.sessionId !== "string" || !request.sessionId.trim() + || typeof request.questionId !== "string" || !request.questionId.trim() + || typeof request.optionId !== "string" || !request.optionId.trim() + || !Number.isSafeInteger(request.controlGeneration) || request.controlGeneration < 0 + ) { + throw new Error("Question interaction request is invalid"); + } + kernel.assertChatFirstMainCapability(request.sessionId, ownerId, request.controlGeneration); + const receipt = recordQuestionInteractionReply(store, { + ownerId, + sessionId: request.sessionId, + questionId: request.questionId, + optionId: request.optionId, + controlGeneration: request.controlGeneration, + }); + const conversationId = receipt.parentTurn?.conversationId + ?? store.getOptionalRow( + `SELECT conversation_id FROM surface_conversations + WHERE owner_id = ? AND agent_session_id = ? AND surface_kind = 'main_chat' + ORDER BY last_active_at_ms DESC LIMIT 1`, + [ownerId, request.sessionId], + )?.conversation_id; + if (typeof conversationId !== "string" || !conversationId) { + throw new Error("Question interaction has no canonical main Chat conversation"); + } + const surface = store.getRow( + `SELECT external_ref_kind, external_ref_id FROM surface_conversations + WHERE owner_id = ? AND agent_session_id = ? AND surface_kind = 'main_chat' + ORDER BY last_active_at_ms DESC LIMIT 1`, + [ownerId, request.sessionId], + ); + const range = listJournalTurns(store, { + ownerId, + conversationId, + afterTurnSeq: 0, + limit: 1, + }); + send({ + type: "journal_operation_result", + protocolVersion: request.protocolVersion, + requestId: request.requestId, + clientId: request.clientId, + operation: "record_question_interaction_reply", + conversationId, + surfaceKind: "main_chat", + externalRefKind: String(surface.external_ref_kind), + externalRefId: String(surface.external_ref_id), + turn: receipt.parentTurn ? journalTurnProjection(receipt.parentTurn) : undefined, + turns: [receipt.userTurn, receipt.assistantTurn] + .filter((turn): turn is ConversationTurn => turn !== null) + .map(journalTurnProjection), + clearedCount: 0, + highWaterTurnSeq: range.highWaterTurnSeq, + generationBaseTurnSeq: range.generationBaseTurnSeq, + conversationGeneration: range.generation, + accepted: receipt.accepted, + duplicate: receipt.duplicate, + continuityKey: receipt.continuityKey, + }); + if (receipt.accepted && !receipt.duplicate) { + for (const turn of [receipt.parentTurn, receipt.userTurn, receipt.assistantTurn]) { + if (!turn) continue; + for (const wake of journalTurnChangedWakes(store, ownerId, turn)) { + send({ type: "journal_turn_changed", ...wake, turn: journalTurnProjection(wake.turn) }); + } + } + pumpJournalOutbox(); + } + } catch (error) { + const envelope = runtimeErrorEnvelope(error); + send({ + type: "error", + protocolVersion: request.protocolVersion, + requestId: request.requestId, + clientId: request.clientId, + message: envelope.message, + failure: envelope.failure, + }); + } + break; + } + case "journal_terminalize_turn": { const request = msg as JournalTerminalizeTurnMessage; try { @@ -3061,6 +3180,32 @@ async function main(): Promise { break; } + case "chat_first_deferral_delivery_result": { + const result = msg as ChatFirstDeferralDeliveryResultMessage; + const ownerId = resolveActiveOwner(result.ownerId); + if ( + typeof result.continuityKey !== "string" || !result.continuityKey + || !Number.isSafeInteger(result.deliveryGeneration) || result.deliveryGeneration <= 0 + || typeof result.payloadHash !== "string" || !result.payloadHash + || typeof result.ok !== "boolean" + ) { + throw new Error("Chat-first deferral delivery result is invalid"); + } + const settled = settleChatFirstDeferralOutbox(store, { + ownerId, + continuityKey: result.continuityKey, + deliveryGeneration: result.deliveryGeneration, + payloadHash: result.payloadHash, + ok: result.ok, + errorCode: result.errorCode, + }); + if (!settled) { + logErr(`Ignoring stale chat-first deferral delivery result key=${result.continuityKey}`); + } + pumpJournalOutbox(); + break; + } + case "refresh_token": { const rtm = msg as RefreshTokenMessage; const transition = authorizeRuntimeTokenRefresh( diff --git a/desktop/macos/agent/src/protocol.ts b/desktop/macos/agent/src/protocol.ts index a2b6860f3a0..5f27da575aa 100644 --- a/desktop/macos/agent/src/protocol.ts +++ b/desktop/macos/agent/src/protocol.ts @@ -387,6 +387,16 @@ export interface AppendChatFirstBlocksMessage extends ProtocolEnvelope { blocks: unknown[]; } +/** Kernel-owned selection for one persisted, tail-actionable question card. */ +export interface RecordQuestionInteractionReplyMessage extends ProtocolEnvelope { + type: "record_question_interaction_reply"; + ownerId: string; + sessionId: string; + questionId: string; + optionId: string; + controlGeneration: number; +} + export interface EnsureAgentSpawnJournalMessage extends ProtocolEnvelope { type: "ensure_agent_spawn_journal"; ownerId: string; @@ -434,6 +444,17 @@ export interface JournalBackendReconcileResultMessage extends ProtocolEnvelope { errorCode?: string; } +/** Swift's physical transport result for the separate deferral outbox. */ +export interface ChatFirstDeferralDeliveryResultMessage extends ProtocolEnvelope { + type: "chat_first_deferral_delivery_result"; + ownerId: string; + continuityKey: string; + deliveryGeneration: number; + payloadHash: string; + ok: boolean; + errorCode?: string; +} + /** Swift pushes a refreshed Firebase ID token to the bridge (piMono mode) */ export interface RefreshTokenMessage { type: "refresh_token"; @@ -474,10 +495,12 @@ export type InboundMessage = | JournalListTurnsMessage | JournalClearTurnsMessage | AppendChatFirstBlocksMessage + | RecordQuestionInteractionReplyMessage | EnsureAgentSpawnJournalMessage | JournalBackendSyncResultMessage | JournalBackendDeleteResultMessage | JournalBackendReconcileResultMessage + | ChatFirstDeferralDeliveryResultMessage | RefreshTokenMessage | RefreshOwnerMessage; @@ -486,6 +509,7 @@ const INBOUND_RESPONSE_MESSAGE_TYPES = new Set([ "journal_backend_sync_result", "journal_backend_delete_result", "journal_backend_reconcile_result", + "chat_first_deferral_delivery_result", ]); /** Response handlers log invalid replies locally; they never echo request errors back to Swift. */ @@ -902,7 +926,7 @@ export interface AgentSpawnJournalEnsuredMessage extends OutboundEnvelope { export interface JournalOperationResultMessage extends OutboundEnvelope { type: "journal_operation_result"; - operation: "record" | "record_exchange" | "import_remote" | "update" | "list" | "clear" | "append_chat_first_blocks"; + operation: "record" | "record_exchange" | "import_remote" | "update" | "list" | "clear" | "append_chat_first_blocks" | "record_question_interaction_reply"; conversationId: string; surfaceKind: string; externalRefKind: string; @@ -914,6 +938,9 @@ export interface JournalOperationResultMessage extends OutboundEnvelope { generationBaseTurnSeq: number; conversationGeneration: number; backendDeleteOperationId?: string; + accepted?: boolean; + duplicate?: boolean; + continuityKey?: string | null; } export interface JournalTurnChangedMessage extends OutboundEnvelope { @@ -974,6 +1001,23 @@ export interface JournalBackendReconcileMessage extends OutboundEnvelope { pageLimit: number; } +export interface ChatFirstDeferralDeliveryMessage extends OutboundEnvelope { + type: "chat_first_deferral_delivery"; + ownerId: string; + continuityKey: string; + controlGeneration: number; + subject: { kind: "task" | "goal" | "capture"; id: string }; + question: { + questionId: string; + text: string; + subject: { kind: "task" | "goal" | "capture"; id: string }; + options: Array<{ optionId: string; label: string; preparedAnswer: string; defer?: boolean }>; + }; + attemptCount: number; + deliveryGeneration: number; + payloadHash: string; +} + export type OutboundMessage = | InitMessage | TextDeltaMessage @@ -1003,7 +1047,8 @@ export type OutboundMessage = | JournalTurnChangedMessage | JournalBackendSyncMessage | JournalBackendDeleteMessage - | JournalBackendReconcileMessage; + | JournalBackendReconcileMessage + | ChatFirstDeferralDeliveryMessage; type OutboundWithEnvelope = Exclude; @@ -1039,7 +1084,8 @@ export type OutboundMessageDraft = | DraftEnvelope | DraftEnvelope | DraftEnvelope - | DraftEnvelope; + | DraftEnvelope + | DraftEnvelope; export function ensureOutboundProtocolVersion(message: OutboundMessageDraft): OutboundMessage { if (message.type === "auth_required" || message.type === "auth_success") { diff --git a/desktop/macos/agent/src/runtime/conversation-journal.ts b/desktop/macos/agent/src/runtime/conversation-journal.ts index 82232c37be7..4609f8501cb 100644 --- a/desktop/macos/agent/src/runtime/conversation-journal.ts +++ b/desktop/macos/agent/src/runtime/conversation-journal.ts @@ -108,6 +108,41 @@ export interface AppendChatFirstBlocksInput { blocks: readonly ConversationContentBlock[]; } +/** + * The sole durable answer path for a chat-first question card. Swift supplies + * only opaque IDs; this journal transaction re-derives the prepared answer, + * subject, and exact tail parent from persisted canonical block data. + */ +export interface RecordQuestionInteractionReplyInput { + ownerId: string; + sessionId: string; + questionId: string; + optionId: string; + controlGeneration: number; + nowMs?: number; +} + +export interface QuestionInteractionReplyReceipt { + accepted: boolean; + duplicate: boolean; + continuityKey: string | null; + parentTurn: ConversationTurn | null; + userTurn: ConversationTurn | null; + assistantTurn: ConversationTurn | null; +} + +export interface ChatFirstDeferralDelivery { + continuityKey: string; + ownerId: string; + conversationId: string; + controlGeneration: number; + subject: { kind: "task" | "goal" | "capture"; id: string }; + question: Extract; + payloadHash: string; + attemptCount: number; + deliveryGeneration: number; +} + export interface TerminalizeJournalTurnInput { ownerId: string; conversationId: string; @@ -680,6 +715,230 @@ export function appendChatFirstBlocksToProducingTurn( }); } +/** + * Select a tail question option, journal its exact ordinary user answer and + * hidden streaming target, then (only for Ask me later) enqueue canonical + * deferral delivery. Every mutation happens under one SQLite transaction so a + * process death can expose neither a half-selection nor a duplicate reply. + */ +export function recordQuestionInteractionReply( + store: AgentStore, + input: RecordQuestionInteractionReplyInput, +): QuestionInteractionReplyReceipt { + const questionId = nonEmpty(input.questionId, "question ID"); + const optionId = nonEmpty(input.optionId, "question option ID"); + if (!Number.isSafeInteger(input.controlGeneration) || input.controlGeneration < 0) { + throw new Error("Question interaction requires a valid control generation"); + } + const now = input.nowMs ?? Date.now(); + return store.withTransaction(() => { + const surface = store.getOptionalRow( + `SELECT conversation_id + FROM surface_conversations + WHERE owner_id = ? AND agent_session_id = ? AND surface_kind = 'main_chat' + ORDER BY last_active_at_ms DESC LIMIT 1`, + [input.ownerId, input.sessionId], + ); + if (!surface) throw new Error("Question interaction requires the current owner-bound main Chat session"); + const conversationId = String(surface.conversation_id); + assertConversationOwner(store, conversationId, input.ownerId); + + // A completed selection is the idempotency receipt. It is intentionally + // checked before tail actionability because retry arrives after its new + // assistant placeholder has become the tail. + const selected = findSelectedQuestionBlock(store, conversationId, questionId); + if (selected) { + const continuityKey = questionInteractionContinuityKey(questionId, selected.optionId); + const childTurns = questionInteractionTurns(store, conversationId, continuityKey); + if (selected.optionId === optionId && childTurns.user && childTurns.assistant) { + return { + accepted: true, + duplicate: true, + continuityKey, + parentTurn: selected.turn, + userTurn: childTurns.user, + assistantTurn: childTurns.assistant, + }; + } + return emptyQuestionInteractionReceipt(); + } + + const tailRow = store.getOptionalRow( + `SELECT turn_id FROM conversation_turns + WHERE conversation_id = ? ORDER BY turn_seq DESC LIMIT 1`, + [conversationId], + ); + if (!tailRow) return emptyQuestionInteractionReceipt(); + const parent = requireJournalTurn(store, conversationId, String(tailRow.turn_id)); + if (parent.role !== "assistant" || parent.status !== "completed") { + return emptyQuestionInteractionReceipt(); + } + const questionIndex = parent.contentBlocks.findIndex((block) => ( + block.type === "questionCard" && block.questionId === questionId && block.selectedOptionId === undefined + )); + if (questionIndex < 0) return emptyQuestionInteractionReceipt(); + const question = parent.contentBlocks[questionIndex] as Extract; + const option = question.options.find((candidate) => candidate.optionId === optionId); + if (!option) return emptyQuestionInteractionReceipt(); + + const continuityKey = questionInteractionContinuityKey(questionId, optionId); + const selectedQuestion: Extract = { + ...structuredClone(question), + selectedOptionId: optionId, + }; + const selectedBlocks = parent.contentBlocks.map((block, index) => ( + index === questionIndex ? selectedQuestion : structuredClone(block) + )); + const selectedParent = updateJournalTurn(store, { + ownerId: input.ownerId, + conversationId, + turnId: parent.turnId, + replaceContentBlocks: selectedBlocks, + nowMs: now, + }); + + const userTurnId = stableQuestionInteractionTurnID(continuityKey, "user"); + const assistantTurnId = stableQuestionInteractionTurnID(continuityKey, "assistant"); + const exchange = recordJournalExchange(store, { + ownerId: input.ownerId, + conversationId, + turns: [ + { + turnId: userTurnId, + role: "user", + surfaceKind: "main_chat", + origin: "typed_chat", + status: "completed", + content: option.preparedAnswer, + contentBlocks: [{ type: "text", id: `${userTurnId}:text`, text: option.preparedAnswer }], + metadataJson: JSON.stringify({ continuityKey }), + createdAtMs: now, + }, + { + turnId: assistantTurnId, + role: "assistant", + surfaceKind: "main_chat", + origin: "typed_chat", + status: "streaming", + content: "", + contentBlocks: [], + metadataJson: JSON.stringify({ continuityKey, hiddenUntilOutput: true }), + createdAtMs: now + 1, + }, + ], + }); + const userTurn = exchange.turns.find((turn) => turn.turnId === userTurnId) ?? null; + const assistantTurn = exchange.turns.find((turn) => turn.turnId === assistantTurnId) ?? null; + if (!userTurn || !assistantTurn) throw new Error("Question interaction did not record both journal turns"); + + if (option.defer === true) { + enqueueChatFirstDeferral(store, { + ownerId: input.ownerId, + conversationId, + controlGeneration: input.controlGeneration, + continuityKey, + question: selectedQuestion, + nowMs: now, + }); + } + return { + accepted: true, + duplicate: false, + continuityKey, + parentTurn: selectedParent, + userTurn, + assistantTurn, + }; + }); +} + +/** Claim a bounded batch from the deferral-only transport. */ +export function drainChatFirstDeferralOutbox( + store: AgentStore, + input: { ownerId: string; limit?: number; nowMs?: number }, +): ChatFirstDeferralDelivery[] { + const now = input.nowMs ?? Date.now(); + const limit = Math.max(1, Math.min(input.limit ?? 20, 50)); + return store.withTransaction(() => { + const rows = store.allRows( + `SELECT continuity_key, owner_id, conversation_id, control_generation, subject_kind, subject_id, + question_json, payload_hash, attempt_count, delivery_generation + FROM chat_first_deferral_outbox + WHERE owner_id = ? AND status IN ('pending', 'retrying') AND available_at_ms <= ? + ORDER BY created_at_ms ASC LIMIT ?`, + [input.ownerId, now, limit], + ); + return rows.map((row) => { + const continuityKey = String(row.continuity_key); + const deliveryGeneration = Number(row.delivery_generation) + 1; + const attemptCount = Number(row.attempt_count) + 1; + store.execute( + `UPDATE chat_first_deferral_outbox + SET status = 'delivering', attempt_count = ?, delivery_generation = ?, + lease_expires_at_ms = ?, updated_at_ms = ? + WHERE continuity_key = ? AND owner_id = ?`, + [attemptCount, deliveryGeneration, now + DEFAULT_OUTBOX_LEASE_MS, now, continuityKey, input.ownerId], + ); + const question = JSON.parse(String(row.question_json)) as Extract; + return { + continuityKey, + ownerId: String(row.owner_id), + conversationId: String(row.conversation_id), + controlGeneration: Number(row.control_generation), + subject: { kind: String(row.subject_kind) as "task" | "goal" | "capture", id: String(row.subject_id) }, + question, + payloadHash: String(row.payload_hash), + attemptCount, + deliveryGeneration, + }; + }); + }); +} + +/** Settle only the current claimed delivery generation; stale replies are ignored. */ +export function settleChatFirstDeferralOutbox( + store: AgentStore, + input: { + ownerId: string; + continuityKey: string; + deliveryGeneration: number; + payloadHash: string; + ok: boolean; + errorCode?: string; + nowMs?: number; + }, +): boolean { + const now = input.nowMs ?? Date.now(); + return store.withTransaction(() => { + const row = store.getOptionalRow( + `SELECT attempt_count FROM chat_first_deferral_outbox + WHERE continuity_key = ? AND owner_id = ? AND delivery_generation = ? + AND payload_hash = ? AND status = 'delivering'`, + [input.continuityKey, input.ownerId, input.deliveryGeneration, input.payloadHash], + ); + if (!row) return false; + const attempts = Number(row.attempt_count); + if (input.ok) { + store.execute( + `UPDATE chat_first_deferral_outbox + SET status = 'delivered', lease_expires_at_ms = NULL, last_error_code = NULL, + delivered_at_ms = ?, updated_at_ms = ? WHERE continuity_key = ?`, + [now, now, input.continuityKey], + ); + } else { + const retryable = !input.errorCode?.endsWith("_4xx") && attempts < 5; + store.execute( + `UPDATE chat_first_deferral_outbox + SET status = ?, available_at_ms = ?, lease_expires_at_ms = NULL, + last_error_code = ?, updated_at_ms = ? WHERE continuity_key = ?`, + [retryable ? "retrying" : "failed", retryable ? now + Math.min(30_000, attempts * 1_000) : now, + boundedOutboxError(input.errorCode), now, input.continuityKey], + ); + } + return true; + }); +} + export function validateProducingJournalTurnAdmission( store: AgentStore, input: ProducingJournalTurnAdmissionInput, @@ -2591,6 +2850,127 @@ function journalDeliveryForSurface(surfaceKind: string): JournalDeliveryDestinat return LOCAL_ONLY_SURFACES.has(surfaceKind) ? "local" : "backend"; } +function emptyQuestionInteractionReceipt(): QuestionInteractionReplyReceipt { + return { + accepted: false, + duplicate: false, + continuityKey: null, + parentTurn: null, + userTurn: null, + assistantTurn: null, + }; +} + +function questionInteractionContinuityKey(questionId: string, optionId: string): string { + return `qri_${createHash("sha256").update(`${questionId}\u0000${optionId}`).digest("hex").slice(0, 32)}`; +} + +function stableQuestionInteractionTurnID(continuityKey: string, role: "user" | "assistant"): string { + return `turn_${createHash("sha256").update(`${continuityKey}\u0000${role}`).digest("hex").slice(0, 16)}`; +} + +function findSelectedQuestionBlock( + store: AgentStore, + conversationId: string, + questionId: string, +): { turn: ConversationTurn; optionId: string } | null { + const rows = store.allRows( + `SELECT turn_id FROM conversation_turns + WHERE conversation_id = ? AND role = 'assistant' ORDER BY turn_seq DESC`, + [conversationId], + ); + for (const row of rows) { + const turn = requireJournalTurn(store, conversationId, String(row.turn_id)); + const block = turn.contentBlocks.find((candidate) => ( + candidate.type === "questionCard" + && candidate.questionId === questionId + && typeof candidate.selectedOptionId === "string" + && candidate.selectedOptionId.length > 0 + )); + if (block?.type === "questionCard" && block.selectedOptionId) { + return { turn, optionId: block.selectedOptionId }; + } + } + return null; +} + +function questionInteractionTurns( + store: AgentStore, + conversationId: string, + continuityKey: string, +): { user: ConversationTurn | null; assistant: ConversationTurn | null } { + const user = store.getOptionalRow( + "SELECT turn_id FROM conversation_turns WHERE conversation_id = ? AND turn_id = ?", + [conversationId, stableQuestionInteractionTurnID(continuityKey, "user")], + ); + const assistant = store.getOptionalRow( + "SELECT turn_id FROM conversation_turns WHERE conversation_id = ? AND turn_id = ?", + [conversationId, stableQuestionInteractionTurnID(continuityKey, "assistant")], + ); + return { + user: user ? requireJournalTurn(store, conversationId, String(user.turn_id)) : null, + assistant: assistant ? requireJournalTurn(store, conversationId, String(assistant.turn_id)) : null, + }; +} + +function enqueueChatFirstDeferral( + store: AgentStore, + input: { + ownerId: string; + conversationId: string; + controlGeneration: number; + continuityKey: string; + question: Extract; + nowMs: number; + }, +): void { + const question = structuredClone(input.question); + // The backend schema deliberately does not carry renderer state. Selection + // is transcript-local; the verbatim question payload remains canonical. + delete question.selectedOptionId; + const payload = { + controlGeneration: input.controlGeneration, + subject: question.subject, + question, + }; + const payloadHash = sha256(stableJson(payload)); + const existing = store.getOptionalRow( + "SELECT payload_hash FROM chat_first_deferral_outbox WHERE continuity_key = ?", + [input.continuityKey], + ); + if (existing) { + if (String(existing.payload_hash) !== payloadHash) { + throw new Error("Question deferral continuity key was reused with different content"); + } + return; + } + store.execute( + `INSERT INTO chat_first_deferral_outbox( + continuity_key, owner_id, conversation_id, control_generation, subject_kind, subject_id, + question_json, payload_hash, status, attempt_count, delivery_generation, + available_at_ms, lease_expires_at_ms, last_error_code, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, 0, ?, NULL, NULL, ?, ?)`, + [ + input.continuityKey, + input.ownerId, + input.conversationId, + input.controlGeneration, + question.subject.kind, + question.subject.id, + JSON.stringify(question), + payloadHash, + input.nowMs, + input.nowMs, + input.nowMs, + ], + ); +} + +function boundedOutboxError(errorCode?: string): string { + const value = typeof errorCode === "string" ? errorCode.trim() : "chat_first_deferral_failed"; + return (value || "chat_first_deferral_failed").slice(0, 128); +} + function validateContentBlocks(blocks: readonly ConversationContentBlock[]): ConversationContentBlock[] { const ids = new Set(); return blocks.map((block) => { @@ -2617,6 +2997,9 @@ function validateContentBlocks(blocks: readonly ConversationContentBlock[]): Con if (option.defer === true) defers += 1; } if (defers > 1) throw new Error("Question card may contain at most one defer option"); + if (block.selectedOptionId !== undefined && !optionIds.has(block.selectedOptionId)) { + throw new Error("Question card selected option is invalid"); + } } else if (block.type === "taskCard") { nonEmpty(block.taskId, "task ID"); } else if (block.type === "goalLink") { diff --git a/desktop/macos/agent/src/runtime/kernel-sessions.ts b/desktop/macos/agent/src/runtime/kernel-sessions.ts index beedc158775..29ffdf30a67 100644 --- a/desktop/macos/agent/src/runtime/kernel-sessions.ts +++ b/desktop/macos/agent/src/runtime/kernel-sessions.ts @@ -158,6 +158,35 @@ export class KernelSessions extends KernelArtifacts { return session; } + /** + * T08 is not a model-tool invocation, but it still needs the same immutable + * server-derived Main Chat capability that admitted rich blocks. Keeping the + * check here avoids a second Swift or UI-side rollout gate. + */ + assertChatFirstMainCapability(sessionId: string, ownerId: string, controlGeneration: number): void { + this.ownedSession(sessionId, ownerId); + const capability = this.chatFirstCapability(sessionId, ownerId, "main_chat"); + if ( + capability?.chatFirstUi !== true + || capability.controlGeneration !== controlGeneration + ) { + throw new Error("Question interaction requires an enabled current main-Chat capability"); + } + } + + /** + * Background chat-first work is permitted only while this process has an + * immutable, enabled Main Chat projection for the active owner. It is not a + * persisted rollout flag: a fresh capability-off launch therefore leaves + * deferral rows dormant instead of delivering feature work to a legacy user. + */ + hasChatFirstMainCapability(ownerId: string): boolean { + for (const [key, capability] of this.chatFirstCapabilities) { + if (key.startsWith(`${ownerId}:`) && capability.chatFirstUi === true) return true; + } + return false; + } + defaultExecutionProfilePreference(ownerId: string): DefaultExecutionProfilePreference | undefined { return readDefaultExecutionProfilePreference(this.store, ownerId); } diff --git a/desktop/macos/agent/src/runtime/sqlite-store.ts b/desktop/macos/agent/src/runtime/sqlite-store.ts index f41dfeca129..fa145c28f7d 100644 --- a/desktop/macos/agent/src/runtime/sqlite-store.ts +++ b/desktop/macos/agent/src/runtime/sqlite-store.ts @@ -69,6 +69,7 @@ const CLEARED_BACKEND_TURN_CLAIMS_MIGRATION_VERSION = 24; const CONTEXT_SOURCE_SURFACE_SCOPE_MIGRATION_VERSION = 25; const BACKEND_RECONCILE_CURSOR_MIGRATION_VERSION = 26; const JOURNAL_PRODUCING_ATTEMPT_MIGRATION_VERSION = 27; +const CHAT_FIRST_DEFERRAL_OUTBOX_MIGRATION_VERSION = 28; const ACTIVE_ATTEMPT_STATUSES = ["queued", "starting", "running", "waiting_input", "waiting_approval", "cancelling"] as const; const TERMINAL_ATTEMPT_STATUSES = ["succeeded", "failed", "cancelled", "timed_out", "orphaned"] as const; @@ -507,6 +508,9 @@ export class SqliteAgentStore implements AgentStore { if (!this.hasMigration(JOURNAL_PRODUCING_ATTEMPT_MIGRATION_VERSION)) { runJournalProducingAttemptMigration(this.db, this.nowMs()); } + if (!this.hasMigration(CHAT_FIRST_DEFERRAL_OUTBOX_MIGRATION_VERSION)) { + runChatFirstDeferralOutboxMigration(this.db, this.nowMs()); + } } withTransaction(work: () => T): T { @@ -735,6 +739,17 @@ export class SqliteAgentStore implements AgentStore { WHERE status = 'delivering'`, ).run(now, now); } + const requeuedChatFirstDeferralIds = this.allRows( + "SELECT continuity_key FROM chat_first_deferral_outbox WHERE status = 'delivering'", + ).map((row) => text(row.continuity_key)); + if (requeuedChatFirstDeferralIds.length > 0) { + this.db.prepare( + `UPDATE chat_first_deferral_outbox + SET status = 'retrying', available_at_ms = ?, lease_expires_at_ms = NULL, + last_error_code = 'daemon_restart', updated_at_ms = ? + WHERE status = 'delivering'`, + ).run(now, now); + } this.db.prepare( `UPDATE backend_reconcile_state SET in_flight_id = NULL, page_cursor = NULL, page_count = 0, @@ -3140,6 +3155,46 @@ function runJournalProducingAttemptMigration( }); } +/** + * T08 deferrals are a second, intentionally narrow durable transport. They + * are not conversation rows and must never be folded into backend_turn_outbox: + * delivery creates task-intelligence state, not transcript state. + */ +function runChatFirstDeferralOutboxMigration( + db: Pick, + appliedAtMs: number, +): void { + runTransaction(db, () => { + db.exec(` + CREATE TABLE chat_first_deferral_outbox( + continuity_key TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + control_generation INTEGER NOT NULL CHECK (control_generation >= 0), + subject_kind TEXT NOT NULL CHECK (subject_kind IN ('task', 'goal', 'capture')), + subject_id TEXT NOT NULL, + question_json TEXT NOT NULL CHECK (json_valid(question_json)), + payload_hash TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'delivering', 'retrying', 'delivered', 'failed')), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + delivery_generation INTEGER NOT NULL DEFAULT 0 CHECK (delivery_generation >= 0), + available_at_ms INTEGER NOT NULL, + lease_expires_at_ms INTEGER, + last_error_code TEXT CHECK (last_error_code IS NULL OR length(last_error_code) <= 128), + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + delivered_at_ms INTEGER + ) STRICT; + CREATE INDEX chat_first_deferral_outbox_drain_idx + ON chat_first_deferral_outbox(owner_id, status, available_at_ms ASC, created_at_ms ASC); + `); + db.prepare("INSERT INTO schema_migrations (version, applied_at_ms) VALUES (?, ?)").run( + CHAT_FIRST_DEFERRAL_OUTBOX_MIGRATION_VERSION, + appliedAtMs, + ); + }); +} + function runTransaction(db: Pick, work: () => T): T { if (db.isTransaction) { return work(); diff --git a/desktop/macos/agent/src/runtime/types.ts b/desktop/macos/agent/src/runtime/types.ts index ea9ef7f1331..9c4cfd640ab 100644 --- a/desktop/macos/agent/src/runtime/types.ts +++ b/desktop/macos/agent/src/runtime/types.ts @@ -112,6 +112,11 @@ export type ConversationContentBlock = text: string; subject: { kind: "task" | "goal" | "capture"; id: string }; options: Array<{ optionId: string; label: string; preparedAnswer: string; defer?: boolean }>; + /** + * Kernel-owned selection receipt. A question remains readable after an + * answer, but this durable value retires its options on every projection. + */ + selectedOptionId?: string; } | { type: "taskCard"; id: string; taskId: string } | { type: "goalLink"; id: string; goalId: string; summary: string } diff --git a/desktop/macos/agent/tests/chat-first-capability-projection.test.ts b/desktop/macos/agent/tests/chat-first-capability-projection.test.ts index e7edf9427b3..9ff90a9f698 100644 --- a/desktop/macos/agent/tests/chat-first-capability-projection.test.ts +++ b/desktop/macos/agent/tests/chat-first-capability-projection.test.ts @@ -29,6 +29,8 @@ describe("chat-first admitted capability projection", () => { chatFirstUi: true, chatFirstControlGeneration: 19, }); + expect(kernel.hasChatFirstMainCapability("owner")).toBe(true); + expect(kernel.hasChatFirstMainCapability("other-owner")).toBe(false); adapter.deferResult(); const runPromise = kernel.executeRun({ @@ -85,6 +87,7 @@ describe("chat-first admitted capability projection", () => { chatFirstUi: false, chatFirstControlGeneration: null, }); + expect(kernel.hasChatFirstMainCapability("owner")).toBe(false); expect(admittedContextSnapshot.capabilities.allowedToolNames).not.toEqual( expect.arrayContaining(CHAT_FIRST_DYNAMIC_TOOLS), ); diff --git a/desktop/macos/agent/tests/conversation-journal.test.ts b/desktop/macos/agent/tests/conversation-journal.test.ts index 0c9daa331b4..0d7b4d01cf4 100644 --- a/desktop/macos/agent/tests/conversation-journal.test.ts +++ b/desktop/macos/agent/tests/conversation-journal.test.ts @@ -15,6 +15,7 @@ import { classifyBackendTurnResultDisposition, drainBackendConversationDeleteOutbox, drainBackendTurnOutbox, + drainChatFirstDeferralOutbox, failBackendTurnOutbox, failBackendReconcile, getJournalObservability, @@ -25,8 +26,10 @@ import { migrateJournalConversation, recordJournalExchange, recordJournalTurn, + recordQuestionInteractionReply, searchJournalConversation, settleClearedBackendTurnClaim, + settleChatFirstDeferralOutbox, assertPublicJournalUpdatePolicy, terminalizeJournalTurn, updateJournalTurn, @@ -81,6 +84,187 @@ describe("kernel conversation journal", () => { fixture.store.close(); }); + it("atomically records a selected question reply and its hidden pre-admitted continuation", () => { + const fixture = newSurface("main_chat", "chat", "question-select"); + recordTerminalQuestion(fixture, "turn-question", [ + { optionId: "focus", label: "Focus this", preparedAnswer: "Yes, I will focus this goal." }, + { optionId: "later", label: "Ask me later", preparedAnswer: "Ask me again later.", defer: true }, + ]); + + const selected = recordQuestionInteractionReply(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + questionId: "question-1", + optionId: "focus", + controlGeneration: 7, + nowMs: 100, + }); + + expect(selected).toMatchObject({ + accepted: true, + duplicate: false, + userTurn: { + role: "user", + status: "completed", + content: "Yes, I will focus this goal.", + }, + assistantTurn: { + role: "assistant", + status: "streaming", + content: "", + }, + }); + expect(JSON.parse(selected.assistantTurn!.metadataJson)).toMatchObject({ + continuityKey: selected.continuityKey, + hiddenUntilOutput: true, + }); + expect(selected.parentTurn?.contentBlocks).toContainEqual(expect.objectContaining({ + type: "questionCard", + questionId: "question-1", + selectedOptionId: "focus", + })); + expect(listJournalTurns(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + }).turns.map((turn) => turn.role)).toEqual(["assistant", "user", "assistant"]); + + // A retry returns the same canonical rows after its placeholder has become + // the tail; it cannot append a second ordinary user message. + expect(recordQuestionInteractionReply(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + questionId: "question-1", + optionId: "focus", + controlGeneration: 7, + nowMs: 101, + })).toMatchObject({ + accepted: true, + duplicate: true, + continuityKey: selected.continuityKey, + userTurn: { turnId: selected.userTurn?.turnId }, + assistantTurn: { turnId: selected.assistantTurn?.turnId }, + }); + expect(fixture.store.getRow( + "SELECT COUNT(*) AS count FROM conversation_turns WHERE conversation_id = ?", + [fixture.conversationId], + ).count).toBe(3); + fixture.store.close(); + }); + + it("rejects stale or conflicting question selections without mutating the journal", () => { + const fixture = newSurface("main_chat", "chat", "question-reject"); + recordTerminalQuestion(fixture, "turn-question", [ + { optionId: "one", label: "One", preparedAnswer: "Choose one." }, + { optionId: "two", label: "Two", preparedAnswer: "Choose two." }, + ]); + const beforeConflict = journalStorageSnapshot(fixture.store); + expect(recordQuestionInteractionReply(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + questionId: "question-1", + optionId: "missing", + controlGeneration: 1, + })).toMatchObject({ accepted: false }); + expect(journalStorageSnapshot(fixture.store)).toEqual(beforeConflict); + + recordCompletedTextTurn(fixture, "turn-later", "A later assistant bubble", 99); + const beforeTailRejection = journalStorageSnapshot(fixture.store); + expect(recordQuestionInteractionReply(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + questionId: "question-1", + optionId: "one", + controlGeneration: 1, + })).toMatchObject({ accepted: false }); + expect(journalStorageSnapshot(fixture.store)).toEqual(beforeTailRejection); + fixture.store.close(); + }); + + it("rolls back the selected parent when recording either continuation half cannot commit", () => { + const fixture = newSurface("main_chat", "chat", "question-atomic"); + recordTerminalQuestion(fixture, "turn-question", [ + { optionId: "focus", label: "Focus", preparedAnswer: "Focus this goal." }, + ]); + const continuityKey = questionContinuityKey("question-1", "focus"); + const collidingUserTurnID = questionInteractionTurnID(continuityKey, "user"); + const other = insertSurface(fixture.store, "main_chat", "chat", "question-collision"); + // `turn_id` lookup is deliberately global for canonical identity. A prior + // incompatible row makes the child exchange fail after the parent update + // has begun, exercising the outer transaction's rollback boundary. + recordJournalTurn(fixture.store, { + ownerId: other.ownerId, + conversationId: other.conversationId, + turnId: collidingUserTurnID, + role: "user", + surfaceKind: "main_chat", + origin: "typed_chat", + status: "completed", + content: "An incompatible historical turn.", + contentBlocks: [{ type: "text", id: "collision:text", text: "An incompatible historical turn." }], + createdAtMs: 1, + }); + const before = journalStorageSnapshot(fixture.store); + + expect(() => recordQuestionInteractionReply(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + questionId: "question-1", + optionId: "focus", + controlGeneration: 1, + })).toThrow(/different journal content/i); + expect(journalStorageSnapshot(fixture.store)).toEqual(before); + fixture.store.close(); + }); + + it("keeps Ask me later delivery in a durable deferral-only outbox across restart", () => { + const stateDir = newStateDir(); + const store = new SqliteAgentStore({ stateDir, reconcileOnOpen: false }); + const fixture = insertSurface(store, "main_chat", "chat", "question-deferral"); + recordTerminalQuestion(fixture, "turn-question", [ + { optionId: "later", label: "Ask me later", preparedAnswer: "Ask me again later.", defer: true }, + ]); + const selected = recordQuestionInteractionReply(store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + questionId: "question-1", + optionId: "later", + controlGeneration: 11, + nowMs: 200, + }); + const [firstClaim] = drainChatFirstDeferralOutbox(store, { ownerId: fixture.ownerId, nowMs: 201 }); + expect(firstClaim).toMatchObject({ + continuityKey: selected.continuityKey, + controlGeneration: 11, + subject: { kind: "goal", id: "goal-1" }, + question: expect.objectContaining({ questionId: "question-1" }), + }); + expect(fixture.store.getRow("SELECT COUNT(*) AS count FROM chat_first_deferral_outbox").count).toBe(1); + store.close(); + + const reopened = new SqliteAgentStore({ stateDir }); + const [recoveredClaim] = drainChatFirstDeferralOutbox(reopened, { + ownerId: fixture.ownerId, + nowMs: 202, + }); + expect(recoveredClaim).toMatchObject({ + continuityKey: firstClaim.continuityKey, + attemptCount: 2, + }); + expect(settleChatFirstDeferralOutbox(reopened, { + ownerId: fixture.ownerId, + continuityKey: recoveredClaim.continuityKey, + deliveryGeneration: recoveredClaim.deliveryGeneration, + payloadHash: recoveredClaim.payloadHash, + ok: true, + nowMs: 203, + })).toBe(true); + expect(reopened.getRow( + "SELECT status FROM chat_first_deferral_outbox WHERE continuity_key = ?", + [recoveredClaim.continuityKey], + )).toEqual({ status: "delivered" }); + reopened.close(); + }); + it("rejects wrong, non-main, stale, multiple, and missing chat-first targets without mutating journal rows", () => { const block: ConversationContentBlock = { type: "taskCard", id: "cfb-rejected", taskId: "task-1" }; const cases: Array<{ @@ -2736,6 +2920,32 @@ function recordCompletedTextTurn( }); } +function recordTerminalQuestion( + fixture: SurfaceFixture, + turnId: string, + options: Array<{ optionId: string; label: string; preparedAnswer: string; defer?: boolean }>, +): void { + recordJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId, + role: "assistant", + surfaceKind: "main_chat", + origin: "typed_chat", + status: "completed", + content: "Which direction should we take?", + contentBlocks: [{ + type: "questionCard", + id: "question-card-1", + questionId: "question-1", + text: "Which direction should we take?", + subject: { kind: "goal", id: "goal-1" }, + options, + }], + createdAtMs: 50, + }); +} + function insertActiveRunAttempt(fixture: SurfaceFixture, suffix: string) { const run = fixture.store.insertRun({ sessionId: fixture.sessionId, @@ -2807,3 +3017,11 @@ function newStateDir(): string { function newDatabasePath(): string { return join(newStateDir(), "agent.sqlite3"); } + +function questionContinuityKey(questionID: string, optionID: string): string { + return `qri_${createHash("sha256").update(`${questionID}\u0000${optionID}`).digest("hex").slice(0, 32)}`; +} + +function questionInteractionTurnID(continuityKey: string, role: "user" | "assistant"): string { + return `turn_${createHash("sha256").update(`${continuityKey}\u0000${role}`).digest("hex").slice(0, 16)}`; +} diff --git a/desktop/macos/changelog/unreleased/20260715-chat-first-tasks.json b/desktop/macos/changelog/unreleased/20260715-chat-first-tasks.json new file mode 100644 index 00000000000..a774e52a44c --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260715-chat-first-tasks.json @@ -0,0 +1,3 @@ +{ + "change": "Added a focused task checklist with inline planning controls for the chat-first experience" +} From b1af5ae40eadff183323ba6c58acb002f68ddbec Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 14:09:37 -0400 Subject: [PATCH 09/28] feat(chat): materialize proactive intents locally Add the cohort-gated foreground Chat materializer, receipt-safe ordered kernel batch, and normal journal reconciliation for server-owned proactive intents.\n\nVerification: static diff checks plus a fresh Sol review/fix gate completed. Runtime builds, suites, harness, and UI exercise remain deferred to the single final verification pass at user direction. --- .../Desktop/Sources/Chat/AgentBridge.swift | 68 ++++ .../Desktop/Sources/Chat/AgentClient.swift | 47 +++ .../Sources/Chat/AgentRuntimeProcess.swift | 137 ++++++- ...ChatFirstPromptMaterializationDriver.swift | 108 ++++++ .../Blocks/ChatFirstRichBlockContext.swift | 5 +- ...irstPromptMaterializationCoordinator.swift | 126 ++++++ .../MainWindow/ChatFirst/ChatFirstShell.swift | 19 +- .../Sources/MainWindow/Pages/ChatPage.swift | 16 + .../Sources/Providers/ChatProvider.swift | 94 +++++ ...romptMaterializationCoordinatorTests.swift | 50 +++ desktop/macos/agent/src/index.ts | 163 +++++++- desktop/macos/agent/src/protocol.ts | 58 ++- .../agent/src/runtime/conversation-journal.ts | 358 ++++++++++++++++++ .../macos/agent/src/runtime/sqlite-store.ts | 35 ++ .../agent/tests/conversation-journal.test.ts | 116 ++++++ 15 files changed, 1395 insertions(+), 5 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift create mode 100644 desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift diff --git a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift index 727edcf501c..addd25dc866 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift @@ -1387,6 +1387,74 @@ actor AgentBridge { ) } + func materializeChatFirstIntents( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + intents: [ChatFirstPromptIntent], + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil + ) async throws -> AgentRuntimeProcess.ChatFirstIntentsMaterialization { + let authorization = try resolveAuthorization( + authorizationSnapshot, + expectedOwnerID: ownerID) + try await start(authorizationSnapshot: authorization) + return try await runtime.materializeChatFirstIntents( + clientId: clientId, + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration, + intents: intents, + authorizationSnapshot: authorization + ) + } + + func listChatFirstMaterializationReceipts( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil + ) async throws -> [ChatFirstMaterializationReceipt] { + let authorization = try resolveAuthorization( + authorizationSnapshot, + expectedOwnerID: ownerID) + try await start(authorizationSnapshot: authorization) + return try await runtime.listChatFirstMaterializationReceipts( + clientId: clientId, + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration, + authorizationSnapshot: authorization + ) + } + + @discardableResult + func acknowledgeChatFirstMaterializationReceipts( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + receipts: [ChatFirstMaterializationReceipt], + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil + ) async throws -> Int { + let authorization = try resolveAuthorization( + authorizationSnapshot, + expectedOwnerID: ownerID) + try await start(authorizationSnapshot: authorization) + return try await runtime.acknowledgeChatFirstMaterializationReceipts( + clientId: clientId, + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration, + receipts: receipts, + authorizationSnapshot: authorization + ) + } + func updateJournalTurn( surface: AgentSurfaceReference, ownerID: String? = nil, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift index 1cf16259022..87a3b0a6c17 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift @@ -259,6 +259,53 @@ enum AgentClient { ) } + func materializeChatFirstIntents( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + intents: [ChatFirstPromptIntent] + ) async throws -> AgentRuntimeProcess.ChatFirstIntentsMaterialization { + try await bridge.materializeChatFirstIntents( + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration, + intents: intents + ) + } + + func listChatFirstMaterializationReceipts( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int + ) async throws -> [ChatFirstMaterializationReceipt] { + try await bridge.listChatFirstMaterializationReceipts( + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration + ) + } + + @discardableResult + func acknowledgeChatFirstMaterializationReceipts( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + receipts: [ChatFirstMaterializationReceipt] + ) async throws -> Int { + try await bridge.acknowledgeChatFirstMaterializationReceipts( + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration, + receipts: receipts + ) + } + func updateJournalTurn( surface: AgentSurfaceReference, ownerID: String? = nil, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift index ceb33ac7bb1..120cbeaa60c 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift @@ -518,6 +518,11 @@ actor AgentRuntimeProcess { let accepted: Bool? = nil let duplicate: Bool? = nil let continuityKey: String? = nil + let suppressedByTailQuestion: Bool = false + let suppressedByStreamingTail: Bool = false + let materializationStoppedByTail: Bool = false + let materializationReceipts: [ChatFirstMaterializationReceipt] = [] + let acknowledgedReceiptCount: Int = 0 } struct QuestionInteractionReply: Sendable { @@ -529,6 +534,12 @@ actor AgentRuntimeProcess { let assistantTurn: KernelJournalTurn } + struct ChatFirstIntentsMaterialization: Sendable { + let accepted: Bool + let stoppedByTail: Bool + let receipts: [ChatFirstMaterializationReceipt] + } + typealias JournalTurnChangedHandler = @Sendable (KernelJournalTurn) -> Void typealias AuthorizedRealtimeToolHandler = @Sendable (AuthorizedToolExecution) async -> AuthorizedRealtimeToolExecutionResult @@ -2037,6 +2048,109 @@ actor AgentRuntimeProcess { ) } + /// Materialize one ordered server batch through the kernel, which owns the + /// canonical assistant rows, tail suppression, and receipt identities. + func materializeChatFirstIntents( + clientId: String, + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + intents: [ChatFirstPromptIntent], + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> ChatFirstIntentsMaterialization { + guard surface.surfaceKind == "main_chat", + controlGeneration >= 0, + !intents.isEmpty, + intents.count <= 8, + intents.allSatisfy({ $0.accountGeneration == controlGeneration && $0.kernelBlocks != nil }), + !sessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw BridgeError.agentError("Invalid chat-first materialization") + } + let result = try await journalOperation( + type: "materialize_chat_first_intents", + operation: "materialize_chat_first_intents", + clientId: clientId, + surface: surface, + ownerID: ownerID, + payload: [ + "sessionId": sessionID, + "controlGeneration": controlGeneration, + "intents": intents.compactMap { intent in + guard let blocks = intent.kernelBlocks else { return nil } + return [ + "intentId": intent.intentID, + "continuityKey": intent.continuityKey, + "source": intent.source.rawValue, + "blocks": blocks, + ] as [String: Any] + }, + ], + authorizationSnapshot: authorizationSnapshot + ) + for turn in result.turns { + recordLifecycleJournalMutation(turn) + } + return ChatFirstIntentsMaterialization( + accepted: result.accepted == true, + stoppedByTail: result.materializationStoppedByTail, + receipts: result.materializationReceipts + ) + } + + func listChatFirstMaterializationReceipts( + clientId: String, + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> [ChatFirstMaterializationReceipt] { + guard surface.surfaceKind == "main_chat", controlGeneration >= 0 else { + throw BridgeError.agentError("Invalid chat-first receipt listing") + } + let result = try await journalOperation( + type: "list_chat_first_materialization_receipts", + operation: "list_chat_first_materialization_receipts", + clientId: clientId, + surface: surface, + ownerID: ownerID, + payload: ["sessionId": sessionID, "controlGeneration": controlGeneration, "limit": 16], + authorizationSnapshot: authorizationSnapshot + ) + return result.materializationReceipts + } + + @discardableResult + func acknowledgeChatFirstMaterializationReceipts( + clientId: String, + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + receipts: [ChatFirstMaterializationReceipt], + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> Int { + guard surface.surfaceKind == "main_chat", controlGeneration >= 0, receipts.count <= 16 else { + throw BridgeError.agentError("Invalid chat-first receipt acknowledgement") + } + let result = try await journalOperation( + type: "acknowledge_chat_first_materialization_receipts", + operation: "acknowledge_chat_first_materialization_receipts", + clientId: clientId, + surface: surface, + ownerID: ownerID, + payload: [ + "sessionId": sessionID, + "controlGeneration": controlGeneration, + "receipts": receipts.map { ["intentId": $0.intentID, "receiptId": $0.receiptID] }, + ], + authorizationSnapshot: authorizationSnapshot + ) + return result.acknowledgedReceiptCount + } + private func journalOperation( type: String, operation: String, @@ -3684,10 +3798,31 @@ actor AgentRuntimeProcess { generationBaseTurnSeq: generationBaseTurnSeq, accepted: message.payload["accepted"] as? Bool, duplicate: message.payload["duplicate"] as? Bool, - continuityKey: message.payload["continuityKey"] as? String + continuityKey: message.payload["continuityKey"] as? String, + suppressedByTailQuestion: message.payload["suppressedByTailQuestion"] as? Bool ?? false, + suppressedByStreamingTail: message.payload["suppressedByStreamingTail"] as? Bool ?? false, + materializationStoppedByTail: message.payload["materializationStoppedByTail"] as? Bool ?? false, + materializationReceipts: Self.chatFirstMaterializationReceipts( + from: message.payload["materializationReceipts"] + ), + acknowledgedReceiptCount: message.payload["acknowledgedReceiptCount"] as? Int ?? 0 )) } + private nonisolated static func chatFirstMaterializationReceipts( + from payload: Any? + ) -> [ChatFirstMaterializationReceipt] { + guard let values = payload as? [[String: Any]] else { return [] } + return values.compactMap { value in + guard let intentID = value["intentId"] as? String, + !intentID.isEmpty, + let receiptID = value["receiptId"] as? String, + !receiptID.isEmpty + else { return nil } + return ChatFirstMaterializationReceipt(intentID: intentID, receiptID: receiptID) + } + } + private func journalTurn(from message: RuntimeMessage) -> KernelJournalTurn? { guard let dictionary = message.payload["turn"] as? [String: Any] else { return nil } let surface = AgentSurfaceReference( diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift new file mode 100644 index 00000000000..e254ddac9e7 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Typed wire contract for T04's fetch/ack endpoint. The server owns due +/// timing and content; desktop only reports presence and commits receipts to +/// its one local journal. +struct ChatFirstMaterializationReceipt: Codable, Equatable, Sendable { + let intentID: String + let receiptID: String + + enum CodingKeys: String, CodingKey { + case intentID = "intent_id" + case receiptID = "receipt_id" + } +} + +/// The provider vends this only while the root-sampled capability remains +/// admitted for the exact main-Chat owner. It is not a persisted rollout flag. +struct ChatFirstMaterializationContext: Equatable, Sendable { + let ownerID: String + let controlGeneration: Int +} + +struct ChatFirstPromptIntent: Decodable { + enum Source: String, Decodable, Sendable { + case dailyOpener = "daily_opener" + case captureArrival = "capture_arrival" + case deferralReraise = "deferral_reraise" + case agentJudgment = "agent_judgment" + } + + let intentID: String + let continuityKey: String + let accountGeneration: Int + let source: Source + let blocks: [OmiAPI.OmiAnyCodable] + + enum CodingKeys: String, CodingKey { + case intentID = "intent_id" + case continuityKey = "continuity_key" + case accountGeneration = "account_generation" + case source, blocks + } + + /// The kernel performs the bounded schema conversion and only it assigns + /// persisted block IDs. Refuse an incomplete server response before it can + /// become a journal mutation. + var kernelBlocks: [[String: Any]]? { + let values = blocks.compactMap { $0.value as? [String: Any] } + return values.count == blocks.count && !values.isEmpty ? values : nil + } +} + +struct ChatFirstMaterializePromptsResponse: Decodable { + let intents: [ChatFirstPromptIntent] +} + +private struct ChatFirstMaterializePromptsRequest: Encodable { + struct Receipt: Encodable { + let intentID: String + let receiptID: String + + enum CodingKeys: String, CodingKey { + case intentID = "intent_id" + case receiptID = "receipt_id" + } + } + + let sourceSurface: String = "main_chat" + let controlGeneration: Int + let ownerFence: String + let windowForeground: Bool + let receipts: [Receipt] + + enum CodingKeys: String, CodingKey { + case sourceSurface = "source_surface" + case controlGeneration = "control_generation" + case ownerFence = "owner_fence" + case windowForeground = "window_foreground" + case receipts + } +} + +extension APIClient { + func materializeChatFirstPrompts( + ownerID: String, + controlGeneration: Int, + windowForeground: Bool, + receipts: [ChatFirstMaterializationReceipt] + ) async throws -> ChatFirstMaterializePromptsResponse { + guard !ownerID.isEmpty, controlGeneration >= 0, receipts.count <= 16 else { + throw APIError.invalidResponse + } + let body = ChatFirstMaterializePromptsRequest( + controlGeneration: controlGeneration, + ownerFence: ownerID, + windowForeground: windowForeground, + receipts: receipts.map { + ChatFirstMaterializePromptsRequest.Receipt(intentID: $0.intentID, receiptID: $0.receiptID) + } + ) + return try await post( + "v1/chat/materialize-prompts", + body: body, + includeBYOK: false, + expectedOwnerId: ownerID + ) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift index 0d6cb43c62b..eab35175ce0 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift @@ -9,14 +9,17 @@ struct ChatFirstRichBlockContext { let navigation: ChatFirstShellNavigation let tasksStore: TasksStore let chatProvider: ChatProvider + let promptMaterializationCoordinator: ChatFirstPromptMaterializationCoordinator init( navigation: ChatFirstShellNavigation, tasksStore: TasksStore = .shared, - chatProvider: ChatProvider + chatProvider: ChatProvider, + promptMaterializationCoordinator: ChatFirstPromptMaterializationCoordinator ) { self.navigation = navigation self.tasksStore = tasksStore self.chatProvider = chatProvider + self.promptMaterializationCoordinator = promptMaterializationCoordinator } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift new file mode 100644 index 00000000000..f4442d72884 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift @@ -0,0 +1,126 @@ +import Combine +import Foundation + +/// Pure debounce policy so foreground handling remains deterministic and cheap +/// to test without a running app or backend. +enum ChatFirstPromptMaterializationPolicy { + static let minimumInterval: TimeInterval = 60 + + static func shouldStart( + transcriptFirstPageLoaded: Bool, + isRunning: Bool, + lastAttemptAt: Date?, + now: Date + ) -> Bool { + guard transcriptFirstPageLoaded, !isRunning else { return false } + guard let lastAttemptAt else { return true } + return now.timeIntervalSince(lastAttemptAt) >= minimumInterval + } +} + +/// Root-owned, silent-until-open coordinator for server-owned prompt intents. +/// It owns timing only: content, due decisions, receipt identity, and journal +/// state remain on the backend/kernel respectively. +@MainActor +final class ChatFirstPromptMaterializationCoordinator: ObservableObject { + private weak var chatProvider: ChatProvider? + private var didLoadTranscriptFirstPage = false + private var lastAttemptAt: Date? + private var requestTask: Task? + private var requestGeneration = 0 + private let now: () -> Date + + init(now: @escaping () -> Date = Date.init) { + self.now = now + } + + func activate(using chatProvider: ChatProvider) { + self.chatProvider = chatProvider + } + + /// Called from the one rich main-chat page after its first transcript page is + /// available. A shell with another route never creates a proactive turn. + func chatTranscriptFirstPageDidLoad() { + didLoadTranscriptFirstPage = true + requestMaterialization(windowForeground: true) + } + + /// Leaving the rich Chat route must immediately make this coordinator inert. + /// In particular, a later app-foreground notification cannot materialize an + /// intent into a transcript the user is not presently viewing. + func chatTranscriptDidDisappear() { + didLoadTranscriptFirstPage = false + requestGeneration &+= 1 + requestTask?.cancel() + requestTask = nil + } + + /// `ChatFirstShell` alone forwards app foreground events. This is never + /// registered by the legacy shell, floating/notch UI, or a background task. + func mainWindowDidBecomeForeground() { + requestMaterialization(windowForeground: true) + } + + private func requestMaterialization(windowForeground: Bool) { + guard ChatFirstPromptMaterializationPolicy.shouldStart( + transcriptFirstPageLoaded: didLoadTranscriptFirstPage, + isRunning: requestTask != nil, + lastAttemptAt: lastAttemptAt, + now: now() + ), let chatProvider, chatProvider.chatFirstMaterializationContext() != nil + else { return } + + lastAttemptAt = now() + requestGeneration &+= 1 + let generation = requestGeneration + requestTask = Task { [weak self, weak chatProvider] in + guard let self, let chatProvider else { return } + await self.materialize(using: chatProvider, windowForeground: windowForeground, generation: generation) + if self.requestGeneration == generation { + self.requestTask = nil + } + } + } + + private func materialize( + using chatProvider: ChatProvider, + windowForeground: Bool, + generation: Int + ) async { + guard isCurrentMaterialization(generation) else { return } + guard let context = chatProvider.chatFirstMaterializationContext() else { return } + do { + let pendingReceipts = try await chatProvider.pendingChatFirstMaterializationReceipts() + guard isCurrentMaterialization(generation) else { return } + // The endpoint atomically accepts prior receipts before returning ready + // intents. Only after a success response do we remove their local copies. + let response = try await APIClient.shared.materializeChatFirstPrompts( + ownerID: context.ownerID, + controlGeneration: context.controlGeneration, + windowForeground: windowForeground, + receipts: pendingReceipts + ) + guard isCurrentMaterialization(generation) else { return } + if !pendingReceipts.isEmpty { + _ = try await chatProvider.acknowledgeChatFirstMaterializationReceipts(pendingReceipts) + guard isCurrentMaterialization(generation) else { return } + } + + guard isCurrentMaterialization(generation), + response.intents.allSatisfy({ $0.accountGeneration == context.controlGeneration }) + else { return } + // Preserve the server's `(created_at, intent_id)` order in one kernel + // transaction. The kernel stops after a question or any blocked tail; + // Swift never reorders, caches, or locally schedules these intents. + _ = try await chatProvider.materializeChatFirstIntents(response.intents) + } catch { + // Failure is intentionally quiet and retryable on the next debounced + // foreground/open. Do not create a notification, badge, or Chat row. + log("Chat-first prompt materialization deferred") + } + } + + private func isCurrentMaterialization(_ generation: Int) -> Bool { + didLoadTranscriptFirstPage && requestGeneration == generation && !Task.isCancelled + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift index fa3e93c70a8..bd9ecf6bd8b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import OmiTheme @@ -9,6 +10,7 @@ struct ChatFirstShell: View { let viewModelContainer: ViewModelContainer @Binding var selectedSettingsSection: SettingsContentView.SettingsSection @Binding var highlightedSettingID: String? + @StateObject private var promptMaterializationCoordinator = ChatFirstPromptMaterializationCoordinator() var body: some View { HStack(spacing: 0) { @@ -29,6 +31,20 @@ struct ChatFirstShell: View { } .background(OmiColors.backgroundPrimary) .environmentObject(navigation) + .onAppear { + promptMaterializationCoordinator.activate(using: viewModelContainer.chatProvider) + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + guard let window = NSApp.mainWindow, window.isKeyWindow, window.isVisible else { return } + promptMaterializationCoordinator.mainWindowDidBecomeForeground() + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in + guard let window = notification.object as? NSWindow, + window === NSApp.mainWindow, + window.isVisible + else { return } + promptMaterializationCoordinator.mainWindowDidBecomeForeground() + } .onExitCommand { guard navigation.route != .chat else { return } OmiMotion.withGated(.easeOut(duration: 0.12)) { @@ -47,7 +63,8 @@ struct ChatFirstShell: View { chatFirstRichBlockContext: ChatFirstRichBlockContext( navigation: navigation, tasksStore: viewModelContainer.tasksStore, - chatProvider: viewModelContainer.chatProvider + chatProvider: viewModelContainer.chatProvider, + promptMaterializationCoordinator: promptMaterializationCoordinator ) ) .accessibilityIdentifier("chat-first-route-chat") diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/ChatPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/ChatPage.swift index 702679239f5..fde60d2ed51 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/ChatPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/ChatPage.swift @@ -14,6 +14,7 @@ struct ChatPage: View { @State private var citedConversation: ServerConversation? @State private var isLoadingCitation = false @State private var copied = false + @State private var didReportChatFirstTranscriptPage = false var selectedApp: OmiApp? { guard let appId = chatProvider.selectedAppId else { return nil } @@ -86,6 +87,12 @@ struct ChatPage: View { .padding(.bottom, OmiSpacing.xxl) } .background(OmiColors.backgroundPrimary) + .onAppear { reportChatFirstTranscriptPageIfReady() } + .onDisappear { + didReportChatFirstTranscriptPage = false + chatFirstRichBlockContext?.promptMaterializationCoordinator.chatTranscriptDidDisappear() + } + .onChange(of: chatProvider.isMainChatJournalFirstPageReady) { _, _ in reportChatFirstTranscriptPageIfReady() } .sheet(item: $citedConversation) { conversation in ConversationDetailView( conversation: conversation, @@ -160,6 +167,15 @@ struct ChatPage: View { } } + private func reportChatFirstTranscriptPageIfReady() { + guard !didReportChatFirstTranscriptPage, + chatFirstRichBlockContext != nil, + chatProvider.isMainChatJournalFirstPageReady + else { return } + didReportChatFirstTranscriptPage = true + chatFirstRichBlockContext?.promptMaterializationCoordinator.chatTranscriptFirstPageDidLoad() + } + // MARK: - Header private var chatHeader: some View { diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index 149e743329b..5464d0d4939 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -1144,6 +1144,9 @@ class ChatProvider: ObservableObject { } @Published var isLoading = false @Published var isLoadingSessions = true // Start true since we load sessions on init + /// Root-only prompt materialization waits for the current main-chat journal + /// replay, never for an unrelated legacy session load. + @Published private(set) var isMainChatJournalFirstPageReady = false @Published var isSending = false @Published var isStopping = false @Published private(set) var activeTurnOwner: ChatTurnOwner? @@ -2391,6 +2394,7 @@ class ChatProvider: ObservableObject { currentSession = session isInDefaultChat = false isLoading = true + isMainChatJournalFirstPageReady = false errorMessage = nil hasMoreMessages = false @@ -2407,6 +2411,7 @@ class ChatProvider: ObservableObject { messagesPaginationOffset = messages.count hasMoreMessages = false log("ChatProvider loaded \(messages.count) kernel journal messages for session \(session.id)") + isMainChatJournalFirstPageReady = true isLoading = false } @@ -3108,6 +3113,7 @@ class ChatProvider: ObservableObject { /// by the bounded, checkpointed legacy importer on first migration. func loadDefaultChatMessages() async { isLoading = true + isMainChatJournalFirstPageReady = false errorMessage = nil hasMoreMessages = false @@ -3126,6 +3132,7 @@ class ChatProvider: ObservableObject { hasMoreMessages = false sessionsLoadError = nil log("ChatProvider loaded \(messages.count) default kernel journal messages") + isMainChatJournalFirstPageReady = true isLoading = false } @@ -3833,6 +3840,93 @@ class ChatProvider: ObservableObject { ) } + /// Root-only prompt materialization is inert until this main-chat surface + /// has a current cohort capability projection. + func chatFirstMaterializationContext() -> ChatFirstMaterializationContext? { + guard let ownerID = runtimeOwnerId else { return nil } + let surface = mainChatSurfaceReference() + guard let capability = chatFirstMainChatProjectionGate.capability(for: surface, ownerID: ownerID) else { + return nil + } + return ChatFirstMaterializationContext( + ownerID: ownerID, + controlGeneration: capability.controlGeneration + ) + } + + func pendingChatFirstMaterializationReceipts() async throws -> [ChatFirstMaterializationReceipt] { + guard let session = try await chatFirstMaterializationSession() else { return [] } + return try await resolvedAgentClient().listChatFirstMaterializationReceipts( + surface: session.surface, + ownerID: session.ownerID, + sessionID: session.agentSession.sessionId, + controlGeneration: session.capability.controlGeneration + ) + } + + @discardableResult + func acknowledgeChatFirstMaterializationReceipts( + _ receipts: [ChatFirstMaterializationReceipt] + ) async throws -> Int { + guard !receipts.isEmpty, let session = try await chatFirstMaterializationSession() else { return 0 } + return try await resolvedAgentClient().acknowledgeChatFirstMaterializationReceipts( + surface: session.surface, + ownerID: session.ownerID, + sessionID: session.agentSession.sessionId, + controlGeneration: session.capability.controlGeneration, + receipts: receipts + ) + } + + /// The local kernel owns every replay-visible effect; this is only its + /// capability-fenced bridge adapter. + @discardableResult + func materializeChatFirstIntents( + _ intents: [ChatFirstPromptIntent] + ) async throws -> AgentRuntimeProcess.ChatFirstIntentsMaterialization? { + guard let session = try await chatFirstMaterializationSession(), + !intents.isEmpty, + intents.count <= 8, + intents.allSatisfy({ $0.accountGeneration == session.capability.controlGeneration }) + else { return nil } + let result = try await resolvedAgentClient().materializeChatFirstIntents( + surface: session.surface, + ownerID: session.ownerID, + sessionID: session.agentSession.sessionId, + controlGeneration: session.capability.controlGeneration, + intents: intents + ) + if result.accepted { + await kernelTurnProjection.refresh(surface: session.surface) + } + return result + } + + private struct ChatFirstMaterializationSession { + let ownerID: String + let capability: ChatFirstCapabilityProjection + let surface: AgentSurfaceReference + let agentSession: AgentSurfaceSession + } + + private func chatFirstMaterializationSession() async throws -> ChatFirstMaterializationSession? { + guard let ownerID = runtimeOwnerId else { return nil } + let surface = mainChatSurfaceReference() + guard let capability = chatFirstMainChatProjectionGate.capability(for: surface, ownerID: ownerID) else { + return nil + } + let agentSession = try await resolveAgentSurfaceSession(surface) + guard runtimeOwnerId == ownerID, + chatFirstMainChatProjectionGate.capability(for: surface, ownerID: ownerID) == capability + else { return nil } + return ChatFirstMaterializationSession( + ownerID: ownerID, + capability: capability, + surface: surface, + agentSession: agentSession + ) + } + /// Send a message and get AI response via Claude Agent SDK bridge /// Persists both user and AI messages to backend /// - Parameters: diff --git a/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift b/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift new file mode 100644 index 00000000000..0dc14774575 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift @@ -0,0 +1,50 @@ +import XCTest + +@testable import Omi_Computer + +final class ChatFirstPromptMaterializationCoordinatorTests: XCTestCase { + func testPolicyRequiresTranscriptReadinessAndDebouncesForegroundFlapping() { + let now = Date(timeIntervalSinceReferenceDate: 10_000) + + XCTAssertFalse( + ChatFirstPromptMaterializationPolicy.shouldStart( + transcriptFirstPageLoaded: false, + isRunning: false, + lastAttemptAt: nil, + now: now + ) + ) + XCTAssertTrue( + ChatFirstPromptMaterializationPolicy.shouldStart( + transcriptFirstPageLoaded: true, + isRunning: false, + lastAttemptAt: nil, + now: now + ) + ) + XCTAssertFalse( + ChatFirstPromptMaterializationPolicy.shouldStart( + transcriptFirstPageLoaded: true, + isRunning: true, + lastAttemptAt: now, + now: now.addingTimeInterval(120) + ) + ) + XCTAssertFalse( + ChatFirstPromptMaterializationPolicy.shouldStart( + transcriptFirstPageLoaded: true, + isRunning: false, + lastAttemptAt: now, + now: now.addingTimeInterval(59) + ) + ) + XCTAssertTrue( + ChatFirstPromptMaterializationPolicy.shouldStart( + transcriptFirstPageLoaded: true, + isRunning: false, + lastAttemptAt: now, + now: now.addingTimeInterval(60) + ) + ) + } +} diff --git a/desktop/macos/agent/src/index.ts b/desktop/macos/agent/src/index.ts index 4979d0c8d38..e348e5d29b8 100644 --- a/desktop/macos/agent/src/index.ts +++ b/desktop/macos/agent/src/index.ts @@ -60,6 +60,9 @@ import type { JournalClearTurnsMessage, AppendChatFirstBlocksMessage, RecordQuestionInteractionReplyMessage, + MaterializeChatFirstIntentsMessage, + ListChatFirstMaterializationReceiptsMessage, + AcknowledgeChatFirstMaterializationReceiptsMessage, EnsureAgentSpawnJournalMessage, JournalBackendSyncResultMessage, JournalBackendDeleteResultMessage, @@ -138,6 +141,9 @@ import { journalTurnChangedWakes, importRemoteJournalTurn, listJournalTurns, + listChatFirstMaterializationReceipts, + acknowledgeChatFirstMaterializationReceipts, + materializeChatFirstIntents, recordJournalExchange, recordQuestionInteractionReply, recordJournalTurn, @@ -2521,7 +2527,8 @@ async function main(): Promise { try { const ownerId = resolveActiveOwner(request.ownerId); if ( - typeof request.sessionId !== "string" || !request.sessionId.trim() + request.surfaceKind !== "main_chat" + || typeof request.sessionId !== "string" || !request.sessionId.trim() || typeof request.questionId !== "string" || !request.questionId.trim() || typeof request.optionId !== "string" || !request.optionId.trim() || !Number.isSafeInteger(request.controlGeneration) || request.controlGeneration < 0 @@ -2603,6 +2610,160 @@ async function main(): Promise { break; } + case "materialize_chat_first_intents": { + const request = msg as MaterializeChatFirstIntentsMessage; + try { + const ownerId = resolveActiveOwner(request.ownerId); + if ( + request.surfaceKind !== "main_chat" + || typeof request.sessionId !== "string" || !request.sessionId.trim() + || !Array.isArray(request.intents) || request.intents.length < 1 || request.intents.length > 8 + || !Number.isSafeInteger(request.controlGeneration) || request.controlGeneration < 0 + ) throw new Error("Chat-first materialization request is invalid"); + kernel.assertChatFirstMainCapability(request.sessionId, ownerId, request.controlGeneration); + const resolved = resolveJournalSurface({ + ownerId, surfaceKind: "main_chat", + externalRefKind: request.externalRefKind, externalRefId: request.externalRefId, + }); + if (resolved.agentSessionId !== request.sessionId) throw new Error("Chat-first materialization session is stale"); + const intents = request.intents.map((intent) => { + if ( + !intent || typeof intent.intentId !== "string" || !intent.intentId.trim() + || typeof intent.continuityKey !== "string" || !intent.continuityKey.trim() + || !Array.isArray(intent.blocks) + ) throw new Error("Chat-first materialization intent is invalid"); + return { + ownerId, conversationId: resolved.conversationId, controlGeneration: request.controlGeneration, + intentId: intent.intentId, continuityKey: intent.continuityKey, + source: intent.source, blocks: intent.blocks, + }; + }); + const result = materializeChatFirstIntents(store, intents); + const committedTurns = result.results + .filter((candidate) => candidate.accepted && !candidate.duplicate && candidate.turn) + .map((candidate) => candidate.turn!); + const range = listJournalTurns(store, { + ownerId, conversationId: resolved.conversationId, + afterTurnSeq: 0, limit: 1, + }); + send({ + type: "journal_operation_result", protocolVersion: request.protocolVersion, + requestId: request.requestId, clientId: request.clientId, + operation: "materialize_chat_first_intents", conversationId: resolved.conversationId, + surfaceKind: "main_chat", externalRefKind: request.externalRefKind, + externalRefId: request.externalRefId, + turn: committedTurns.at(-1) ? journalTurnProjection(committedTurns.at(-1)!) : undefined, + turns: committedTurns.map(journalTurnProjection), clearedCount: 0, + highWaterTurnSeq: range.highWaterTurnSeq, generationBaseTurnSeq: range.generationBaseTurnSeq, + conversationGeneration: range.generation, + accepted: result.results.some((candidate) => candidate.accepted), + duplicate: result.results.every((candidate) => candidate.duplicate), + suppressedByTailQuestion: result.results.some((candidate) => candidate.suppressedByTailQuestion), + suppressedByStreamingTail: result.results.some((candidate) => candidate.suppressedByStreamingTail), + materializationStoppedByTail: result.stoppedByTail, + materializationReceipts: result.results.flatMap((candidate) => candidate.receipt ? [candidate.receipt] : []), + }); + if (committedTurns.length > 0) { + for (const turn of committedTurns) for (const wake of journalTurnChangedWakes(store, ownerId, turn)) { + send({ type: "journal_turn_changed", ...wake, turn: journalTurnProjection(wake.turn) }); + } + pumpJournalOutbox(); + } + } catch (error) { + const envelope = runtimeErrorEnvelope(error); + send({ + type: "error", protocolVersion: request.protocolVersion, requestId: request.requestId, + clientId: request.clientId, message: envelope.message, failure: envelope.failure, + }); + } + break; + } + + case "list_chat_first_materialization_receipts": { + const request = msg as ListChatFirstMaterializationReceiptsMessage; + try { + const ownerId = resolveActiveOwner(request.ownerId); + if ( + request.surfaceKind !== "main_chat" + || typeof request.sessionId !== "string" || !request.sessionId.trim() + || !Number.isSafeInteger(request.controlGeneration) || request.controlGeneration < 0 + ) throw new Error("Chat-first receipt listing request is invalid"); + kernel.assertChatFirstMainCapability(request.sessionId, ownerId, request.controlGeneration); + const resolved = resolveJournalSurface({ + ownerId, surfaceKind: "main_chat", + externalRefKind: request.externalRefKind, externalRefId: request.externalRefId, + }); + if (resolved.agentSessionId !== request.sessionId) throw new Error("Chat-first receipt session is stale"); + const range = listJournalTurns(store, { + ownerId, conversationId: resolved.conversationId, afterTurnSeq: 0, limit: 1, + }); + send({ + type: "journal_operation_result", protocolVersion: request.protocolVersion, + requestId: request.requestId, clientId: request.clientId, + operation: "list_chat_first_materialization_receipts", conversationId: resolved.conversationId, + surfaceKind: "main_chat", externalRefKind: request.externalRefKind, + externalRefId: request.externalRefId, turns: [], clearedCount: 0, + highWaterTurnSeq: range.highWaterTurnSeq, generationBaseTurnSeq: range.generationBaseTurnSeq, + conversationGeneration: range.generation, + materializationReceipts: listChatFirstMaterializationReceipts(store, { + ownerId, controlGeneration: request.controlGeneration, limit: request.limit, + }), + }); + } catch (error) { + const envelope = runtimeErrorEnvelope(error); + send({ + type: "error", protocolVersion: request.protocolVersion, requestId: request.requestId, + clientId: request.clientId, message: envelope.message, failure: envelope.failure, + }); + } + break; + } + + case "acknowledge_chat_first_materialization_receipts": { + const request = msg as AcknowledgeChatFirstMaterializationReceiptsMessage; + try { + const ownerId = resolveActiveOwner(request.ownerId); + if ( + request.surfaceKind !== "main_chat" + || typeof request.sessionId !== "string" || !request.sessionId.trim() + || !Number.isSafeInteger(request.controlGeneration) || request.controlGeneration < 0 + || !Array.isArray(request.receipts) || request.receipts.length > 16 + ) throw new Error("Chat-first receipt acknowledgement request is invalid"); + kernel.assertChatFirstMainCapability(request.sessionId, ownerId, request.controlGeneration); + const resolved = resolveJournalSurface({ + ownerId, surfaceKind: "main_chat", + externalRefKind: request.externalRefKind, externalRefId: request.externalRefId, + }); + if (resolved.agentSessionId !== request.sessionId) throw new Error("Chat-first receipt session is stale"); + const receipts = request.receipts.map((receipt) => ({ + intentId: typeof receipt?.intentId === "string" ? receipt.intentId : "", + receiptId: typeof receipt?.receiptId === "string" ? receipt.receiptId : "", + })); + const acknowledgedReceiptCount = acknowledgeChatFirstMaterializationReceipts(store, { + ownerId, controlGeneration: request.controlGeneration, receipts, + }); + const range = listJournalTurns(store, { + ownerId, conversationId: resolved.conversationId, afterTurnSeq: 0, limit: 1, + }); + send({ + type: "journal_operation_result", protocolVersion: request.protocolVersion, + requestId: request.requestId, clientId: request.clientId, + operation: "acknowledge_chat_first_materialization_receipts", conversationId: resolved.conversationId, + surfaceKind: "main_chat", externalRefKind: request.externalRefKind, + externalRefId: request.externalRefId, turns: [], clearedCount: 0, + highWaterTurnSeq: range.highWaterTurnSeq, generationBaseTurnSeq: range.generationBaseTurnSeq, + conversationGeneration: range.generation, acknowledgedReceiptCount, + }); + } catch (error) { + const envelope = runtimeErrorEnvelope(error); + send({ + type: "error", protocolVersion: request.protocolVersion, requestId: request.requestId, + clientId: request.clientId, message: envelope.message, failure: envelope.failure, + }); + } + break; + } + case "journal_terminalize_turn": { const request = msg as JournalTerminalizeTurnMessage; try { diff --git a/desktop/macos/agent/src/protocol.ts b/desktop/macos/agent/src/protocol.ts index 5f27da575aa..f92b3676a20 100644 --- a/desktop/macos/agent/src/protocol.ts +++ b/desktop/macos/agent/src/protocol.ts @@ -390,6 +390,9 @@ export interface AppendChatFirstBlocksMessage extends ProtocolEnvelope { /** Kernel-owned selection for one persisted, tail-actionable question card. */ export interface RecordQuestionInteractionReplyMessage extends ProtocolEnvelope { type: "record_question_interaction_reply"; + surfaceKind: string; + externalRefKind: string; + externalRefId: string; ownerId: string; sessionId: string; questionId: string; @@ -397,6 +400,51 @@ export interface RecordQuestionInteractionReplyMessage extends ProtocolEnvelope controlGeneration: number; } +/** + * Privileged local receipt for an ordered server-owned deterministic-tier + * batch. Swift only transports typed server responses; the kernel derives + * journal identities and enforces tail suppression in one transaction. + */ +export interface MaterializeChatFirstIntentsMessage extends ProtocolEnvelope { + type: "materialize_chat_first_intents"; + surfaceKind: string; + externalRefKind: string; + externalRefId: string; + ownerId: string; + sessionId: string; + controlGeneration: number; + intents: Array<{ + intentId: string; + continuityKey: string; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + blocks: unknown[]; + }>; +} + +/** Read restart-safe kernel receipts to include in the next server fetch/ack. */ +export interface ListChatFirstMaterializationReceiptsMessage extends ProtocolEnvelope { + type: "list_chat_first_materialization_receipts"; + surfaceKind: string; + externalRefKind: string; + externalRefId: string; + ownerId: string; + sessionId: string; + controlGeneration: number; + limit?: number; +} + +/** Drop only receipts that the server accepted in a successful fetch/ack call. */ +export interface AcknowledgeChatFirstMaterializationReceiptsMessage extends ProtocolEnvelope { + type: "acknowledge_chat_first_materialization_receipts"; + surfaceKind: string; + externalRefKind: string; + externalRefId: string; + ownerId: string; + sessionId: string; + controlGeneration: number; + receipts: Array<{ intentId: string; receiptId: string }>; +} + export interface EnsureAgentSpawnJournalMessage extends ProtocolEnvelope { type: "ensure_agent_spawn_journal"; ownerId: string; @@ -496,6 +544,9 @@ export type InboundMessage = | JournalClearTurnsMessage | AppendChatFirstBlocksMessage | RecordQuestionInteractionReplyMessage + | MaterializeChatFirstIntentsMessage + | ListChatFirstMaterializationReceiptsMessage + | AcknowledgeChatFirstMaterializationReceiptsMessage | EnsureAgentSpawnJournalMessage | JournalBackendSyncResultMessage | JournalBackendDeleteResultMessage @@ -926,7 +977,7 @@ export interface AgentSpawnJournalEnsuredMessage extends OutboundEnvelope { export interface JournalOperationResultMessage extends OutboundEnvelope { type: "journal_operation_result"; - operation: "record" | "record_exchange" | "import_remote" | "update" | "list" | "clear" | "append_chat_first_blocks" | "record_question_interaction_reply"; + operation: "record" | "record_exchange" | "import_remote" | "update" | "list" | "clear" | "append_chat_first_blocks" | "record_question_interaction_reply" | "materialize_chat_first_intents" | "list_chat_first_materialization_receipts" | "acknowledge_chat_first_materialization_receipts"; conversationId: string; surfaceKind: string; externalRefKind: string; @@ -941,6 +992,11 @@ export interface JournalOperationResultMessage extends OutboundEnvelope { accepted?: boolean; duplicate?: boolean; continuityKey?: string | null; + suppressedByTailQuestion?: boolean; + suppressedByStreamingTail?: boolean; + materializationStoppedByTail?: boolean; + materializationReceipts?: Array<{ intentId: string; receiptId: string }>; + acknowledgedReceiptCount?: number; } export interface JournalTurnChangedMessage extends OutboundEnvelope { diff --git a/desktop/macos/agent/src/runtime/conversation-journal.ts b/desktop/macos/agent/src/runtime/conversation-journal.ts index 4609f8501cb..d7a6df59a92 100644 --- a/desktop/macos/agent/src/runtime/conversation-journal.ts +++ b/desktop/macos/agent/src/runtime/conversation-journal.ts @@ -143,6 +143,42 @@ export interface ChatFirstDeferralDelivery { deliveryGeneration: number; } +/** A restart-safe receipt for a locally committed deterministic-tier intent. */ +export interface ChatFirstMaterializationReceipt { + intentId: string; + receiptId: string; +} + +export interface MaterializeChatFirstIntentInput { + ownerId: string; + conversationId: string; + controlGeneration: number; + intentId: string; + continuityKey: string; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + blocks: readonly unknown[]; + nowMs?: number; +} + +export interface ChatFirstIntentMaterializationResult { + accepted: boolean; + duplicate: boolean; + suppressedByTailQuestion: boolean; + suppressedByStreamingTail: boolean; + turn: ConversationTurn | null; + receipt: ChatFirstMaterializationReceipt | null; +} + +/** + * One ordered server batch becomes one kernel transaction. The server owns + * creation order; the kernel owns whether the current transcript tail admits + * the next intent and records the only visible rows. + */ +export interface ChatFirstIntentsMaterializationResult { + results: ChatFirstIntentMaterializationResult[]; + stoppedByTail: boolean; +} + export interface TerminalizeJournalTurnInput { ownerId: string; conversationId: string; @@ -852,6 +888,176 @@ export function recordQuestionInteractionReply( }); } +/** + * Commits one server-owned proactive intent into the canonical main-chat + * journal. Receipt persistence happens in this same transaction, allowing a + * retry to acknowledge the exact committed assistant row after a crash. + */ +function materializeChatFirstIntentInTransaction( + store: AgentStore, + input: MaterializeChatFirstIntentInput, +): ChatFirstIntentMaterializationResult { + const now = input.nowMs ?? Date.now(); + const intentId = nonEmpty(input.intentId, "chat-first intent ID"); + const continuityKey = nonEmpty(input.continuityKey, "chat-first intent continuity key"); + if (!Number.isSafeInteger(input.controlGeneration) || input.controlGeneration < 0) { + throw new Error("Chat-first materialization requires a valid control generation"); + } + if (!["daily_opener", "capture_arrival", "deferral_reraise", "agent_judgment"].includes(input.source)) { + throw new Error("Chat-first materialization source is invalid"); + } + const blocks = chatFirstIntentBlocks(intentId, input.blocks); + const turnId = stableChatFirstIntentTurnID(intentId); + const receiptId = stableChatFirstMaterializationReceiptID(intentId, continuityKey); + + assertConversationOwner(store, input.conversationId, input.ownerId); + const existingReceipt = store.getOptionalRow( + `SELECT owner_id, conversation_id, control_generation, receipt_id, turn_id + FROM chat_first_materialization_receipts WHERE intent_id = ?`, + [intentId], + ); + if (existingReceipt) { + if ( + String(existingReceipt.owner_id) !== input.ownerId + || String(existingReceipt.conversation_id) !== input.conversationId + || Number(existingReceipt.control_generation) !== input.controlGeneration + || String(existingReceipt.receipt_id) !== receiptId + || String(existingReceipt.turn_id) !== turnId + ) { + throw new Error("Chat-first intent ID was reused with different receipt identity"); + } + return { + accepted: true, + duplicate: true, + suppressedByTailQuestion: false, + suppressedByStreamingTail: false, + turn: requireJournalTurn(store, input.conversationId, turnId), + receipt: { intentId, receiptId }, + }; + } + + const tail = materializationTailState(store, input.conversationId); + if (tail.unansweredQuestion || tail.streaming) { + return { + accepted: false, + duplicate: false, + suppressedByTailQuestion: tail.unansweredQuestion, + suppressedByStreamingTail: tail.streaming, + turn: null, + receipt: null, + }; + } + + const recorded = recordJournalTurn(store, { + ownerId: input.ownerId, + conversationId: input.conversationId, + turnId, + producerId: `chat-first-intent:${intentId}`, + role: "assistant", + surfaceKind: "main_chat", + origin: "typed_chat", + status: "completed", + // Rich blocks are the visible content. Keep this empty rather than + // inventing an assistant sentence that could become a "Done." filler. + content: "", + contentBlocks: blocks, + metadataJson: JSON.stringify({ + continuityKey, + chatFirstIntentId: intentId, + chatFirstIntentSource: input.source, + }), + createdAtMs: now, + }); + store.execute( + `INSERT INTO chat_first_materialization_receipts( + intent_id, owner_id, conversation_id, control_generation, receipt_id, turn_id, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [intentId, input.ownerId, input.conversationId, input.controlGeneration, receiptId, turnId, now], + ); + return { + accepted: true, + duplicate: recorded.duplicate, + suppressedByTailQuestion: false, + suppressedByStreamingTail: false, + turn: recorded.turn, + receipt: { intentId, receiptId }, + }; +} + +export function materializeChatFirstIntent( + store: AgentStore, + input: MaterializeChatFirstIntentInput, +): ChatFirstIntentMaterializationResult { + return store.withTransaction(() => materializeChatFirstIntentInTransaction(store, input)); +} + +export function materializeChatFirstIntents( + store: AgentStore, + inputs: readonly MaterializeChatFirstIntentInput[], +): ChatFirstIntentsMaterializationResult { + if (inputs.length < 1 || inputs.length > 8) { + throw new Error("Chat-first materialization batch requires one to eight intents"); + } + return store.withTransaction(() => { + const results: ChatFirstIntentMaterializationResult[] = []; + for (const input of inputs) { + const result = materializeChatFirstIntentInTransaction(store, input); + results.push(result); + // Do not submit an additional intent after a current tail suppresses the + // batch, or after this intent itself becomes the new unanswered tail. + if ( + result.suppressedByTailQuestion + || result.suppressedByStreamingTail + || materializationTailState(store, input.conversationId).unansweredQuestion + ) { + return { results, stoppedByTail: true }; + } + } + return { results, stoppedByTail: false }; + }); +} + +/** Receipts remain pending locally until Swift receives a successful server acknowledgement. */ +export function listChatFirstMaterializationReceipts( + store: AgentStore, + input: { ownerId: string; controlGeneration: number; limit?: number }, +): ChatFirstMaterializationReceipt[] { + if (!Number.isSafeInteger(input.controlGeneration) || input.controlGeneration < 0) { + throw new Error("Chat-first receipt listing requires a valid control generation"); + } + const limit = Math.max(1, Math.min(input.limit ?? 16, 16)); + return store.allRows( + `SELECT intent_id, receipt_id FROM chat_first_materialization_receipts + WHERE owner_id = ? AND control_generation = ? + ORDER BY created_at_ms ASC LIMIT ?`, + [input.ownerId, input.controlGeneration, limit], + ).map((row) => ({ intentId: String(row.intent_id), receiptId: String(row.receipt_id) })); +} + +/** Delete only the exact server-acknowledged receipt identities. */ +export function acknowledgeChatFirstMaterializationReceipts( + store: AgentStore, + input: { ownerId: string; controlGeneration: number; receipts: readonly ChatFirstMaterializationReceipt[] }, +): number { + if (!Number.isSafeInteger(input.controlGeneration) || input.controlGeneration < 0) { + throw new Error("Chat-first receipt acknowledgement requires a valid control generation"); + } + return store.withTransaction(() => { + let acknowledged = 0; + for (const receipt of input.receipts) { + const intentId = nonEmpty(receipt.intentId, "chat-first receipt intent ID"); + const receiptId = nonEmpty(receipt.receiptId, "chat-first receipt ID"); + const result = store.execute( + `DELETE FROM chat_first_materialization_receipts + WHERE intent_id = ? AND owner_id = ? AND control_generation = ? AND receipt_id = ?`, + [intentId, input.ownerId, input.controlGeneration, receiptId], + ); + acknowledged += result; + } + return acknowledged; + }); +} + /** Claim a bounded batch from the deferral-only transport. */ export function drainChatFirstDeferralOutbox( store: AgentStore, @@ -2869,6 +3075,142 @@ function stableQuestionInteractionTurnID(continuityKey: string, role: "user" | " return `turn_${createHash("sha256").update(`${continuityKey}\u0000${role}`).digest("hex").slice(0, 16)}`; } +function stableChatFirstIntentTurnID(intentId: string): string { + return `turn_cfi_${createHash("sha256").update(intentId).digest("hex").slice(0, 24)}`; +} + +function stableChatFirstMaterializationReceiptID(intentId: string, continuityKey: string): string { + return `cfi_receipt_${createHash("sha256") + .update(`${intentId}\u0000${continuityKey}`) + .digest("hex") + .slice(0, 24)}`; +} + +/** Any unanswered question or streaming assistant tail blocks proactive arrival. */ +function materializationTailState( + store: AgentStore, + conversationId: string, +): { unansweredQuestion: boolean; streaming: boolean } { + const tail = store.getOptionalRow( + `SELECT turn_id FROM conversation_turns + WHERE conversation_id = ? ORDER BY turn_seq DESC LIMIT 1`, + [conversationId], + ); + if (!tail) return { unansweredQuestion: false, streaming: false }; + const turn = requireJournalTurn(store, conversationId, String(tail.turn_id)); + const assistant = turn.role === "assistant"; + return { + unansweredQuestion: assistant + && turn.status === "completed" + && turn.contentBlocks.some((block) => block.type === "questionCard" && block.selectedOptionId === undefined), + streaming: assistant && (turn.status === "pending" || turn.status === "streaming"), + }; +} + +/** + * Proactive intents are server contracts with snake_case fields; normalize and + * bound them once before they become kernel-owned persisted blocks. This keeps + * Swift a transport/projection layer instead of another block writer. + */ +function chatFirstIntentBlocks( + intentId: string, + rawBlocks: readonly unknown[], +): ConversationContentBlock[] { + if (rawBlocks.length < 1 || rawBlocks.length > 8) { + throw new Error("Chat-first intent requires one to eight blocks"); + } + return rawBlocks.map((raw, index) => { + const block = recordValue(raw, "chat-first intent block"); + const type = nonEmptyString(block.type, "chat-first intent block type"); + const id = `cfi_block_${createHash("sha256") + .update(`${intentId}\u0000${index}\u0000${type}`) + .digest("hex") + .slice(0, 20)}`; + switch (type) { + case "questionCard": { + const subject = recordValue(block.subject, "chat-first question subject"); + const options = arrayValue(block.options, "chat-first question options").map((rawOption) => { + const option = recordValue(rawOption, "chat-first question option"); + const defer = option.defer === undefined ? undefined : booleanValue(option.defer, "chat-first question defer"); + return { + optionId: nonEmptyString(option.option_id, "chat-first question option ID"), + label: nonEmptyString(option.label, "chat-first question option label"), + preparedAnswer: nonEmptyString(option.prepared_answer, "chat-first question prepared answer"), + ...(defer === true ? { defer: true } : {}), + }; + }); + return { + type, + id, + questionId: nonEmptyString(block.question_id, "chat-first question ID"), + text: nonEmptyString(block.text, "chat-first question text"), + subject: { + kind: chatFirstSubjectKind(subject.kind), + id: nonEmptyString(subject.id, "chat-first question subject ID"), + }, + options, + }; + } + case "taskCard": + return { type, id, taskId: nonEmptyString(block.task_id, "chat-first task ID") }; + case "goalLink": + return { + type, + id, + goalId: nonEmptyString(block.goal_id, "chat-first goal ID"), + summary: nonEmptyString(block.summary, "chat-first goal summary"), + }; + case "captureLink": { + const rawMoment = block.moment_timestamp_ms; + const momentTimestampMs = rawMoment === undefined || rawMoment === null + ? undefined + : safeNonNegativeInteger(rawMoment, "chat-first capture moment"); + return { + type, + id, + conversationId: nonEmptyString(block.conversation_id, "chat-first capture ID"), + ...(momentTimestampMs === undefined ? {} : { momentTimestampMs }), + summary: nonEmptyString(block.summary, "chat-first capture summary"), + }; + } + default: + throw new Error("Chat-first intent block type is invalid"); + } + }); +} + +function recordValue(value: unknown, name: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} is invalid`); + return value as Record; +} + +function arrayValue(value: unknown, name: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${name} is invalid`); + return value; +} + +function nonEmptyString(value: unknown, name: string): string { + if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is invalid`); + return value; +} + +function booleanValue(value: unknown, name: string): boolean { + if (typeof value !== "boolean") throw new Error(`${name} is invalid`); + return value; +} + +function safeNonNegativeInteger(value: unknown, name: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} is invalid`); + } + return value; +} + +function chatFirstSubjectKind(value: unknown): "task" | "goal" | "capture" { + if (value === "task" || value === "goal" || value === "capture") return value; + throw new Error("chat-first question subject kind is invalid"); +} + function findSelectedQuestionBlock( store: AgentStore, conversationId: string, @@ -3127,6 +3469,8 @@ function journalTurnPayloadHash(value: Record): string { function backendTurnPayload(turn: ConversationTurn): BackendTurnPayload { const metadata = parseObjectJson(turn.metadataJson) as Record; + const isChatFirstMaterialization = typeof metadata.chatFirstIntentId === "string" + && metadata.chatFirstIntentId.length > 0; const backendMetadata = { ...metadata, ...(turn.contentBlocks.length > 0 ? { content_blocks: turn.contentBlocks } : {}), @@ -3134,6 +3478,8 @@ function backendTurnPayload(turn: ConversationTurn): BackendTurnPayload { }; const projectedText = turn.content.trim() ? turn.content + : isChatFirstMaterialization + ? "" : turn.role === "assistant" && turn.status === "completed" && (turn.contentBlocks.length > 0 || turn.resources.length > 0) @@ -3162,6 +3508,18 @@ function boundedJournalRevision(revision: number): number { function backendTombstoneCode(turn: ConversationTurn): string | null { const payload = backendTurnPayload(turn); if (payload.text.trim()) return null; + const metadata = parseObjectJson(turn.metadataJson) as Record; + if ( + turn.role === "assistant" + && turn.status === "completed" + && typeof metadata.chatFirstIntentId === "string" + && metadata.chatFirstIntentId.length > 0 + && turn.contentBlocks.length > 0 + ) { + // The canonical structured blocks are the content. This must still use + // the normal reconciliation outbox, just without fabricating "Done.". + return null; + } if (turn.status === "failed") return "empty_failed_turn_cancelled"; if (turn.status === "completed") return "empty_completed_turn_cancelled"; return null; diff --git a/desktop/macos/agent/src/runtime/sqlite-store.ts b/desktop/macos/agent/src/runtime/sqlite-store.ts index fa145c28f7d..7aae94fc7c0 100644 --- a/desktop/macos/agent/src/runtime/sqlite-store.ts +++ b/desktop/macos/agent/src/runtime/sqlite-store.ts @@ -70,6 +70,7 @@ const CONTEXT_SOURCE_SURFACE_SCOPE_MIGRATION_VERSION = 25; const BACKEND_RECONCILE_CURSOR_MIGRATION_VERSION = 26; const JOURNAL_PRODUCING_ATTEMPT_MIGRATION_VERSION = 27; const CHAT_FIRST_DEFERRAL_OUTBOX_MIGRATION_VERSION = 28; +const CHAT_FIRST_MATERIALIZATION_RECEIPTS_MIGRATION_VERSION = 29; const ACTIVE_ATTEMPT_STATUSES = ["queued", "starting", "running", "waiting_input", "waiting_approval", "cancelling"] as const; const TERMINAL_ATTEMPT_STATUSES = ["succeeded", "failed", "cancelled", "timed_out", "orphaned"] as const; @@ -511,6 +512,9 @@ export class SqliteAgentStore implements AgentStore { if (!this.hasMigration(CHAT_FIRST_DEFERRAL_OUTBOX_MIGRATION_VERSION)) { runChatFirstDeferralOutboxMigration(this.db, this.nowMs()); } + if (!this.hasMigration(CHAT_FIRST_MATERIALIZATION_RECEIPTS_MIGRATION_VERSION)) { + runChatFirstMaterializationReceiptsMigration(this.db, this.nowMs()); + } } withTransaction(work: () => T): T { @@ -3195,6 +3199,37 @@ function runChatFirstDeferralOutboxMigration( }); } +/** + * Kernel materialization receipts are deliberately separate from both the + * transcript reconciliation outbox and question deferrals. The server only + * marks an intent delivered after this receipt is observed, while the local + * receipt survives a process crash after its assistant row committed. + */ +function runChatFirstMaterializationReceiptsMigration( + db: Pick, + appliedAtMs: number, +): void { + runTransaction(db, () => { + db.exec(` + CREATE TABLE chat_first_materialization_receipts( + intent_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + control_generation INTEGER NOT NULL CHECK (control_generation >= 0), + receipt_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + created_at_ms INTEGER NOT NULL + ) STRICT; + CREATE INDEX chat_first_materialization_receipts_owner_idx + ON chat_first_materialization_receipts(owner_id, conversation_id, control_generation, created_at_ms ASC); + `); + db.prepare("INSERT INTO schema_migrations (version, applied_at_ms) VALUES (?, ?)").run( + CHAT_FIRST_MATERIALIZATION_RECEIPTS_MIGRATION_VERSION, + appliedAtMs, + ); + }); +} + function runTransaction(db: Pick, work: () => T): T { if (db.isTransaction) { return work(); diff --git a/desktop/macos/agent/tests/conversation-journal.test.ts b/desktop/macos/agent/tests/conversation-journal.test.ts index 0d7b4d01cf4..cf4ad9e75fe 100644 --- a/desktop/macos/agent/tests/conversation-journal.test.ts +++ b/desktop/macos/agent/tests/conversation-journal.test.ts @@ -9,6 +9,7 @@ import { ackBackendTurnOutboxWithWakes, applyBackendReconcilePage, appendChatFirstBlocksToProducingTurn, + acknowledgeChatFirstMaterializationReceipts, beginBackendReconcile, beginBackendReconcilesForOwner, clearJournalConversation, @@ -23,6 +24,9 @@ import { journalTurnForSurfaceProjection, journalTurnChangedWakes, listJournalTurns, + listChatFirstMaterializationReceipts, + materializeChatFirstIntent, + materializeChatFirstIntents, migrateJournalConversation, recordJournalExchange, recordJournalTurn, @@ -45,6 +49,118 @@ afterEach(() => { }); describe("kernel conversation journal", () => { + it("materializes each server intent once, persists its receipt, and stops at an unanswered tail question", () => { + const fixture = newSurface("main_chat", "chat", "chat-first-materialization"); + const first = materializeChatFirstIntent(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + controlGeneration: 7, + intentId: "intent-open", + continuityKey: "intent-open", + source: "daily_opener", + blocks: [{ type: "taskCard", task_id: "task-1" }], + nowMs: 100, + }); + expect(first).toMatchObject({ + accepted: true, + duplicate: false, + turn: { role: "assistant", status: "completed", content: "" }, + receipt: { intentId: "intent-open" }, + }); + expect(first.turn?.contentBlocks).toEqual([{ type: "taskCard", id: expect.any(String), taskId: "task-1" }]); + + const replay = materializeChatFirstIntent(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + controlGeneration: 7, + intentId: "intent-open", + continuityKey: "intent-open", + source: "daily_opener", + blocks: [{ type: "taskCard", task_id: "task-1" }], + nowMs: 101, + }); + expect(replay).toMatchObject({ accepted: true, duplicate: true, turn: { turnId: first.turn?.turnId } }); + expect(listChatFirstMaterializationReceipts(fixture.store, { + ownerId: fixture.ownerId, controlGeneration: 7, + })).toEqual([first.receipt]); + + recordTerminalQuestion(fixture, "tail-question", [ + { optionId: "later", label: "Later", preparedAnswer: "Ask me later.", defer: true }, + ]); + const suppressed = materializeChatFirstIntent(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + controlGeneration: 7, + intentId: "intent-capture", + continuityKey: "intent-capture", + source: "capture_arrival", + blocks: [{ type: "captureLink", conversation_id: "capture-1", summary: "Capture" }], + nowMs: 102, + }); + expect(suppressed).toMatchObject({ accepted: false, suppressedByTailQuestion: true, turn: null, receipt: null }); + + expect(acknowledgeChatFirstMaterializationReceipts(fixture.store, { + ownerId: fixture.ownerId, + controlGeneration: 7, + receipts: [first.receipt!], + })).toBe(1); + expect(listChatFirstMaterializationReceipts(fixture.store, { + ownerId: fixture.ownerId, controlGeneration: 7, + })).toEqual([]); + fixture.store.close(); + }); + + it("commits an ordered materialization batch once, stops after its question, and preserves a block-only outbox payload", () => { + const fixture = newSurface("main_chat", "chat", "chat-first-batch"); + const batch = materializeChatFirstIntents(fixture.store, [ + { + ownerId: fixture.ownerId, conversationId: fixture.conversationId, controlGeneration: 9, + intentId: "intent-first", continuityKey: "intent-first", source: "daily_opener", + blocks: [{ type: "taskCard", task_id: "task-1" }], nowMs: 100, + }, + { + ownerId: fixture.ownerId, conversationId: fixture.conversationId, controlGeneration: 9, + intentId: "intent-question", continuityKey: "intent-question", source: "deferral_reraise", + blocks: [{ + type: "questionCard", question_id: "question-1", text: "Continue?", + subject: { kind: "goal", id: "goal-1" }, + options: [{ option_id: "yes", label: "Yes", prepared_answer: "Yes" }], + }], nowMs: 101, + }, + { + ownerId: fixture.ownerId, conversationId: fixture.conversationId, controlGeneration: 9, + intentId: "intent-after-question", continuityKey: "intent-after-question", source: "capture_arrival", + blocks: [{ type: "captureLink", conversation_id: "capture-1", summary: "Capture" }], nowMs: 102, + }, + ]); + + expect(batch.stoppedByTail).toBe(true); + expect(batch.results).toHaveLength(2); + expect(batch.results.map((result) => result.turn?.turnId)).toEqual([ + expect.stringMatching(/^turn_cfi_/), expect.stringMatching(/^turn_cfi_/), + ]); + const deliveries = drainBackendTurnOutbox(fixture.store, { ownerId: fixture.ownerId, nowMs: 103 }); + expect(deliveries).toHaveLength(2); + expect(deliveries.map((delivery) => delivery.payload.text)).toEqual(["", ""]); + expect(deliveries.every((delivery) => delivery.payload.metadata?.includes("content_blocks"))).toBe(true); + + fixture.store.close(); + }); + + it("suppresses a materialization batch behind a streaming assistant tail", () => { + const fixture = newSurface("main_chat", "chat", "chat-first-streaming-tail"); + recordStreamingAssistantPlaceholder(fixture, "streaming-tail"); + const batch = materializeChatFirstIntents(fixture.store, [{ + ownerId: fixture.ownerId, conversationId: fixture.conversationId, controlGeneration: 4, + intentId: "intent-late", continuityKey: "intent-late", source: "capture_arrival", + blocks: [{ type: "captureLink", conversation_id: "capture-1", summary: "Capture" }], nowMs: 100, + }]); + expect(batch).toMatchObject({ stoppedByTail: true, results: [{ + accepted: false, suppressedByStreamingTail: true, turn: null, + }] }); + fixture.store.close(); + }); + it("atomically binds the one current main-chat placeholder and replays validated chat-first blocks", () => { const fixture = newSurface("main_chat", "chat", "chat-first-append"); const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-append"); From 7bba73e2de1d92bd061cca9b17c9a399ed4a75b7 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 14:47:34 -0400 Subject: [PATCH 10/28] feat(chat): add canonical goals and cold start Add the cohort-scoped canonical Goals surface and a generation-bound rich/sparse cold-start path. Cold-start delivery remains receipt-backed, and sparse-sequence terminal state now lifts proactive suppression through a durable journal receipt.\n\nVerification: static diff checks and fresh Sol review/fix gates completed. Runtime builds, suites, harness, and UI exercise remain intentionally deferred to the final consolidated verification pass at user direction. --- backend/database/chat_first_intents.py | 177 +++++++- backend/models/chat_first.py | 74 +++- backend/routers/chat_first.py | 88 +++- .../unit/test_chat_first_proactive_engine.py | 36 ++ .../unit/test_chat_first_proactive_intents.py | 111 ++++- .../unit/test_chat_first_proactive_router.py | 62 ++- .../task_intelligence/proactive_engine.py | 121 ++++- .../Desktop/Sources/Chat/AgentBridge.swift | 4 +- .../Desktop/Sources/Chat/AgentClient.swift | 4 +- .../Sources/Chat/AgentRuntimeProcess.swift | 52 ++- ...ChatFirstPromptMaterializationDriver.swift | 156 ++++++- .../Blocks/ChatFirstContentBlockViews.swift | 12 +- .../Blocks/ChatFirstRichBlockContext.swift | 3 + .../ChatFirst/CanonicalGoalsStore.swift | 306 +++++++++++++ .../ChatFirst/ChatFirstGoalsPage.swift | 410 +++++++++++++++++ ...irstPromptMaterializationCoordinator.swift | 54 +-- .../MainWindow/ChatFirst/ChatFirstRoute.swift | 24 +- .../MainWindow/ChatFirst/ChatFirstShell.swift | 11 +- .../ChatFirst/ChatFirstTasksPage.swift | 51 ++- .../MainWindow/Components/ChatBubble.swift | 3 +- .../Components/ChatScrollBehavior.swift | 19 + .../Sources/MainWindow/DesktopHomeView.swift | 3 +- .../Sources/Providers/ChatProvider.swift | 6 +- .../APIClient/APIClient+TaskCatalog.swift | 60 ++- .../Desktop/Sources/Stores/TasksStore.swift | 65 +++ .../Desktop/Sources/ViewModelContainer.swift | 4 + .../Tests/CanonicalGoalsStoreTests.swift | 231 ++++++++++ .../ChatFirstLegacyGoalIsolationTests.swift | 28 ++ ...romptMaterializationCoordinatorTests.swift | 146 +++++++ .../Desktop/Tests/ChatFirstShellTests.swift | 20 +- .../Tests/ChatFirstTasksPageTests.swift | 22 + desktop/macos/agent/src/index.ts | 26 +- desktop/macos/agent/src/protocol.ts | 18 +- .../agent/src/runtime/conversation-journal.ts | 413 +++++++++++++++++- .../macos/agent/src/runtime/sqlite-store.ts | 40 ++ desktop/macos/agent/src/runtime/types.ts | 4 +- .../agent/tests/conversation-journal.test.ts | 181 +++++++- .../unreleased/20260715-chat-first-goals.json | 3 + 38 files changed, 2937 insertions(+), 111 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift create mode 100644 desktop/macos/Desktop/Tests/CanonicalGoalsStoreTests.swift create mode 100644 desktop/macos/Desktop/Tests/ChatFirstLegacyGoalIsolationTests.swift create mode 100644 desktop/macos/changelog/unreleased/20260715-chat-first-goals.json diff --git a/backend/database/chat_first_intents.py b/backend/database/chat_first_intents.py index 2858ba035d5..f99a07833a5 100644 --- a/backend/database/chat_first_intents.py +++ b/backend/database/chat_first_intents.py @@ -2,7 +2,7 @@ import hashlib from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone from typing import Any, Iterable, cast from google.cloud import firestore @@ -11,6 +11,7 @@ from models.chat_first import ( ChatFirstBlockSpec, ChatFirstSubject, + ColdStartSequenceTerminalState, DeferralReceipt, ProactiveBudgetState, ProactiveDeferral, @@ -301,6 +302,169 @@ def apply(write_transaction: Any) -> tuple[ProactiveIntent, bool]: return apply(transaction) +def get_or_create_cold_start_intent( + uid: str, + *, + source: ProactiveIntentSource, + continuity_key: str, + subject: ChatFirstSubject | None, + blocks: list[ChatFirstBlockSpec], + account_generation: int, + now: datetime, + firestore_client: Any = None, +) -> tuple[ProactiveIntent, bool]: + """Persist exactly one generation-bound cold-start intent. + + Cold-start richness is sampled only for the first writer. The stable ID is + deliberately independent of the selected rich/sparse source so a retry + after canonical data changes returns the original ready intent rather than + producing a second first-run experience. + """ + + if source not in {'cold_start_rich', 'cold_start_sparse'}: + raise ValueError('cold-start intents require a cold-start source') + client = _db(firestore_client) + intent_id = _stable_id('cfi', uid, account_generation, 'cold_start', continuity_key) + intent = ProactiveIntent( + intent_id=intent_id, + continuity_key=continuity_key, + account_generation=account_generation, + source=source, + subject=subject, + blocks=blocks, + delivery_state='pending_kernel_receipt', + created_at=now, + ) + intent_ref = _intent_ref(uid, intent_id, firestore_client=client) + transaction = client.transaction() + + @firestore.transactional + def apply(write_transaction: Any) -> tuple[ProactiveIntent, bool]: + control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) + _require_control(control_snapshot, account_generation) + existing_snapshot = intent_ref.get(transaction=write_transaction) + if existing_snapshot.exists: + existing = ProactiveIntent.model_validate(_snapshot_dict(existing_snapshot)) + if ( + existing.account_generation != account_generation + or existing.continuity_key != continuity_key + or existing.source not in {'cold_start_rich', 'cold_start_sparse'} + ): + raise ChatFirstIntentConflictError('cold-start continuity key was reused') + return existing, False + write_transaction.set(intent_ref, _intent_payload(intent)) + return intent, True + + return apply(transaction) + + +def has_cold_start_intent_created_on( + uid: str, + *, + account_generation: int, + date_value: date, + firestore_client: Any = None, +) -> bool: + """Whether this generation already used today's deterministic opener slot.""" + + client = _db(firestore_client) + _require_current_control(uid, account_generation=account_generation, firestore_client=client) + collection = _user_ref(uid, firestore_client=client).collection(INTENTS_COLLECTION) + for snapshot in collection.stream(): + payload = _snapshot_dict(snapshot) + if payload.get('account_generation') != account_generation: + continue + if payload.get('source') not in {'cold_start_rich', 'cold_start_sparse'}: + continue + intent = ProactiveIntent.model_validate(payload) + if intent.created_at.date() == date_value: + return True + return False + + +def acknowledge_sparse_cold_start_sequence_terminal( + uid: str, + *, + sequence_id: str, + receipt_id: str, + terminal_state: ColdStartSequenceTerminalState, + account_generation: int, + now: datetime, + firestore_client: Any = None, +) -> ProactiveIntent: + """Accept one local-journal terminal receipt for the sparse sequence. + + The receipt is attached to the original cold-start intent so it cannot + become a client/operator completion flag. A sparse sequence remains active + through the crash window before its initial materialization receipt reaches + the server, then releases agent-tier judgment only after this terminal + journal fact is durably acknowledged. + """ + + expected_sequence_id = f'cold-start:{account_generation}' + if sequence_id != expected_sequence_id: + raise ChatFirstIntentConflictError('cold-start terminal sequence does not match generation') + client = _db(firestore_client) + intent_id = _stable_id('cfi', uid, account_generation, 'cold_start', sequence_id) + intent_ref = _intent_ref(uid, intent_id, firestore_client=client) + transaction = client.transaction() + + @firestore.transactional + def apply(write_transaction: Any) -> ProactiveIntent: + control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) + _require_control(control_snapshot, account_generation) + snapshot = intent_ref.get(transaction=write_transaction) + if not snapshot.exists: + raise ProactiveIntentNotReady('cold-start intent is not ready') + intent = ProactiveIntent.model_validate(_snapshot_dict(snapshot)) + if ( + intent.account_generation != account_generation + or intent.source != 'cold_start_sparse' + or intent.subject != ChatFirstSubject(kind='cold_start', id=sequence_id) + or intent.delivery_state != 'delivered' + or intent.materialization_receipt_id is None + ): + raise ProactiveIntentNotReady('cold-start sequence is not ready for terminal acknowledgement') + if intent.cold_start_sequence_terminal_receipt_id is not None: + if ( + intent.cold_start_sequence_terminal_receipt_id != receipt_id + or intent.cold_start_sequence_terminal_state != terminal_state + ): + raise ChatFirstIntentConflictError('cold-start sequence was already terminalized differently') + return intent + terminalized = intent.model_copy( + update={ + 'cold_start_sequence_terminal_state': terminal_state, + 'cold_start_sequence_terminal_receipt_id': receipt_id, + } + ) + write_transaction.set(intent_ref, _intent_payload(terminalized)) + return terminalized + + return apply(transaction) + + +def has_active_sparse_cold_start_sequence( + uid: str, + *, + account_generation: int, + firestore_client: Any = None, +) -> bool: + """Whether a sparse local-journal sequence can still own the Chat tail.""" + + client = _db(firestore_client) + _require_current_control(uid, account_generation=account_generation, firestore_client=client) + collection = _user_ref(uid, firestore_client=client).collection(INTENTS_COLLECTION) + for snapshot in collection.stream(): + payload = _snapshot_dict(snapshot) + if payload.get('account_generation') != account_generation or payload.get('source') != 'cold_start_sparse': + continue + intent = ProactiveIntent.model_validate(payload) + if intent.cold_start_sequence_terminal_receipt_id is None: + return True + return False + + def fetch_ready_intents( uid: str, *, @@ -316,7 +480,10 @@ def fetch_ready_intents( ready: list[ProactiveIntent] = [] for snapshot in collection.stream(): payload = _snapshot_dict(snapshot) - if payload.get('account_generation') != account_generation or payload.get('delivery_state') != 'ready': + if payload.get('account_generation') != account_generation or payload.get('delivery_state') not in { + 'ready', + 'pending_kernel_receipt', + }: continue ready.append(ProactiveIntent.model_validate(payload)) ready.sort(key=lambda intent: (intent.created_at, intent.intent_id)) @@ -354,7 +521,7 @@ def apply(write_transaction: Any) -> ProactiveIntent: if intent.materialization_receipt_id != receipt_id: raise ChatFirstIntentConflictError('intent was already acknowledged by a different receipt') return intent - if intent.delivery_state != 'ready': + if intent.delivery_state not in {'ready', 'pending_kernel_receipt'}: raise ProactiveIntentNotReady('proactive intent is not ready') delivered = intent.model_copy( @@ -534,6 +701,10 @@ def iter_ready_intent_ids( 'acknowledge_materialization', 'admit_agent_judgment', 'create_intent', + 'get_or_create_cold_start_intent', + 'has_cold_start_intent_created_on', + 'acknowledge_sparse_cold_start_sequence_terminal', + 'has_active_sparse_cold_start_sequence', 'fetch_ready_intents', 'get_budget_state', 'iter_ready_intent_ids', diff --git a/backend/models/chat_first.py b/backend/models/chat_first.py index 11bd4f7fdb4..f9f39e407b0 100644 --- a/backend/models/chat_first.py +++ b/backend/models/chat_first.py @@ -14,7 +14,7 @@ class _StrictModel(BaseModel): class ChatFirstSubject(_StrictModel): - kind: Literal['task', 'goal', 'capture'] + kind: Literal['task', 'goal', 'capture', 'cold_start'] id: StableId @@ -25,12 +25,20 @@ class QuestionOption(_StrictModel): defer: bool = False +class ColdStartSequence(_StrictModel): + """Explicit local-only identity for the fixed sparse cold-start script.""" + + sequence_id: StableId + step: int = Field(ge=1, le=3) + + class QuestionCardSpec(_StrictModel): type: Literal['questionCard'] question_id: StableId text: str = Field(min_length=1, max_length=300) subject: ChatFirstSubject options: list[QuestionOption] = Field(min_length=1, max_length=4) + cold_start_sequence: ColdStartSequence | None = None @model_validator(mode='after') def validate_options(self): @@ -39,6 +47,14 @@ def validate_options(self): raise ValueError('question option IDs must be unique') if sum(option.defer for option in self.options) > 1: raise ValueError('question card may contain at most one defer option') + is_cold_start = self.subject.kind == 'cold_start' + if is_cold_start != (self.cold_start_sequence is not None): + raise ValueError('cold-start subject and sequence descriptor must be paired') + if self.cold_start_sequence is not None: + if self.subject.id != self.cold_start_sequence.sequence_id: + raise ValueError('cold-start subject must match sequence identity') + if self.cold_start_sequence.step != 1: + raise ValueError('server cold-start intent must begin at sequence step one') return self @@ -87,16 +103,25 @@ class ChatFirstBlockValidationReceipt(_StrictModel): blocks: list[dict[str, object]] = Field(default_factory=list) -ProactiveIntentSource = Literal['daily_opener', 'capture_arrival', 'deferral_reraise', 'agent_judgment'] -ProactiveIntentDeliveryState = Literal['ready', 'delivered'] +ProactiveIntentSource = Literal[ + 'daily_opener', + 'capture_arrival', + 'deferral_reraise', + 'agent_judgment', + 'cold_start_rich', + 'cold_start_sparse', +] +ProactiveIntentDeliveryState = Literal['ready', 'pending_kernel_receipt', 'delivered'] +ColdStartSequenceTerminalState = Literal['completed', 'abandoned'] class ProactiveIntent(_StrictModel): """A server-side instruction, not a Chat transcript row. The local desktop kernel is the sole writer of the visible assistant turn. - This record remains ready until that kernel has committed and acknowledged - its stable ``intent_id``. + This record remains deliverable until that kernel has committed and + acknowledged its stable ``intent_id``; cold-start intents make that + explicit as ``pending_kernel_receipt``. """ intent_id: StableId @@ -109,6 +134,25 @@ class ProactiveIntent(_StrictModel): created_at: datetime delivered_at: datetime | None = None materialization_receipt_id: StableId | None = None + # This is a terminal local-journal receipt on the same sparse cold-start + # intent, never an operator-owned completion switch. It is the bounded + # server projection needed to stop suppressing agent-tier turns once the + # sequence has actually ended in the canonical transcript. + cold_start_sequence_terminal_state: ColdStartSequenceTerminalState | None = None + cold_start_sequence_terminal_receipt_id: StableId | None = None + + @model_validator(mode='after') + def validate_cold_start_sequence_state(self): + has_terminal_receipt = self.cold_start_sequence_terminal_receipt_id is not None + has_terminal_state = self.cold_start_sequence_terminal_state is not None + if has_terminal_receipt != has_terminal_state: + raise ValueError('cold-start terminal state and receipt must be paired') + if self.source == 'cold_start_sparse': + if self.subject is None or self.subject.kind != 'cold_start': + raise ValueError('sparse cold-start intent requires a cold-start subject') + elif has_terminal_receipt: + raise ValueError('only sparse cold-start intents may carry a terminal receipt') + return self @property def consumes_turn_budget(self) -> bool: @@ -135,18 +179,33 @@ class ProactiveMaterializationReceipt(_StrictModel): receipt_id: StableId +class ColdStartSequenceTerminalReceipt(_StrictModel): + """A durable local-journal acknowledgement that sparse sequencing ended.""" + + sequence_id: StableId + receipt_id: StableId + terminal_state: ColdStartSequenceTerminalState + + class MaterializePromptsRequest(_StrictModel): source_surface: Literal['main_chat'] control_generation: int = Field(ge=0) owner_fence: StableId window_foreground: bool = False + initial_page_loaded: bool = False receipts: list[ProactiveMaterializationReceipt] = Field(default_factory=list, max_length=16) + cold_start_sequence_terminal_receipts: list[ColdStartSequenceTerminalReceipt] = Field( + default_factory=list, max_length=16 + ) @model_validator(mode='after') def validate_unique_receipts(self): intent_ids = [receipt.intent_id for receipt in self.receipts] if len(intent_ids) != len(set(intent_ids)): raise ValueError('materialization receipt intent IDs must be unique') + sequence_ids = [receipt.sequence_id for receipt in self.cold_start_sequence_terminal_receipts] + if len(sequence_ids) != len(set(sequence_ids)): + raise ValueError('cold-start terminal receipt sequence IDs must be unique') return self @@ -168,6 +227,8 @@ class DeferralCreateRequest(_StrictModel): def require_question_subject_match(self): if self.question.subject != self.subject: raise ValueError('deferral question subject must match the deferred subject') + if self.subject.kind == 'cold_start': + raise ValueError('cold-start question cards cannot be deferred') return self @@ -205,6 +266,9 @@ def stable_block_id(*, uid: str, generation: int, block: ChatFirstBlockSpec) -> 'ChatFirstBlockValidationReceipt', 'ChatFirstBlockValidationRequest', 'ChatFirstSubject', + 'ColdStartSequenceTerminalReceipt', + 'ColdStartSequenceTerminalState', + 'ColdStartSequence', 'DeferralCreateRequest', 'DeferralReceipt', 'GoalLinkSpec', diff --git a/backend/routers/chat_first.py b/backend/routers/chat_first.py index c97f10e3a1e..ae730cdac56 100644 --- a/backend/routers/chat_first.py +++ b/backend/routers/chat_first.py @@ -35,7 +35,11 @@ from utils.metrics import CHAT_FIRST_PROACTIVE_TOTAL from utils.other import endpoints as auth from utils.task_intelligence.chat_first_eligibility import resolve_chat_first_eligibility -from utils.task_intelligence.proactive_engine import persist_daily_opener_intent +from utils.task_intelligence.proactive_engine import ( + classify_cold_start_profile, + persist_cold_start_intent, + persist_daily_opener_intent, +) from utils.task_intelligence.rollout import resolve_task_intelligence_for_user router = APIRouter() @@ -92,6 +96,22 @@ def _maybe_persist_daily_opener(uid: str, *, control_generation: int, now: datet """Best-effort lazy opener preparation; a failure never breaks Chat fetch.""" try: + if chat_first_intents_db.has_active_sparse_cold_start_sequence( + uid, + account_generation=control_generation, + ): + # A sparse sequence is itself the deterministic Chat tail. Keep a + # later daily opener out of the server queue until that journaled + # sequence ends, rather than letting it compete behind a question. + return + if chat_first_intents_db.has_cold_start_intent_created_on( + uid, + account_generation=control_generation, + date_value=now.date(), + ): + # The cold-start turn is this UTC day's first opener. Do not make + # a second card compete with the new Chat experience. + return blocks, subject = _daily_opener_blocks(uid) if not blocks: return @@ -108,6 +128,35 @@ def _maybe_persist_daily_opener(uid: str, *, control_generation: int, now: datet logger.warning('chat_first_daily_opener_prepare_failed uid=%s error=%s', uid, type(exc).__name__) +def _maybe_persist_cold_start(uid: str, *, control_generation: int, now: datetime) -> None: + """Persist the stable first-run intent only after capability admission.""" + + try: + # The documented decision table depends only on canonical existence, + # not focus scoring or model inference. Fetch the richer opener shape + # only when the deterministic counts admit it. + canonical_goals = goals_db.get_user_goals(uid, limit=1) + open_tasks = action_items_db.get_action_items(uid, completed=False, limit=1) + profile = classify_cold_start_profile( + canonical_goal_count=len(canonical_goals), + open_task_count=len(open_tasks), + ) + rich_blocks, rich_subject = _daily_opener_blocks(uid) if profile == 'rich' else ([], None) + persist_cold_start_intent( + uid, + profile=profile, + rich_blocks=rich_blocks, + rich_subject=rich_subject, + expected_generation=control_generation, + now=now, + eligibility_resolver=_eligibility, + ) + except Exception as exc: + # First-run preparation is retryable and must not turn an ordinary + # foreground Chat fetch into an error or leak product content. + logger.warning('chat_first_cold_start_prepare_failed uid=%s error=%s', uid, type(exc).__name__) + + def _entity_available(uid: str, block: ChatFirstBlockSpec) -> bool: if isinstance(block, TaskCardSpec): task = action_items_db.get_action_item(uid, block.task_id) @@ -119,6 +168,10 @@ def _entity_available(uid: str, block: ChatFirstBlockSpec) -> bool: return bool(capture and capture.get('source') == 'omi' and not capture.get('discarded', False)) if isinstance(block, QuestionCardSpec): subject = block.subject + if subject.kind == 'cold_start': + # Synthetic cold-start subjects are admitted only through the + # deterministic materialization endpoint, never agent tool input. + return False if subject.kind == 'task': task = action_items_db.get_action_item(uid, subject.id) return bool(task and not task.get('is_locked', False)) @@ -191,6 +244,11 @@ def materialize_prompts( owner_fence=request.owner_fence, control_generation=request.control_generation, ) + # A materialization request is meaningful only from the already-loaded + # rich main-Chat transcript. This keeps cold start and all proactive + # delivery inert for legacy, notch, and background callers. + if not request.initial_page_loaded or not request.window_foreground: + return MaterializePromptsResponse() now = datetime.now(timezone.utc) for receipt in request.receipts: try: @@ -210,6 +268,30 @@ def materialize_prompts( raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='invalid materialization receipt') from exc CHAT_FIRST_PROACTIVE_TOTAL.labels(event='kernel_receipt', source='materialization').inc() + # The kernel can only emit this after it durably terminalizes the scripted + # sequence in its canonical journal. This is an acknowledgement on the + # existing sparse intent, not a client-controlled rollout/completion flag. + for terminal_receipt in request.cold_start_sequence_terminal_receipts: + try: + chat_first_intents_db.acknowledge_sparse_cold_start_sequence_terminal( + uid, + sequence_id=terminal_receipt.sequence_id, + receipt_id=terminal_receipt.receipt_id, + terminal_state=terminal_receipt.terminal_state, + account_generation=request.control_generation, + now=now, + ) + except chat_first_intents_db.ChatFirstIntentGenerationMismatch as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='account generation mismatch') from exc + except ( + chat_first_intents_db.ChatFirstIntentConflictError, + chat_first_intents_db.ProactiveIntentNotReady, + ) as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail='invalid cold-start terminal receipt' + ) from exc + CHAT_FIRST_PROACTIVE_TOTAL.labels(event='cold_start_terminal_receipt', source='cold_start_sparse').inc() + try: released = chat_first_intents_db.release_due_deferrals( uid, @@ -218,8 +300,8 @@ def materialize_prompts( ) for _intent in released: CHAT_FIRST_PROACTIVE_TOTAL.labels(event='deferral_released', source='deferral_reraise').inc() - if request.window_foreground: - _maybe_persist_daily_opener(uid, control_generation=request.control_generation, now=now) + _maybe_persist_cold_start(uid, control_generation=request.control_generation, now=now) + _maybe_persist_daily_opener(uid, control_generation=request.control_generation, now=now) intents = chat_first_intents_db.fetch_ready_intents( uid, account_generation=request.control_generation, diff --git a/backend/tests/unit/test_chat_first_proactive_engine.py b/backend/tests/unit/test_chat_first_proactive_engine.py index 3fc43b20c37..dcb40ee9d37 100644 --- a/backend/tests/unit/test_chat_first_proactive_engine.py +++ b/backend/tests/unit/test_chat_first_proactive_engine.py @@ -2,6 +2,8 @@ from datetime import datetime, timezone +import pytest + import utils.task_intelligence.proactive_engine as engine from models.chat_first import ( ChatFirstSubject, @@ -14,6 +16,11 @@ SUBJECT = ChatFirstSubject(kind='goal', id='goal-1') +@pytest.fixture(autouse=True) +def _no_sparse_cold_start(monkeypatch): + monkeypatch.setattr(engine.intent_db, 'has_active_sparse_cold_start_sequence', lambda *args, **kwargs: False) + + class _Judge: model_version = 'fixture.v1' @@ -40,6 +47,35 @@ def _question(): ) +def test_cold_start_decision_table_requires_both_canonical_facts(): + assert engine.classify_cold_start_profile(canonical_goal_count=0, open_task_count=0) == 'sparse' + assert engine.classify_cold_start_profile(canonical_goal_count=1, open_task_count=0) == 'sparse' + assert engine.classify_cold_start_profile(canonical_goal_count=0, open_task_count=1) == 'sparse' + assert engine.classify_cold_start_profile(canonical_goal_count=1, open_task_count=1) == 'rich' + + +def test_sparse_cold_start_suppresses_agent_tier_without_calling_the_judge(monkeypatch): + monkeypatch.setattr(engine.intent_db, 'release_due_deferrals', lambda *args, **kwargs: []) + monkeypatch.setattr(engine.intent_db, 'has_active_sparse_cold_start_sequence', lambda *args, **kwargs: True) + monkeypatch.setattr( + engine.intent_db, + 'admit_agent_judgment', + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('agent admission must stay quiet')), + ) + judge = _Judge(engine.ProactiveSelection(blocks=[_question()])) + + result = engine.wake_after_commit( + 'user-1', + _trigger(), + judge=judge, + now=NOW, + eligibility_resolver=lambda _uid: ChatFirstEligibility(enabled=True, account_generation=7), + ) + + assert result.outcome == 'suppressed_by_cold_start' + assert judge.calls == 0 + + def test_capability_off_wake_has_zero_feature_store_provider_and_metric_work(monkeypatch): monkeypatch.setattr( engine.intent_db, diff --git a/backend/tests/unit/test_chat_first_proactive_intents.py b/backend/tests/unit/test_chat_first_proactive_intents.py index 26645af9306..5c6933fcefe 100644 --- a/backend/tests/unit/test_chat_first_proactive_intents.py +++ b/backend/tests/unit/test_chat_first_proactive_intents.py @@ -6,7 +6,13 @@ import pytest import database.chat_first_intents as intents_db -from models.chat_first import CaptureLinkSpec, ChatFirstSubject, QuestionCardSpec, QuestionOption +from models.chat_first import ( + CaptureLinkSpec, + ChatFirstSubject, + ColdStartSequence, + QuestionCardSpec, + QuestionOption, +) from models.chat_first import ProactiveBudgetState from models.proactive_budget import budget_allows from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode @@ -323,6 +329,109 @@ def test_capture_arrival_retry_creates_one_deterministic_receipt_intent(firestor assert retry.source == 'capture_arrival' +def test_cold_start_generation_retries_one_pending_intent_until_its_kernel_receipt(firestore): + sequence_id = f'cold-start:{GENERATION}' + question = QuestionCardSpec( + type='questionCard', + question_id=f'{sequence_id}:step:1', + text='What matters now?', + subject=ChatFirstSubject(kind='cold_start', id=sequence_id), + cold_start_sequence=ColdStartSequence(sequence_id=sequence_id, step=1), + options=[QuestionOption(option_id='progress', label='Make progress', prepared_answer='Make progress.')], + ) + first, created = intents_db.get_or_create_cold_start_intent( + UID, + source='cold_start_sparse', + continuity_key=sequence_id, + subject=question.subject, + blocks=[question], + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + retried, retry_created = intents_db.get_or_create_cold_start_intent( + UID, + source='cold_start_rich', + continuity_key=sequence_id, + subject=ChatFirstSubject(kind='goal', id='goal-1'), + blocks=[CaptureLinkSpec(type='captureLink', conversation_id='capture-1', summary='Ignored retry shape')], + account_generation=GENERATION, + now=NOW + timedelta(minutes=1), + firestore_client=firestore, + ) + + assert created is True + assert retry_created is False + assert retried == first + assert first.delivery_state == 'pending_kernel_receipt' + assert ( + intents_db.has_active_sparse_cold_start_sequence(UID, account_generation=GENERATION, firestore_client=firestore) + is True + ) + assert intents_db.fetch_ready_intents(UID, account_generation=GENERATION, firestore_client=firestore) == [first] + delivered = intents_db.acknowledge_materialization( + UID, + intent_id=first.intent_id, + receipt_id='kernel-receipt-1', + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + assert delivered.delivery_state == 'delivered' + assert ( + intents_db.has_active_sparse_cold_start_sequence(UID, account_generation=GENERATION, firestore_client=firestore) + is True + ) + terminalized = intents_db.acknowledge_sparse_cold_start_sequence_terminal( + UID, + sequence_id=sequence_id, + receipt_id='sequence-terminal-receipt-1', + terminal_state='abandoned', + account_generation=GENERATION, + now=NOW + timedelta(seconds=1), + firestore_client=firestore, + ) + assert terminalized.cold_start_sequence_terminal_state == 'abandoned' + assert terminalized.cold_start_sequence_terminal_receipt_id == 'sequence-terminal-receipt-1' + assert ( + intents_db.has_active_sparse_cold_start_sequence(UID, account_generation=GENERATION, firestore_client=firestore) + is False + ) + assert ( + intents_db.acknowledge_sparse_cold_start_sequence_terminal( + UID, + sequence_id=sequence_id, + receipt_id='sequence-terminal-receipt-1', + terminal_state='abandoned', + account_generation=GENERATION, + now=NOW + timedelta(seconds=2), + firestore_client=firestore, + ) + == terminalized + ) + with pytest.raises(intents_db.ChatFirstIntentConflictError): + intents_db.acknowledge_sparse_cold_start_sequence_terminal( + UID, + sequence_id=sequence_id, + receipt_id='another-terminal-receipt', + terminal_state='completed', + account_generation=GENERATION, + now=NOW + timedelta(seconds=3), + firestore_client=firestore, + ) + with pytest.raises(intents_db.ChatFirstIntentConflictError): + intents_db.acknowledge_materialization( + UID, + intent_id=first.intent_id, + receipt_id='different-kernel-receipt', + account_generation=GENERATION, + now=NOW + timedelta(seconds=1), + firestore_client=firestore, + ) + assert intents_db.fetch_ready_intents(UID, account_generation=GENERATION, firestore_client=firestore) == [] + + def test_deferral_releases_once_verbatim_when_due_or_subject_changes(firestore): question = _question() receipt, created = intents_db.record_deferral( diff --git a/backend/tests/unit/test_chat_first_proactive_router.py b/backend/tests/unit/test_chat_first_proactive_router.py index d3ad48062a1..03ec1096a5c 100644 --- a/backend/tests/unit/test_chat_first_proactive_router.py +++ b/backend/tests/unit/test_chat_first_proactive_router.py @@ -18,13 +18,15 @@ def _client() -> TestClient: return TestClient(app) -def _request(*, generation: int = 7, owner_fence: str = 'user-1', receipts=None) -> dict: +def _request(*, generation: int = 7, owner_fence: str = 'user-1', receipts=None, terminal_receipts=None) -> dict: return { 'source_surface': 'main_chat', 'control_generation': generation, 'owner_fence': owner_fence, - 'window_foreground': False, + 'window_foreground': True, + 'initial_page_loaded': True, 'receipts': receipts or [], + 'cold_start_sequence_terminal_receipts': terminal_receipts or [], } @@ -64,7 +66,12 @@ def test_materialize_capability_off_does_zero_feature_store_or_metric_work(monke 'resolve_task_intelligence_for_user', lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), ) - for name in ('acknowledge_materialization', 'release_due_deferrals', 'fetch_ready_intents'): + for name in ( + 'acknowledge_materialization', + 'acknowledge_sparse_cold_start_sequence_terminal', + 'release_due_deferrals', + 'fetch_ready_intents', + ): monkeypatch.setattr( chat_first_router.chat_first_intents_db, name, @@ -109,19 +116,36 @@ def test_materialize_returns_ready_intents_and_acknowledges_only_kernel_receipts created_at=datetime(2026, 7, 15, tzinfo=timezone.utc), ) acknowledgements = [] + terminal_acknowledgements = [] monkeypatch.setattr( chat_first_router.chat_first_intents_db, 'acknowledge_materialization', lambda *args, **kwargs: acknowledgements.append(kwargs) or intent, ) + monkeypatch.setattr( + chat_first_router.chat_first_intents_db, + 'acknowledge_sparse_cold_start_sequence_terminal', + lambda *args, **kwargs: terminal_acknowledgements.append(kwargs) or intent, + ) monkeypatch.setattr(chat_first_router.chat_first_intents_db, 'release_due_deferrals', lambda *args, **kwargs: []) + monkeypatch.setattr(chat_first_router, '_maybe_persist_cold_start', lambda *args, **kwargs: None) + monkeypatch.setattr(chat_first_router, '_maybe_persist_daily_opener', lambda *args, **kwargs: None) monkeypatch.setattr( chat_first_router.chat_first_intents_db, 'fetch_ready_intents', lambda *args, **kwargs: [intent] ) response = _client().post( '/v1/chat/materialize-prompts', - json=_request(receipts=[{'intent_id': 'intent-1', 'receipt_id': 'kernel-receipt-1'}]), + json=_request( + receipts=[{'intent_id': 'intent-1', 'receipt_id': 'kernel-receipt-1'}], + terminal_receipts=[ + { + 'sequence_id': 'cold-start:7', + 'receipt_id': 'sequence-terminal-1', + 'terminal_state': 'completed', + } + ], + ), ) assert response.status_code == 200 @@ -129,10 +153,40 @@ def test_materialize_returns_ready_intents_and_acknowledges_only_kernel_receipts assert acknowledgements[0]['intent_id'] == 'intent-1' assert acknowledgements[0]['receipt_id'] == 'kernel-receipt-1' assert acknowledgements[0]['account_generation'] == 7 + assert len(terminal_acknowledgements) == 1 + assert terminal_acknowledgements[0]['sequence_id'] == 'cold-start:7' + assert terminal_acknowledgements[0]['receipt_id'] == 'sequence-terminal-1' + assert terminal_acknowledgements[0]['terminal_state'] == 'completed' + assert terminal_acknowledgements[0]['account_generation'] == 7 + assert terminal_acknowledgements[0]['now'].tzinfo is not None assert response.json()['intents'][0]['intent_id'] == 'intent-1' assert response.json()['intents'][0]['delivery_state'] == 'ready' +def test_daily_opener_waits_for_an_active_sparse_cold_start_sequence(monkeypatch): + monkeypatch.setattr( + chat_first_router.chat_first_intents_db, + 'has_active_sparse_cold_start_sequence', + lambda *args, **kwargs: True, + ) + monkeypatch.setattr( + chat_first_router.chat_first_intents_db, + 'has_cold_start_intent_created_on', + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('daily lookup must not run')), + ) + monkeypatch.setattr( + chat_first_router, + 'persist_daily_opener_intent', + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('daily intent must not be created')), + ) + + chat_first_router._maybe_persist_daily_opener( + 'user-1', + control_generation=7, + now=datetime(2026, 7, 15, tzinfo=timezone.utc), + ) + + def test_deferral_receiver_is_capability_gated_before_its_store(monkeypatch): monkeypatch.setattr( chat_first_router.task_control_db, diff --git a/backend/utils/task_intelligence/proactive_engine.py b/backend/utils/task_intelligence/proactive_engine.py index fca3f47aaf9..358a75b95aa 100644 --- a/backend/utils/task_intelligence/proactive_engine.py +++ b/backend/utils/task_intelligence/proactive_engine.py @@ -6,13 +6,61 @@ from typing import Callable, Literal, Protocol import database.chat_first_intents as intent_db -from models.chat_first import CaptureLinkSpec, ChatFirstBlockSpec, ChatFirstSubject, ProactiveIntent +from models.chat_first import ( + CaptureLinkSpec, + ChatFirstBlockSpec, + ChatFirstSubject, + ColdStartSequence, + ProactiveIntent, + QuestionCardSpec, + QuestionOption, +) from utils.metrics import CHAT_FIRST_PROACTIVE_TOTAL from utils.task_intelligence.chat_first_eligibility import ChatFirstEligibility, resolve_chat_first_eligibility logger = logging.getLogger(__name__) WakeTriggerKind = Literal['task_changed', 'goal_changed', 'capture_finalized', 'deferral_due'] +ColdStartProfile = Literal['rich', 'sparse'] + + +def classify_cold_start_profile(*, canonical_goal_count: int, open_task_count: int) -> ColdStartProfile: + """Return ``rich`` only when canonical goals and open tasks both exist. + + This intentionally is the entire first-run decision table: it has no + model call, ranking heuristic, or dependency on legacy onboarding state. + """ + + return 'rich' if canonical_goal_count >= 1 and open_task_count >= 1 else 'sparse' + + +def cold_start_sparse_question(*, sequence_id: str) -> QuestionCardSpec: + """Return sparse cold-start script step one as a typed server intent.""" + + return QuestionCardSpec( + type='questionCard', + question_id=f'{sequence_id}:step:1', + text="What's the main outcome you're working toward right now? You can choose one or tell me in your own words.", + subject=ChatFirstSubject(kind='cold_start', id=sequence_id), + cold_start_sequence=ColdStartSequence(sequence_id=sequence_id, step=1), + options=[ + QuestionOption( + option_id=f'{sequence_id}:outcome:progress', + label='Make progress', + prepared_answer='I want to make progress on something important.', + ), + QuestionOption( + option_id=f'{sequence_id}:outcome:organize', + label='Get organized', + prepared_answer='I want to get organized and make a clear plan.', + ), + QuestionOption( + option_id=f'{sequence_id}:outcome:decide', + label='Decide what matters', + prepared_answer='I want help deciding what to focus on next.', + ), + ], + ) @dataclass(frozen=True) @@ -57,7 +105,16 @@ def judge(self, candidates: list[ProactiveCandidate]) -> ProactiveSelection | No @dataclass(frozen=True) class ProactiveWakeResult: - outcome: Literal['disabled', 'stale', 'budget_exhausted', 'already_pending', 'declined', 'created', 'no_candidate'] + outcome: Literal[ + 'disabled', + 'stale', + 'budget_exhausted', + 'already_pending', + 'declined', + 'created', + 'no_candidate', + 'suppressed_by_cold_start', + ] intent: ProactiveIntent | None = None @@ -101,6 +158,12 @@ def wake_after_commit( if trigger.kind not in {'task_changed', 'goal_changed'}: return ProactiveWakeResult(outcome='no_candidate') + # Sparse cold start is an ordered local-journal interaction. Its terminal + # receipt lives on the original intent, so agent judgment is quiet only + # while that sequence is active—not for the entire account generation. + if intent_db.has_active_sparse_cold_start_sequence(uid, account_generation=generation): + return ProactiveWakeResult(outcome='suppressed_by_cold_start') + try: admission = intent_db.admit_agent_judgment( uid, @@ -254,6 +317,56 @@ def persist_daily_opener_intent( return intent +def persist_cold_start_intent( + uid: str, + *, + profile: ColdStartProfile, + rich_blocks: list[ChatFirstBlockSpec], + rich_subject: ChatFirstSubject | None, + expected_generation: int | None = None, + now: datetime | None = None, + eligibility_resolver: Callable[[str], ChatFirstEligibility] = resolve_chat_first_eligibility, +) -> ProactiveIntent | None: + """Create the one deterministic, generation-bound first-run intent. + + The intent store picks the first winning rich/sparse payload under its + transaction and returns it verbatim thereafter. This function never + writes a visible Chat row; only the desktop kernel can do that. + """ + + resolved_now = now or datetime.now(timezone.utc) + eligibility = eligibility_resolver(uid) + if not eligibility.enabled or ( + expected_generation is not None and eligibility.account_generation != expected_generation + ): + return None + assert eligibility.account_generation is not None + generation = eligibility.account_generation + sequence_id = f'cold-start:{generation}' + if profile == 'rich': + if not rich_blocks or rich_subject is None: + return None + source: Literal['cold_start_rich', 'cold_start_sparse'] = 'cold_start_rich' + blocks = rich_blocks + subject = rich_subject + else: + source = 'cold_start_sparse' + blocks = [cold_start_sparse_question(sequence_id=sequence_id)] + subject = ChatFirstSubject(kind='cold_start', id=sequence_id) + intent, created = intent_db.get_or_create_cold_start_intent( + uid, + source=source, + continuity_key=sequence_id, + subject=subject, + blocks=blocks, + account_generation=generation, + now=resolved_now, + ) + if created: + _meter('intent_created', source) + return intent + + def _deterministic_shortlist(trigger: ProactiveWakeTrigger) -> list[ProactiveCandidate]: return [ ProactiveCandidate( @@ -272,12 +385,16 @@ def _meter(event: str, source: str) -> None: __all__ = [ 'EmptyProactiveJudge', + 'ColdStartProfile', 'ProactiveCandidate', 'ProactiveJudge', 'ProactiveSelection', 'ProactiveWakeResult', 'ProactiveWakeTrigger', 'persist_capture_arrival_intent', + 'classify_cold_start_profile', + 'cold_start_sparse_question', + 'persist_cold_start_intent', 'persist_daily_opener_intent', 'run_post_commit_wake', 'wake_after_commit', diff --git a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift index addd25dc866..6ee85cae605 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift @@ -1416,7 +1416,7 @@ actor AgentBridge { sessionID: String, controlGeneration: Int, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil - ) async throws -> [ChatFirstMaterializationReceipt] { + ) async throws -> ChatFirstPromptReceiptBatch { let authorization = try resolveAuthorization( authorizationSnapshot, expectedOwnerID: ownerID) @@ -1437,7 +1437,7 @@ actor AgentBridge { ownerID: String, sessionID: String, controlGeneration: Int, - receipts: [ChatFirstMaterializationReceipt], + receipts: ChatFirstPromptReceiptBatch, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil ) async throws -> Int { let authorization = try resolveAuthorization( diff --git a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift index 87a3b0a6c17..3988f613d92 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift @@ -280,7 +280,7 @@ enum AgentClient { ownerID: String, sessionID: String, controlGeneration: Int - ) async throws -> [ChatFirstMaterializationReceipt] { + ) async throws -> ChatFirstPromptReceiptBatch { try await bridge.listChatFirstMaterializationReceipts( surface: surface, ownerID: ownerID, @@ -295,7 +295,7 @@ enum AgentClient { ownerID: String, sessionID: String, controlGeneration: Int, - receipts: [ChatFirstMaterializationReceipt] + receipts: ChatFirstPromptReceiptBatch ) async throws -> Int { try await bridge.acknowledgeChatFirstMaterializationReceipts( surface: surface, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift index 120cbeaa60c..0801df24a38 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift @@ -522,6 +522,7 @@ actor AgentRuntimeProcess { let suppressedByStreamingTail: Bool = false let materializationStoppedByTail: Bool = false let materializationReceipts: [ChatFirstMaterializationReceipt] = [] + let coldStartSequenceTerminalReceipts: [ChatFirstColdStartSequenceTerminalReceipt] = [] let acknowledgedReceiptCount: Int = 0 } @@ -2106,7 +2107,7 @@ actor AgentRuntimeProcess { sessionID: String, controlGeneration: Int, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot - ) async throws -> [ChatFirstMaterializationReceipt] { + ) async throws -> ChatFirstPromptReceiptBatch { guard surface.surfaceKind == "main_chat", controlGeneration >= 0 else { throw BridgeError.agentError("Invalid chat-first receipt listing") } @@ -2119,7 +2120,10 @@ actor AgentRuntimeProcess { payload: ["sessionId": sessionID, "controlGeneration": controlGeneration, "limit": 16], authorizationSnapshot: authorizationSnapshot ) - return result.materializationReceipts + return ChatFirstPromptReceiptBatch( + materializationReceipts: result.materializationReceipts, + coldStartSequenceTerminalReceipts: result.coldStartSequenceTerminalReceipts + ) } @discardableResult @@ -2129,10 +2133,14 @@ actor AgentRuntimeProcess { ownerID: String, sessionID: String, controlGeneration: Int, - receipts: [ChatFirstMaterializationReceipt], + receipts: ChatFirstPromptReceiptBatch, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot ) async throws -> Int { - guard surface.surfaceKind == "main_chat", controlGeneration >= 0, receipts.count <= 16 else { + guard surface.surfaceKind == "main_chat", + controlGeneration >= 0, + receipts.materializationReceipts.count <= 16, + receipts.coldStartSequenceTerminalReceipts.count <= 16 + else { throw BridgeError.agentError("Invalid chat-first receipt acknowledgement") } let result = try await journalOperation( @@ -2144,7 +2152,16 @@ actor AgentRuntimeProcess { payload: [ "sessionId": sessionID, "controlGeneration": controlGeneration, - "receipts": receipts.map { ["intentId": $0.intentID, "receiptId": $0.receiptID] }, + "receipts": receipts.materializationReceipts.map { + ["intentId": $0.intentID, "receiptId": $0.receiptID] + }, + "coldStartSequenceTerminalReceipts": receipts.coldStartSequenceTerminalReceipts.map { + [ + "sequenceId": $0.sequenceID, + "receiptId": $0.receiptID, + "terminalState": $0.terminalState.rawValue, + ] + }, ], authorizationSnapshot: authorizationSnapshot ) @@ -3805,7 +3822,10 @@ actor AgentRuntimeProcess { materializationReceipts: Self.chatFirstMaterializationReceipts( from: message.payload["materializationReceipts"] ), - acknowledgedReceiptCount: message.payload["acknowledgedReceiptCount"] as? Int ?? 0 + acknowledgedReceiptCount: message.payload["acknowledgedReceiptCount"] as? Int ?? 0, + coldStartSequenceTerminalReceipts: Self.chatFirstColdStartSequenceTerminalReceipts( + from: message.payload["coldStartSequenceTerminalReceipts"] + ) )) } @@ -3823,6 +3843,26 @@ actor AgentRuntimeProcess { } } + private nonisolated static func chatFirstColdStartSequenceTerminalReceipts( + from payload: Any? + ) -> [ChatFirstColdStartSequenceTerminalReceipt] { + guard let values = payload as? [[String: Any]] else { return [] } + return values.compactMap { value in + guard let sequenceID = value["sequenceId"] as? String, + !sequenceID.isEmpty, + let receiptID = value["receiptId"] as? String, + !receiptID.isEmpty, + let rawState = value["terminalState"] as? String, + let terminalState = ChatFirstColdStartSequenceTerminalReceipt.TerminalState(rawValue: rawState) + else { return nil } + return ChatFirstColdStartSequenceTerminalReceipt( + sequenceID: sequenceID, + receiptID: receiptID, + terminalState: terminalState + ) + } + } + private func journalTurn(from message: RuntimeMessage) -> KernelJournalTurn? { guard let dictionary = message.payload["turn"] as? [String: Any] else { return nil } let surface = AgentSurfaceReference( diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift index e254ddac9e7..63ff467de3c 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift @@ -13,6 +13,38 @@ struct ChatFirstMaterializationReceipt: Codable, Equatable, Sendable { } } +/// A terminal receipt is derived only from the local main-Chat journal after +/// the fixed sparse script completes or the user explicitly abandons it. It +/// is carried beside materialization receipts through the existing server +/// fetch/ack boundary, never persisted as a client rollout preference. +struct ChatFirstColdStartSequenceTerminalReceipt: Codable, Equatable, Sendable { + enum TerminalState: String, Codable, Sendable { + case completed + case abandoned + } + + let sequenceID: String + let receiptID: String + let terminalState: TerminalState + + enum CodingKeys: String, CodingKey { + case sequenceID = "sequence_id" + case receiptID = "receipt_id" + case terminalState = "terminal_state" + } +} + +struct ChatFirstPromptReceiptBatch: Equatable, Sendable { + let materializationReceipts: [ChatFirstMaterializationReceipt] + let coldStartSequenceTerminalReceipts: [ChatFirstColdStartSequenceTerminalReceipt] + + static let empty = Self(materializationReceipts: [], coldStartSequenceTerminalReceipts: []) + + var isEmpty: Bool { + materializationReceipts.isEmpty && coldStartSequenceTerminalReceipts.isEmpty + } +} + /// The provider vends this only while the root-sampled capability remains /// admitted for the exact main-Chat owner. It is not a persisted rollout flag. struct ChatFirstMaterializationContext: Equatable, Sendable { @@ -26,6 +58,8 @@ struct ChatFirstPromptIntent: Decodable { case captureArrival = "capture_arrival" case deferralReraise = "deferral_reraise" case agentJudgment = "agent_judgment" + case coldStartRich = "cold_start_rich" + case coldStartSparse = "cold_start_sparse" } let intentID: String @@ -54,6 +88,95 @@ struct ChatFirstMaterializePromptsResponse: Decodable { let intents: [ChatFirstPromptIntent] } +/// The coordinator consumes this narrow driver rather than owning an API +/// client, journal, or cache. It keeps the production flow testable while the +/// ChatProvider remains the sole Swift projection of the kernel transcript. +@MainActor +protocol ChatFirstPromptMaterializationDriving: AnyObject { + func materializationContext() -> ChatFirstMaterializationContext? + func pendingReceipts() async throws -> ChatFirstPromptReceiptBatch + func fetchPrompts( + ownerID: String, + controlGeneration: Int, + windowForeground: Bool, + receipts: ChatFirstPromptReceiptBatch + ) async throws -> ChatFirstMaterializePromptsResponse + func acknowledge(_ receipts: ChatFirstPromptReceiptBatch) async throws + func materialize(_ intents: [ChatFirstPromptIntent]) async throws +} + +/// One presence-triggered fetch/ack/materialize pass. The local receipt is +/// removed only after the server fetch accepted it; an acknowledgement failure +/// leaves the kernel receipt available for the next attempt. +@MainActor +enum ChatFirstPromptMaterializationRunner { + static func run( + driver: any ChatFirstPromptMaterializationDriving, + context: ChatFirstMaterializationContext, + windowForeground: Bool, + isCurrent: @escaping @MainActor () -> Bool + ) async throws { + guard isCurrent() else { return } + let pendingReceipts = try await driver.pendingReceipts() + guard isCurrent() else { return } + let response = try await driver.fetchPrompts( + ownerID: context.ownerID, + controlGeneration: context.controlGeneration, + windowForeground: windowForeground, + receipts: pendingReceipts + ) + guard isCurrent() else { return } + if !pendingReceipts.isEmpty { + try await driver.acknowledge(pendingReceipts) + guard isCurrent() else { return } + } + guard response.intents.allSatisfy({ $0.accountGeneration == context.controlGeneration }) else { return } + try await driver.materialize(response.intents) + } +} + +@MainActor +final class APIChatFirstPromptMaterializationDriver: ChatFirstPromptMaterializationDriving { + private weak var chatProvider: ChatProvider? + + init(chatProvider: ChatProvider) { + self.chatProvider = chatProvider + } + + func materializationContext() -> ChatFirstMaterializationContext? { + chatProvider?.chatFirstMaterializationContext() + } + + func pendingReceipts() async throws -> ChatFirstPromptReceiptBatch { + guard let chatProvider else { return .empty } + return try await chatProvider.pendingChatFirstMaterializationReceipts() + } + + func fetchPrompts( + ownerID: String, + controlGeneration: Int, + windowForeground: Bool, + receipts: ChatFirstPromptReceiptBatch + ) async throws -> ChatFirstMaterializePromptsResponse { + try await APIClient.shared.materializeChatFirstPrompts( + ownerID: ownerID, + controlGeneration: controlGeneration, + windowForeground: windowForeground, + receipts: receipts + ) + } + + func acknowledge(_ receipts: ChatFirstPromptReceiptBatch) async throws { + guard let chatProvider else { return } + _ = try await chatProvider.acknowledgeChatFirstMaterializationReceipts(receipts) + } + + func materialize(_ intents: [ChatFirstPromptIntent]) async throws { + guard let chatProvider else { return } + _ = try await chatProvider.materializeChatFirstIntents(intents) + } +} + private struct ChatFirstMaterializePromptsRequest: Encodable { struct Receipt: Encodable { let intentID: String @@ -65,18 +188,34 @@ private struct ChatFirstMaterializePromptsRequest: Encodable { } } + struct ColdStartSequenceTerminalReceipt: Encodable { + let sequenceID: String + let receiptID: String + let terminalState: ChatFirstColdStartSequenceTerminalReceipt.TerminalState + + enum CodingKeys: String, CodingKey { + case sequenceID = "sequence_id" + case receiptID = "receipt_id" + case terminalState = "terminal_state" + } + } + let sourceSurface: String = "main_chat" let controlGeneration: Int let ownerFence: String let windowForeground: Bool + let initialPageLoaded: Bool = true let receipts: [Receipt] + let coldStartSequenceTerminalReceipts: [ColdStartSequenceTerminalReceipt] enum CodingKeys: String, CodingKey { case sourceSurface = "source_surface" case controlGeneration = "control_generation" case ownerFence = "owner_fence" case windowForeground = "window_foreground" + case initialPageLoaded = "initial_page_loaded" case receipts + case coldStartSequenceTerminalReceipts = "cold_start_sequence_terminal_receipts" } } @@ -85,17 +224,28 @@ extension APIClient { ownerID: String, controlGeneration: Int, windowForeground: Bool, - receipts: [ChatFirstMaterializationReceipt] + receipts: ChatFirstPromptReceiptBatch ) async throws -> ChatFirstMaterializePromptsResponse { - guard !ownerID.isEmpty, controlGeneration >= 0, receipts.count <= 16 else { + guard !ownerID.isEmpty, + controlGeneration >= 0, + receipts.materializationReceipts.count <= 16, + receipts.coldStartSequenceTerminalReceipts.count <= 16 + else { throw APIError.invalidResponse } let body = ChatFirstMaterializePromptsRequest( controlGeneration: controlGeneration, ownerFence: ownerID, windowForeground: windowForeground, - receipts: receipts.map { + receipts: receipts.materializationReceipts.map { ChatFirstMaterializePromptsRequest.Receipt(intentID: $0.intentID, receiptID: $0.receiptID) + }, + coldStartSequenceTerminalReceipts: receipts.coldStartSequenceTerminalReceipts.map { + ChatFirstMaterializePromptsRequest.ColdStartSequenceTerminalReceipt( + sequenceID: $0.sequenceID, + receiptID: $0.receiptID, + terminalState: $0.terminalState + ) } ) return try await post( diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift index b9da1e24425..0a37ae1465f 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift @@ -225,6 +225,7 @@ struct GoalLinkView: View { let goalID: String let summary: String let navigation: ChatFirstShellNavigation + @ObservedObject var goalsStore: CanonicalGoalsStore @State private var isOpening = false @State private var isUnavailable = false @@ -251,14 +252,13 @@ struct GoalLinkView: View { isOpening = true Task { @MainActor in defer { isOpening = false } - do { - // Validate the canonical entity at interaction time. The actual - // destination stays typed and is owned by the shell. - _ = try await APIClient.shared.getCanonicalGoalDetail(goalID: goalID) - navigation.open(focus: .goal(id: goalID)) - } catch { + // Validate through the root-owned canonical projection. The actual + // destination remains a typed shell focus, never a display-string URL. + guard await goalsStore.loadDetail(goalID: goalID) != nil else { isUnavailable = true + return } + navigation.open(focus: .goal(id: goalID)) } } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift index eab35175ce0..8355a532aae 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift @@ -9,17 +9,20 @@ struct ChatFirstRichBlockContext { let navigation: ChatFirstShellNavigation let tasksStore: TasksStore let chatProvider: ChatProvider + let canonicalGoalsStore: CanonicalGoalsStore let promptMaterializationCoordinator: ChatFirstPromptMaterializationCoordinator init( navigation: ChatFirstShellNavigation, tasksStore: TasksStore = .shared, chatProvider: ChatProvider, + canonicalGoalsStore: CanonicalGoalsStore, promptMaterializationCoordinator: ChatFirstPromptMaterializationCoordinator ) { self.navigation = navigation self.tasksStore = tasksStore self.chatProvider = chatProvider + self.canonicalGoalsStore = canonicalGoalsStore self.promptMaterializationCoordinator = promptMaterializationCoordinator } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift new file mode 100644 index 00000000000..5ba7265941b --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift @@ -0,0 +1,306 @@ +import Combine +import Foundation + +/// The narrow canonical-goal contract shared by the cohort-only Chat renderers +/// and Goals destination. It intentionally has no relationship to the +/// Dashboard recommendation/outbox state. +protocol CanonicalGoalsClient: AnyObject { + func getCanonicalGoals( + includeEnded: Bool, + expectedOwnerId: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? + ) async throws -> [OmiAPI.GoalResponse] + func getCanonicalGoalDetail( + goalID: String, + expectedOwnerId: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? + ) async throws -> OmiAPI.GoalDetailProjection + func focusCanonicalGoal( + goalID: String, + replacementGoalID: String?, + focusRank: Int?, + accountGeneration: Int, + idempotencyKey: String, + expectedOwnerId: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? + ) async throws -> OmiAPI.GoalResponse +} + +extension APIClient: CanonicalGoalsClient {} + +/// Root-owned canonical projection for the chat-first cohort. The store accepts +/// only the immutable capability sampled by the root shell; it never reads a +/// local rollout preference or re-decides the cohort from cached goal data. +@MainActor +final class CanonicalGoalsStore: ObservableObject { + enum Availability: Equatable { + case inactive + case loading + case ready + case unavailable(String) + } + + private struct Scope: Equatable { + let ownerID: String + let authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? + let capability: ChatFirstCapabilityProjection + let revision: UInt + } + + @Published private(set) var goals: [OmiAPI.GoalResponse] = [] + @Published private(set) var selectedGoalDetail: OmiAPI.GoalDetailProjection? + @Published private(set) var availability: Availability = .inactive + @Published private(set) var error: String? + @Published private(set) var focusMutationGoalID: String? + + private let client: any CanonicalGoalsClient + private let ownerIDProvider: () -> String? + private let authorizationSnapshotProvider: (String) -> RuntimeOwnerAuthorizationSnapshot? + private var capability: ChatFirstCapabilityProjection? + private var ownerID: String? + private var ownerRevision: UInt = 0 + private var activeLoadToken: UUID? + + init( + client: any CanonicalGoalsClient = APIClient.shared, + ownerIDProvider: @escaping () -> String? = { RuntimeOwnerIdentity.currentOwnerId() }, + authorizationSnapshotProvider: @escaping (String) -> RuntimeOwnerAuthorizationSnapshot? = { + RuntimeOwnerIdentity.captureAuthorizationSnapshot(expectedOwnerID: $0) + } + ) { + self.client = client + self.ownerIDProvider = ownerIDProvider + self.authorizationSnapshotProvider = authorizationSnapshotProvider + } + + var isLoading: Bool { + if case .loading = availability { return true } + return false + } + + var primaryFocusedGoal: OmiAPI.GoalResponse? { + focusedGoals.first + } + + var focusedGoals: [OmiAPI.GoalResponse] { + activeGoals + .filter { $0.status == .focused } + .sorted { ($0.focusRank ?? Int.max, $0.updatedAt) < ($1.focusRank ?? Int.max, $1.updatedAt) } + } + + var activeGoals: [OmiAPI.GoalResponse] { + goals + .filter { $0.isActive && $0.status != .achieved && $0.status != .abandoned } + .sorted { ($0.focusRank ?? Int.max, $0.updatedAt) < ($1.focusRank ?? Int.max, $1.updatedAt) } + } + + var otherActiveGoals: [OmiAPI.GoalResponse] { + let primaryID = primaryFocusedGoal?.goalId + return activeGoals.filter { $0.goalId != primaryID } + } + + /// Called exclusively by `ChatFirstShell` with its one app-session sample. + /// A changed/missing owner fails closed to an unavailable projection rather + /// than letting a new account consume a previous account's goal state. + func activate(capability: ChatFirstCapabilityProjection) { + guard capability.chatFirstUi, + let normalizedOwnerID = normalized(ownerIDProvider()) + else { + deactivateAsUnavailable() + return + } + + if let existingCapability = self.capability, let ownerID { + guard existingCapability == capability, ownerID == normalizedOwnerID else { + deactivateAsUnavailable() + return + } + return + } + + self.capability = capability + ownerID = normalizedOwnerID + ownerRevision &+= 1 + goals = [] + selectedGoalDetail = nil + error = nil + availability = .inactive + } + + func resetSessionState() { + capability = nil + ownerID = nil + ownerRevision &+= 1 + activeLoadToken = nil + goals = [] + selectedGoalDetail = nil + focusMutationGoalID = nil + error = nil + availability = .inactive + } + + func load() async { + guard let scope = captureScope() else { return } + guard activeLoadToken == nil else { return } + + let token = UUID() + activeLoadToken = token + error = nil + availability = .loading + defer { + if activeLoadToken == token { + activeLoadToken = nil + } + } + + do { + let loadedGoals = try await client.getCanonicalGoals( + includeEnded: false, + expectedOwnerId: scope.ownerID, + authorizationSnapshot: scope.authorizationSnapshot + ) + guard scopeIsCurrent(scope), activeLoadToken == token else { return } + goals = loadedGoals + availability = .ready + } catch { + guard scopeIsCurrent(scope), activeLoadToken == token else { return } + goals = [] + selectedGoalDetail = nil + let message = "Goals are unavailable right now. Try again." + error = message + availability = .unavailable(message) + } + } + + /// Fetching detail is also the ownership/deletion validation boundary for a + /// journaled goal link. The result is display data only; task mutations stay + /// in `TasksStore`. + @discardableResult + func loadDetail(goalID: String) async -> OmiAPI.GoalDetailProjection? { + guard let scope = captureScope(), let normalizedGoalID = normalized(goalID) else { return nil } + do { + let detail = try await client.getCanonicalGoalDetail( + goalID: normalizedGoalID, + expectedOwnerId: scope.ownerID, + authorizationSnapshot: scope.authorizationSnapshot + ) + guard scopeIsCurrent(scope), detail.goal.goalId == normalizedGoalID else { return nil } + selectedGoalDetail = detail + if case .unavailable = availability { + // A confirmed canonical detail is enough to let a deep link render its + // target while the list can be retried independently. + availability = .ready + } + error = nil + return detail + } catch { + guard scopeIsCurrent(scope) else { return nil } + // A missing/revoked target must remain an inert link or an honest page + // state; do not substitute a legacy-goal record. + selectedGoalDetail = nil + error = "This goal is no longer available." + return nil + } + } + + /// Focus is a server mutation with the root-sampled generation and an + /// occurrence-specific idempotency key. The page always reconciles the full + /// projection afterwards instead of locally reordering an optimistic list. + @discardableResult + func setAsFocus(goalID: String) async -> Bool { + guard let scope = captureScope(), let normalizedGoalID = normalized(goalID) else { return false } + guard activeGoals.contains(where: { $0.goalId == normalizedGoalID }) else { + error = "This goal is no longer available." + return false + } + if primaryFocusedGoal?.goalId == normalizedGoalID { return true } + guard focusMutationGoalID == nil else { return false } + + let replacementGoalID = primaryFocusedGoal?.goalId + focusMutationGoalID = normalizedGoalID + defer { focusMutationGoalID = nil } + do { + _ = try await client.focusCanonicalGoal( + goalID: normalizedGoalID, + replacementGoalID: replacementGoalID, + focusRank: nil, + accountGeneration: scope.capability.controlGeneration, + idempotencyKey: "goal-focus:\(normalizedGoalID):\(UUID().uuidString.lowercased())", + expectedOwnerId: scope.ownerID, + authorizationSnapshot: scope.authorizationSnapshot + ) + guard scopeIsCurrent(scope) else { return false } + await load() + guard scopeIsCurrent(scope) else { return false } + return true + } catch { + guard scopeIsCurrent(scope) else { return false } + error = "Goal focus could not be updated." + return false + } + } + + private func captureScope() -> Scope? { + guard let capability, let ownerID, normalized(ownerIDProvider()) == ownerID else { + deactivateAsUnavailable() + return nil + } + return Scope( + ownerID: ownerID, + authorizationSnapshot: authorizationSnapshotProvider(ownerID), + capability: capability, + revision: ownerRevision + ) + } + + private func scopeIsCurrent(_ scope: Scope) -> Bool { + scope.ownerID == ownerID + && scope.capability == capability + && scope.revision == ownerRevision + && normalized(ownerIDProvider()) == scope.ownerID + && (scope.authorizationSnapshot.map(RuntimeOwnerIdentity.isAuthorizationCurrent) ?? true) + && !Task.isCancelled + } + + private func deactivateAsUnavailable() { + capability = nil + ownerID = nil + ownerRevision &+= 1 + activeLoadToken = nil + goals = [] + selectedGoalDetail = nil + focusMutationGoalID = nil + let message = "Goals are unavailable right now. Try again." + error = message + availability = .unavailable(message) + } + + private func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } +} + +/// Pure detail presentation rules keep generated aggregate task records as +/// display references. They are never converted into mutable task models. +enum ChatFirstGoalDetailPolicy { + static func completedTaskCount(in detail: OmiAPI.GoalDetailProjection) -> Int { + detail.tasks.count(where: \.completed) + } + + static func nextTaskIDs(in detail: OmiAPI.GoalDetailProjection, limit: Int = 3) -> [String] { + detail.tasks + .filter { !$0.completed } + .prefix(max(0, limit)) + .map(\.id) + } + + static func focusToAcknowledge( + pendingFocus: ChatFirstPendingFocus?, + visibleGoalID: String + ) -> ChatFirstPendingFocus? { + guard case .goal(let goalID) = pendingFocus, goalID == visibleGoalID else { return nil } + return pendingFocus + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift new file mode 100644 index 00000000000..137af90d6c7 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift @@ -0,0 +1,410 @@ +import Foundation +import SwiftUI +import OmiTheme + +/// Cohort-only canonical Goals destination. The page owns no copied goal or +/// task state: it renders the shared projection and delegates task mutation to +/// `TasksStore` after that store hydrates the canonical task ID. +@MainActor +struct ChatFirstGoalsPage: View { + @ObservedObject var navigation: ChatFirstShellNavigation + @ObservedObject var goalsStore: CanonicalGoalsStore + @ObservedObject var tasksStore: TasksStore + let chatProvider: ChatProvider + + @State private var resolvingTaskIDs = Set() + + private var pendingGoalID: String? { + guard case .goal(let id) = navigation.pendingFocus else { return nil } + return id + } + + private var displayedDetail: OmiAPI.GoalDetailProjection? { + guard let detail = goalsStore.selectedGoalDetail else { return nil } + if let pendingGoalID { return detail.goal.goalId == pendingGoalID ? detail : nil } + return detail.goal.goalId == goalsStore.primaryFocusedGoal?.goalId ? detail : nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + + Group { + switch goalsStore.availability { + case .unavailable(let message): + unavailableState(message) + case .inactive, .loading: + if goalsStore.activeGoals.isEmpty { + ProgressView("Loading goals") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + goalContent + } + default: + if goalsStore.activeGoals.isEmpty { + emptyState + } else { + goalContent + } + } + } + } + .background(OmiColors.backgroundPrimary) + .onAppear { + Task { await refreshProjectionAndDetail() } + } + .onChange(of: navigation.pendingFocus) { _, _ in + Task { await loadDetailForCurrentFocus() } + } + .accessibilityIdentifier("chat-first-goals-page") + } + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: OmiSpacing.xxs) { + Text("Goals") + .scaledFont(size: OmiType.title, weight: .bold) + .foregroundStyle(OmiColors.textPrimary) + Text("Keep the work that matters in view.") + .scaledFont(size: OmiType.body) + .foregroundStyle(OmiColors.textSecondary) + } + Spacer() + Button { + Task { await refreshProjectionAndDetail() } + } label: { + Image(systemName: "arrow.clockwise") + .scaledFont(size: OmiType.body, weight: .medium) + } + .buttonStyle(.plain) + .disabled(goalsStore.isLoading) + .accessibilityLabel("Refresh goals") + .accessibilityIdentifier("chat-first-goals-refresh") + } + .padding(.horizontal, OmiSpacing.xxl) + .padding(.vertical, OmiSpacing.xl) + } + + private var goalContent: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: OmiSpacing.xxl) { + if let detail = displayedDetail { + ChatFirstFocusedGoalSection( + detail: detail, + isResolvingTask: { resolvingTaskIDs.contains($0) }, + onToggleTask: toggleTask, + onViewTasks: { + navigation.open(focus: .goal(id: detail.goal.goalId), destination: .tasks) + }, + onContinueInChat: { navigation.discuss(.goal(id: detail.goal.goalId), using: chatProvider) }, + onVisible: acknowledgeVisibleGoal + ) + .id(detail.goal.goalId) + } else if goalsStore.primaryFocusedGoal != nil || pendingGoalID != nil { + ProgressView("Loading goal") + .frame(maxWidth: .infinity) + } + + if !goalsStore.otherActiveGoals.isEmpty { + VStack(alignment: .leading, spacing: OmiSpacing.md) { + Text("Other active goals") + .scaledFont(size: OmiType.subheading, weight: .semibold) + .foregroundStyle(OmiColors.textPrimary) + + ForEach(goalsStore.otherActiveGoals, id: \.goalId) { goal in + ChatFirstGoalRow( + goal: goal, + isSettingFocus: goalsStore.focusMutationGoalID == goal.goalId, + onDiscuss: { navigation.discuss(.goal(id: goal.goalId), using: chatProvider) }, + onSetFocus: { setFocus(goalID: goal.goalId) } + ) + } + } + } + + if let error = goalsStore.error, displayedDetail != nil { + Text(error) + .scaledFont(size: OmiType.caption) + .foregroundStyle(OmiColors.textSecondary) + .accessibilityIdentifier("chat-first-goals-inline-error") + } + } + .padding(.horizontal, OmiSpacing.xxl) + .padding(.bottom, OmiSpacing.xxl) + } + } + + private var emptyState: some View { + ContentUnavailableView { + Label("No active goals", systemImage: "target") + } description: { + Text("Omi can help you turn what matters into a clear goal.") + } actions: { + Button("Talk to Omi about a goal") { + navigation.discuss(.goals, using: chatProvider) + } + .accessibilityIdentifier("chat-first-goals-empty-discuss") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func unavailableState(_ message: String) -> some View { + ContentUnavailableView { + Label("Goals are unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("Refresh") { + Task { await refreshProjectionAndDetail() } + } + .accessibilityIdentifier("chat-first-goals-unavailable-refresh") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func refreshProjectionAndDetail() async { + await goalsStore.load() + await loadDetailForCurrentFocus() + } + + private func loadDetailForCurrentFocus() async { + let goalID = pendingGoalID ?? goalsStore.primaryFocusedGoal?.goalId + guard let goalID else { return } + _ = await goalsStore.loadDetail(goalID: goalID) + } + + private func acknowledgeVisibleGoal(_ goalID: String) { + guard let focus = ChatFirstGoalDetailPolicy.focusToAcknowledge( + pendingFocus: navigation.pendingFocus, + visibleGoalID: goalID + ) else { return } + _ = navigation.acknowledgeFocus(focus) + } + + private func setFocus(goalID: String) { + Task { @MainActor in + guard await goalsStore.setAsFocus(goalID: goalID) else { return } + _ = await goalsStore.loadDetail(goalID: goalID) + } + } + + private func toggleTask(_ taskID: String) { + guard !resolvingTaskIDs.contains(taskID) else { return } + resolvingTaskIDs.insert(taskID) + Task { @MainActor in + defer { resolvingTaskIDs.remove(taskID) } + guard let task = await tasksStore.resolveCanonicalTask(id: taskID) else { return } + await tasksStore.toggleTask(task) + if let goalID = displayedDetail?.goal.goalId { + _ = await goalsStore.loadDetail(goalID: goalID) + } + } + } +} + +private struct ChatFirstFocusedGoalSection: View { + let detail: OmiAPI.GoalDetailProjection + let isResolvingTask: (String) -> Bool + let onToggleTask: (String) -> Void + let onViewTasks: () -> Void + let onContinueInChat: () -> Void + let onVisible: (String) -> Void + + private var completedCount: Int { ChatFirstGoalDetailPolicy.completedTaskCount(in: detail) } + private var nextTasks: [OmiAPI.ActionItemResponse] { + detail.tasks.filter { !$0.completed }.prefix(3).map { $0 } + } + + var body: some View { + VStack(alignment: .leading, spacing: OmiSpacing.lg) { + HStack(alignment: .top, spacing: OmiSpacing.md) { + Image(systemName: "target") + .scaledFont(size: OmiType.subheading, weight: .semibold) + .foregroundStyle(OmiColors.textSecondary) + .frame(width: 28, height: 28) + .omiControlSurface( + fill: OmiColors.backgroundPrimary.opacity(0.7), + radius: OmiChrome.chipRadius, + stroke: OmiColors.border.opacity(0.55) + ) + + VStack(alignment: .leading, spacing: OmiSpacing.xxs) { + Text("Focused goal") + .scaledFont(size: OmiType.caption, weight: .semibold) + .foregroundStyle(OmiColors.textSecondary) + Text(detail.goal.title) + .scaledFont(size: OmiType.subheading, weight: .bold) + .foregroundStyle(OmiColors.textPrimary) + Text(detail.goal.desiredOutcome) + .scaledFont(size: OmiType.body) + .foregroundStyle(OmiColors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + if let whyItMatters = detail.goal.whyItMatters, !whyItMatters.isEmpty { + Text(whyItMatters) + .scaledFont(size: OmiType.caption) + .foregroundStyle(OmiColors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: OmiSpacing.xs) { + HStack { + Text("Progress") + Spacer() + Text("\(completedCount) of \(detail.tasks.count) tasks") + } + .scaledFont(size: OmiType.caption, weight: .medium) + .foregroundStyle(OmiColors.textSecondary) + ProgressView(value: Double(completedCount), total: Double(max(1, detail.tasks.count))) + .tint(OmiColors.success) + } + + if !nextTasks.isEmpty { + VStack(alignment: .leading, spacing: OmiSpacing.sm) { + Text("Next up") + .scaledFont(size: OmiType.caption, weight: .semibold) + .foregroundStyle(OmiColors.textSecondary) + ForEach(nextTasks, id: \.id) { task in + ChatFirstGoalTaskRow( + task: task, + isResolving: isResolvingTask(task.id), + onToggle: { onToggleTask(task.id) } + ) + } + } + } + + HStack(spacing: OmiSpacing.lg) { + Button("View related tasks", action: onViewTasks) + .buttonStyle(.plain) + .foregroundStyle(OmiColors.textSecondary) + .accessibilityIdentifier("chat-first-goal-\(detail.goal.goalId)-tasks") + Button("Continue in Chat", action: onContinueInChat) + .buttonStyle(.plain) + .foregroundStyle(OmiColors.textSecondary) + .accessibilityIdentifier("chat-first-goal-\(detail.goal.goalId)-discuss") + } + .scaledFont(size: OmiType.caption, weight: .semibold) + } + .padding(OmiSpacing.xl) + .frame(maxWidth: 680, alignment: .leading) + .omiPanel( + fill: OmiColors.backgroundTertiary.opacity(0.88), + radius: OmiChrome.sectionRadius, + stroke: OmiColors.border.opacity(0.55), + shadowOpacity: 0.05, + shadowRadius: 5, + shadowY: 2 + ) + .onAppear { onVisible(detail.goal.goalId) } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("chat-first-goal-focused-\(detail.goal.goalId)") + } +} + +private struct ChatFirstGoalTaskRow: View { + let task: OmiAPI.ActionItemResponse + let isResolving: Bool + let onToggle: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: OmiSpacing.sm) { + Button(action: onToggle) { + Image(systemName: task.completed ? "checkmark.circle.fill" : "circle") + .scaledFont(size: OmiType.body, weight: .medium) + .foregroundStyle(task.completed ? OmiColors.success : OmiColors.textTertiary) + } + .buttonStyle(.plain) + .disabled(isResolving) + .accessibilityLabel(task.completed ? "Mark \(task.description_) incomplete" : "Mark \(task.description_) complete") + .accessibilityIdentifier("chat-first-goal-task-\(task.id)-toggle") + + Text(task.description_) + .scaledFont(size: OmiType.body) + .foregroundStyle(task.completed ? OmiColors.textTertiary : OmiColors.textPrimary) + .strikethrough(task.completed, color: OmiColors.textTertiary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + if isResolving { + ProgressView() + .controlSize(.small) + } + } + .accessibilityIdentifier("chat-first-goal-task-\(task.id)") + } +} + +private struct ChatFirstGoalRow: View { + let goal: OmiAPI.GoalResponse + let isSettingFocus: Bool + let onDiscuss: () -> Void + let onSetFocus: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: OmiSpacing.md) { + Image(systemName: goal.status == .focused ? "target" : "circle") + .scaledFont(size: OmiType.body, weight: .medium) + .foregroundStyle(OmiColors.textTertiary) + .frame(width: 22, height: 22) + + VStack(alignment: .leading, spacing: OmiSpacing.xxs) { + Text(goal.title) + .scaledFont(size: OmiType.body, weight: .semibold) + .foregroundStyle(OmiColors.textPrimary) + Text(goal.desiredOutcome) + .scaledFont(size: OmiType.caption) + .foregroundStyle(OmiColors.textSecondary) + .lineLimit(2) + Text(ChatFirstGoalProgressPolicy.summary(for: goal)) + .scaledFont(size: OmiType.caption, weight: .medium) + .foregroundStyle(OmiColors.textTertiary) + } + Spacer(minLength: OmiSpacing.md) + VStack(alignment: .trailing, spacing: OmiSpacing.sm) { + Button("Discuss", action: onDiscuss) + .buttonStyle(.plain) + .foregroundStyle(OmiColors.textSecondary) + .accessibilityIdentifier("chat-first-goal-\(goal.goalId)-discuss") + Button { + onSetFocus() + } label: { + HStack(spacing: OmiSpacing.xxs) { + if isSettingFocus { ProgressView().controlSize(.small) } + Text("Set as focus") + } + } + .buttonStyle(.plain) + .foregroundStyle(OmiColors.textSecondary) + .disabled(isSettingFocus) + .accessibilityIdentifier("chat-first-goal-\(goal.goalId)-set-focus") + } + .scaledFont(size: OmiType.caption, weight: .medium) + } + .padding(OmiSpacing.md) + .frame(maxWidth: 680, alignment: .leading) + .omiControlSurface( + fill: OmiColors.backgroundTertiary.opacity(0.65), + radius: OmiChrome.smallControlRadius, + stroke: OmiColors.border.opacity(0.45) + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("chat-first-goal-row-\(goal.goalId)") + } +} + +/// Canonical goals use a generic numeric projection. The list must therefore +/// show progress from that projection rather than inventing a task count or +/// consulting the legacy goal store. +enum ChatFirstGoalProgressPolicy { + static func summary(for goal: OmiAPI.GoalResponse) -> String { + let unit = goal.unit.map { " \($0)" } ?? "" + return "Progress: \(formatted(goal.currentValue)) of \(formatted(goal.targetValue))\(unit)" + } + + private static func formatted(_ value: Double) -> String { + if value.rounded() == value { return String(Int(value)) } + return String(format: "%.1f", value) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift index f4442d72884..72ec90457ca 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift @@ -7,12 +7,13 @@ enum ChatFirstPromptMaterializationPolicy { static let minimumInterval: TimeInterval = 60 static func shouldStart( + hasChatFirstMainChatContext: Bool, transcriptFirstPageLoaded: Bool, isRunning: Bool, lastAttemptAt: Date?, now: Date ) -> Bool { - guard transcriptFirstPageLoaded, !isRunning else { return false } + guard hasChatFirstMainChatContext, transcriptFirstPageLoaded, !isRunning else { return false } guard let lastAttemptAt else { return true } return now.timeIntervalSince(lastAttemptAt) >= minimumInterval } @@ -23,7 +24,7 @@ enum ChatFirstPromptMaterializationPolicy { /// state remain on the backend/kernel respectively. @MainActor final class ChatFirstPromptMaterializationCoordinator: ObservableObject { - private weak var chatProvider: ChatProvider? + private var driver: (any ChatFirstPromptMaterializationDriving)? private var didLoadTranscriptFirstPage = false private var lastAttemptAt: Date? private var requestTask: Task? @@ -35,7 +36,14 @@ final class ChatFirstPromptMaterializationCoordinator: ObservableObject { } func activate(using chatProvider: ChatProvider) { - self.chatProvider = chatProvider + driver = APIChatFirstPromptMaterializationDriver(chatProvider: chatProvider) + } + + /// Test seam for the same narrow driver used in production. This does not + /// accept a local capability or transcript writer, so it cannot bypass the + /// provider/kernel authority boundary. + func activate(driver: any ChatFirstPromptMaterializationDriving) { + self.driver = driver } /// Called from the one rich main-chat page after its first transcript page is @@ -63,19 +71,20 @@ final class ChatFirstPromptMaterializationCoordinator: ObservableObject { private func requestMaterialization(windowForeground: Bool) { guard ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: driver?.materializationContext() != nil, transcriptFirstPageLoaded: didLoadTranscriptFirstPage, isRunning: requestTask != nil, lastAttemptAt: lastAttemptAt, now: now() - ), let chatProvider, chatProvider.chatFirstMaterializationContext() != nil + ), let driver else { return } lastAttemptAt = now() requestGeneration &+= 1 let generation = requestGeneration - requestTask = Task { [weak self, weak chatProvider] in - guard let self, let chatProvider else { return } - await self.materialize(using: chatProvider, windowForeground: windowForeground, generation: generation) + requestTask = Task { [weak self, driver] in + guard let self, let driver else { return } + await self.materialize(using: driver, windowForeground: windowForeground, generation: generation) if self.requestGeneration == generation { self.requestTask = nil } @@ -83,36 +92,21 @@ final class ChatFirstPromptMaterializationCoordinator: ObservableObject { } private func materialize( - using chatProvider: ChatProvider, + using driver: any ChatFirstPromptMaterializationDriving, windowForeground: Bool, generation: Int ) async { guard isCurrentMaterialization(generation) else { return } - guard let context = chatProvider.chatFirstMaterializationContext() else { return } + guard let context = driver.materializationContext() else { return } do { - let pendingReceipts = try await chatProvider.pendingChatFirstMaterializationReceipts() - guard isCurrentMaterialization(generation) else { return } - // The endpoint atomically accepts prior receipts before returning ready - // intents. Only after a success response do we remove their local copies. - let response = try await APIClient.shared.materializeChatFirstPrompts( - ownerID: context.ownerID, - controlGeneration: context.controlGeneration, + try await ChatFirstPromptMaterializationRunner.run( + driver: driver, + context: context, windowForeground: windowForeground, - receipts: pendingReceipts + isCurrent: { [weak self] in + self?.isCurrentMaterialization(generation) ?? false + } ) - guard isCurrentMaterialization(generation) else { return } - if !pendingReceipts.isEmpty { - _ = try await chatProvider.acknowledgeChatFirstMaterializationReceipts(pendingReceipts) - guard isCurrentMaterialization(generation) else { return } - } - - guard isCurrentMaterialization(generation), - response.intents.allSatisfy({ $0.accountGeneration == context.controlGeneration }) - else { return } - // Preserve the server's `(created_at, intent_id)` order in one kernel - // transaction. The kernel stops after a question or any blocked tail; - // Swift never reorders, caches, or locally schedules these intents. - _ = try await chatProvider.materializeChatFirstIntents(response.intents) } catch { // Failure is intentionally quiet and retryable on the next debounced // foreground/open. Do not create a notification, badge, or Chat row. diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift index 2cf7c49ee4f..3774b5f3e46 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift @@ -130,6 +130,7 @@ enum ChatFirstPendingFocus: Equatable, Sendable { /// prompts from display strings or pass raw URLs into Chat. enum ChatFirstDiscussionContext: Equatable, Sendable { case tasks + case goals case goal(id: String) case capture(id: String, momentTimestamp: TimeInterval?) @@ -137,6 +138,8 @@ enum ChatFirstDiscussionContext: Equatable, Sendable { switch self { case .tasks: return "Help me review my current tasks." + case .goals: + return "Help me create a goal." case .goal(let id): return "Help me continue working on goal \(id)." case .capture(let id, let momentTimestamp): @@ -163,6 +166,10 @@ final class ChatFirstShellNavigation: ObservableObject { @Published private(set) var route: ChatFirstRoute @Published private(set) var pendingFocus: ChatFirstPendingFocus? + /// A related-entity link can intentionally land in a different primary + /// destination (for example, a Goal's task list). This is transient like the + /// focus itself and is never restored across launches. + @Published private(set) var pendingFocusDestination: ChatFirstRoute? @Published private(set) var lastAcknowledgedFocusKind: String? @Published private(set) var isSidebarCollapsed: Bool @@ -180,6 +187,7 @@ final class ChatFirstShellNavigation: ObservableObject { isSidebarCollapsed = false } pendingFocus = nil + pendingFocusDestination = nil lastAcknowledgedFocusKind = nil } @@ -187,20 +195,31 @@ final class ChatFirstShellNavigation: ObservableObject { guard destination.isPrimaryDestination else { return } route = destination pendingFocus = nil + pendingFocusDestination = nil persistNavigation() } func selectMore(_ page: ChatFirstMorePage) { route = .more(page) pendingFocus = nil + pendingFocusDestination = nil persistNavigation() } /// Used by a typed rich-Chat link. Unlike direct navigation it carries the /// focus until the destination calls `acknowledgeFocus` after visible load. func open(focus: ChatFirstPendingFocus) { - route = focus.route + open(focus: focus, destination: focus.route) + } + + /// Preserves the typed focus contract while allowing a relationship link to + /// choose its destination. Destinations must remain in the cohort primary + /// navigation; no legacy page can receive a pending focus. + func open(focus: ChatFirstPendingFocus, destination: ChatFirstRoute) { + guard destination.isPrimaryDestination else { return } + route = destination pendingFocus = focus + pendingFocusDestination = destination persistNavigation() } @@ -216,8 +235,9 @@ final class ChatFirstShellNavigation: ObservableObject { @discardableResult func acknowledgeFocus(_ focus: ChatFirstPendingFocus) -> Bool { - guard route == focus.route, pendingFocus == focus else { return false } + guard route == pendingFocusDestination, pendingFocus == focus else { return false } pendingFocus = nil + pendingFocusDestination = nil lastAcknowledgedFocusKind = focus.stableName return true } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift index bd9ecf6bd8b..08473b12513 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift @@ -8,6 +8,7 @@ struct ChatFirstShell: View { @ObservedObject var navigation: ChatFirstShellNavigation @ObservedObject var appState: AppState let viewModelContainer: ViewModelContainer + let capability: ChatFirstCapabilityProjection @Binding var selectedSettingsSection: SettingsContentView.SettingsSection @Binding var highlightedSettingID: String? @StateObject private var promptMaterializationCoordinator = ChatFirstPromptMaterializationCoordinator() @@ -33,6 +34,7 @@ struct ChatFirstShell: View { .environmentObject(navigation) .onAppear { promptMaterializationCoordinator.activate(using: viewModelContainer.chatProvider) + viewModelContainer.canonicalGoalsStore.activate(capability: capability) } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in guard let window = NSApp.mainWindow, window.isKeyWindow, window.isVisible else { return } @@ -64,6 +66,7 @@ struct ChatFirstShell: View { navigation: navigation, tasksStore: viewModelContainer.tasksStore, chatProvider: viewModelContainer.chatProvider, + canonicalGoalsStore: viewModelContainer.canonicalGoalsStore, promptMaterializationCoordinator: promptMaterializationCoordinator ) ) @@ -82,9 +85,11 @@ struct ChatFirstShell: View { ) .accessibilityIdentifier("chat-first-route-tasks") case .goals: - ChatFirstDeferredDestination( - title: "Goals", - message: "Your canonical goals will appear here." + ChatFirstGoalsPage( + navigation: navigation, + goalsStore: viewModelContainer.canonicalGoalsStore, + tasksStore: viewModelContainer.tasksStore, + chatProvider: viewModelContainer.chatProvider ) .accessibilityIdentifier("chat-first-route-goals") case .memories: diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift index a70c7ab8338..e2b6e35dd2d 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift @@ -97,6 +97,18 @@ enum ChatFirstTaskPagePolicy { return pendingFocus } + static func goalFocusToAcknowledge( + pendingFocus: ChatFirstPendingFocus?, + visibleGoalID: String + ) -> ChatFirstPendingFocus? { + guard case .goal(let pendingID) = pendingFocus, pendingID == visibleGoalID else { return nil } + return pendingFocus + } + + static func goalFocusAnchor(_ goalID: String) -> String { + "chat-first-tasks-goal-focus:\(goalID)" + } + private static func normalizedID(_ id: String?) -> String? { guard let id else { return nil } let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines) @@ -151,6 +163,11 @@ struct ChatFirstTasksPage: View { return id } + private var pendingGoalID: String? { + guard case .goal(let id) = navigation.pendingFocus else { return nil } + return id + } + var body: some View { VStack(alignment: .leading, spacing: 0) { header @@ -233,9 +250,9 @@ struct ChatFirstTasksPage: View { .padding(.horizontal, OmiSpacing.xxl) .padding(.bottom, OmiSpacing.xxl) } - .onAppear { scrollPendingTaskIntoView(proxy) } - .onChange(of: pendingTaskID) { _ in scrollPendingTaskIntoView(proxy) } - .onChange(of: visibleTasks.map(\.id)) { _ in scrollPendingTaskIntoView(proxy) } + .onAppear { scrollPendingFocusIntoView(proxy) } + .onChange(of: navigation.pendingFocus) { _, _ in scrollPendingFocusIntoView(proxy) } + .onChange(of: visibleTasks.map(\.id)) { _ in scrollPendingFocusIntoView(proxy) } } } @@ -271,6 +288,11 @@ struct ChatFirstTasksPage: View { .id(task.id) } } + .id(goalGroup.goalID.map(ChatFirstTaskPagePolicy.goalFocusAnchor) ?? goalGroup.id) + .onAppear { + guard let goalID = goalGroup.goalID else { return } + acknowledgeVisibleGoalIfNeeded(goalID) + } } ChatFirstTaskAddRow( @@ -331,12 +353,21 @@ struct ChatFirstTasksPage: View { } } - private func scrollPendingTaskIntoView(_ proxy: ScrollViewProxy) { - guard let taskID = pendingTaskID, + private func scrollPendingFocusIntoView(_ proxy: ScrollViewProxy) { + if let taskID = pendingTaskID, visibleTasks.contains(where: { $0.id == taskID }) + { + withAnimation(OmiMotion.gated(.easeOut(duration: 0.18))) { + proxy.scrollTo(taskID, anchor: .center) + } + return + } + + guard let goalID = pendingGoalID, + visibleTasks.contains(where: { $0.goalId == goalID }) else { return } withAnimation(OmiMotion.gated(.easeOut(duration: 0.18))) { - proxy.scrollTo(taskID, anchor: .center) + proxy.scrollTo(ChatFirstTaskPagePolicy.goalFocusAnchor(goalID), anchor: .center) } } @@ -354,6 +385,14 @@ struct ChatFirstTasksPage: View { highlightedTaskID = nil } } + + private func acknowledgeVisibleGoalIfNeeded(_ goalID: String) { + guard let focus = ChatFirstTaskPagePolicy.goalFocusToAcknowledge( + pendingFocus: navigation.pendingFocus, + visibleGoalID: goalID + ) else { return } + _ = navigation.acknowledgeFocus(focus) + } } private struct ChatFirstTaskRow: View { diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index a17a39cdc55..a2fce75948e 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -329,7 +329,8 @@ struct ChatBubble: View { GoalLinkView( goalID: goalID, summary: summary, - navigation: chatFirstRichBlockContext.navigation + navigation: chatFirstRichBlockContext.navigation, + goalsStore: chatFirstRichBlockContext.canonicalGoalsStore ) ) case .captureLink(_, let conversationID, let momentTimestampMs, let summary): diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift index bf9030e49f5..92f4c6bdc74 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift @@ -207,6 +207,25 @@ enum ChatScrollMode: Equatable { case freeScrolling } +/// Deterministic arrival policy shared by the main transcript's live edge. +/// A prompt materialized while opening Chat should be visible; the same prompt +/// arriving while the user reads history must leave their position untouched +/// and surface the existing jump-to-latest affordance instead. +enum ChatArrivalScrollPolicy { + enum Action: Equatable { + case restoreTail + case followTail + case preserveReadingPosition + case none + } + + static func action(oldCount: Int, newCount: Int, mode: ChatScrollMode) -> Action { + guard newCount > oldCount else { return .none } + if oldCount == 0 { return .restoreTail } + return mode == .followingBottom ? .followTail : .preserveReadingPosition + } +} + /// First-class chat scroll container used by floating/notch transcripts. /// It follows streaming content only while the reader is at the live edge. struct ChatScrollContainer: View { diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 4a03259b3e0..6dfa96c65c2 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -1119,12 +1119,13 @@ struct DesktopHomeView: View { /// expression. The runtime choice is already immutable for this app session; /// this is only an erased rendering boundary, not a second state owner. private var shellContent: AnyView { - if case .chatFirst = chatFirstCapabilitySample.variant { + if case .chatFirst(let capability) = chatFirstCapabilitySample.variant { return AnyView( ChatFirstShell( navigation: chatFirstNavigation, appState: appState, viewModelContainer: viewModelContainer, + capability: capability, selectedSettingsSection: $selectedSettingsSection, highlightedSettingID: $highlightedSettingId ) diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index 5464d0d4939..31aea5672db 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -3854,8 +3854,8 @@ class ChatProvider: ObservableObject { ) } - func pendingChatFirstMaterializationReceipts() async throws -> [ChatFirstMaterializationReceipt] { - guard let session = try await chatFirstMaterializationSession() else { return [] } + func pendingChatFirstMaterializationReceipts() async throws -> ChatFirstPromptReceiptBatch { + guard let session = try await chatFirstMaterializationSession() else { return .empty } return try await resolvedAgentClient().listChatFirstMaterializationReceipts( surface: session.surface, ownerID: session.ownerID, @@ -3866,7 +3866,7 @@ class ChatProvider: ObservableObject { @discardableResult func acknowledgeChatFirstMaterializationReceipts( - _ receipts: [ChatFirstMaterializationReceipt] + _ receipts: ChatFirstPromptReceiptBatch ) async throws -> Int { guard !receipts.isEmpty, let session = try await chatFirstMaterializationSession() else { return 0 } return try await resolvedAgentClient().acknowledgeChatFirstMaterializationReceipts( diff --git a/desktop/macos/Desktop/Sources/Services/APIClient/APIClient+TaskCatalog.swift b/desktop/macos/Desktop/Sources/Services/APIClient/APIClient+TaskCatalog.swift index 70226fded0f..4cbab7603e5 100644 --- a/desktop/macos/Desktop/Sources/Services/APIClient/APIClient+TaskCatalog.swift +++ b/desktop/macos/Desktop/Sources/Services/APIClient/APIClient+TaskCatalog.swift @@ -436,11 +436,43 @@ extension APIClient { } func getCanonicalGoals(includeEnded: Bool = true) async throws -> [OmiAPI.GoalResponse] { - try await get("v1/goals/all?include_ended=\(includeEnded ? "true" : "false")") + try await getCanonicalGoals( + includeEnded: includeEnded, + expectedOwnerId: nil, + authorizationSnapshot: nil + ) + } + + func getCanonicalGoals( + includeEnded: Bool, + expectedOwnerId: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? + ) async throws -> [OmiAPI.GoalResponse] { + try await get( + "v1/goals/all?include_ended=\(includeEnded ? "true" : "false")", + expectedOwnerId: expectedOwnerId, + authorizationSnapshot: authorizationSnapshot + ) } func getCanonicalGoalDetail(goalID: String) async throws -> OmiAPI.GoalDetailProjection { - try await get("v1/goals/\(goalID)/detail") + try await getCanonicalGoalDetail( + goalID: goalID, + expectedOwnerId: nil, + authorizationSnapshot: nil + ) + } + + func getCanonicalGoalDetail( + goalID: String, + expectedOwnerId: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? + ) async throws -> OmiAPI.GoalDetailProjection { + try await get( + "v1/goals/\(goalID)/detail", + expectedOwnerId: expectedOwnerId, + authorizationSnapshot: authorizationSnapshot + ) } func createCanonicalGoal( @@ -481,6 +513,26 @@ extension APIClient { focusRank: Int?, accountGeneration: Int, idempotencyKey: String + ) async throws -> OmiAPI.GoalResponse { + try await focusCanonicalGoal( + goalID: goalID, + replacementGoalID: replacementGoalID, + focusRank: focusRank, + accountGeneration: accountGeneration, + idempotencyKey: idempotencyKey, + expectedOwnerId: nil, + authorizationSnapshot: nil + ) + } + + func focusCanonicalGoal( + goalID: String, + replacementGoalID: String?, + focusRank: Int?, + accountGeneration: Int, + idempotencyKey: String, + expectedOwnerId: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? ) async throws -> OmiAPI.GoalResponse { struct Request: Encodable { let replacement_goal_id: String? @@ -491,7 +543,9 @@ extension APIClient { method: "POST", body: Request(replacement_goal_id: replacementGoalID, focus_rank: focusRank), idempotencyKey: idempotencyKey, - accountGeneration: accountGeneration + accountGeneration: accountGeneration, + expectedOwnerId: expectedOwnerId, + authorizationSnapshot: authorizationSnapshot ) } diff --git a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift index 1a7c01c0d09..d5112256715 100644 --- a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift @@ -2222,6 +2222,71 @@ class TasksStore: ObservableObject { } } + /// Hydrates a canonical goal-detail task through the owner-fenced store + /// before a goal page attempts any mutation. + func resolveCanonicalTask( + id: String, + expectedOwnerID: String? = nil, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil, + operations: OwnerBoundOperations = OwnerBoundOperations() + ) async -> TaskActionItem? { + let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedID.isEmpty, + let lease = captureOwnerLease( + expectedOwnerID: expectedOwnerID, + authorizationSnapshot: authorizationSnapshot + ) + else { return nil } + + if let existing = tasks.first(where: { $0.id == normalizedID && $0.deleted != true }) { + return existing + } + do { + let remoteTask: TaskActionItem? + if let fetchTaskDetail = operations.fetchTaskDetail { + remoteTask = try await fetchTaskDetail(normalizedID, lease.ownerID) + } else { + remoteTask = try await APIClient.shared.getActionItem( + id: normalizedID, + expectedOwnerId: lease.ownerID, + authorizationSnapshot: lease.authorizationSnapshot + ) + } + guard isCurrent(lease), + let remoteTask, + remoteTask.id == normalizedID, + remoteTask.deleted != true + else { return nil } + try await syncPage([remoteTask], lease: lease, operations: operations) + guard isCurrent(lease), + let hydratedTask = try await ActionItemStorage.shared.getLocalActionItem(byBackendId: normalizedID), + hydratedTask.deleted != true + else { return nil } + publishHydratedCanonicalTask(hydratedTask) + await refreshDashboard(lease: lease, operations: operations) + guard isCurrent(lease) else { return nil } + return hydratedTask + } catch { + guard isCurrent(lease) else { return nil } + self.error = "This task is no longer available." + logError("TasksStore: Failed to hydrate canonical task", error: error) + return nil + } + } + + private func publishHydratedCanonicalTask(_ task: TaskActionItem) { + incompleteTasks.removeAll { $0.id == task.id } + completedTasks.removeAll { $0.id == task.id } + deletedTasks.removeAll { $0.id == task.id } + if task.deleted == true { + deletedTasks.insert(task, at: 0) + } else if task.completed { + completedTasks.insert(task, at: 0) + } else { + incompleteTasks.insert(task, at: 0) + } + } + func toggleTask( _ task: TaskActionItem, expectedOwnerID: String? = nil, diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index e4a3318bcb6..fa5166830c3 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -6,6 +6,9 @@ import SwiftUI class ViewModelContainer: ObservableObject { // Shared stores (single source of truth) let tasksStore = TasksStore.shared + /// Cohort-only canonical goal projection. It is injected only by the + /// capability-gated chat-first shell. + let canonicalGoalsStore = CanonicalGoalsStore() // ViewModels for each page let dashboardViewModel = DashboardViewModel() @@ -144,6 +147,7 @@ class ViewModelContainer: ObservableObject { memoriesViewModel.resetSessionState() appProvider.resetSessionState() memoryGraphViewModel.resetSessionState() + canonicalGoalsStore.resetSessionState() isInitialLoadComplete = false isLoading = false databaseInitFailed = false diff --git a/desktop/macos/Desktop/Tests/CanonicalGoalsStoreTests.swift b/desktop/macos/Desktop/Tests/CanonicalGoalsStoreTests.swift new file mode 100644 index 00000000000..9ad4d216f94 --- /dev/null +++ b/desktop/macos/Desktop/Tests/CanonicalGoalsStoreTests.swift @@ -0,0 +1,231 @@ +import XCTest + +@testable import Omi_Computer + +@MainActor +final class CanonicalGoalsStoreTests: XCTestCase { + func testLoadProjectsFocusedGoalAheadOfOtherActiveGoals() async { + let api = FakeCanonicalGoalsClient() + api.goals = [ + goal(id: "background", status: .background, rank: nil), + goal(id: "focused", status: .focused, rank: 0), + ] + let owner = TestOwner("owner-a") + let store = CanonicalGoalsStore(client: api, ownerIDProvider: { owner.value }) + + store.activate(capability: capability(generation: 7)) + await store.load() + + XCTAssertEqual(store.primaryFocusedGoal?.goalId, "focused") + XCTAssertEqual(store.otherActiveGoals.map(\.goalId), ["background"]) + XCTAssertEqual(store.availability, .ready) + XCTAssertEqual(api.goalRequestOwnerIDs, ["owner-a"]) + } + + func testSetFocusUsesSampledGenerationPrimaryReplacementAndReconciles() async { + let api = FakeCanonicalGoalsClient() + api.goals = [ + goal(id: "focused", status: .focused, rank: 0), + goal(id: "background", status: .background, rank: nil), + ] + api.goalsAfterFocus = [ + goal(id: "focused", status: .background, rank: nil), + goal(id: "background", status: .focused, rank: 0), + ] + let owner = TestOwner("owner-a") + let store = CanonicalGoalsStore(client: api, ownerIDProvider: { owner.value }) + + store.activate(capability: capability(generation: 12)) + await store.load() + let updated = await store.setAsFocus(goalID: "background") + + XCTAssertTrue(updated) + XCTAssertEqual(api.focusRequests.count, 1) + XCTAssertEqual(api.focusRequests[0].goalID, "background") + XCTAssertEqual(api.focusRequests[0].replacementGoalID, "focused") + XCTAssertEqual(api.focusRequests[0].accountGeneration, 12) + XCTAssertEqual(api.focusRequests[0].expectedOwnerID, "owner-a") + XCTAssertTrue(api.focusRequests[0].idempotencyKey.hasPrefix("goal-focus:background:")) + XCTAssertEqual(store.primaryFocusedGoal?.goalId, "background") + } + + func testUnavailableProjectionClearsCanonicalDataAndDoesNotRetainPriorOwnerState() async { + let api = FakeCanonicalGoalsClient() + api.goals = [goal(id: "owner-a", status: .focused, rank: 0)] + let owner = TestOwner("owner-a") + let store = CanonicalGoalsStore(client: api, ownerIDProvider: { owner.value }) + + store.activate(capability: capability(generation: 7)) + await store.load() + XCTAssertEqual(store.goals.map(\.goalId), ["owner-a"]) + + owner.value = "owner-b" + await store.load() + + XCTAssertTrue(store.goals.isEmpty) + XCTAssertEqual(store.availability, .unavailable("Goals are unavailable right now. Try again.")) + } + + func testUnavailableProjectionClearsDataWhenCanonicalFetchFails() async { + let api = FakeCanonicalGoalsClient() + api.goals = [goal(id: "focused", status: .focused, rank: 0)] + let owner = TestOwner("owner-a") + let store = CanonicalGoalsStore(client: api, ownerIDProvider: { owner.value }) + + store.activate(capability: capability(generation: 7)) + await store.load() + XCTAssertEqual(store.goals.map(\.goalId), ["focused"]) + + api.goalFetchError = .missing + await store.load() + + XCTAssertTrue(store.goals.isEmpty) + XCTAssertNil(store.selectedGoalDetail) + XCTAssertEqual(store.availability, .unavailable("Goals are unavailable right now. Try again.")) + } + + func testDetailPolicyUsesAggregateTasksOnlyForDisplayAndVisibleFocusAcknowledgement() { + let detail = OmiAPI.GoalDetailProjection( + activeThreads: [], + goal: goal(id: "goal-a", status: .focused, rank: 0), + progressEvents: [], + tasks: [ + actionItem(id: "complete", completed: true), + actionItem(id: "next", completed: false), + actionItem(id: "later", completed: false), + ] + ) + + XCTAssertEqual(ChatFirstGoalDetailPolicy.completedTaskCount(in: detail), 1) + XCTAssertEqual(ChatFirstGoalDetailPolicy.nextTaskIDs(in: detail), ["next", "later"]) + XCTAssertEqual( + ChatFirstGoalProgressPolicy.summary(for: detail.goal), + "Progress: 1 of 10" + ) + XCTAssertEqual( + ChatFirstGoalDetailPolicy.focusToAcknowledge( + pendingFocus: .goal(id: "goal-a"), + visibleGoalID: "goal-a" + ), + .goal(id: "goal-a") + ) + XCTAssertNil( + ChatFirstGoalDetailPolicy.focusToAcknowledge( + pendingFocus: .goal(id: "goal-a"), + visibleGoalID: "goal-b" + ) + ) + } + + private func capability(generation: Int) -> ChatFirstCapabilityProjection { + ChatFirstCapabilityProjection(control: OmiAPI.TaskWorkflowControl( + accountGeneration: generation, + chatFirstUi: true, + workflowMode: .read + ))! + } + + private func goal(id: String, status: OmiAPI.GoalStatus, rank: Int?) -> OmiAPI.GoalResponse { + OmiAPI.GoalResponse( + advice: nil, + createdAt: "2027-01-01T08:00:00Z", + currentValue: 1, + desiredOutcome: "Finish \(id)", + endedAt: nil, + focusRank: rank, + goalId: id, + goalType: "numeric", + horizonAt: nil, + id: id, + isActive: true, + latestProgressSequence: nil, + maxValue: 10, + metric: nil, + minValue: 0, + source: .user, + status: status, + successCriteria: [], + targetValue: 10, + title: "Goal \(id)", + unit: nil, + updatedAt: "2027-01-10T08:00:00Z", + whyItMatters: nil + ) + } + + private func actionItem(id: String, completed: Bool) -> OmiAPI.ActionItemResponse { + OmiAPI.ActionItemResponse(completed: completed, description_: id, id: id) + } +} + +private final class TestOwner { + var value: String? + + init(_ value: String?) { + self.value = value + } +} + +private final class FakeCanonicalGoalsClient: CanonicalGoalsClient { + enum FakeError: Error { + case missing + } + + struct FocusRequest { + let goalID: String + let replacementGoalID: String? + let accountGeneration: Int + let idempotencyKey: String + let expectedOwnerID: String? + } + + var goals: [OmiAPI.GoalResponse] = [] + var goalsAfterFocus: [OmiAPI.GoalResponse]? + var focusRequests: [FocusRequest] = [] + var goalRequestOwnerIDs: [String?] = [] + var goalFetchError: FakeError? + private var hasFocused = false + + func getCanonicalGoals( + includeEnded: Bool, + expectedOwnerId: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? + ) async throws -> [OmiAPI.GoalResponse] { + goalRequestOwnerIDs.append(expectedOwnerId) + if let goalFetchError { throw goalFetchError } + if hasFocused, let goalsAfterFocus { + return goalsAfterFocus + } + return goals + } + + func getCanonicalGoalDetail( + goalID: String, + expectedOwnerId: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? + ) async throws -> OmiAPI.GoalDetailProjection { + guard let goal = goals.first(where: { $0.goalId == goalID }) else { throw FakeError.missing } + return OmiAPI.GoalDetailProjection(activeThreads: [], goal: goal, progressEvents: [], tasks: []) + } + + func focusCanonicalGoal( + goalID: String, + replacementGoalID: String?, + focusRank: Int?, + accountGeneration: Int, + idempotencyKey: String, + expectedOwnerId: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? + ) async throws -> OmiAPI.GoalResponse { + focusRequests.append(FocusRequest( + goalID: goalID, + replacementGoalID: replacementGoalID, + accountGeneration: accountGeneration, + idempotencyKey: idempotencyKey, + expectedOwnerID: expectedOwnerId + )) + guard let goal = goals.first(where: { $0.goalId == goalID }) else { throw FakeError.missing } + hasFocused = true + return goal + } +} diff --git a/desktop/macos/Desktop/Tests/ChatFirstLegacyGoalIsolationTests.swift b/desktop/macos/Desktop/Tests/ChatFirstLegacyGoalIsolationTests.swift new file mode 100644 index 00000000000..699ec75dae7 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstLegacyGoalIsolationTests.swift @@ -0,0 +1,28 @@ +import Foundation +import XCTest + +@testable import Omi_Computer + +final class ChatFirstLegacyGoalIsolationTests: XCTestCase { + func testChatFirstSourcesDoNotReferenceLegacyGoalModels() throws { + // omi-test-quality: source-inspection -- static contract: cohort-only goal + // UI must never add a legacy-goal dependency, which behavior tests cannot + // observe after a forbidden fallback has already been compiled in. + let testsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let chatFirstDirectory = testsDirectory + .deletingLastPathComponent() + .appendingPathComponent("Sources/MainWindow/ChatFirst") + let forbiddenSymbols = ["GoalStorage", "GoalRecord", "GoalsHistoryPage"] + let files = FileManager.default.enumerator( + at: chatFirstDirectory, + includingPropertiesForKeys: [.isRegularFileKey] + )?.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" } ?? [] + + for file in files { + let source = try String(contentsOf: file, encoding: .utf8) + for symbol in forbiddenSymbols { + XCTAssertFalse(source.contains(symbol), "\(file.lastPathComponent) must not depend on \(symbol)") + } + } + } +} diff --git a/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift b/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift index 0dc14774575..c36f5f8afbd 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift @@ -8,6 +8,7 @@ final class ChatFirstPromptMaterializationCoordinatorTests: XCTestCase { XCTAssertFalse( ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: true, transcriptFirstPageLoaded: false, isRunning: false, lastAttemptAt: nil, @@ -16,6 +17,7 @@ final class ChatFirstPromptMaterializationCoordinatorTests: XCTestCase { ) XCTAssertTrue( ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: true, transcriptFirstPageLoaded: true, isRunning: false, lastAttemptAt: nil, @@ -24,6 +26,7 @@ final class ChatFirstPromptMaterializationCoordinatorTests: XCTestCase { ) XCTAssertFalse( ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: true, transcriptFirstPageLoaded: true, isRunning: true, lastAttemptAt: now, @@ -32,6 +35,7 @@ final class ChatFirstPromptMaterializationCoordinatorTests: XCTestCase { ) XCTAssertFalse( ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: true, transcriptFirstPageLoaded: true, isRunning: false, lastAttemptAt: now, @@ -40,6 +44,7 @@ final class ChatFirstPromptMaterializationCoordinatorTests: XCTestCase { ) XCTAssertTrue( ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: true, transcriptFirstPageLoaded: true, isRunning: false, lastAttemptAt: now, @@ -47,4 +52,145 @@ final class ChatFirstPromptMaterializationCoordinatorTests: XCTestCase { ) ) } + + @MainActor + func testPolicyRejectsLegacyAndNotchDriversBeforeAnyMaterializationWork() { + let now = Date(timeIntervalSinceReferenceDate: 10_000) + let legacyDriver = FakePromptMaterializationDriver( + context: nil, + pendingReceipts: .empty, + response: ChatFirstMaterializePromptsResponse(intents: []) + ) + let notchDriver = FakePromptMaterializationDriver( + context: nil, + pendingReceipts: .empty, + response: ChatFirstMaterializePromptsResponse(intents: []) + ) + for driver in [legacyDriver, notchDriver] { + XCTAssertNil(driver.materializationContext()) + XCTAssertFalse( + ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: false, + transcriptFirstPageLoaded: true, + isRunning: false, + lastAttemptAt: nil, + now: now + ) + ) + } + } + + @MainActor + func testFailedReceiptAcknowledgementReplaysTheSameKernelReceipt() async throws { + let receipt = ChatFirstMaterializationReceipt(intentID: "intent-1", receiptID: "receipt-1") + let terminalReceipt = ChatFirstColdStartSequenceTerminalReceipt( + sequenceID: "cold-start:3", + receiptID: "terminal-receipt-1", + terminalState: .completed + ) + let pendingReceipts = ChatFirstPromptReceiptBatch( + materializationReceipts: [receipt], + coldStartSequenceTerminalReceipts: [terminalReceipt] + ) + let driver = FakePromptMaterializationDriver( + context: ChatFirstMaterializationContext(ownerID: "owner", controlGeneration: 3), + pendingReceipts: pendingReceipts, + response: ChatFirstMaterializePromptsResponse(intents: []) + ) + driver.acknowledgementError = FixtureError.failedAcknowledgement + + do { + try await ChatFirstPromptMaterializationRunner.run( + driver: driver, + context: ChatFirstMaterializationContext(ownerID: "owner", controlGeneration: 3), + windowForeground: true, + isCurrent: { true } + ) + XCTFail("Expected acknowledgement failure") + } catch FixtureError.failedAcknowledgement { + // The driver intentionally keeps the kernel receipt until a successful local acknowledgement. + } + + XCTAssertEqual(driver.fetchReceiptBatches, [pendingReceipts]) + XCTAssertEqual(driver.acknowledgementBatches, [pendingReceipts]) + XCTAssertTrue(driver.materializedBatches.isEmpty) + + driver.acknowledgementError = nil + try await ChatFirstPromptMaterializationRunner.run( + driver: driver, + context: ChatFirstMaterializationContext(ownerID: "owner", controlGeneration: 3), + windowForeground: true, + isCurrent: { true } + ) + XCTAssertEqual(driver.fetchReceiptBatches, [pendingReceipts, pendingReceipts]) + XCTAssertEqual(driver.acknowledgementBatches, [pendingReceipts, pendingReceipts]) + } + + func testArrivalScrollPolicyFollowsFreshChatButPreservesScrollback() { + XCTAssertEqual( + ChatArrivalScrollPolicy.action(oldCount: 0, newCount: 2, mode: .followingBottom), + .restoreTail + ) + XCTAssertEqual( + ChatArrivalScrollPolicy.action(oldCount: 3, newCount: 4, mode: .followingBottom), + .followTail + ) + XCTAssertEqual( + ChatArrivalScrollPolicy.action(oldCount: 3, newCount: 4, mode: .freeScrolling), + .preserveReadingPosition + ) + } +} + +@MainActor +private final class FakePromptMaterializationDriver: ChatFirstPromptMaterializationDriving { + private let contextValue: ChatFirstMaterializationContext? + private var storedPendingReceipts: ChatFirstPromptReceiptBatch + private let response: ChatFirstMaterializePromptsResponse + var acknowledgementError: Error? + private(set) var fetchReceiptBatches: [ChatFirstPromptReceiptBatch] = [] + private(set) var acknowledgementBatches: [ChatFirstPromptReceiptBatch] = [] + private(set) var materializedBatches: [[ChatFirstPromptIntent]] = [] + + init( + context: ChatFirstMaterializationContext?, + pendingReceipts: ChatFirstPromptReceiptBatch, + response: ChatFirstMaterializePromptsResponse + ) { + contextValue = context + storedPendingReceipts = pendingReceipts + self.response = response + } + + func materializationContext() -> ChatFirstMaterializationContext? { + contextValue + } + + func pendingReceipts() async throws -> ChatFirstPromptReceiptBatch { + storedPendingReceipts + } + + func fetchPrompts( + ownerID _: String, + controlGeneration _: Int, + windowForeground _: Bool, + receipts: ChatFirstPromptReceiptBatch + ) async throws -> ChatFirstMaterializePromptsResponse { + fetchReceiptBatches.append(receipts) + return response + } + + func acknowledge(_ receipts: ChatFirstPromptReceiptBatch) async throws { + acknowledgementBatches.append(receipts) + if let acknowledgementError { throw acknowledgementError } + storedPendingReceipts = .empty + } + + func materialize(_ intents: [ChatFirstPromptIntent]) async throws { + materializedBatches.append(intents) + } +} + +private enum FixtureError: Error { + case failedAcknowledgement } diff --git a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift index 95f7705eaf6..5778474ed50 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift @@ -49,7 +49,7 @@ final class ChatFirstShellTests: XCTestCase { func testNavigationPersistsOnlyRouteAndCollapseAndRetainsFocusUntilAcknowledged() { let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults.standard.removePersistentDomain(forName: suiteName) } + defer { defaults.removePersistentDomain(forName: suiteName) } let navigation = ChatFirstShellNavigation(defaults: defaults) XCTAssertEqual(navigation.route, .chat) @@ -75,7 +75,7 @@ final class ChatFirstShellTests: XCTestCase { func testDirectAndLegacyNavigationClearFocusAndMapToTypedRoutes() { let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! - defer { UserDefaults.standard.removePersistentDomain(forName: suiteName) } + defer { defaults.removePersistentDomain(forName: suiteName) } let navigation = ChatFirstShellNavigation(defaults: defaults) navigation.open(focus: .goal(id: "goal-1")) @@ -92,6 +92,22 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertEqual(navigation.route, .chat) } + func testRelatedGoalFocusCanLandInTasksAndAcknowledgesAfterTasksVisibility() { + let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let navigation = ChatFirstShellNavigation(defaults: defaults) + let focus = ChatFirstPendingFocus.goal(id: "goal-1") + navigation.open(focus: focus, destination: .tasks) + + XCTAssertEqual(navigation.route, .tasks) + XCTAssertEqual(navigation.pendingFocus, focus) + XCTAssertEqual(navigation.pendingFocusDestination, .tasks) + XCTAssertTrue(navigation.acknowledgeFocus(focus)) + XCTAssertNil(navigation.pendingFocus) + } + func testPrimaryAutomationRouteIncludesGoalsWithoutRepurposingLegacyPages() { XCTAssertEqual(ChatFirstRoute.primaryAutomationDestination(named: "goals"), .goals) XCTAssertEqual(ChatFirstRoute.primaryAutomationDestination(named: "GOALS"), .goals) diff --git a/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift b/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift index 62aa2c464a2..686fa3c914f 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift @@ -59,6 +59,28 @@ final class ChatFirstTasksPageTests: XCTestCase { visibleTaskID: "task-a")) } + func testGoalFocusAcknowledgesOnlyTheVisibleRelatedGoalGroup() { + let requested = ChatFirstPendingFocus.goal(id: "goal-a") + + XCTAssertEqual( + ChatFirstTaskPagePolicy.goalFocusToAcknowledge( + pendingFocus: requested, + visibleGoalID: "goal-a" + ), + requested + ) + XCTAssertNil( + ChatFirstTaskPagePolicy.goalFocusToAcknowledge( + pendingFocus: requested, + visibleGoalID: "goal-b" + ) + ) + XCTAssertEqual( + ChatFirstTaskPagePolicy.goalFocusAnchor("goal-a"), + "chat-first-tasks-goal-focus:goal-a" + ) + } + private func task( id: String, dueAt: Date? = nil, diff --git a/desktop/macos/agent/src/index.ts b/desktop/macos/agent/src/index.ts index e348e5d29b8..bd723127a3c 100644 --- a/desktop/macos/agent/src/index.ts +++ b/desktop/macos/agent/src/index.ts @@ -1577,6 +1577,11 @@ async function main(): Promise { // must leave chat-first background work entirely dormant. if (kernel.hasChatFirstMainCapability(activeOwnerId)) { for (const delivery of drainChatFirstDeferralOutbox(store, { ownerId: activeOwnerId, limit: 20 })) { + const deferredQuestionSubject = delivery.question.subject; + if (deferredQuestionSubject.kind === "cold_start") { + throw new Error("Cold-start sequence questions cannot enter the deferral outbox"); + } + const deferralSubject = deferredQuestionSubject as { kind: "task" | "goal" | "capture"; id: string }; send({ type: "chat_first_deferral_delivery", requestId: `chat-first-deferral:${delivery.continuityKey}:${delivery.deliveryGeneration}`, @@ -1588,7 +1593,7 @@ async function main(): Promise { question: { questionId: delivery.question.questionId, text: delivery.question.text, - subject: delivery.question.subject, + subject: deferralSubject, options: delivery.question.options, }, attemptCount: delivery.attemptCount, @@ -2705,7 +2710,7 @@ async function main(): Promise { externalRefId: request.externalRefId, turns: [], clearedCount: 0, highWaterTurnSeq: range.highWaterTurnSeq, generationBaseTurnSeq: range.generationBaseTurnSeq, conversationGeneration: range.generation, - materializationReceipts: listChatFirstMaterializationReceipts(store, { + ...listChatFirstMaterializationReceipts(store, { ownerId, controlGeneration: request.controlGeneration, limit: request.limit, }), }); @@ -2728,6 +2733,8 @@ async function main(): Promise { || typeof request.sessionId !== "string" || !request.sessionId.trim() || !Number.isSafeInteger(request.controlGeneration) || request.controlGeneration < 0 || !Array.isArray(request.receipts) || request.receipts.length > 16 + || !Array.isArray(request.coldStartSequenceTerminalReceipts) + || request.coldStartSequenceTerminalReceipts.length > 16 ) throw new Error("Chat-first receipt acknowledgement request is invalid"); kernel.assertChatFirstMainCapability(request.sessionId, ownerId, request.controlGeneration); const resolved = resolveJournalSurface({ @@ -2739,8 +2746,21 @@ async function main(): Promise { intentId: typeof receipt?.intentId === "string" ? receipt.intentId : "", receiptId: typeof receipt?.receiptId === "string" ? receipt.receiptId : "", })); + const coldStartSequenceTerminalReceipts = request.coldStartSequenceTerminalReceipts.map((receipt) => { + if (receipt?.terminalState !== "completed" && receipt?.terminalState !== "abandoned") { + throw new Error("Cold-start terminal receipt is invalid"); + } + return { + sequenceId: typeof receipt.sequenceId === "string" ? receipt.sequenceId : "", + receiptId: typeof receipt.receiptId === "string" ? receipt.receiptId : "", + terminalState: receipt.terminalState, + }; + }); const acknowledgedReceiptCount = acknowledgeChatFirstMaterializationReceipts(store, { - ownerId, controlGeneration: request.controlGeneration, receipts, + ownerId, + controlGeneration: request.controlGeneration, + receipts, + coldStartSequenceTerminalReceipts, }); const range = listJournalTurns(store, { ownerId, conversationId: resolved.conversationId, afterTurnSeq: 0, limit: 1, diff --git a/desktop/macos/agent/src/protocol.ts b/desktop/macos/agent/src/protocol.ts index f92b3676a20..53ac40dfdee 100644 --- a/desktop/macos/agent/src/protocol.ts +++ b/desktop/macos/agent/src/protocol.ts @@ -416,7 +416,13 @@ export interface MaterializeChatFirstIntentsMessage extends ProtocolEnvelope { intents: Array<{ intentId: string; continuityKey: string; - source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + source: + | "daily_opener" + | "capture_arrival" + | "deferral_reraise" + | "agent_judgment" + | "cold_start_rich" + | "cold_start_sparse"; blocks: unknown[]; }>; } @@ -443,6 +449,11 @@ export interface AcknowledgeChatFirstMaterializationReceiptsMessage extends Prot sessionId: string; controlGeneration: number; receipts: Array<{ intentId: string; receiptId: string }>; + coldStartSequenceTerminalReceipts: Array<{ + sequenceId: string; + receiptId: string; + terminalState: "completed" | "abandoned"; + }>; } export interface EnsureAgentSpawnJournalMessage extends ProtocolEnvelope { @@ -996,6 +1007,11 @@ export interface JournalOperationResultMessage extends OutboundEnvelope { suppressedByStreamingTail?: boolean; materializationStoppedByTail?: boolean; materializationReceipts?: Array<{ intentId: string; receiptId: string }>; + coldStartSequenceTerminalReceipts?: Array<{ + sequenceId: string; + receiptId: string; + terminalState: "completed" | "abandoned"; + }>; acknowledgedReceiptCount?: number; } diff --git a/desktop/macos/agent/src/runtime/conversation-journal.ts b/desktop/macos/agent/src/runtime/conversation-journal.ts index d7a6df59a92..c3e3b99e1b2 100644 --- a/desktop/macos/agent/src/runtime/conversation-journal.ts +++ b/desktop/macos/agent/src/runtime/conversation-journal.ts @@ -149,13 +149,32 @@ export interface ChatFirstMaterializationReceipt { receiptId: string; } +/** A restart-safe journal receipt that a sparse cold-start script has ended. */ +export interface ChatFirstColdStartSequenceTerminalReceipt { + sequenceId: string; + receiptId: string; + terminalState: "completed" | "abandoned"; +} + +/** The two receipt classes travel through one foreground materialize fetch. */ +export interface ChatFirstPromptReceiptBatch { + materializationReceipts: ChatFirstMaterializationReceipt[]; + coldStartSequenceTerminalReceipts: ChatFirstColdStartSequenceTerminalReceipt[]; +} + export interface MaterializeChatFirstIntentInput { ownerId: string; conversationId: string; controlGeneration: number; intentId: string; continuityKey: string; - source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + source: + | "daily_opener" + | "capture_arrival" + | "deferral_reraise" + | "agent_judgment" + | "cold_start_rich" + | "cold_start_sparse"; blocks: readonly unknown[]; nowMs?: number; } @@ -412,7 +431,6 @@ export function recordJournalTurn( const canonicalDelivery = canonicalJournalDelivery(store, input.ownerId, input.conversationId); assertCanonicalJournalDelivery(input.surfaceKind, canonicalDelivery); assertProducingRunOwner(store, input.producingRunId ?? null, input.ownerId); - const existingByTurnId = findJournalTurnById(store, turnId); const existingByProducer = findJournalTurnByProducer(store, input.conversationId, producerId); if ( @@ -443,6 +461,8 @@ export function recordJournalTurn( return { turn: existing, created: false, duplicate: true, outboxStatus }; } + retirePendingColdStartQuestionForUnrelatedUserTurn(store, input, now); + const sequence = nextJournalSequence(store, input.conversationId, now); const payloadHash = journalTurnPayloadHash({ turnId, @@ -538,6 +558,62 @@ export function recordJournalExchange( }); } +/** + * A user may always bypass sparse cold-start suggestions by composing a + * normal main-Chat message. Mark only the current unselected script card as + * retired before recording that ordinary user turn; a card selected through + * `recordQuestionInteractionReply` is already marked selected and is left + * untouched. This state is journal-local and never inferred from prose. + */ +function retirePendingColdStartQuestionForUnrelatedUserTurn( + store: AgentStore, + input: RecordJournalTurnInput, + nowMs: number, +): void { + if (input.role !== "user" || input.surfaceKind !== "main_chat") return; + const tail = store.getOptionalRow( + `SELECT turn_id FROM conversation_turns + WHERE conversation_id = ? ORDER BY turn_seq DESC LIMIT 1`, + [input.conversationId], + ); + if (!tail) return; + const parent = requireJournalTurn(store, input.conversationId, String(tail.turn_id)); + if (parent.role !== "assistant" || parent.status !== "completed") return; + const questionIndex = parent.contentBlocks.findIndex((block) => ( + block.type === "questionCard" + && block.selectedOptionId === undefined + && block.coldStartSequence !== undefined + && block.coldStartSequence.retired !== true + )); + if (questionIndex < 0) return; + const retiredBlocks = parent.contentBlocks.map((block, index) => { + if (index !== questionIndex || block.type !== "questionCard" || !block.coldStartSequence) { + return structuredClone(block); + } + return { + ...structuredClone(block), + coldStartSequence: { ...block.coldStartSequence, retired: true as const }, + }; + }); + updateJournalTurn(store, { + ownerId: input.ownerId, + conversationId: input.conversationId, + turnId: parent.turnId, + replaceContentBlocks: retiredBlocks, + nowMs, + }); + const retired = parent.contentBlocks[questionIndex]; + if (retired?.type !== "questionCard" || !retired.coldStartSequence) return; + recordColdStartSequenceTerminalReceipt(store, { + ownerId: input.ownerId, + conversationId: input.conversationId, + sequenceId: retired.coldStartSequence.sequenceId, + terminalState: "abandoned", + terminalTurnId: parent.turnId, + nowMs, + }); +} + /** Update or complete the producing turn; block/resource IDs are idempotent. */ export function updateJournalTurn(store: AgentStore, input: UpdateJournalTurnInput): ConversationTurn { if ( @@ -810,7 +886,10 @@ export function recordQuestionInteractionReply( return emptyQuestionInteractionReceipt(); } const questionIndex = parent.contentBlocks.findIndex((block) => ( - block.type === "questionCard" && block.questionId === questionId && block.selectedOptionId === undefined + block.type === "questionCard" + && block.questionId === questionId + && block.selectedOptionId === undefined + && block.coldStartSequence?.retired !== true )); if (questionIndex < 0) return emptyQuestionInteractionReceipt(); const question = parent.contentBlocks[questionIndex] as Extract; @@ -847,7 +926,10 @@ export function recordQuestionInteractionReply( status: "completed", content: option.preparedAnswer, contentBlocks: [{ type: "text", id: `${userTurnId}:text`, text: option.preparedAnswer }], - metadataJson: JSON.stringify({ continuityKey }), + metadataJson: JSON.stringify({ + continuityKey, + ...(question.coldStartSequence ? { coldStartSequence: question.coldStartSequence } : {}), + }), createdAtMs: now, }, { @@ -858,7 +940,11 @@ export function recordQuestionInteractionReply( status: "streaming", content: "", contentBlocks: [], - metadataJson: JSON.stringify({ continuityKey, hiddenUntilOutput: true }), + metadataJson: JSON.stringify({ + continuityKey, + hiddenUntilOutput: true, + ...(question.coldStartSequence ? { coldStartSequence: question.coldStartSequence } : {}), + }), createdAtMs: now + 1, }, ], @@ -903,10 +989,17 @@ function materializeChatFirstIntentInTransaction( if (!Number.isSafeInteger(input.controlGeneration) || input.controlGeneration < 0) { throw new Error("Chat-first materialization requires a valid control generation"); } - if (!["daily_opener", "capture_arrival", "deferral_reraise", "agent_judgment"].includes(input.source)) { + if (![ + "daily_opener", + "capture_arrival", + "deferral_reraise", + "agent_judgment", + "cold_start_rich", + "cold_start_sparse", + ].includes(input.source)) { throw new Error("Chat-first materialization source is invalid"); } - const blocks = chatFirstIntentBlocks(intentId, input.blocks); + const blocks = chatFirstIntentBlocks(intentId, input.controlGeneration, input.source, input.blocks); const turnId = stableChatFirstIntentTurnID(intentId); const receiptId = stableChatFirstMaterializationReceiptID(intentId, continuityKey); @@ -1021,23 +1114,39 @@ export function materializeChatFirstIntents( export function listChatFirstMaterializationReceipts( store: AgentStore, input: { ownerId: string; controlGeneration: number; limit?: number }, -): ChatFirstMaterializationReceipt[] { +): ChatFirstPromptReceiptBatch { if (!Number.isSafeInteger(input.controlGeneration) || input.controlGeneration < 0) { throw new Error("Chat-first receipt listing requires a valid control generation"); } const limit = Math.max(1, Math.min(input.limit ?? 16, 16)); - return store.allRows( + const materializationReceipts = store.allRows( `SELECT intent_id, receipt_id FROM chat_first_materialization_receipts WHERE owner_id = ? AND control_generation = ? ORDER BY created_at_ms ASC LIMIT ?`, [input.ownerId, input.controlGeneration, limit], ).map((row) => ({ intentId: String(row.intent_id), receiptId: String(row.receipt_id) })); + const coldStartSequenceTerminalReceipts = store.allRows( + `SELECT sequence_id, receipt_id, terminal_state FROM chat_first_cold_start_sequence_receipts + WHERE owner_id = ? AND control_generation = ? + ORDER BY created_at_ms ASC LIMIT ?`, + [input.ownerId, input.controlGeneration, limit], + ).map((row) => ({ + sequenceId: String(row.sequence_id), + receiptId: String(row.receipt_id), + terminalState: String(row.terminal_state) as "completed" | "abandoned", + })); + return { materializationReceipts, coldStartSequenceTerminalReceipts }; } /** Delete only the exact server-acknowledged receipt identities. */ export function acknowledgeChatFirstMaterializationReceipts( store: AgentStore, - input: { ownerId: string; controlGeneration: number; receipts: readonly ChatFirstMaterializationReceipt[] }, + input: { + ownerId: string; + controlGeneration: number; + receipts: readonly ChatFirstMaterializationReceipt[]; + coldStartSequenceTerminalReceipts: readonly ChatFirstColdStartSequenceTerminalReceipt[]; + }, ): number { if (!Number.isSafeInteger(input.controlGeneration) || input.controlGeneration < 0) { throw new Error("Chat-first receipt acknowledgement requires a valid control generation"); @@ -1054,6 +1163,20 @@ export function acknowledgeChatFirstMaterializationReceipts( ); acknowledged += result; } + for (const receipt of input.coldStartSequenceTerminalReceipts) { + const sequenceId = nonEmpty(receipt.sequenceId, "cold-start sequence ID"); + const receiptId = nonEmpty(receipt.receiptId, "cold-start terminal receipt ID"); + if (receipt.terminalState !== "completed" && receipt.terminalState !== "abandoned") { + throw new Error("Cold-start terminal receipt state is invalid"); + } + const result = store.execute( + `DELETE FROM chat_first_cold_start_sequence_receipts + WHERE sequence_id = ? AND owner_id = ? AND control_generation = ? + AND receipt_id = ? AND terminal_state = ?`, + [sequenceId, input.ownerId, input.controlGeneration, receiptId, receipt.terminalState], + ); + acknowledged += result; + } return acknowledged; }); } @@ -1395,10 +1518,215 @@ export function terminalizeJournalTurn( if (input.disposition === "discard") { markDiscardedBackendProjection(store, input.turnId, now); } + if (input.disposition === "accept" && terminalized.status === "completed") { + appendNextColdStartSequenceQuestion(store, input.ownerId, terminalized, now); + } return terminalized; }); } +/** + * Advance the fixed sparse sequence only from the selected option's ordinary + * assistant continuation after that continuation reaches a successful + * terminal state. The same SQLite transaction commits both facts, making a + * crash retry replay-safe without a separate sequence-completed flag. + */ +function appendNextColdStartSequenceQuestion( + store: AgentStore, + ownerId: string, + terminalized: ConversationTurn, + nowMs: number, +): void { + const metadata = parseObjectJson(terminalized.metadataJson) as Record; + const descriptor = localColdStartSequenceDescriptor(metadata.coldStartSequence); + const continuityKey = typeof metadata.continuityKey === "string" ? metadata.continuityKey : null; + if (!descriptor || !continuityKey) return; + const tail = store.getOptionalRow( + `SELECT turn_id FROM conversation_turns + WHERE conversation_id = ? ORDER BY turn_seq DESC LIMIT 1`, + [terminalized.conversationId], + ); + if (!tail || String(tail.turn_id) !== terminalized.turnId) return; + if (!selectedColdStartParentMatchesContinuation(store, terminalized, descriptor, continuityKey)) return; + + if (descriptor.step === 3) { + recordColdStartSequenceTerminalReceipt(store, { + ownerId, + conversationId: terminalized.conversationId, + sequenceId: descriptor.sequenceId, + terminalState: "completed", + terminalTurnId: terminalized.turnId, + nowMs, + }); + return; + } + + const nextStep = (descriptor.step + 1) as 2 | 3; + const nextQuestion = coldStartSequenceQuestion(descriptor.sequenceId, nextStep); + const turnId = stableColdStartSequenceTurnID(descriptor.sequenceId, nextStep); + recordJournalTurn(store, { + ownerId, + conversationId: terminalized.conversationId, + turnId, + producerId: `cold-start-sequence:${descriptor.sequenceId}:step:${nextStep}`, + role: "assistant", + surfaceKind: "main_chat", + origin: "typed_chat", + status: "completed", + content: "", + contentBlocks: [nextQuestion], + metadataJson: JSON.stringify({ coldStartSequence: { sequenceId: descriptor.sequenceId, step: nextStep } }), + createdAtMs: nowMs + 1, + }); +} + +function localColdStartSequenceDescriptor( + value: unknown, +): { sequenceId: string; step: 1 | 2 | 3 } | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const sequenceId = typeof record.sequenceId === "string" ? record.sequenceId : ""; + const step = record.step; + if (!sequenceId || (step !== 1 && step !== 2 && step !== 3)) return null; + return { sequenceId, step }; +} + +function selectedColdStartParentMatchesContinuation( + store: AgentStore, + terminalized: ConversationTurn, + descriptor: { sequenceId: string; step: 1 | 2 | 3 }, + continuityKey: string, +): boolean { + const rows = store.allRows( + `SELECT turn_id FROM conversation_turns + WHERE conversation_id = ? AND role = 'assistant' AND turn_seq < ? + ORDER BY turn_seq DESC`, + [terminalized.conversationId, terminalized.turnSeq], + ); + for (const row of rows) { + const turn = requireJournalTurn(store, terminalized.conversationId, String(row.turn_id)); + for (const block of turn.contentBlocks) { + if ( + block.type !== "questionCard" + || block.selectedOptionId === undefined + || block.coldStartSequence?.retired === true + || block.coldStartSequence?.sequenceId !== descriptor.sequenceId + || block.coldStartSequence?.step !== descriptor.step + ) continue; + if (questionInteractionContinuityKey(block.questionId, block.selectedOptionId) === continuityKey) return true; + } + } + return false; +} + +function coldStartSequenceQuestion( + sequenceId: string, + step: 2 | 3, +): Extract { + const questionId = `${sequenceId}:step:${step}`; + const options = step === 2 + ? [ + { + optionId: `${sequenceId}:goal:create`, + label: "Yes, create a goal", + preparedAnswer: "Yes, turn that into a goal for me.", + }, + { + optionId: `${sequenceId}:goal:not-yet`, + label: "Not yet", + preparedAnswer: "Not yet. I want to keep thinking about it first.", + }, + ] + : [ + { + optionId: `${sequenceId}:tasks:draft`, + label: "Yes, draft tasks", + preparedAnswer: "Yes, draft a few first tasks for me.", + }, + { + optionId: `${sequenceId}:tasks:not-yet`, + label: "I will add them later", + preparedAnswer: "I will add tasks later.", + }, + ]; + return { + type: "questionCard", + id: stableColdStartSequenceBlockID(sequenceId, step), + questionId, + text: step === 2 + ? "Would you like me to turn that into a goal?" + : "Want me to draft a few first tasks?", + subject: { kind: "cold_start", id: sequenceId }, + options, + coldStartSequence: { sequenceId, step }, + }; +} + +function stableColdStartSequenceTurnID(sequenceId: string, step: 2 | 3): string { + return `turn_cfs_${createHash("sha256").update(`${sequenceId}\u0000${step}`).digest("hex").slice(0, 24)}`; +} + +function stableColdStartSequenceBlockID(sequenceId: string, step: 2 | 3): string { + return `cfs_block_${createHash("sha256").update(`${sequenceId}\u0000${step}`).digest("hex").slice(0, 20)}`; +} + +function recordColdStartSequenceTerminalReceipt( + store: AgentStore, + input: { + ownerId: string; + conversationId: string; + sequenceId: string; + terminalState: "completed" | "abandoned"; + terminalTurnId: string; + nowMs: number; + }, +): void { + const controlGeneration = coldStartSequenceGeneration(input.sequenceId); + if (controlGeneration === null) throw new Error("Cold-start sequence identity is invalid"); + const receiptId = `cfs_terminal_${createHash("sha256") + .update(`${input.sequenceId}\u0000${input.terminalState}\u0000${input.terminalTurnId}`) + .digest("hex") + .slice(0, 24)}`; + const existing = store.getOptionalRow( + `SELECT owner_id, conversation_id, control_generation, receipt_id, terminal_state + FROM chat_first_cold_start_sequence_receipts WHERE sequence_id = ?`, + [input.sequenceId], + ); + if (existing) { + if ( + String(existing.owner_id) !== input.ownerId + || String(existing.conversation_id) !== input.conversationId + || Number(existing.control_generation) !== controlGeneration + || String(existing.receipt_id) !== receiptId + || String(existing.terminal_state) !== input.terminalState + ) { + throw new Error("Cold-start sequence terminal receipt conflicts with canonical journal state"); + } + return; + } + store.execute( + `INSERT INTO chat_first_cold_start_sequence_receipts( + sequence_id, owner_id, conversation_id, control_generation, receipt_id, terminal_state, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + input.sequenceId, + input.ownerId, + input.conversationId, + controlGeneration, + receiptId, + input.terminalState, + input.nowMs, + ], + ); +} + +function coldStartSequenceGeneration(sequenceId: string): number | null { + const match = /^cold-start:(\d+)$/.exec(sequenceId); + if (!match) return null; + const generation = Number(match[1]); + return Number.isSafeInteger(generation) && generation >= 0 ? generation : null; +} + function markDiscardedBackendProjection(store: AgentStore, turnId: string, nowMs: number): void { store.execute( `UPDATE backend_turn_outbox @@ -3102,7 +3430,11 @@ function materializationTailState( return { unansweredQuestion: assistant && turn.status === "completed" - && turn.contentBlocks.some((block) => block.type === "questionCard" && block.selectedOptionId === undefined), + && turn.contentBlocks.some((block) => ( + block.type === "questionCard" + && block.selectedOptionId === undefined + && block.coldStartSequence?.retired !== true + )), streaming: assistant && (turn.status === "pending" || turn.status === "streaming"), }; } @@ -3114,6 +3446,8 @@ function materializationTailState( */ function chatFirstIntentBlocks( intentId: string, + controlGeneration: number, + source: MaterializeChatFirstIntentInput["source"], rawBlocks: readonly unknown[], ): ConversationContentBlock[] { if (rawBlocks.length < 1 || rawBlocks.length > 8) { @@ -3129,6 +3463,25 @@ function chatFirstIntentBlocks( switch (type) { case "questionCard": { const subject = recordValue(block.subject, "chat-first question subject"); + const subjectKind = chatFirstSubjectKind(subject.kind); + const subjectId = nonEmptyString(subject.id, "chat-first question subject ID"); + const coldStartSequence = block.cold_start_sequence === undefined + ? undefined + : coldStartSequenceValue(block.cold_start_sequence); + const isColdStart = subjectKind === "cold_start"; + if (isColdStart !== (coldStartSequence !== undefined)) { + throw new Error("chat-first cold-start question descriptor is invalid"); + } + if (coldStartSequence) { + if ( + source !== "cold_start_sparse" + || coldStartSequence.step !== 1 + || coldStartSequence.sequenceId !== subjectId + || subjectId !== `cold-start:${controlGeneration}` + ) { + throw new Error("chat-first cold-start question is invalid"); + } + } const options = arrayValue(block.options, "chat-first question options").map((rawOption) => { const option = recordValue(rawOption, "chat-first question option"); const defer = option.defer === undefined ? undefined : booleanValue(option.defer, "chat-first question defer"); @@ -3145,10 +3498,11 @@ function chatFirstIntentBlocks( questionId: nonEmptyString(block.question_id, "chat-first question ID"), text: nonEmptyString(block.text, "chat-first question text"), subject: { - kind: chatFirstSubjectKind(subject.kind), - id: nonEmptyString(subject.id, "chat-first question subject ID"), + kind: subjectKind, + id: subjectId, }, options, + ...(coldStartSequence ? { coldStartSequence } : {}), }; } case "taskCard": @@ -3199,6 +3553,16 @@ function booleanValue(value: unknown, name: string): boolean { return value; } +function coldStartSequenceValue(value: unknown): { sequenceId: string; step: 1 | 2 | 3 } { + const sequence = recordValue(value, "chat-first cold-start sequence"); + const step = safeNonNegativeInteger(sequence.step, "chat-first cold-start sequence step"); + if (step < 1 || step > 3) throw new Error("chat-first cold-start sequence step is invalid"); + return { + sequenceId: nonEmptyString(sequence.sequence_id, "chat-first cold-start sequence ID"), + step: step as 1 | 2 | 3, + }; +} + function safeNonNegativeInteger(value: unknown, name: string): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { throw new Error(`${name} is invalid`); @@ -3206,8 +3570,8 @@ function safeNonNegativeInteger(value: unknown, name: string): number { return value; } -function chatFirstSubjectKind(value: unknown): "task" | "goal" | "capture" { - if (value === "task" || value === "goal" || value === "capture") return value; +function chatFirstSubjectKind(value: unknown): "task" | "goal" | "capture" | "cold_start" { + if (value === "task" || value === "goal" || value === "capture" || value === "cold_start") return value; throw new Error("chat-first question subject kind is invalid"); } @@ -3267,6 +3631,9 @@ function enqueueChatFirstDeferral( }, ): void { const question = structuredClone(input.question); + if (question.subject.kind === "cold_start") { + throw new Error("Cold-start sequence questions cannot enter the deferral outbox"); + } // The backend schema deliberately does not carry renderer state. Selection // is transcript-local; the verbatim question payload remains canonical. delete question.selectedOptionId; @@ -3324,7 +3691,21 @@ function validateContentBlocks(blocks: readonly ConversationContentBlock[]): Con nonEmpty(block.questionId, "question ID"); if (block.text.length === 0 || block.text.length > 300) throw new Error("Question card text is out of bounds"); nonEmpty(block.subject.id, "question subject ID"); - if (!["task", "goal", "capture"].includes(block.subject.kind)) throw new Error("Question subject kind is invalid"); + if (!["task", "goal", "capture", "cold_start"].includes(block.subject.kind)) { + throw new Error("Question subject kind is invalid"); + } + const isColdStart = block.subject.kind === "cold_start"; + if (isColdStart !== (block.coldStartSequence !== undefined)) { + throw new Error("Cold-start question descriptor is invalid"); + } + if (block.coldStartSequence) { + if ( + block.coldStartSequence.sequenceId !== block.subject.id + || ![1, 2, 3].includes(block.coldStartSequence.step) + ) { + throw new Error("Cold-start question sequence is invalid"); + } + } if (block.options.length < 1 || block.options.length > 4) throw new Error("Question card option count is out of bounds"); const optionIds = new Set(); let defers = 0; diff --git a/desktop/macos/agent/src/runtime/sqlite-store.ts b/desktop/macos/agent/src/runtime/sqlite-store.ts index 7aae94fc7c0..72c9d7b02a2 100644 --- a/desktop/macos/agent/src/runtime/sqlite-store.ts +++ b/desktop/macos/agent/src/runtime/sqlite-store.ts @@ -71,6 +71,7 @@ const BACKEND_RECONCILE_CURSOR_MIGRATION_VERSION = 26; const JOURNAL_PRODUCING_ATTEMPT_MIGRATION_VERSION = 27; const CHAT_FIRST_DEFERRAL_OUTBOX_MIGRATION_VERSION = 28; const CHAT_FIRST_MATERIALIZATION_RECEIPTS_MIGRATION_VERSION = 29; +const CHAT_FIRST_COLD_START_SEQUENCE_RECEIPTS_MIGRATION_VERSION = 30; const ACTIVE_ATTEMPT_STATUSES = ["queued", "starting", "running", "waiting_input", "waiting_approval", "cancelling"] as const; const TERMINAL_ATTEMPT_STATUSES = ["succeeded", "failed", "cancelled", "timed_out", "orphaned"] as const; @@ -377,6 +378,9 @@ export function probeNodeSqliteRuntime(options: NodeSqliteProbeOptions = {}): vo runJournalGenerationBaseMigration(db, Date.now()); runContextSourceSurfaceScopeMigration(db, Date.now()); runJournalProducingAttemptMigration(db, Date.now()); + runChatFirstDeferralOutboxMigration(db, Date.now()); + runChatFirstMaterializationReceiptsMigration(db, Date.now()); + runChatFirstColdStartSequenceReceiptsMigration(db, Date.now()); runTransaction(db, () => { db?.prepare("INSERT INTO sessions (session_id, owner_id, status, surface_kind, default_adapter_id, created_at_ms, updated_at_ms, last_activity_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run( "ses_probe", @@ -515,6 +519,9 @@ export class SqliteAgentStore implements AgentStore { if (!this.hasMigration(CHAT_FIRST_MATERIALIZATION_RECEIPTS_MIGRATION_VERSION)) { runChatFirstMaterializationReceiptsMigration(this.db, this.nowMs()); } + if (!this.hasMigration(CHAT_FIRST_COLD_START_SEQUENCE_RECEIPTS_MIGRATION_VERSION)) { + runChatFirstColdStartSequenceReceiptsMigration(this.db, this.nowMs()); + } } withTransaction(work: () => T): T { @@ -3230,6 +3237,39 @@ function runChatFirstMaterializationReceiptsMigration( }); } +/** + * Terminal sparse-sequence receipts are a local-journal outbox, not a second + * transcript state or client-side rollout flag. The server attaches the + * accepted receipt to the originating cold-start intent before it permits + * agent-tier judgment again. + */ +function runChatFirstColdStartSequenceReceiptsMigration( + db: Pick, + appliedAtMs: number, +): void { + runTransaction(db, () => { + db.exec(` + CREATE TABLE chat_first_cold_start_sequence_receipts( + sequence_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + control_generation INTEGER NOT NULL CHECK (control_generation >= 0), + receipt_id TEXT NOT NULL, + terminal_state TEXT NOT NULL CHECK (terminal_state IN ('completed', 'abandoned')), + created_at_ms INTEGER NOT NULL + ) STRICT; + CREATE INDEX chat_first_cold_start_sequence_receipts_owner_idx + ON chat_first_cold_start_sequence_receipts( + owner_id, conversation_id, control_generation, created_at_ms ASC + ); + `); + db.prepare("INSERT INTO schema_migrations (version, applied_at_ms) VALUES (?, ?)").run( + CHAT_FIRST_COLD_START_SEQUENCE_RECEIPTS_MIGRATION_VERSION, + appliedAtMs, + ); + }); +} + function runTransaction(db: Pick, work: () => T): T { if (db.isTransaction) { return work(); diff --git a/desktop/macos/agent/src/runtime/types.ts b/desktop/macos/agent/src/runtime/types.ts index 9c4cfd640ab..08d68d2137c 100644 --- a/desktop/macos/agent/src/runtime/types.ts +++ b/desktop/macos/agent/src/runtime/types.ts @@ -110,8 +110,10 @@ export type ConversationContentBlock = id: string; questionId: string; text: string; - subject: { kind: "task" | "goal" | "capture"; id: string }; + subject: { kind: "task" | "goal" | "capture" | "cold_start"; id: string }; options: Array<{ optionId: string; label: string; preparedAnswer: string; defer?: boolean }>; + /** Explicit local script identity; never inferred from question text. */ + coldStartSequence?: { sequenceId: string; step: 1 | 2 | 3; retired?: true }; /** * Kernel-owned selection receipt. A question remains readable after an * answer, but this durable value retires its options on every projection. diff --git a/desktop/macos/agent/tests/conversation-journal.test.ts b/desktop/macos/agent/tests/conversation-journal.test.ts index cf4ad9e75fe..e15e308e5dc 100644 --- a/desktop/macos/agent/tests/conversation-journal.test.ts +++ b/desktop/macos/agent/tests/conversation-journal.test.ts @@ -82,7 +82,10 @@ describe("kernel conversation journal", () => { expect(replay).toMatchObject({ accepted: true, duplicate: true, turn: { turnId: first.turn?.turnId } }); expect(listChatFirstMaterializationReceipts(fixture.store, { ownerId: fixture.ownerId, controlGeneration: 7, - })).toEqual([first.receipt]); + })).toEqual({ + materializationReceipts: [first.receipt], + coldStartSequenceTerminalReceipts: [], + }); recordTerminalQuestion(fixture, "tail-question", [ { optionId: "later", label: "Later", preparedAnswer: "Ask me later.", defer: true }, @@ -103,10 +106,11 @@ describe("kernel conversation journal", () => { ownerId: fixture.ownerId, controlGeneration: 7, receipts: [first.receipt!], + coldStartSequenceTerminalReceipts: [], })).toBe(1); expect(listChatFirstMaterializationReceipts(fixture.store, { ownerId: fixture.ownerId, controlGeneration: 7, - })).toEqual([]); + })).toEqual({ materializationReceipts: [], coldStartSequenceTerminalReceipts: [] }); fixture.store.close(); }); @@ -267,6 +271,179 @@ describe("kernel conversation journal", () => { fixture.store.close(); }); + it("advances sparse cold start only after the selected normal answer terminalizes and retires on unrelated input", () => { + const fixture = newSurface("main_chat", "chat", "cold-start-sequence"); + const first = materializeChatFirstIntent(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + controlGeneration: 7, + intentId: "cold-start-intent", + continuityKey: "cold-start:7", + source: "cold_start_sparse", + blocks: [{ + type: "questionCard", + question_id: "cold-start:7:step:1", + text: "What matters now?", + subject: { kind: "cold_start", id: "cold-start:7" }, + cold_start_sequence: { sequence_id: "cold-start:7", step: 1 }, + options: [{ option_id: "progress", label: "Make progress", prepared_answer: "I want to make progress." }], + }], + nowMs: 10, + }); + const selected = recordQuestionInteractionReply(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + questionId: "cold-start:7:step:1", + optionId: "progress", + controlGeneration: 7, + nowMs: 20, + }); + const { run, attempt } = insertActiveRunAttempt(fixture, "cold-start-sequence"); + updateJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId: selected.assistantTurn!.turnId, + producingRunId: run.runId, + producingAttemptId: attempt.attemptId, + nowMs: 21, + }); + fixture.store.execute("UPDATE runs SET status = 'succeeded' WHERE run_id = ?", [run.runId]); + fixture.store.execute("UPDATE run_attempts SET status = 'succeeded' WHERE attempt_id = ?", [attempt.attemptId]); + terminalizeJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId: selected.assistantTurn!.turnId, + producingRunId: run.runId, + producingAttemptId: attempt.attemptId, + disposition: "accept", + content: "Let us shape that together.", + nowMs: 22, + }); + const turns = listJournalTurns(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + }).turns; + expect(turns.at(-1)?.contentBlocks).toContainEqual(expect.objectContaining({ + type: "questionCard", + questionId: "cold-start:7:step:2", + coldStartSequence: { sequenceId: "cold-start:7", step: 2 }, + })); + + const second = recordQuestionInteractionReply(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + questionId: "cold-start:7:step:2", + optionId: "cold-start:7:goal:create", + controlGeneration: 7, + nowMs: 30, + }); + const secondRunAttempt = insertActiveRunAttempt(fixture, "cold-start-sequence-step-2"); + updateJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId: second.assistantTurn!.turnId, + producingRunId: secondRunAttempt.run.runId, + producingAttemptId: secondRunAttempt.attempt.attemptId, + nowMs: 31, + }); + fixture.store.execute("UPDATE runs SET status = 'succeeded' WHERE run_id = ?", [secondRunAttempt.run.runId]); + fixture.store.execute("UPDATE run_attempts SET status = 'succeeded' WHERE attempt_id = ?", [secondRunAttempt.attempt.attemptId]); + terminalizeJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId: second.assistantTurn!.turnId, + producingRunId: secondRunAttempt.run.runId, + producingAttemptId: secondRunAttempt.attempt.attemptId, + disposition: "accept", + content: "I will turn that into a goal.", + nowMs: 32, + }); + + const third = recordQuestionInteractionReply(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + questionId: "cold-start:7:step:3", + optionId: "cold-start:7:tasks:draft", + controlGeneration: 7, + nowMs: 40, + }); + const thirdRunAttempt = insertActiveRunAttempt(fixture, "cold-start-sequence-step-3"); + updateJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId: third.assistantTurn!.turnId, + producingRunId: thirdRunAttempt.run.runId, + producingAttemptId: thirdRunAttempt.attempt.attemptId, + nowMs: 41, + }); + fixture.store.execute("UPDATE runs SET status = 'succeeded' WHERE run_id = ?", [thirdRunAttempt.run.runId]); + fixture.store.execute("UPDATE run_attempts SET status = 'succeeded' WHERE attempt_id = ?", [thirdRunAttempt.attempt.attemptId]); + terminalizeJournalTurn(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + turnId: third.assistantTurn!.turnId, + producingRunId: thirdRunAttempt.run.runId, + producingAttemptId: thirdRunAttempt.attempt.attemptId, + disposition: "accept", + content: "I will draft those first tasks.", + nowMs: 42, + }); + expect(listChatFirstMaterializationReceipts(fixture.store, { + ownerId: fixture.ownerId, controlGeneration: 7, + }).coldStartSequenceTerminalReceipts).toEqual([ + expect.objectContaining({ sequenceId: "cold-start:7", terminalState: "completed" }), + ]); + + const unrelated = newSurface("main_chat", "chat", "cold-start-unrelated"); + materializeChatFirstIntent(unrelated.store, { + ownerId: unrelated.ownerId, + conversationId: unrelated.conversationId, + controlGeneration: 7, + intentId: "cold-start-unrelated-intent", + continuityKey: "cold-start:7", + source: "cold_start_sparse", + blocks: [{ + type: "questionCard", + question_id: "cold-start:7:step:1", + text: "What matters now?", + subject: { kind: "cold_start", id: "cold-start:7" }, + cold_start_sequence: { sequence_id: "cold-start:7", step: 1 }, + options: [{ option_id: "progress", label: "Make progress", prepared_answer: "I want to make progress." }], + }], + nowMs: 10, + }); + recordJournalTurn(unrelated.store, { + ownerId: unrelated.ownerId, + conversationId: unrelated.conversationId, + role: "user", + surfaceKind: "main_chat", + origin: "typed_chat", + status: "completed", + content: "Actually, help me with something else.", + contentBlocks: [{ type: "text", id: "unrelated:text", text: "Actually, help me with something else." }], + createdAtMs: 20, + }); + const retired = listJournalTurns(unrelated.store, { + ownerId: unrelated.ownerId, + conversationId: unrelated.conversationId, + }).turns[0]!.contentBlocks[0] as Extract; + expect(retired.coldStartSequence?.retired).toBe(true); + expect(listChatFirstMaterializationReceipts(unrelated.store, { + ownerId: unrelated.ownerId, controlGeneration: 7, + }).coldStartSequenceTerminalReceipts).toEqual([ + expect.objectContaining({ sequenceId: "cold-start:7", terminalState: "abandoned" }), + ]); + expect(recordQuestionInteractionReply(unrelated.store, { + ownerId: unrelated.ownerId, + sessionId: unrelated.sessionId, + questionId: "cold-start:7:step:1", + optionId: "progress", + controlGeneration: 7, + })).toMatchObject({ accepted: false }); + fixture.store.close(); + unrelated.store.close(); + }); + it("rejects stale or conflicting question selections without mutating the journal", () => { const fixture = newSurface("main_chat", "chat", "question-reject"); recordTerminalQuestion(fixture, "turn-question", [ diff --git a/desktop/macos/changelog/unreleased/20260715-chat-first-goals.json b/desktop/macos/changelog/unreleased/20260715-chat-first-goals.json new file mode 100644 index 00000000000..dd011ecf674 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260715-chat-first-goals.json @@ -0,0 +1,3 @@ +{ + "change": "Added a cohort-only Goals workspace with canonical focus controls and task follow-through" +} From 760b4595342bc9dad66cd54e29b746ec77e36bb4 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 15:05:17 -0400 Subject: [PATCH 11/28] feat(chat): add cohort-safe analytics and fixtures Add shape-only Chat-first analytics, local-only focus acknowledgement evidence, and non-production fixture contracts for cohort-negative validation.\n\nVerification: static diff checks plus fresh Sol review/fix gate completed. The consolidated runtime, manifest-byte, and named-bundle verification pass begins next. --- .../Desktop/Sources/AnalyticsManager.swift | 11 ++ .../Sources/Chat/ChatFirstAnalytics.swift | 185 ++++++++++++++++++ .../Chat/ChatFirstAutomationFixture.swift | 113 +++++++++++ .../Sources/DesktopAutomationBridge.swift | 28 +++ .../Blocks/ChatFirstContentBlockViews.swift | 69 ++++++- .../MainWindow/ChatFirst/ChatFirstRoute.swift | 63 +++++- .../MainWindow/ChatFirst/ChatFirstShell.swift | 1 + .../ChatFirst/ChatFirstTasksPage.swift | 41 +++- .../MainWindow/Components/ChatBubble.swift | 8 +- .../Sources/MainWindow/DesktopHomeView.swift | 30 +++ .../Tests/ChatFirstAnalyticsTests.swift | 158 +++++++++++++++ .../Desktop/Tests/ChatFirstShellTests.swift | 34 ++++ 12 files changed, 727 insertions(+), 14 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/Chat/ChatFirstAnalytics.swift create mode 100644 desktop/macos/Desktop/Sources/Chat/ChatFirstAutomationFixture.swift create mode 100644 desktop/macos/Desktop/Tests/ChatFirstAnalyticsTests.swift diff --git a/desktop/macos/Desktop/Sources/AnalyticsManager.swift b/desktop/macos/Desktop/Sources/AnalyticsManager.swift index ad5082e1849..622f621234a 100644 --- a/desktop/macos/Desktop/Sources/AnalyticsManager.swift +++ b/desktop/macos/Desktop/Sources/AnalyticsManager.swift @@ -548,6 +548,17 @@ class AnalyticsManager { // MARK: - Claude Agent Events + /// Sends a Chat-first event only after its closed, content-free mapper has + /// produced the payload. Views must use this typed entry point instead of a + /// generic PostHog event so rich controls cannot leak user text or IDs. + func chatFirst(_ event: ChatFirstAnalyticsEvent) { + let payload = event.analyticsPayload + PostHogManager.shared.track( + payload.eventName, + properties: payload.properties.mapValues { $0 as Any } + ) + } + func chatQueryTelemetry(_ event: ChatQueryTelemetryEvent) { let payload = event.analyticsPayload PostHogManager.shared.track(payload.eventName, properties: payload.properties) diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstAnalytics.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstAnalytics.swift new file mode 100644 index 00000000000..b72e6b53b3f --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstAnalytics.swift @@ -0,0 +1,185 @@ +import Foundation + +/// Closed, content-free analytics schema for the cohort-only Chat-first +/// experience. The associated values are all finite enums or bounded numeric +/// buckets: callers cannot put titles, entity IDs, question answers, URLs, +/// prompts, transcripts, or raw errors into a payload. +enum ChatFirstAnalyticsEvent: Equatable, Sendable { + enum Route: String, CaseIterable, Sendable { + case chat + case conversations + case tasks + case goals + case memories + case more + } + + enum RouteOrigin: String, CaseIterable, Sendable { + case shellLaunch = "shell_launch" + case sidebar + case chatDeeplink = "chat_deeplink" + case more + } + + enum RichBlockKind: String, CaseIterable, Sendable { + case taskCard = "task_card" + case goalLink = "goal_link" + case captureLink = "capture_link" + case questionCard = "question_card" + } + + enum RichBlockOutcome: String, CaseIterable, Sendable { + case rendered + case acted + case stalePlaceholder = "stale_placeholder" + case rejected + } + + enum RichBlockAction: String, CaseIterable, Sendable { + case none + case open + case toggle + case select + } + + enum QuestionLifecycle: String, CaseIterable, Sendable { + case shown + case answered + case retiredUnseen = "retired_unseen" + case deferred + } + + enum TaskMutationLifecycle: String, CaseIterable, Sendable { + case attempt + case success + case rollback + } + + enum TaskMutation: String, CaseIterable, Sendable { + case create + case completion + case rename + case schedule + } + + enum CapabilityOutcome: String, CaseIterable, Sendable { + case enabled + case disabled + case unavailable + case projectionRejected = "projection_rejected" + } + + enum CapabilityGenerationBucket: String, CaseIterable, Sendable { + case none + case zeroToNine = "0_9" + case tenToNinetyNine = "10_99" + case hundredPlus = "100_plus" + + static func bucket(for generation: Int?) -> Self { + guard let generation, generation >= 0 else { return .none } + switch generation { + case 0...9: return .zeroToNine + case 10...99: return .tenToNinetyNine + default: return .hundredPlus + } + } + } + + enum CapabilityErrorClass: String, CaseIterable, Sendable { + case none + case unavailable + case ownerChanged = "owner_changed" + case invalidControl = "invalid_control" + case projectionRejected = "projection_rejected" + } + + case routeEntered(route: Route, origin: RouteOrigin) + case richBlock(kind: RichBlockKind, outcome: RichBlockOutcome, action: RichBlockAction) + case question(lifecycle: QuestionLifecycle) + case taskMutation(lifecycle: TaskMutationLifecycle, mutation: TaskMutation) + case capabilityResolution( + outcome: CapabilityOutcome, + generationBucket: CapabilityGenerationBucket, + errorClass: CapabilityErrorClass + ) +} + +/// The only map accepted by `AnalyticsManager` for Chat-first events. String +/// keys are private to this mapper and values come exclusively from the enums +/// above, so a UI cannot accidentally widen the event contract with free text. +struct ChatFirstAnalyticsPayload { + let eventName: String + let properties: [String: String] +} + +extension ChatFirstAnalyticsEvent { + var analyticsPayload: ChatFirstAnalyticsPayload { + switch self { + case .routeEntered(let route, let origin): + return ChatFirstAnalyticsPayload( + eventName: "chat_first_route_entered", + properties: [ + "route": route.rawValue, + "origin": origin.rawValue, + "telemetry_schema_version": "1", + ] + ) + case .richBlock(let kind, let outcome, let action): + return ChatFirstAnalyticsPayload( + eventName: "chat_first_rich_block", + properties: [ + "kind": kind.rawValue, + "outcome": outcome.rawValue, + "action": action.rawValue, + "telemetry_schema_version": "1", + ] + ) + case .question(let lifecycle): + return ChatFirstAnalyticsPayload( + eventName: "chat_first_question", + properties: [ + "lifecycle": lifecycle.rawValue, + "telemetry_schema_version": "1", + ] + ) + case .taskMutation(let lifecycle, let mutation): + return ChatFirstAnalyticsPayload( + eventName: "chat_first_task_mutation", + properties: [ + "lifecycle": lifecycle.rawValue, + "mutation": mutation.rawValue, + "telemetry_schema_version": "1", + ] + ) + case .capabilityResolution(let outcome, let generationBucket, let errorClass): + return ChatFirstAnalyticsPayload( + eventName: "chat_first_capability_resolution", + properties: [ + "outcome": outcome.rawValue, + "generation_bucket": generationBucket.rawValue, + "error_class": errorClass.rawValue, + "telemetry_schema_version": "1", + ] + ) + } + } +} + +/// Converts the existing owner-safe task result into the only terminal shape +/// Chat-first may record. It deliberately collapses transport and owner detail. +enum ChatFirstTaskMutationTelemetry { + static func terminalLifecycle( + for outcome: TasksStore.TaskUpdateOutcome + ) -> ChatFirstAnalyticsEvent.TaskMutationLifecycle { + switch outcome { + case .updated: + return .success + case .preservedLocalAfterRemoteFailure, + .rolledBackAfterRemoteFailure, + .rollbackFailed, + .localWriteFailed, + .ownerChanged: + return .rollback + } + } +} diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstAutomationFixture.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstAutomationFixture.swift new file mode 100644 index 00000000000..bcb479730d8 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstAutomationFixture.swift @@ -0,0 +1,113 @@ +import Foundation + +/// Deterministic, non-content fixture facts used by the named Chat-first +/// bundle. This is deliberately a contract probe, not a second production +/// state store: it exposes no IDs, titles, question text, prepared answers, +/// transcripts, or tool manifests. The harness validates actual raw manifests +/// out of process, where byte identity can be compared to the frozen fixture. +enum ChatFirstAutomationFixture { + enum Scenario: String, CaseIterable, Sendable { + case interactiveQuestion = "interactive_question" + case deferredQuestion = "deferred_question" + case mixedCapture = "mixed_capture" + case uiFlagOff = "ui_flag_off" + case outOfCohort = "out_of_cohort" + } + + struct Contract: Equatable, Sendable { + let validRichBlockCount: Int + let hasValidQuestion: Bool + let hasPreparedAnswer: Bool + let fakeClockEpochSeconds: Int + let deferralSeconds: Int + let captureSourceMode: String + let proactiveJudgeCalls: Int + let proactiveEmissions: Int + let materializationCount: Int + let rawManifestProofMode: String + let shellVariant: String + let chatFirstToolCount: Int + + var bridgeDetail: [String: String] { + [ + "valid_rich_block_count": "\(validRichBlockCount)", + "has_valid_question": hasValidQuestion ? "true" : "false", + "has_prepared_answer": hasPreparedAnswer ? "true" : "false", + "fake_clock_epoch_seconds": "\(fakeClockEpochSeconds)", + "deferral_seconds": "\(deferralSeconds)", + "capture_source_mode": captureSourceMode, + "proactive_judge_calls": "\(proactiveJudgeCalls)", + "proactive_emissions": "\(proactiveEmissions)", + "materialization_count": "\(materializationCount)", + "raw_manifest_proof_mode": rawManifestProofMode, + "shell_variant": shellVariant, + "chat_first_tool_count": "\(chatFirstToolCount)", + ] + } + } + + static func contract(for scenario: Scenario) -> Contract { + switch scenario { + case .interactiveQuestion: + return Contract( + validRichBlockCount: 1, + hasValidQuestion: true, + hasPreparedAnswer: true, + fakeClockEpochSeconds: 1_784_347_200, + deferralSeconds: 0, + captureSourceMode: "none", + proactiveJudgeCalls: 0, + proactiveEmissions: 0, + materializationCount: 0, + rawManifestProofMode: "external_raw_bytes_digest", + shellVariant: "chatFirst", + chatFirstToolCount: 2 + ) + case .deferredQuestion: + return Contract( + validRichBlockCount: 1, + hasValidQuestion: true, + hasPreparedAnswer: true, + fakeClockEpochSeconds: 1_784_347_200, + deferralSeconds: 86_400, + captureSourceMode: "none", + proactiveJudgeCalls: 0, + proactiveEmissions: 0, + materializationCount: 0, + rawManifestProofMode: "external_raw_bytes_digest", + shellVariant: "chatFirst", + chatFirstToolCount: 2 + ) + case .mixedCapture: + return Contract( + validRichBlockCount: 1, + hasValidQuestion: false, + hasPreparedAnswer: false, + fakeClockEpochSeconds: 1_784_347_200, + deferralSeconds: 0, + captureSourceMode: "mixed", + proactiveJudgeCalls: 0, + proactiveEmissions: 0, + materializationCount: 0, + rawManifestProofMode: "external_raw_bytes_digest", + shellVariant: "chatFirst", + chatFirstToolCount: 2 + ) + case .uiFlagOff, .outOfCohort: + return Contract( + validRichBlockCount: 0, + hasValidQuestion: false, + hasPreparedAnswer: false, + fakeClockEpochSeconds: 1_784_347_200, + deferralSeconds: 0, + captureSourceMode: "none", + proactiveJudgeCalls: 0, + proactiveEmissions: 0, + materializationCount: 0, + rawManifestProofMode: "external_raw_bytes_digest", + shellVariant: "legacy", + chatFirstToolCount: 0 + ) + } + } +} diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift index 351834bdde2..e09fb91d69b 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift @@ -131,6 +131,11 @@ struct DesktopAutomationSnapshot: Codable, Sendable { /// Shape-only focus telemetry for route acknowledgement; entity IDs stay local. var pendingFocusKind: String? var acknowledgedFocusKind: String? + /// The focused entity is available only through the local non-production + /// bridge so named-bundle probes can prove the acknowledgement target. It is + /// never an analytics dimension or a persisted navigation value. + var focusedEntityID: String? + var isFocusedEntityAcknowledged: Bool var showsPrimarySidebar: Bool var isSidebarCollapsed: Bool var hasCompletedOnboarding: Bool @@ -442,6 +447,8 @@ final class DesktopAutomationStateStore { chatFirstRoute: nil, pendingFocusKind: nil, acknowledgedFocusKind: nil, + focusedEntityID: nil, + isFocusedEntityAcknowledged: false, showsPrimarySidebar: false, isSidebarCollapsed: true, hasCompletedOnboarding: false, @@ -725,6 +732,27 @@ final class DesktopAutomationActionRegistry { return nil } + if AppBuild.isNonProduction { + register( + name: "chat_first_fixture_contract", + summary: "Read one deterministic Chat-first non-production fixture contract without user content", + params: ["scenario"], + category: "read", + surfaces: ["main_chat"], + safety: "read_only", + sideEffects: [] + ) { params in + guard let scenario = ChatFirstAutomationFixture.Scenario( + rawValue: params["scenario"] ?? "" + ) else { + throw DesktopAutomationActionError.invalidParams( + "scenario must be interactive_question, deferred_question, mixed_capture, ui_flag_off, or out_of_cohort" + ) + } + return ChatFirstAutomationFixture.contract(for: scenario).bridgeDetail + } + } + // CHAT-05: read the free-tier monthly chat usage-limiter state so a harness can // prove the counter is deterministic without spending LLM calls. Read-only. register( diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift index 0a37ae1465f..268fce76ed6 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift @@ -10,6 +10,7 @@ struct QuestionCardView: View { private struct Option: Identifiable { let id: String let label: String + let isDeferral: Bool init?(_ dictionary: [String: Any]) { guard let id = dictionary["optionId"] as? String, @@ -19,6 +20,7 @@ struct QuestionCardView: View { else { return nil } self.id = id self.label = label + self.isDeferral = dictionary["defer"] as? Bool ?? false } } @@ -27,7 +29,7 @@ struct QuestionCardView: View { let options: [[String: Any]] let selectedOptionID: String? let isActionable: Bool - let onSelect: (String) -> Void + let onSelect: (String, Bool) -> Void private var validOptions: [Option] { options.compactMap(Option.init) } @@ -45,7 +47,7 @@ struct QuestionCardView: View { FlowLayout(spacing: OmiSpacing.sm) { ForEach(validOptions) { option in Button { - onSelect(option.id) + onSelect(option.id, option.isDeferral) } label: { Text(option.label) .scaledFont(size: OmiType.caption, weight: .medium) @@ -77,6 +79,16 @@ struct QuestionCardView: View { ) .accessibilityElement(children: .contain) .accessibilityIdentifier("chat-first-question-\(questionID)") + .onAppear { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .questionCard, outcome: .rendered, action: .none) + ) + AnalyticsManager.shared.chatFirst(.question(lifecycle: .shown)) + } + .onChange(of: isActionable) { wasActionable, nowActionable in + guard wasActionable, !nowActionable, selectedOptionID == nil else { return } + AnalyticsManager.shared.chatFirst(.question(lifecycle: .retiredUnseen)) + } } } @@ -104,8 +116,18 @@ struct TaskCardView: View { Group { if let task { card(task) + .onAppear { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .taskCard, outcome: .rendered, action: .none) + ) + } } else { ChatFirstUnavailableBlockView(entityName: "Task") + .onAppear { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .taskCard, outcome: .stalePlaceholder, action: .none) + ) + } } } .accessibilityIdentifier("chat-first-task-\(taskID)") @@ -181,16 +203,35 @@ struct TaskCardView: View { guard !isToggling else { return } let intendedCompletion = !task.completed isToggling = true + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .taskCard, outcome: .acted, action: .toggle) + ) + AnalyticsManager.shared.chatFirst( + .taskMutation(lifecycle: .attempt, mutation: .completion) + ) Task { @MainActor in await tasksStore.toggleTask(task) isToggling = false + let reconciledTask = self.task + AnalyticsManager.shared.chatFirst( + .taskMutation( + lifecycle: reconciledTask?.completed == intendedCompletion ? .success : .rollback, + mutation: .completion + ) + ) + if reconciledTask?.completed != intendedCompletion { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .taskCard, outcome: .rejected, action: .toggle) + ) + } + // `TasksStore` owns local-first mutation and rollback. Acknowledgement // is derived only from its reconciled record, never from the tap. guard ChatFirstTaskCardReconciliation.shouldShowCompletionAcknowledgement( intendedCompletion: intendedCompletion, - reconciledTask: self.task + reconciledTask: reconciledTask ) else { return } OmiMotion.withGated(.spring(response: 0.26, dampingFraction: 0.72)) { @@ -245,6 +286,11 @@ struct GoalLinkView: View { openGoal() } } + .onAppear { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .goalLink, outcome: .rendered, action: .none) + ) + } } private func openGoal() { @@ -256,8 +302,14 @@ struct GoalLinkView: View { // destination remains a typed shell focus, never a display-string URL. guard await goalsStore.loadDetail(goalID: goalID) != nil else { isUnavailable = true + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .goalLink, outcome: .stalePlaceholder, action: .open) + ) return } + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .goalLink, outcome: .acted, action: .open) + ) navigation.open(focus: .goal(id: goalID)) } } @@ -287,6 +339,11 @@ struct CaptureLinkView: View { openCapture() } } + .onAppear { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .captureLink, outcome: .rendered, action: .none) + ) + } } private func openCapture() { @@ -297,9 +354,15 @@ struct CaptureLinkView: View { do { _ = try await APIClient.shared.getConversation(id: conversationID) let moment = momentTimestampMs.map { TimeInterval($0) / 1_000 } + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .captureLink, outcome: .acted, action: .open) + ) navigation.open(focus: .capture(id: conversationID, momentTs: moment)) } catch { isUnavailable = true + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .captureLink, outcome: .stalePlaceholder, action: .open) + ) } } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift index 3774b5f3e46..607b1dfc942 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift @@ -123,6 +123,16 @@ enum ChatFirstPendingFocus: Equatable, Sendable { case .memory: return "memory" } } + + /// This identifier is intentionally retained only in the in-memory + /// navigation contract. The non-production automation bridge can prove the + /// exact focus acknowledgement without sending it to analytics or persisting + /// it across launches. + var entityID: String { + switch self { + case .task(let id), .goal(let id), .capture(let id, _), .memory(let id): return id + } + } } /// A route-safe, strongly typed origin for the one normal user turn created @@ -171,12 +181,23 @@ final class ChatFirstShellNavigation: ObservableObject { /// focus itself and is never restored across launches. @Published private(set) var pendingFocusDestination: ChatFirstRoute? @Published private(set) var lastAcknowledgedFocusKind: String? + /// Test-only bridge state for proving route focus reaches the intended + /// entity. This is neither persisted nor emitted in analytics. + @Published private(set) var focusedEntityID: String? + @Published private(set) var isFocusedEntityAcknowledged: Bool @Published private(set) var isSidebarCollapsed: Bool private let defaults: UserDefaults + private let analytics: (ChatFirstAnalyticsEvent) -> Void - init(defaults: UserDefaults = .standard) { + init( + defaults: UserDefaults = .standard, + analytics: @escaping (ChatFirstAnalyticsEvent) -> Void = { event in + AnalyticsManager.shared.chatFirst(event) + } + ) { self.defaults = defaults + self.analytics = analytics if let data = defaults.data(forKey: Self.storageKey), let persisted = try? JSONDecoder().decode(ChatFirstPersistedNavigation.self, from: data) { @@ -189,21 +210,26 @@ final class ChatFirstShellNavigation: ObservableObject { pendingFocus = nil pendingFocusDestination = nil lastAcknowledgedFocusKind = nil + focusedEntityID = nil + isFocusedEntityAcknowledged = false } - func selectPrimary(_ destination: ChatFirstRoute) { + func selectPrimary( + _ destination: ChatFirstRoute, + origin: ChatFirstAnalyticsEvent.RouteOrigin = .sidebar + ) { guard destination.isPrimaryDestination else { return } route = destination - pendingFocus = nil - pendingFocusDestination = nil + clearFocus() persistNavigation() + analytics(.routeEntered(route: analyticsRoute(destination), origin: origin)) } func selectMore(_ page: ChatFirstMorePage) { route = .more(page) - pendingFocus = nil - pendingFocusDestination = nil + clearFocus() persistNavigation() + analytics(.routeEntered(route: .more, origin: .more)) } /// Used by a typed rich-Chat link. Unlike direct navigation it carries the @@ -220,14 +246,17 @@ final class ChatFirstShellNavigation: ObservableObject { route = destination pendingFocus = focus pendingFocusDestination = destination + focusedEntityID = focus.entityID + isFocusedEntityAcknowledged = false persistNavigation() + analytics(.routeEntered(route: analyticsRoute(destination), origin: .chatDeeplink)) } /// Routes first, then records exactly one ordinary main-Chat user turn. /// `ChatProvider` remains the single journal owner; navigation stores no /// transcript copy or separate session identity. func discuss(_ context: ChatFirstDiscussionContext, using chatProvider: ChatProvider) { - selectPrimary(.chat) + selectPrimary(.chat, origin: .chatDeeplink) Task { _ = await chatProvider.sendMessage(context.userMessage) } @@ -239,6 +268,8 @@ final class ChatFirstShellNavigation: ObservableObject { pendingFocus = nil pendingFocusDestination = nil lastAcknowledgedFocusKind = focus.stableName + focusedEntityID = focus.entityID + isFocusedEntityAcknowledged = true return true } @@ -276,6 +307,24 @@ final class ChatFirstShellNavigation: ObservableObject { let persisted = ChatFirstPersistedNavigation(route: route, isSidebarCollapsed: isSidebarCollapsed) defaults.set(try? JSONEncoder().encode(persisted), forKey: Self.storageKey) } + + private func clearFocus() { + pendingFocus = nil + pendingFocusDestination = nil + focusedEntityID = nil + isFocusedEntityAcknowledged = false + } + + private func analyticsRoute(_ route: ChatFirstRoute) -> ChatFirstAnalyticsEvent.Route { + switch route { + case .chat: return .chat + case .conversations: return .conversations + case .tasks: return .tasks + case .goals: return .goals + case .memories: return .memories + case .more: return .more + } + } } /// An immutable per-root sampling result. A failed, missing, stale, or diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift index 08473b12513..5959c163996 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift @@ -35,6 +35,7 @@ struct ChatFirstShell: View { .onAppear { promptMaterializationCoordinator.activate(using: viewModelContainer.chatProvider) viewModelContainer.canonicalGoalsStore.activate(capability: capability) + AnalyticsManager.shared.chatFirst(.routeEntered(route: .chat, origin: .shellLaunch)) } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in guard let window = NSApp.mainWindow, window.isKeyWindow, window.isVisible else { return } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift index e2b6e35dd2d..93da257ea15 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift @@ -343,11 +343,17 @@ struct ChatFirstTasksPage: View { addingGroups.insert(group) Task { @MainActor in - _ = await tasksStore.createTask( + AnalyticsManager.shared.chatFirst( + .taskMutation(lifecycle: .attempt, mutation: .create) + ) + let created = await tasksStore.createTask( description: description, dueAt: ChatFirstTaskPagePolicy.suggestedDueDate(for: group), priority: nil ) + AnalyticsManager.shared.chatFirst( + .taskMutation(lifecycle: created == nil ? .rollback : .success, mutation: .create) + ) addDrafts[group] = "" addingGroups.remove(group) } @@ -502,8 +508,19 @@ private struct ChatFirstTaskRow: View { private func toggle() { guard !isToggling else { return } isToggling = true + let intendedCompletion = !task.completed Task { @MainActor in + AnalyticsManager.shared.chatFirst( + .taskMutation(lifecycle: .attempt, mutation: .completion) + ) await tasksStore.toggleTask(task) + let reconciledTask = tasksStore.tasks.first { $0.id == task.id && $0.deleted != true } + AnalyticsManager.shared.chatFirst( + .taskMutation( + lifecycle: reconciledTask?.completed == intendedCompletion ? .success : .rollback, + mutation: .completion + ) + ) isToggling = false } } @@ -521,11 +538,20 @@ private struct ChatFirstTaskRow: View { isEditing = false isSaving = true Task { @MainActor in - _ = await tasksStore.updateTask( + AnalyticsManager.shared.chatFirst( + .taskMutation(lifecycle: .attempt, mutation: .rename) + ) + let outcome = await tasksStore.updateTask( task, description: description, remoteFailureBehavior: .rollbackForChatFirst ) + AnalyticsManager.shared.chatFirst( + .taskMutation( + lifecycle: ChatFirstTaskMutationTelemetry.terminalLifecycle(for: outcome), + mutation: .rename + ) + ) isSaving = false } } @@ -540,11 +566,20 @@ private struct ChatFirstTaskRow: View { guard !isSaving else { return } isSaving = true Task { @MainActor in - _ = await tasksStore.updateTask( + AnalyticsManager.shared.chatFirst( + .taskMutation(lifecycle: .attempt, mutation: .schedule) + ) + let outcome = await tasksStore.updateTask( task, dueAt: ChatFirstTaskPagePolicy.suggestedDueDate(for: moveTarget), remoteFailureBehavior: .rollbackForChatFirst ) + AnalyticsManager.shared.chatFirst( + .taskMutation( + lifecycle: ChatFirstTaskMutationTelemetry.terminalLifecycle(for: outcome), + mutation: .schedule + ) + ) isSaving = false } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index a2fce75948e..58ea7dfd4d2 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -304,8 +304,14 @@ struct ChatBubble: View { questionID: questionID, selectedOptionID: selectedOptionID ), - onSelect: { optionID in + onSelect: { optionID, isDeferral in Task { @MainActor in + AnalyticsManager.shared.chatFirst( + .question(lifecycle: isDeferral ? .deferred : .answered) + ) + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .questionCard, outcome: .acted, action: .select) + ) await chatFirstRichBlockContext.chatProvider.selectQuestionCardOption( questionID: questionID, optionID: optionID diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 6dfa96c65c2..3bba2b73c1f 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -641,6 +641,8 @@ struct DesktopHomeView: View { chatFirstRoute: chatFirstRoute?.stableName, pendingFocusKind: chatFirstNavigation.pendingFocus?.stableName, acknowledgedFocusKind: chatFirstNavigation.lastAcknowledgedFocusKind, + focusedEntityID: chatFirstNavigation.focusedEntityID, + isFocusedEntityAcknowledged: chatFirstNavigation.isFocusedEntityAcknowledged, showsPrimarySidebar: showsPrimarySidebar, isSidebarCollapsed: usesChatFirstShell ? chatFirstNavigation.isSidebarCollapsed : isSidebarCollapsed, @@ -931,10 +933,18 @@ struct DesktopHomeView: View { ownerIsStillCurrent: false ) _ = viewModelContainer.chatProvider.configureChatFirstMainChatCapability(nil) + AnalyticsManager.shared.chatFirst( + .capabilityResolution( + outcome: .unavailable, + generationBucket: .none, + errorClass: .ownerChanged + ) + ) reportAutomationState() return } + var capabilityErrorClass: ChatFirstAnalyticsEvent.CapabilityErrorClass = .none do { let control = try await APIClient.shared.getCandidateWorkflowControl( expectedOwnerId: ownerID, @@ -955,6 +965,7 @@ struct DesktopHomeView: View { requestedOwnerID: ownerID, ownerIsStillCurrent: current ) + capabilityErrorClass = .unavailable log("DesktopHomeView: chat-first control unavailable; using legacy shell") } @@ -965,8 +976,27 @@ struct DesktopHomeView: View { // A pre-existing Main Chat session cannot be retroactively upgraded with // dynamic tools. Keep this launch on the byte-equivalent legacy path. chatFirstCapabilitySample.failClosed() + capabilityErrorClass = .projectionRejected log("DesktopHomeView: chat-first projection handoff rejected; using legacy shell") } + let projection = chatFirstCapabilitySample.variant.projection + let capabilityOutcome: ChatFirstAnalyticsEvent.CapabilityOutcome + if capabilityErrorClass == .projectionRejected { + capabilityOutcome = .projectionRejected + } else if capabilityErrorClass == .unavailable { + capabilityOutcome = .unavailable + } else if projection != nil { + capabilityOutcome = .enabled + } else { + capabilityOutcome = .disabled + } + AnalyticsManager.shared.chatFirst( + .capabilityResolution( + outcome: capabilityOutcome, + generationBucket: .bucket(for: projection?.controlGeneration), + errorClass: capabilityErrorClass + ) + ) reportAutomationState() } diff --git a/desktop/macos/Desktop/Tests/ChatFirstAnalyticsTests.swift b/desktop/macos/Desktop/Tests/ChatFirstAnalyticsTests.swift new file mode 100644 index 00000000000..b71b82c0857 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstAnalyticsTests.swift @@ -0,0 +1,158 @@ +import XCTest + +@testable import Omi_Computer + +final class ChatFirstAnalyticsTests: XCTestCase { + func testEveryChatFirstEventMapsToAnExactBoundedSchema() { + let cases: [(ChatFirstAnalyticsEvent, String, Set)] = [ + ( + .routeEntered(route: .goals, origin: .chatDeeplink), + "chat_first_route_entered", + ["route", "origin", "telemetry_schema_version"] + ), + ( + .richBlock(kind: .goalLink, outcome: .acted, action: .open), + "chat_first_rich_block", + ["kind", "outcome", "action", "telemetry_schema_version"] + ), + ( + .question(lifecycle: .answered), + "chat_first_question", + ["lifecycle", "telemetry_schema_version"] + ), + ( + .taskMutation(lifecycle: .rollback, mutation: .schedule), + "chat_first_task_mutation", + ["lifecycle", "mutation", "telemetry_schema_version"] + ), + ( + .capabilityResolution( + outcome: .unavailable, + generationBucket: .none, + errorClass: .unavailable + ), + "chat_first_capability_resolution", + ["outcome", "generation_bucket", "error_class", "telemetry_schema_version"] + ), + ] + + for (event, eventName, keys) in cases { + let payload = event.analyticsPayload + XCTAssertEqual(payload.eventName, eventName) + XCTAssertEqual(Set(payload.properties.keys), keys) + XCTAssertEqual(payload.properties["telemetry_schema_version"], "1") + } + } + + func testMapperCannotAcceptUnknownDimensionsOrFreeFormText() { + // These enums are the only public event dimensions. A raw title, entity + // ID, question answer, transcript, URL, or exception cannot become a + // `ChatFirstAnalyticsEvent` and unknown wire values are rejected. + XCTAssertNil(ChatFirstAnalyticsEvent.Route(rawValue: "private task title")) + XCTAssertNil(ChatFirstAnalyticsEvent.RichBlockKind(rawValue: "https://omi.me/private")) + XCTAssertNil(ChatFirstAnalyticsEvent.QuestionLifecycle(rawValue: "answer text")) + XCTAssertNil(ChatFirstAnalyticsEvent.TaskMutation(rawValue: "task-123")) + XCTAssertNil(ChatFirstAnalyticsEvent.CapabilityErrorClass(rawValue: "network timeout details")) + + let payload = ChatFirstAnalyticsEvent.richBlock( + kind: .questionCard, + outcome: .rejected, + action: .select + ).analyticsPayload + let prohibitedKeys = [ + "id", "title", "text", "prompt", "answer", "suggestion", "url", "exception", "transcript", "memory", + ] + XCTAssertTrue(Set(payload.properties.keys).isDisjoint(with: prohibitedKeys)) + } + + func testChatFirstInteractionSurfacesUseAnalyticsManagerAsTheirOnlyTelemetryPath() throws { + // omi-test-quality: source-inspection -- static contract: direct telemetry + // calls could bypass the closed mapper without a runtime-observable result. + let desktopDirectory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let sources = [ + "Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift", + "Sources/MainWindow/ChatFirst/ChatFirstRoute.swift", + "Sources/MainWindow/ChatFirst/ChatFirstShell.swift", + "Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift", + "Sources/MainWindow/Components/ChatBubble.swift", + "Sources/MainWindow/DesktopHomeView.swift", + ] + + for path in sources { + let source = try String(contentsOf: desktopDirectory.appendingPathComponent(path), encoding: .utf8) + XCTAssertFalse(source.contains("PostHogManager.shared.track"), "\(path) bypasses AnalyticsManager") + XCTAssertFalse(source.contains("PostHogManager.shared.capture"), "\(path) bypasses AnalyticsManager") + } + } + + func testCapabilityGenerationIsBucketedAndTaskResultHasNoTransportDimension() { + XCTAssertEqual(ChatFirstAnalyticsEvent.CapabilityGenerationBucket.bucket(for: nil), .none) + XCTAssertEqual(ChatFirstAnalyticsEvent.CapabilityGenerationBucket.bucket(for: 0), .zeroToNine) + XCTAssertEqual(ChatFirstAnalyticsEvent.CapabilityGenerationBucket.bucket(for: 19), .tenToNinetyNine) + XCTAssertEqual(ChatFirstAnalyticsEvent.CapabilityGenerationBucket.bucket(for: 100), .hundredPlus) + + XCTAssertEqual( + ChatFirstTaskMutationTelemetry.terminalLifecycle(for: .updated), + .success + ) + XCTAssertEqual( + ChatFirstTaskMutationTelemetry.terminalLifecycle(for: .rolledBackAfterRemoteFailure), + .rollback + ) + XCTAssertEqual( + ChatFirstTaskMutationTelemetry.terminalLifecycle(for: .ownerChanged), + .rollback + ) + } + + func testNonProductionFixtureContractUsesOnlyDeterministicShapeFacts() { + let interactive = ChatFirstAutomationFixture.contract(for: .interactiveQuestion) + XCTAssertEqual(interactive.validRichBlockCount, 1) + XCTAssertTrue(interactive.hasValidQuestion) + XCTAssertTrue(interactive.hasPreparedAnswer) + XCTAssertEqual(interactive.proactiveJudgeCalls, 0) + XCTAssertEqual(interactive.materializationCount, 0) + XCTAssertEqual(interactive.rawManifestProofMode, "external_raw_bytes_digest") + XCTAssertEqual(interactive.shellVariant, "chatFirst") + XCTAssertEqual(interactive.chatFirstToolCount, 2) + + let deferred = ChatFirstAutomationFixture.contract(for: .deferredQuestion) + XCTAssertEqual(deferred.deferralSeconds, 86_400) + XCTAssertEqual(deferred.fakeClockEpochSeconds, interactive.fakeClockEpochSeconds) + + let capture = ChatFirstAutomationFixture.contract(for: .mixedCapture) + XCTAssertEqual(capture.captureSourceMode, "mixed") + XCTAssertFalse(capture.hasPreparedAnswer) + XCTAssertTrue( + Set(capture.bridgeDetail.keys).isDisjoint(with: ["text", "answer", "title", "id", "transcript", "url", "error"]) + ) + + for scenario in [ChatFirstAutomationFixture.Scenario.uiFlagOff, .outOfCohort] { + let disabled = ChatFirstAutomationFixture.contract(for: scenario) + XCTAssertEqual(disabled.shellVariant, "legacy") + XCTAssertEqual(disabled.chatFirstToolCount, 0) + XCTAssertEqual(disabled.validRichBlockCount, 0) + XCTAssertEqual(disabled.materializationCount, 0) + XCTAssertEqual(disabled.proactiveJudgeCalls, 0) + XCTAssertEqual(disabled.proactiveEmissions, 0) + } + } + + @MainActor + func testFixtureBridgeActionIsDiscoverableWithoutContentParameters() { + let registry = DesktopAutomationActionRegistry.shared + registry.registerBuiltins() + let descriptor = registry.descriptors().first { $0.name == "chat_first_fixture_contract" } + + guard AppBuild.isNonProduction else { + XCTAssertNil(descriptor) + return + } + XCTAssertEqual(descriptor?.params, ["scenario"]) + XCTAssertEqual(descriptor?.category, "read") + XCTAssertEqual(descriptor?.safety, "read_only") + XCTAssertEqual(descriptor?.sideEffects, []) + } +} diff --git a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift index 5778474ed50..2666735df82 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift @@ -60,16 +60,22 @@ final class ChatFirstShellTests: XCTestCase { navigation.toggleSidebar() XCTAssertEqual(navigation.route, .conversations) XCTAssertEqual(navigation.pendingFocus, focus) + XCTAssertEqual(navigation.focusedEntityID, "capture-1") + XCTAssertFalse(navigation.isFocusedEntityAcknowledged) XCTAssertFalse(navigation.acknowledgeFocus(.task(id: "task-1"))) XCTAssertEqual(navigation.pendingFocus, focus) XCTAssertTrue(navigation.acknowledgeFocus(focus)) XCTAssertNil(navigation.pendingFocus) XCTAssertEqual(navigation.lastAcknowledgedFocusKind, "capture") + XCTAssertEqual(navigation.focusedEntityID, "capture-1") + XCTAssertTrue(navigation.isFocusedEntityAcknowledged) let restored = ChatFirstShellNavigation(defaults: defaults) XCTAssertEqual(restored.route, .conversations) XCTAssertTrue(restored.isSidebarCollapsed) XCTAssertNil(restored.pendingFocus) + XCTAssertNil(restored.focusedEntityID) + XCTAssertFalse(restored.isFocusedEntityAcknowledged) } func testDirectAndLegacyNavigationClearFocusAndMapToTypedRoutes() { @@ -82,6 +88,8 @@ final class ChatFirstShellTests: XCTestCase { navigation.selectPrimary(.tasks) XCTAssertEqual(navigation.route, .tasks) XCTAssertNil(navigation.pendingFocus) + XCTAssertNil(navigation.focusedEntityID) + XCTAssertFalse(navigation.isFocusedEntityAcknowledged) navigation.open(focus: .memory(id: "memory-1")) navigation.selectLegacyDestination(.settings) @@ -92,6 +100,32 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertEqual(navigation.route, .chat) } + func testNavigationUsesTypedOriginsWithoutEntityIdentifiersInAnalytics() { + let suiteName = "ChatFirstShellTests.analytics.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + var events: [ChatFirstAnalyticsEvent] = [] + let navigation = ChatFirstShellNavigation(defaults: defaults) { event in + events.append(event) + } + + navigation.selectPrimary(.tasks) + navigation.open(focus: .goal(id: "private-goal-id")) + navigation.selectMore(.settings) + + XCTAssertEqual( + events, + [ + .routeEntered(route: .tasks, origin: .sidebar), + .routeEntered(route: .goals, origin: .chatDeeplink), + .routeEntered(route: .more, origin: .more), + ] + ) + XCTAssertFalse( + events.map(\.analyticsPayload).flatMap { $0.properties.values }.contains("private-goal-id") + ) + } + func testRelatedGoalFocusCanLandInTasksAndAcknowledgesAfterTasksVisibility() { let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! From 67a3785fcd7091a91a8103860f0477055de9b932 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 15:19:44 -0400 Subject: [PATCH 12/28] fix(chat): repair desktop chat-first integration Repair Swift runtime, wire, and view integration faults surfaced by the consolidated desktop compile.\n\nVerification: static parse/diff checks and a fresh Sol repair review passed; focused Swift runtime verification resumes next. --- .../Sources/Chat/AgentRuntimeProcess.swift | 2 +- .../Chat/AgentRuntimeStatusStore.swift | 2 +- .../Chat/ChatFirstDeferralOutboxDriver.swift | 13 +++-- ...ChatFirstPromptMaterializationDriver.swift | 2 +- .../Blocks/ChatFirstContentBlockViews.swift | 52 ++++++++++--------- .../ChatFirst/CanonicalGoalsStore.swift | 6 +-- .../ChatFirst/ChatFirstTasksPage.swift | 2 +- .../Sources/Providers/ChatProvider.swift | 1 + .../ChatFirstDeferralOutboxDriverTests.swift | 30 +++++++++++ 9 files changed, 75 insertions(+), 35 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift index 0801df24a38..8cc42acdc4b 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift @@ -2078,7 +2078,7 @@ actor AgentRuntimeProcess { payload: [ "sessionId": sessionID, "controlGeneration": controlGeneration, - "intents": intents.compactMap { intent in + "intents": intents.compactMap { intent -> [String: Any]? in guard let blocks = intent.kernelBlocks else { return nil } return [ "intentId": intent.intentID, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeStatusStore.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeStatusStore.swift index 7519481b6f9..3ae5a151b57 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeStatusStore.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeStatusStore.swift @@ -332,7 +332,7 @@ final class AgentRuntimeStatusStore: ObservableObject { case .initMessage, .toolUse, .authorizedToolExecution, .authRequired, .authSuccess, .controlToolResult, .journalOperationResult, .journalTurnChanged, .journalBackendSync, .journalBackendDelete, - .journalBackendReconcile, + .journalBackendReconcile, .chatFirstDeferralDelivery, .defaultExecutionProfileConfigured, .surfaceSessionResolved, .sessionExecutionProfileMigrated, .contextSourceUpdated, .contextSnapshot, .legacyMainChatSessionsImported, diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift index 3a2efe76a1b..618f4be0498 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift @@ -13,13 +13,13 @@ struct ChatFirstDeferralDeliveryRequest: Sendable, Equatable { let optionID: String let label: String let preparedAnswer: String - let defer: Bool + let isDeferred: Bool enum CodingKeys: String, CodingKey { case optionID = "option_id" case label case preparedAnswer = "prepared_answer" - case defer + case isDeferred = "defer" } } @@ -76,11 +76,16 @@ struct ChatFirstDeferralDeliveryRequest: Sendable, Equatable { let preparedAnswer = option["preparedAnswer"] as? String, !preparedAnswer.isEmpty, preparedAnswer.count <= 500 else { return nil } - return Option(optionID: optionID, label: label, preparedAnswer: preparedAnswer, defer: option["defer"] as? Bool ?? false) + return Option( + optionID: optionID, + label: label, + preparedAnswer: preparedAnswer, + isDeferred: option["defer"] as? Bool ?? false + ) } guard options.count == optionsPayload.count, Set(options.map(\.optionID)).count == options.count, - options.filter(\.defer).count <= 1 + options.filter(\.isDeferred).count <= 1 else { return nil } self.ownerID = ownerID diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift index 63ff467de3c..712c693b05f 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift @@ -66,7 +66,7 @@ struct ChatFirstPromptIntent: Decodable { let continuityKey: String let accountGeneration: Int let source: Source - let blocks: [OmiAPI.OmiAnyCodable] + let blocks: [OmiAnyCodable] enum CodingKeys: String, CodingKey { case intentID = "intent_id" diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift index 268fce76ed6..d0655f628de 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift @@ -272,18 +272,20 @@ struct GoalLinkView: View { @State private var isUnavailable = false var body: some View { - if isUnavailable { - ChatFirstUnavailableBlockView(entityName: "Goal") - } else { - ChatFirstLinkBlockView( - eyebrow: "Goal", - systemImage: "target", - summary: summary, - actionTitle: "Open in Goals", - isOpening: isOpening, - accessibilityID: "chat-first-goal-\(goalID)-open" - ) { - openGoal() + Group { + if isUnavailable { + ChatFirstUnavailableBlockView(entityName: "Goal") + } else { + ChatFirstLinkBlockView( + eyebrow: "Goal", + systemImage: "target", + summary: summary, + actionTitle: "Open in Goals", + isOpening: isOpening, + accessibilityID: "chat-first-goal-\(goalID)-open" + ) { + openGoal() + } } } .onAppear { @@ -325,18 +327,20 @@ struct CaptureLinkView: View { @State private var isUnavailable = false var body: some View { - if isUnavailable { - ChatFirstUnavailableBlockView(entityName: "Conversation") - } else { - ChatFirstLinkBlockView( - eyebrow: "Conversation", - systemImage: "waveform", - summary: summary, - actionTitle: "Open conversation", - isOpening: isOpening, - accessibilityID: "chat-first-capture-\(conversationID)-open" - ) { - openCapture() + Group { + if isUnavailable { + ChatFirstUnavailableBlockView(entityName: "Conversation") + } else { + ChatFirstLinkBlockView( + eyebrow: "Conversation", + systemImage: "waveform", + summary: summary, + actionTitle: "Open conversation", + isOpening: isOpening, + accessibilityID: "chat-first-capture-\(conversationID)-open" + ) { + openCapture() + } } } .onAppear { diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift index 5ba7265941b..e9489657d1e 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift @@ -167,7 +167,7 @@ final class CanonicalGoalsStore: ObservableObject { goals = [] selectedGoalDetail = nil let message = "Goals are unavailable right now. Try again." - error = message + self.error = message availability = .unavailable(message) } } @@ -198,7 +198,7 @@ final class CanonicalGoalsStore: ObservableObject { // A missing/revoked target must remain an inert link or an honest page // state; do not substitute a legacy-goal record. selectedGoalDetail = nil - error = "This goal is no longer available." + self.error = "This goal is no longer available." return nil } } @@ -235,7 +235,7 @@ final class CanonicalGoalsStore: ObservableObject { return true } catch { guard scopeIsCurrent(scope) else { return false } - error = "Goal focus could not be updated." + self.error = "Goal focus could not be updated." return false } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift index 93da257ea15..a969707d56d 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift @@ -463,7 +463,7 @@ private struct ChatFirstTaskRow: View { } .buttonStyle(.plain) .disabled(isSaving) - .onKeyPress(.return) { _ in + .onKeyPress(.return) { titleDraft = task.description isEditing = true return .handled diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index 31aea5672db..948caef1d08 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -4043,6 +4043,7 @@ class ChatProvider: ObservableObject { clearChatTelemetryState(for: sendGen) releaseSendLock(sendGeneration: sendGen) + return nil } guard let sid = currentSessionId else { diff --git a/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift b/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift index 2aa0d13763c..b3e165c3ca1 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift @@ -3,6 +3,36 @@ import XCTest @testable import Omi_Computer final class ChatFirstDeferralOutboxDriverTests: XCTestCase { + func testDeferredOptionRetainsItsWireKey() throws { + let request = try XCTUnwrap(ChatFirstDeferralDeliveryRequest(payload: [ + "ownerId": "owner-1", + "continuityKey": "continuity-1", + "controlGeneration": 1, + "subject": ["kind": "goal", "id": "goal-1"], + "question": [ + "questionId": "question-1", + "text": "What should happen next?", + "subject": ["kind": "goal", "id": "goal-1"], + "options": [[ + "optionId": "later", + "label": "Ask tomorrow", + "preparedAnswer": "Ask me again tomorrow.", + "defer": true, + ]], + ], + "attemptCount": 1, + "deliveryGeneration": 1, + "payloadHash": "hash-1", + ])) + + XCTAssertTrue(request.question.options[0].isDeferred) + let data = try JSONEncoder().encode(request.question) + let wire = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let option = try XCTUnwrap((wire["options"] as? [[String: Any]])?.first) + XCTAssertEqual(option["defer"] as? Bool, true) + XCTAssertNil(option["isDeferred"]) + } + func testDeferralDeliveryRetriesTransientAndServerFailuresOnly() { for status in [408, 425, 429, 500, 503] { XCTAssertEqual( From 5d0d2bbbd459576e1bbcf1f4156b3fa367ceec77 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 15:25:22 -0400 Subject: [PATCH 13/28] fix(chat): align navigation and materialization actors Repair actor-isolated analytics injection, materialization driver capture, and explicit shared task-store ownership.\n\nVerification: static parse/diff checks and fresh Sol review passed; focused Swift verification resumes next. --- .../ChatFirst/Blocks/ChatFirstRichBlockContext.swift | 2 +- .../ChatFirstPromptMaterializationCoordinator.swift | 2 +- .../Sources/MainWindow/ChatFirst/ChatFirstRoute.swift | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift index 8355a532aae..b691f53521d 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift @@ -14,7 +14,7 @@ struct ChatFirstRichBlockContext { init( navigation: ChatFirstShellNavigation, - tasksStore: TasksStore = .shared, + tasksStore: TasksStore, chatProvider: ChatProvider, canonicalGoalsStore: CanonicalGoalsStore, promptMaterializationCoordinator: ChatFirstPromptMaterializationCoordinator diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift index 72ec90457ca..27d93193710 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift @@ -83,7 +83,7 @@ final class ChatFirstPromptMaterializationCoordinator: ObservableObject { requestGeneration &+= 1 let generation = requestGeneration requestTask = Task { [weak self, driver] in - guard let self, let driver else { return } + guard let self else { return } await self.materialize(using: driver, windowForeground: windowForeground, generation: generation) if self.requestGeneration == generation { self.requestTask = nil diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift index 607b1dfc942..23f6ffaad9f 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift @@ -188,16 +188,16 @@ final class ChatFirstShellNavigation: ObservableObject { @Published private(set) var isSidebarCollapsed: Bool private let defaults: UserDefaults - private let analytics: (ChatFirstAnalyticsEvent) -> Void + private let analytics: @MainActor (ChatFirstAnalyticsEvent) -> Void init( defaults: UserDefaults = .standard, - analytics: @escaping (ChatFirstAnalyticsEvent) -> Void = { event in - AnalyticsManager.shared.chatFirst(event) - } + analytics: (@MainActor (ChatFirstAnalyticsEvent) -> Void)? = nil ) { self.defaults = defaults - self.analytics = analytics + self.analytics = analytics ?? { event in + AnalyticsManager.shared.chatFirst(event) + } if let data = defaults.data(forKey: Self.storageKey), let persisted = try? JSONDecoder().decode(ChatFirstPersistedNavigation.self, from: data) { From 942f2967d01f8a85faac67dac33d560094d1cb0a Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 15:34:36 -0400 Subject: [PATCH 14/28] fix(chat): complete journal result forwarding Add an explicit JournalOperationResult initializer for materialization fields and repair async archive assertions.\n\nVerification: focused desktop compile and 39 Chat-first/capture tests passed; fresh Sol review passed. --- .../Sources/Chat/AgentRuntimeProcess.swift | 56 ++++++++++++++++--- .../Desktop/Tests/CaptureArchiveTests.swift | 6 +- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift index 8cc42acdc4b..e11fcd36e44 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift @@ -515,15 +515,53 @@ actor AgentRuntimeProcess { let highWaterTurnSeq: Int let conversationGeneration: Int let generationBaseTurnSeq: Int - let accepted: Bool? = nil - let duplicate: Bool? = nil - let continuityKey: String? = nil - let suppressedByTailQuestion: Bool = false - let suppressedByStreamingTail: Bool = false - let materializationStoppedByTail: Bool = false - let materializationReceipts: [ChatFirstMaterializationReceipt] = [] - let coldStartSequenceTerminalReceipts: [ChatFirstColdStartSequenceTerminalReceipt] = [] - let acknowledgedReceiptCount: Int = 0 + let accepted: Bool? + let duplicate: Bool? + let continuityKey: String? + let suppressedByTailQuestion: Bool + let suppressedByStreamingTail: Bool + let materializationStoppedByTail: Bool + let materializationReceipts: [ChatFirstMaterializationReceipt] + let coldStartSequenceTerminalReceipts: [ChatFirstColdStartSequenceTerminalReceipt] + let acknowledgedReceiptCount: Int + + init( + operation: String, + conversationId: String, + turn: KernelJournalTurn?, + turns: [KernelJournalTurn], + clearedCount: Int, + highWaterTurnSeq: Int, + conversationGeneration: Int, + generationBaseTurnSeq: Int, + accepted: Bool? = nil, + duplicate: Bool? = nil, + continuityKey: String? = nil, + suppressedByTailQuestion: Bool = false, + suppressedByStreamingTail: Bool = false, + materializationStoppedByTail: Bool = false, + materializationReceipts: [ChatFirstMaterializationReceipt] = [], + coldStartSequenceTerminalReceipts: [ChatFirstColdStartSequenceTerminalReceipt] = [], + acknowledgedReceiptCount: Int = 0 + ) { + self.operation = operation + self.conversationId = conversationId + self.turn = turn + self.turns = turns + self.clearedCount = clearedCount + self.highWaterTurnSeq = highWaterTurnSeq + self.conversationGeneration = conversationGeneration + self.generationBaseTurnSeq = generationBaseTurnSeq + self.accepted = accepted + self.duplicate = duplicate + self.continuityKey = continuityKey + self.suppressedByTailQuestion = suppressedByTailQuestion + self.suppressedByStreamingTail = suppressedByStreamingTail + self.materializationStoppedByTail = materializationStoppedByTail + self.materializationReceipts = materializationReceipts + self.coldStartSequenceTerminalReceipts = coldStartSequenceTerminalReceipts + self.acknowledgedReceiptCount = acknowledgedReceiptCount + } } struct QuestionInteractionReply: Sendable { diff --git a/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift b/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift index 4a3257311da..ac3c78e4d32 100644 --- a/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift +++ b/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift @@ -163,8 +163,10 @@ final class CaptureArchiveTests: XCTestCase { let controller = CapturePlaybackController(provider: provider) let capture = archiveCapture(id: "omi-1") - XCTAssertEqual(await controller.prepare(for: capture), pending) - XCTAssertEqual(await controller.prepare(for: capture, forceRefresh: true), ready) + let firstResolution = await controller.prepare(for: capture) + XCTAssertEqual(firstResolution, pending) + let refreshedResolution = await controller.prepare(for: capture, forceRefresh: true) + XCTAssertEqual(refreshedResolution, ready) XCTAssertEqual(provider.resolveCount, 2) } From 5aa130e23ad7b97ab96c37dbe08d98f56f9ac32e Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 15:45:24 -0400 Subject: [PATCH 15/28] fix(chat): preserve journal revision and deferral recovery Project immutable journal revisions correctly and reclaim expired deferral leases without allowing stale delivery settlement.\n\nVerification: focused journal suite passes 54/54; fresh Sol review passed. --- .../agent/src/runtime/conversation-journal.ts | 9 +- .../macos/agent/src/runtime/sqlite-store.ts | 4 +- .../agent/tests/conversation-journal.test.ts | 91 +++++++++++++++++-- 3 files changed, 91 insertions(+), 13 deletions(-) diff --git a/desktop/macos/agent/src/runtime/conversation-journal.ts b/desktop/macos/agent/src/runtime/conversation-journal.ts index c3e3b99e1b2..3e4f50bce78 100644 --- a/desktop/macos/agent/src/runtime/conversation-journal.ts +++ b/desktop/macos/agent/src/runtime/conversation-journal.ts @@ -1193,9 +1193,12 @@ export function drainChatFirstDeferralOutbox( `SELECT continuity_key, owner_id, conversation_id, control_generation, subject_kind, subject_id, question_json, payload_hash, attempt_count, delivery_generation FROM chat_first_deferral_outbox - WHERE owner_id = ? AND status IN ('pending', 'retrying') AND available_at_ms <= ? + WHERE owner_id = ? AND ( + (status IN ('pending', 'retrying') AND available_at_ms <= ?) + OR (status = 'delivering' AND lease_expires_at_ms IS NOT NULL AND lease_expires_at_ms <= ?) + ) ORDER BY created_at_ms ASC LIMIT ?`, - [input.ownerId, now, limit], + [input.ownerId, now, now, limit], ); return rows.map((row) => { const continuityKey = String(row.continuity_key); @@ -3334,7 +3337,7 @@ function assertIdempotentRecord( }, ): void { if (existing.conversationId !== input.conversationId) { - throw new Error("Canonical turn ID belongs to a different conversation"); + throw new Error("Canonical turn identity collision has different journal content or belongs to another conversation"); } const equivalent = existing.role === input.role && existing.surfaceKind === input.surfaceKind diff --git a/desktop/macos/agent/src/runtime/sqlite-store.ts b/desktop/macos/agent/src/runtime/sqlite-store.ts index 72c9d7b02a2..ab2fa473f44 100644 --- a/desktop/macos/agent/src/runtime/sqlite-store.ts +++ b/desktop/macos/agent/src/runtime/sqlite-store.ts @@ -756,10 +756,10 @@ export class SqliteAgentStore implements AgentStore { if (requeuedChatFirstDeferralIds.length > 0) { this.db.prepare( `UPDATE chat_first_deferral_outbox - SET status = 'retrying', available_at_ms = ?, lease_expires_at_ms = NULL, + SET status = 'retrying', available_at_ms = 0, lease_expires_at_ms = NULL, last_error_code = 'daemon_restart', updated_at_ms = ? WHERE status = 'delivering'`, - ).run(now, now); + ).run(now); } this.db.prepare( `UPDATE backend_reconcile_state diff --git a/desktop/macos/agent/tests/conversation-journal.test.ts b/desktop/macos/agent/tests/conversation-journal.test.ts index e15e308e5dc..a1f9bc73e5b 100644 --- a/desktop/macos/agent/tests/conversation-journal.test.ts +++ b/desktop/macos/agent/tests/conversation-journal.test.ts @@ -243,10 +243,7 @@ describe("kernel conversation journal", () => { questionId: "question-1", selectedOptionId: "focus", })); - expect(listJournalTurns(fixture.store, { - ownerId: fixture.ownerId, - conversationId: fixture.conversationId, - }).turns.map((turn) => turn.role)).toEqual(["assistant", "user", "assistant"]); + expect(currentJournalTurns(fixture).map((turn) => turn.role)).toEqual(["assistant", "user", "assistant"]); // A retry returns the same canonical rows after its placeholder has become // the tail; it cannot append a second ordinary user message. @@ -423,10 +420,9 @@ describe("kernel conversation journal", () => { contentBlocks: [{ type: "text", id: "unrelated:text", text: "Actually, help me with something else." }], createdAtMs: 20, }); - const retired = listJournalTurns(unrelated.store, { - ownerId: unrelated.ownerId, - conversationId: unrelated.conversationId, - }).turns[0]!.contentBlocks[0] as Extract; + const currentUnrelatedTurns = currentJournalTurns(unrelated); + expect(currentUnrelatedTurns.map((turn) => turn.role)).toEqual(["assistant", "user"]); + const retired = currentUnrelatedTurns[0]!.contentBlocks[0] as Extract; expect(retired.coldStartSequence?.retired).toBe(true); expect(listChatFirstMaterializationReceipts(unrelated.store, { ownerId: unrelated.ownerId, controlGeneration: 7, @@ -558,6 +554,72 @@ describe("kernel conversation journal", () => { reopened.close(); }); + it("reclaims an expired Ask me later delivery lease with a new claim generation", () => { + const fixture = newSurface("main_chat", "chat", "question-deferral-expired-lease"); + recordTerminalQuestion(fixture, "turn-question", [ + { optionId: "later", label: "Ask me later", preparedAnswer: "Ask me again later.", defer: true }, + ]); + const selected = recordQuestionInteractionReply(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + questionId: "question-1", + optionId: "later", + controlGeneration: 11, + nowMs: 200, + }); + const [firstClaim] = drainChatFirstDeferralOutbox(fixture.store, { ownerId: fixture.ownerId, nowMs: 201 }); + const leaseExpiresAtMs = Number(fixture.store.getRow( + "SELECT lease_expires_at_ms FROM chat_first_deferral_outbox WHERE continuity_key = ?", + [selected.continuityKey], + ).lease_expires_at_ms); + + expect(drainChatFirstDeferralOutbox(fixture.store, { + ownerId: fixture.ownerId, + nowMs: leaseExpiresAtMs - 1, + })).toEqual([]); + const [reclaimedClaim] = drainChatFirstDeferralOutbox(fixture.store, { + ownerId: fixture.ownerId, + nowMs: leaseExpiresAtMs, + }); + expect(reclaimedClaim).toEqual(expect.objectContaining({ + continuityKey: firstClaim.continuityKey, + attemptCount: 2, + deliveryGeneration: 2, + })); + // A late result for the expired claim cannot settle the replacement + // delivery. The reclaim preserves both the payload identity and the + // monotonic attempt/generation counters. + expect(settleChatFirstDeferralOutbox(fixture.store, { + ownerId: fixture.ownerId, + continuityKey: firstClaim.continuityKey, + deliveryGeneration: firstClaim.deliveryGeneration, + payloadHash: firstClaim.payloadHash, + ok: true, + nowMs: leaseExpiresAtMs + 1, + })).toBe(false); + expect(settleChatFirstDeferralOutbox(fixture.store, { + ownerId: fixture.ownerId, + continuityKey: reclaimedClaim.continuityKey, + deliveryGeneration: reclaimedClaim.deliveryGeneration, + payloadHash: reclaimedClaim.payloadHash, + ok: true, + nowMs: leaseExpiresAtMs + 2, + })).toBe(true); + expect(settleChatFirstDeferralOutbox(fixture.store, { + ownerId: fixture.ownerId, + continuityKey: reclaimedClaim.continuityKey, + deliveryGeneration: reclaimedClaim.deliveryGeneration, + payloadHash: reclaimedClaim.payloadHash, + ok: true, + nowMs: leaseExpiresAtMs + 3, + })).toBe(false); + expect(fixture.store.getRow( + "SELECT status, attempt_count, delivery_generation FROM chat_first_deferral_outbox WHERE continuity_key = ?", + [reclaimedClaim.continuityKey], + )).toEqual({ status: "delivered", attempt_count: 2, delivery_generation: 2 }); + fixture.store.close(); + }); + it("rejects wrong, non-main, stale, multiple, and missing chat-first targets without mutating journal rows", () => { const block: ConversationContentBlock = { type: "taskCard", id: "cfb-rejected", taskId: "task-1" }; const cases: Array<{ @@ -3239,6 +3301,19 @@ function recordTerminalQuestion( }); } +/** The journal list is an ordered revision stream; chat projects its latest revision per turn identity. */ +function currentJournalTurns(fixture: SurfaceFixture): ConversationTurn[] { + const latestByTurnId = new Map(); + for (const revision of listJournalTurns(fixture.store, { + ownerId: fixture.ownerId, + conversationId: fixture.conversationId, + }).turns) { + const current = latestByTurnId.get(revision.turnId); + if (!current || current.turnSeq < revision.turnSeq) latestByTurnId.set(revision.turnId, revision); + } + return [...latestByTurnId.values()].sort((left, right) => left.turnSeq - right.turnSeq); +} + function insertActiveRunAttempt(fixture: SurfaceFixture, suffix: string) { const run = fixture.store.insertRun({ sessionId: fixture.sessionId, From bd6db8ed287c522e7f5754694a645283e75a07b5 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 15 Jul 2026 18:18:22 -0400 Subject: [PATCH 16/28] feat(chat): harden cohort-safe chat-first e2e --- Makefile | 8 +- backend/config/chat_first_e2e_fixture.py | 85 +++ backend/database/chat_first_intents.py | 75 ++- backend/database/task_intelligence_control.py | 11 +- backend/main.py | 6 + backend/models/chat_first_e2e.py | 60 +++ backend/route_policy_manifest.yaml | 69 +++ backend/routers/candidates.py | 6 + backend/routers/chat_first_e2e.py | 66 +++ backend/tests/unit/test_candidates_router.py | 14 + .../tests/unit/test_chat_first_e2e_fixture.py | 332 ++++++++++++ .../unit/test_chat_first_proactive_intents.py | 89 ++++ .../test_what_matters_now_smoke_fixture.py | 14 + .../utils/task_intelligence/ARCHITECTURE.md | 59 ++ .../chat_first_e2e_fixture.py | 503 ++++++++++++++++++ backend/utils/task_intelligence/rollout.py | 7 +- .../Sources/Chat/AgentBridge+ChatFirst.swift | 142 +++++ .../Desktop/Sources/Chat/AgentBridge.swift | 123 +---- .../Desktop/Sources/Chat/AgentClient.swift | 14 + ...AgentRuntimeProcess+ChatFirstJournal.swift | 399 ++++++++++++++ .../Sources/Chat/AgentRuntimeProcess.swift | 498 ++++------------- .../Chat/AgentRuntimeStatusStore.swift | 3 +- .../Chat/ChatFirstAutomationFixture.swift | 113 ---- .../Chat/ChatFirstBlockValidation.swift | 3 +- .../DesktopAutomationBridge+ChatFirst.swift | 69 +++ .../Sources/DesktopAutomationBridge.swift | 30 +- .../ChatFirst/CaptureArchivePage.swift | 25 +- .../ChatFirstAutomationRuntime.swift | 401 ++++++++++++++ .../ChatFirst/ChatFirstGoalsPage.swift | 18 + ...irstPromptMaterializationCoordinator.swift | 11 +- .../MainWindow/ChatFirst/ChatFirstRoute.swift | 37 ++ .../MainWindow/ChatFirst/ChatFirstShell.swift | 50 +- .../ChatFirst/ChatFirstTasksPage.swift | 26 +- .../Sources/MainWindow/DesktopHomeView.swift | 2 + .../ChatFirstBlockToolExecutor.swift | 79 +++ .../Sources/Providers/ChatToolExecutor.swift | 77 +-- .../Tests/AgentRuntimeProcessTests.swift | 6 +- .../Tests/AuthorizedToolExecutionTests.swift | 34 ++ .../Tests/ChatFirstAnalyticsTests.swift | 70 --- .../ChatFirstAutomationRuntimeTests.swift | 37 ++ .../ChatFirstLegacyGoalIsolationTests.swift | 28 - .../Tests/ChatFirstRichBlockTests.swift | 30 ++ .../Desktop/Tests/ChatFirstShellTests.swift | 56 ++ desktop/macos/agent/src/index.ts | 282 ++++++++++ desktop/macos/agent/src/protocol.ts | 31 ++ .../agent/src/runtime/kernel-sessions.ts | 120 +++++ .../macos/agent/tests/sqlite-store.test.ts | 17 +- .../agent/tests/workstream-continuity.test.ts | 2 +- .../chat-first-capability-isolation.yaml | 62 +++ .../macos/e2e/flows/chat-first-cohesive.yaml | 206 +++++++ .../e2e/flows/chat-first-cold-start.yaml | 86 +++ .../flows/chat-first-question-deferral.yaml | 149 ++++++ desktop/macos/e2e/flows/chat-first.yaml | 132 +++++ desktop/macos/e2e/harness.md | 73 ++- desktop/macos/scripts/desktop-flow-lint.py | 47 ++ desktop/macos/scripts/omi-harness | 181 ++++++- desktop/macos/tests/test-omi-harness.sh | 124 +++++ scripts/dev-harness/MEMORY_SCENARIOS.md | 27 +- scripts/dev-harness/chat-first-e2e-fixture.sh | 141 +++++ .../dev_harness/memory_scenarios.py | 18 +- .../tests/test_memory_scenarios.py | 8 +- 61 files changed, 4587 insertions(+), 904 deletions(-) create mode 100644 backend/config/chat_first_e2e_fixture.py create mode 100644 backend/models/chat_first_e2e.py create mode 100644 backend/routers/chat_first_e2e.py create mode 100644 backend/tests/unit/test_chat_first_e2e_fixture.py create mode 100644 backend/utils/task_intelligence/ARCHITECTURE.md create mode 100644 backend/utils/task_intelligence/chat_first_e2e_fixture.py create mode 100644 desktop/macos/Desktop/Sources/Chat/AgentBridge+ChatFirst.swift create mode 100644 desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess+ChatFirstJournal.swift delete mode 100644 desktop/macos/Desktop/Sources/Chat/ChatFirstAutomationFixture.swift create mode 100644 desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift create mode 100644 desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift create mode 100644 desktop/macos/Desktop/Tests/ChatFirstAutomationRuntimeTests.swift delete mode 100644 desktop/macos/Desktop/Tests/ChatFirstLegacyGoalIsolationTests.swift create mode 100644 desktop/macos/e2e/flows/chat-first-capability-isolation.yaml create mode 100644 desktop/macos/e2e/flows/chat-first-cohesive.yaml create mode 100644 desktop/macos/e2e/flows/chat-first-cold-start.yaml create mode 100644 desktop/macos/e2e/flows/chat-first-question-deferral.yaml create mode 100644 desktop/macos/e2e/flows/chat-first.yaml create mode 100644 scripts/dev-harness/chat-first-e2e-fixture.sh diff --git a/Makefile b/Makefile index 00ead0e2fb8..94d4ccfdc34 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,11 @@ HOOKS_DIR := $(shell git rev-parse --git-path hooks) PYTHON ?= $(shell if [ -x backend/venv/bin/python ]; then printf backend/venv/bin/python; else printf python3; fi) DESKTOP_USER ?= alice DESKTOP_APP_NAME ?= +CHAT_FIRST_E2E_ACTION ?= prepare +CHAT_FIRST_E2E_CASE ?= enabled +CHAT_FIRST_E2E_SECONDS ?= 86400 -.PHONY: setup setup-main setup-hooks preflight runtime-image-source-closure runtime-image-smoke dev-check dev-up dev-status dev-summary dev-reset dev-down dev-logs dev dev-desktop dev-init dev-verify list-memory-scenarios seed-memory-scenario reset-memory-scenario desktop-run-local run-canonical-promotion +.PHONY: setup setup-main setup-hooks preflight runtime-image-source-closure runtime-image-smoke dev-check dev-up dev-status dev-summary dev-reset dev-down dev-logs dev dev-desktop dev-init dev-verify list-memory-scenarios seed-memory-scenario reset-memory-scenario desktop-run-local chat-first-e2e-fixture run-canonical-promotion setup: setup-main setup-hooks @echo "Worktree setup complete." @@ -75,5 +78,8 @@ desktop-run-local: PYTHON="$(PYTHON)" bash scripts/dev-harness/desktop-run-local.sh "$(DESKTOP_USER)"; \ fi +chat-first-e2e-fixture: + PYTHON="$(PYTHON)" bash scripts/dev-harness/chat-first-e2e-fixture.sh "$(CHAT_FIRST_E2E_ACTION)" "$(CHAT_FIRST_E2E_CASE)" "$(CHAT_FIRST_E2E_SECONDS)" + run-canonical-promotion: PYTHON="$(PYTHON)" PYTHONPATH="scripts/dev-harness:backend$(if $(PYTHONPATH),:$(PYTHONPATH),)" $(PYTHON) scripts/dev-harness/run-canonical-promotion.py "$(PROMOTION_USER)" diff --git a/backend/config/chat_first_e2e_fixture.py b/backend/config/chat_first_e2e_fixture.py new file mode 100644 index 00000000000..ba82c978874 --- /dev/null +++ b/backend/config/chat_first_e2e_fixture.py @@ -0,0 +1,85 @@ +"""Firebase Auth emulator identities and runtime guard for Chat-first E2E. + +The Firebase Auth emulator assigns the authenticated ``localId``. It does not +reliably honour a caller-provided ``localId`` during seed, so fixture access +resolves two logical synthetic principals through the dev-harness manifest +instead of relying on a production-like auth bypass or hard-coded UID. +""" + +import json +import os +from pathlib import Path + +CHAT_FIRST_E2E_ENABLED_PRINCIPAL = 'omi-chat-first-e2e-enabled' +CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL = 'omi-chat-first-e2e-out-of-cohort' +_LOCAL_E2E_STAGES = frozenset({'local', 'offline'}) +_AUTH_UID_MANIFEST_NAME = 'canonical-auth-uids.json' + + +def is_chat_first_e2e_harness_runtime(*, stage: str | None = None) -> bool: + """Return whether the harness may exist in this process at all. + + This uses the existing backend stage boundary rather than a deployable + feature flag. The router is not registered outside local/offline, and its + handlers repeat this check before doing any fixture work. + """ + + runtime_stage = (os.getenv('OMI_ENV_STAGE') if stage is None else stage) or '' + return runtime_stage.strip().lower() in _LOCAL_E2E_STAGES + + +def _fixture_auth_uids(*, state_root: str | None = None) -> dict[str, str]: + """Resolve the two real Auth emulator UIDs from harness-owned state. + + Missing or malformed local state is fail-closed: fixture identities are + never recognized by their logical names and never available in deployable + environments. ``state_root`` is injectable solely for hermetic tests. + """ + + root = (os.getenv('OMI_HARNESS_STATE_ROOT') if state_root is None else state_root) or '' + if not root.strip(): + return {} + manifest_path = Path(root).expanduser() / 'manifests' / _AUTH_UID_MANIFEST_NAME + try: + payload = json.loads(manifest_path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return {} + users = payload.get('users') if isinstance(payload, dict) else None + if not isinstance(users, dict): + return {} + resolved: dict[str, str] = {} + for principal in (CHAT_FIRST_E2E_ENABLED_PRINCIPAL, CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL): + uid = users.get(principal) + if isinstance(uid, str) and uid.strip(): + resolved[principal] = uid.strip() + return resolved + + +def fixture_uid_for_principal(principal: str, *, state_root: str | None = None) -> str | None: + """Return the Auth emulator UID for one logical fixture principal.""" + + return _fixture_auth_uids(state_root=state_root).get(principal) + + +def is_chat_first_e2e_enabled_fixture(uid: str, *, stage: str | None = None) -> bool: + """Return whether one local-only fixture identity is in the test cohort.""" + + return is_chat_first_e2e_harness_runtime(stage=stage) and uid == fixture_uid_for_principal( + CHAT_FIRST_E2E_ENABLED_PRINCIPAL + ) + + +def is_chat_first_e2e_fixture_uid(uid: str, *, stage: str | None = None) -> bool: + """Return whether ``uid`` is one of the two isolated E2E accounts.""" + + return is_chat_first_e2e_harness_runtime(stage=stage) and uid in set(_fixture_auth_uids().values()) + + +__all__ = [ + 'CHAT_FIRST_E2E_ENABLED_PRINCIPAL', + 'CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL', + 'fixture_uid_for_principal', + 'is_chat_first_e2e_enabled_fixture', + 'is_chat_first_e2e_fixture_uid', + 'is_chat_first_e2e_harness_runtime', +] diff --git a/backend/database/chat_first_intents.py b/backend/database/chat_first_intents.py index f99a07833a5..4f6d818da48 100644 --- a/backend/database/chat_first_intents.py +++ b/backend/database/chat_first_intents.py @@ -3,11 +3,12 @@ import hashlib from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone -from typing import Any, Iterable, cast +from typing import Any, Iterable from google.cloud import firestore from database._client import get_firestore_client +from database.read_boundary import MalformedDocError, parse_snapshot_strict from models.chat_first import ( ChatFirstBlockSpec, ChatFirstSubject, @@ -92,15 +93,13 @@ def _stable_id(prefix: str, *parts: object) -> str: return f'{prefix}_{hashlib.sha256(raw).hexdigest()[:32]}' -def _snapshot_dict(snapshot: Any) -> dict[str, Any]: - payload = snapshot.to_dict() - return cast(dict[str, Any], payload) if isinstance(payload, dict) else {} - - def _require_control(snapshot: Any, account_generation: int) -> None: control = TaskWorkflowControl() if snapshot.exists: - control = TaskWorkflowControl.model_validate(_snapshot_dict(snapshot)) + try: + control = parse_snapshot_strict(TaskWorkflowControl, snapshot) + except MalformedDocError as error: + raise ChatFirstIntentGenerationMismatch('chat-first capability state is malformed') from error if ( control.workflow_mode != TaskWorkflowMode.read or control.account_generation != account_generation @@ -112,12 +111,33 @@ def _require_control(snapshot: Any, account_generation: int) -> None: def _budget_from_snapshot(snapshot: Any, *, account_generation: int, now: datetime) -> ProactiveBudgetState: if not snapshot.exists: return ProactiveBudgetState(account_generation=account_generation) - state = ProactiveBudgetState.model_validate(_snapshot_dict(snapshot)) + try: + state = parse_snapshot_strict(ProactiveBudgetState, snapshot) + except MalformedDocError as error: + raise ChatFirstIntentGenerationMismatch('chat-first proactive budget state is malformed') from error if state.account_generation != account_generation: return ProactiveBudgetState(account_generation=account_generation) return normalized_budget_state(state, now=now) +def _intent_from_snapshot(snapshot: Any) -> ProactiveIntent: + """Load correctness-critical proactive state without treating corruption as absent.""" + + try: + return parse_snapshot_strict(ProactiveIntent, snapshot) + except MalformedDocError as error: + raise ChatFirstIntentGenerationMismatch('chat-first proactive intent is malformed') from error + + +def _deferral_from_snapshot(snapshot: Any) -> ProactiveDeferral: + """Load correctness-critical deferred-question state without a fallback.""" + + try: + return parse_snapshot_strict(ProactiveDeferral, snapshot) + except MalformedDocError as error: + raise ChatFirstIntentGenerationMismatch('chat-first deferral is malformed') from error + + def _require_current_control(uid: str, *, account_generation: int, firestore_client: Any) -> None: """Fence read-only entry points before they inspect feature-specific rows.""" @@ -172,7 +192,7 @@ def apply(write_transaction: Any) -> AgentJudgmentAdmission: _require_control(control_snapshot, account_generation) existing_snapshot = intent_ref.get(transaction=write_transaction) if existing_snapshot.exists: - existing = ProactiveIntent.model_validate(_snapshot_dict(existing_snapshot)) + existing = _intent_from_snapshot(existing_snapshot) if ( existing.account_generation != account_generation or existing.source != 'agent_judgment' @@ -275,7 +295,7 @@ def apply(write_transaction: Any) -> tuple[ProactiveIntent, bool]: else None ) if existing_snapshot.exists: - existing = ProactiveIntent.model_validate(_snapshot_dict(existing_snapshot)) + existing = _intent_from_snapshot(existing_snapshot) if ( existing.account_generation != account_generation or existing.source != source @@ -344,7 +364,7 @@ def apply(write_transaction: Any) -> tuple[ProactiveIntent, bool]: _require_control(control_snapshot, account_generation) existing_snapshot = intent_ref.get(transaction=write_transaction) if existing_snapshot.exists: - existing = ProactiveIntent.model_validate(_snapshot_dict(existing_snapshot)) + existing = _intent_from_snapshot(existing_snapshot) if ( existing.account_generation != account_generation or existing.continuity_key != continuity_key @@ -371,12 +391,11 @@ def has_cold_start_intent_created_on( _require_current_control(uid, account_generation=account_generation, firestore_client=client) collection = _user_ref(uid, firestore_client=client).collection(INTENTS_COLLECTION) for snapshot in collection.stream(): - payload = _snapshot_dict(snapshot) - if payload.get('account_generation') != account_generation: + intent = _intent_from_snapshot(snapshot) + if intent.account_generation != account_generation: continue - if payload.get('source') not in {'cold_start_rich', 'cold_start_sparse'}: + if intent.source not in {'cold_start_rich', 'cold_start_sparse'}: continue - intent = ProactiveIntent.model_validate(payload) if intent.created_at.date() == date_value: return True return False @@ -416,7 +435,7 @@ def apply(write_transaction: Any) -> ProactiveIntent: snapshot = intent_ref.get(transaction=write_transaction) if not snapshot.exists: raise ProactiveIntentNotReady('cold-start intent is not ready') - intent = ProactiveIntent.model_validate(_snapshot_dict(snapshot)) + intent = _intent_from_snapshot(snapshot) if ( intent.account_generation != account_generation or intent.source != 'cold_start_sparse' @@ -456,10 +475,9 @@ def has_active_sparse_cold_start_sequence( _require_current_control(uid, account_generation=account_generation, firestore_client=client) collection = _user_ref(uid, firestore_client=client).collection(INTENTS_COLLECTION) for snapshot in collection.stream(): - payload = _snapshot_dict(snapshot) - if payload.get('account_generation') != account_generation or payload.get('source') != 'cold_start_sparse': + intent = _intent_from_snapshot(snapshot) + if intent.account_generation != account_generation or intent.source != 'cold_start_sparse': continue - intent = ProactiveIntent.model_validate(payload) if intent.cold_start_sequence_terminal_receipt_id is None: return True return False @@ -479,13 +497,13 @@ def fetch_ready_intents( collection = _user_ref(uid, firestore_client=client).collection(INTENTS_COLLECTION) ready: list[ProactiveIntent] = [] for snapshot in collection.stream(): - payload = _snapshot_dict(snapshot) - if payload.get('account_generation') != account_generation or payload.get('delivery_state') not in { + intent = _intent_from_snapshot(snapshot) + if intent.account_generation != account_generation or intent.delivery_state not in { 'ready', 'pending_kernel_receipt', }: continue - ready.append(ProactiveIntent.model_validate(payload)) + ready.append(intent) ready.sort(key=lambda intent: (intent.created_at, intent.intent_id)) return ready[:limit] @@ -513,7 +531,7 @@ def apply(write_transaction: Any) -> ProactiveIntent: intent_snapshot = intent_ref.get(transaction=write_transaction) if not intent_snapshot.exists: raise ProactiveIntentNotReady('proactive intent is not ready') - intent = ProactiveIntent.model_validate(_snapshot_dict(intent_snapshot)) + intent = _intent_from_snapshot(intent_snapshot) budget_snapshot = budget_ref.get(transaction=write_transaction) if intent.consumes_turn_budget else None if intent.account_generation != account_generation: raise ChatFirstIntentGenerationMismatch('intent account generation changed') @@ -574,7 +592,7 @@ def apply(write_transaction: Any) -> tuple[DeferralReceipt, bool]: _require_control(control_snapshot, account_generation) existing_snapshot = ref.get(transaction=write_transaction) if existing_snapshot.exists: - existing = ProactiveDeferral.model_validate(_snapshot_dict(existing_snapshot)) + existing = _deferral_from_snapshot(existing_snapshot) if ( existing.account_generation != account_generation or existing.continuity_key != continuity_key @@ -607,10 +625,9 @@ def release_due_deferrals( collection = _user_ref(uid, firestore_client=client).collection(DEFERRALS_COLLECTION) candidates: list[ProactiveDeferral] = [] for snapshot in collection.stream(): - payload = _snapshot_dict(snapshot) - if payload.get('account_generation') != account_generation or payload.get('state') != 'pending': + deferred = _deferral_from_snapshot(snapshot) + if deferred.account_generation != account_generation or deferred.state != 'pending': continue - deferred = ProactiveDeferral.model_validate(payload) if subject is not None: if deferred.subject != subject: continue @@ -662,11 +679,11 @@ def apply(write_transaction: Any) -> ProactiveIntent | None: intent_snapshot = intent_ref.get(transaction=write_transaction) if not deferral_snapshot.exists: return None - current = ProactiveDeferral.model_validate(_snapshot_dict(deferral_snapshot)) + current = _deferral_from_snapshot(deferral_snapshot) if current.account_generation != account_generation or current.state != 'pending': return None if intent_snapshot.exists: - existing = ProactiveIntent.model_validate(_snapshot_dict(intent_snapshot)) + existing = _intent_from_snapshot(intent_snapshot) if existing.source != 'deferral_reraise' or existing.continuity_key != current.continuity_key: raise ChatFirstIntentConflictError('deferral intent collision') released = current.model_copy(update={'state': 'released', 'released_intent_id': existing.intent_id}) diff --git a/backend/database/task_intelligence_control.py b/backend/database/task_intelligence_control.py index 6ed5c8961d3..f5648e21145 100644 --- a/backend/database/task_intelligence_control.py +++ b/backend/database/task_intelligence_control.py @@ -46,14 +46,9 @@ def ensure_development_smoke_fixture(uid: str, *, stage: str | None = None) -> b except (AlreadyExists, Conflict): snapshot = ref.get() if snapshot.exists: - payload = snapshot.to_dict() - if isinstance(payload, dict): - try: - existing = TaskWorkflowControl.model_validate(cast(dict[str, Any], payload)) - except ValueError: - existing = None - if existing is not None and existing.persisted_payload() == expected_payload: - return False + existing = parse_snapshot_or_none(TaskWorkflowControl, snapshot) + if existing is not None and existing.persisted_payload() == expected_payload: + return False raise DevelopmentSmokeFixtureConflictError( 'development smoke fixture control already exists with differing state' ) from None diff --git a/backend/main.py b/backend/main.py index 8bea9109b07..d60602c18d9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ import os from utils.env_loader import load_backend_env +from config.chat_first_e2e_fixture import is_chat_first_e2e_harness_runtime load_backend_env() # No-op if no env files exist (production); stage + local overrides otherwise @@ -42,6 +43,7 @@ action_items, candidates, chat_first, + chat_first_e2e, task_integrations, integrations, x_connector, @@ -120,6 +122,10 @@ app.include_router(action_items.router) app.include_router(candidates.router) app.include_router(chat_first.router) +if is_chat_first_e2e_harness_runtime(): + # The fixture router has its own runtime check as defense in depth. It is + # intentionally absent from dev/prod route tables, not merely disabled. + app.include_router(chat_first_e2e.router) app.include_router(task_integrations.router) app.include_router(integrations.router) app.include_router(x_connector.router) diff --git a/backend/models/chat_first_e2e.py b/backend/models/chat_first_e2e.py new file mode 100644 index 00000000000..656ecfa5472 --- /dev/null +++ b/backend/models/chat_first_e2e.py @@ -0,0 +1,60 @@ +"""Strict, content-free contracts for the local Chat-first E2E harness.""" + +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + + +class ChatFirstE2EFixtureCase(str, Enum): + enabled = 'enabled' + question = 'question' + ui_flag_off = 'ui_flag_off' + out_of_cohort = 'out_of_cohort' + unreachable_control = 'unreachable_control' + cold_start = 'cold_start' + + +class ChatFirstE2EControlEndpointMode(str, Enum): + reachable = 'reachable' + unreachable = 'unreachable' + + +class ChatFirstE2EExpectedShell(str, Enum): + legacy = 'legacy' + chat_first = 'chat_first' + + +class _StrictHarnessModel(BaseModel): + model_config = ConfigDict(extra='forbid', frozen=True) + + +class ChatFirstE2EPrepareRequest(_StrictHarnessModel): + fixture_case: ChatFirstE2EFixtureCase + + +class ChatFirstE2EAdvanceRequest(_StrictHarnessModel): + seconds: int = Field(ge=1, le=172800) + + +class ChatFirstE2EFixtureSnapshot(_StrictHarnessModel): + """Shape-only observations. Product text and entity payloads never leave here.""" + + fixture_case: ChatFirstE2EFixtureCase + fixture_revision: int = Field(ge=1) + expected_shell: ChatFirstE2EExpectedShell + control_endpoint_mode: ChatFirstE2EControlEndpointMode + advanced_seconds: int = Field(ge=0) + materialized_intent_count: int = Field(ge=0) + ready_intent_count: int = Field(ge=0) + proactive_intent_count: int = Field(ge=0) + pending_deferral_count: int = Field(ge=0) + + +__all__ = [ + 'ChatFirstE2EAdvanceRequest', + 'ChatFirstE2EControlEndpointMode', + 'ChatFirstE2EExpectedShell', + 'ChatFirstE2EFixtureCase', + 'ChatFirstE2EFixtureSnapshot', + 'ChatFirstE2EPrepareRequest', +] diff --git a/backend/route_policy_manifest.yaml b/backend/route_policy_manifest.yaml index 0c81890be8c..17ffd7c2d2c 100644 --- a/backend/route_policy_manifest.yaml +++ b/backend/route_policy_manifest.yaml @@ -274,6 +274,75 @@ routes: deprecation: state: active owner: backend + - route_type: http + method: POST + path: /v1/chat-first/blocks/validate + policy: + review_status: reviewed + auth: + mechanisms: + - firebase_id_token + placement: dependency + scopes: [] + byok: validated_when_headers_present + rate_limit: + policy_name: none + key_subject: none + enforcement: none + placement: none + timeout_class: default_method + surface: first_party_app + visibility: first_party + data_domain: unknown + deprecation: + state: active + owner: backend + - route_type: http + method: POST + path: /v1/chat/materialize-prompts + policy: + review_status: reviewed + auth: + mechanisms: + - firebase_id_token + placement: dependency + scopes: [] + byok: validated_when_headers_present + rate_limit: + policy_name: none + key_subject: none + enforcement: none + placement: none + timeout_class: default_method + surface: first_party_app + visibility: first_party + data_domain: unknown + deprecation: + state: active + owner: backend + - route_type: http + method: POST + path: /v1/chat/deferrals + policy: + review_status: reviewed + auth: + mechanisms: + - firebase_id_token + placement: dependency + scopes: [] + byok: validated_when_headers_present + rate_limit: + policy_name: none + key_subject: none + enforcement: none + placement: none + timeout_class: default_method + surface: first_party_app + visibility: first_party + data_domain: unknown + deprecation: + state: active + owner: backend - route_type: http method: GET path: /v1/candidates/{candidate_id} diff --git a/backend/routers/candidates.py b/backend/routers/candidates.py index 962bb31c77f..0890b1c8c4c 100644 --- a/backend/routers/candidates.py +++ b/backend/routers/candidates.py @@ -27,6 +27,7 @@ from utils.task_intelligence.capture_policy import MINIMUM_CAPTURE_CONFIDENCE from utils.task_intelligence.recommendations import candidate_recommendation_dedupe_key from utils.task_intelligence.rollout import resolve_chat_first_ui, resolve_task_intelligence_for_user +from utils.task_intelligence import chat_first_e2e_fixture from utils.task_intelligence.task_links import TaskLinkValidationError from utils.task_intelligence.staged_migration import migrate_staged_tasks @@ -232,6 +233,11 @@ def migrate_staged_candidates( @router.get('/v1/candidates/control', response_model=TaskWorkflowControl, tags=['candidates']) def get_candidate_workflow_control(uid: str = Depends(auth.get_current_user_uid)) -> TaskWorkflowControl: + # The named E2E bundle exercises the real desktop transport failure path, + # rather than accepting a client-supplied capability override. This is + # false for every non-local/offline account and unavailable in production. + if chat_first_e2e_fixture.is_control_unreachable(uid): + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Control temporarily unavailable') try: control = task_control_db.get_task_workflow_control(uid) except Exception: diff --git a/backend/routers/chat_first_e2e.py b/backend/routers/chat_first_e2e.py new file mode 100644 index 00000000000..08ffc4a3f01 --- /dev/null +++ b/backend/routers/chat_first_e2e.py @@ -0,0 +1,66 @@ +"""Local/offline-only control plane for the named Chat-first E2E bundle.""" + +from fastapi import APIRouter, Depends, HTTPException, status + +from config.chat_first_e2e_fixture import is_chat_first_e2e_harness_runtime +from models.chat_first_e2e import ( + ChatFirstE2EAdvanceRequest, + ChatFirstE2EFixtureSnapshot, + ChatFirstE2EPrepareRequest, +) +from utils.other import endpoints as auth +from utils.task_intelligence import chat_first_e2e_fixture + +router = APIRouter(prefix='/v1/dev-harness/chat-first', include_in_schema=False) + + +def _raise_harness_error(exc: RuntimeError) -> None: + if isinstance(exc, chat_first_e2e_fixture.ChatFirstE2EFixtureUnavailable): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Not found') from exc + if isinstance(exc, chat_first_e2e_fixture.ChatFirstE2EFixtureIdentityError): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Not found') from exc + if isinstance(exc, chat_first_e2e_fixture.ChatFirstE2EFixtureNotPrepared): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Fixture is not prepared') from exc + raise exc + + +def _require_local_harness() -> None: + # Defense in depth: main.py does not register this router outside local or + # offline, and a direct router inclusion in a test/server still fails here. + if not is_chat_first_e2e_harness_runtime(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Not found') + + +@router.post('/prepare', response_model=ChatFirstE2EFixtureSnapshot) +def prepare_chat_first_e2e_fixture( + request: ChatFirstE2EPrepareRequest, + uid: str = Depends(auth.get_current_user_uid), +) -> ChatFirstE2EFixtureSnapshot: + _require_local_harness() + try: + return chat_first_e2e_fixture.prepare_fixture(uid, fixture_case=request.fixture_case) + except RuntimeError as exc: + _raise_harness_error(exc) + + +@router.post('/advance-clock', response_model=ChatFirstE2EFixtureSnapshot) +def advance_chat_first_e2e_fixture_clock( + request: ChatFirstE2EAdvanceRequest, + uid: str = Depends(auth.get_current_user_uid), +) -> ChatFirstE2EFixtureSnapshot: + _require_local_harness() + try: + return chat_first_e2e_fixture.advance_fixture_clock(uid, seconds=request.seconds) + except RuntimeError as exc: + _raise_harness_error(exc) + + +@router.get('/snapshot', response_model=ChatFirstE2EFixtureSnapshot) +def get_chat_first_e2e_fixture_snapshot( + uid: str = Depends(auth.get_current_user_uid), +) -> ChatFirstE2EFixtureSnapshot: + _require_local_harness() + try: + return chat_first_e2e_fixture.snapshot_fixture(uid) + except RuntimeError as exc: + _raise_harness_error(exc) diff --git a/backend/tests/unit/test_candidates_router.py b/backend/tests/unit/test_candidates_router.py index 879f952dc0c..dbd32548e54 100644 --- a/backend/tests/unit/test_candidates_router.py +++ b/backend/tests/unit/test_candidates_router.py @@ -164,6 +164,20 @@ def test_candidate_workflow_control_defaults_chat_first_ui_off_when_the_flag_is_ assert 'chat_first_ui_enabled' not in response.json() +def test_candidate_workflow_control_e2e_fixture_uses_real_transport_failure(monkeypatch): + monkeypatch.setattr(candidates_router.chat_first_e2e_fixture, 'is_control_unreachable', lambda uid: True) + monkeypatch.setattr( + candidates_router.task_control_db, + 'get_task_workflow_control', + lambda uid: pytest.fail('fixture transport failure must precede control resolution'), + ) + + response = _workflow_control_client().get('/v1/candidates/control') + + assert response.status_code == 503 + assert response.json() == {'detail': 'Control temporarily unavailable'} + + def test_candidate_record_serialization_satisfies_its_response_schema(): validate(_record().model_dump(mode='json'), CandidateRecord.model_json_schema()) diff --git a/backend/tests/unit/test_chat_first_e2e_fixture.py b/backend/tests/unit/test_chat_first_e2e_fixture.py new file mode 100644 index 00000000000..d0d03a2f37f --- /dev/null +++ b/backend/tests/unit/test_chat_first_e2e_fixture.py @@ -0,0 +1,332 @@ +"""Hermetic contracts for the local-only Chat-first E2E fixture harness.""" + +from copy import deepcopy +from datetime import datetime, timedelta, timezone +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from config.chat_first_e2e_fixture import ( + CHAT_FIRST_E2E_ENABLED_PRINCIPAL, + CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL, + fixture_uid_for_principal, + is_chat_first_e2e_enabled_fixture, + is_chat_first_e2e_fixture_uid, + is_chat_first_e2e_harness_runtime, +) +import database.chat_first_intents as intents_db +from models.chat_first import ChatFirstSubject, ProactiveDeferral +from models.chat_first_e2e import ChatFirstE2EFixtureCase +from models.task_intelligence import TaskWorkflowMode +from utils.memory.memory_system import MemorySystem +import utils.task_intelligence.chat_first_e2e_fixture as fixture +import utils.task_intelligence.rollout as rollout +import routers.chat_first_e2e as fixture_router + +ENABLED_UID = 'auth-emulator-enabled-fixture' +OUT_OF_COHORT_UID = 'auth-emulator-out-of-cohort-fixture' + + +class _Snapshot: + def __init__(self, database, path): + self._database = database + self._path = path + self.exists = path in database.rows + self.reference = _Document(database, path) + + def to_dict(self): + return deepcopy(self._database.rows.get(self._path)) + + +class _Document: + def __init__(self, database, path): + self._database = database + self._path = path + + @property + def id(self): + return self._path[-1] + + @property + def path(self): + return '/'.join(self._path) + + def collection(self, name): + return _Collection(self._database, (*self._path, name)) + + def get(self, transaction=None): + return _Snapshot(self._database, self._path) + + +class _Collection: + def __init__(self, database, path): + self._database = database + self._path = path + + def document(self, identifier): + return _Document(self._database, (*self._path, identifier)) + + def stream(self): + child_length = len(self._path) + 1 + return [ + _Snapshot(self._database, path) + for path in sorted(self._database.rows) + if path[: len(self._path)] == self._path and len(path) == child_length + ] + + +class _Transaction: + def __init__(self, database): + self._database = database + self._operations = [] + + def set(self, ref, payload): + self._operations.append(('set', ref._path, deepcopy(payload))) + + def update(self, ref, payload): + self._operations.append(('update', ref._path, deepcopy(payload))) + + def delete(self, ref): + self._operations.append(('delete', ref._path, None)) + + def commit(self): + for operation, path, payload in self._operations: + if operation == 'delete': + self._database.rows.pop(path, None) + elif operation == 'update': + self._database.rows[path] = {**self._database.rows[path], **payload} + else: + self._database.rows[path] = payload + + +class _Firestore: + def __init__(self): + self.rows = {} + + def collection(self, name): + return _Collection(self, (name,)) + + def transaction(self): + return _Transaction(self) + + +@pytest.fixture +def firestore(monkeypatch, tmp_path): + monkeypatch.setenv('OMI_ENV_STAGE', 'local') + manifest_dir = tmp_path / 'manifests' + manifest_dir.mkdir() + (manifest_dir / 'canonical-auth-uids.json').write_text( + json.dumps( + { + 'users': { + CHAT_FIRST_E2E_ENABLED_PRINCIPAL: ENABLED_UID, + CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL: OUT_OF_COHORT_UID, + } + } + ), + encoding='utf-8', + ) + monkeypatch.setenv('OMI_HARNESS_STATE_ROOT', str(tmp_path)) + fake = _Firestore() + monkeypatch.setattr(fixture, 'get_firestore_client', lambda: fake) + return fake + + +def test_harness_runtime_and_fixture_identities_are_local_offline_only(monkeypatch, tmp_path): + manifest_dir = tmp_path / 'manifests' + manifest_dir.mkdir() + (manifest_dir / 'canonical-auth-uids.json').write_text( + json.dumps( + { + 'users': { + CHAT_FIRST_E2E_ENABLED_PRINCIPAL: ENABLED_UID, + CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL: OUT_OF_COHORT_UID, + } + } + ), + encoding='utf-8', + ) + monkeypatch.setenv('OMI_HARNESS_STATE_ROOT', str(tmp_path)) + for stage in ('local', 'offline'): + assert is_chat_first_e2e_harness_runtime(stage=stage) + assert fixture_uid_for_principal(CHAT_FIRST_E2E_ENABLED_PRINCIPAL) == ENABLED_UID + assert is_chat_first_e2e_enabled_fixture(ENABLED_UID, stage=stage) + assert is_chat_first_e2e_fixture_uid(OUT_OF_COHORT_UID, stage=stage) + for stage in ('dev', 'prod', ''): + assert not is_chat_first_e2e_harness_runtime(stage=stage) + assert not is_chat_first_e2e_enabled_fixture(ENABLED_UID, stage=stage) + assert not is_chat_first_e2e_fixture_uid(OUT_OF_COHORT_UID, stage=stage) + monkeypatch.delenv('OMI_ENV_STAGE', raising=False) + assert not is_chat_first_e2e_harness_runtime() + + +def test_fixture_identity_is_fail_closed_without_live_auth_uid_manifest(monkeypatch): + monkeypatch.setenv('OMI_ENV_STAGE', 'local') + monkeypatch.delenv('OMI_HARNESS_STATE_ROOT', raising=False) + + assert fixture_uid_for_principal(CHAT_FIRST_E2E_ENABLED_PRINCIPAL) is None + assert not is_chat_first_e2e_enabled_fixture(ENABLED_UID) + assert not is_chat_first_e2e_fixture_uid(OUT_OF_COHORT_UID) + + +def test_fixture_router_is_defensively_hidden_when_directly_included_outside_local(monkeypatch): + app = FastAPI() + app.include_router(fixture_router.router) + app.dependency_overrides[fixture_router.auth.get_current_user_uid] = lambda: ENABLED_UID + monkeypatch.setattr(fixture_router, 'is_chat_first_e2e_harness_runtime', lambda: False) + monkeypatch.setattr( + fixture_router.chat_first_e2e_fixture, + 'prepare_fixture', + lambda *args, **kwargs: pytest.fail('non-local harness route must not prepare state'), + ) + + response = TestClient(app).post('/v1/dev-harness/chat-first/prepare', json={'fixture_case': 'enabled'}) + + assert response.status_code == 404 + assert response.json() == {'detail': 'Not found'} + + +def test_prepare_resets_fixed_canonical_fixture_rows_atomically(firestore): + first = fixture.prepare_fixture( + ENABLED_UID, + fixture_case=ChatFirstE2EFixtureCase.enabled, + ) + daily_opener = next( + value + for path, value in firestore.rows.items() + if path[-2] == intents_db.INTENTS_COLLECTION and path[-1].startswith('cfi_') + ) + second = fixture.prepare_fixture( + ENABLED_UID, + fixture_case=ChatFirstE2EFixtureCase.ui_flag_off, + ) + + assert first.expected_shell == 'chat_first' + assert first.proactive_intent_count == 1 + assert second.fixture_revision == 2 + assert second.expected_shell == 'legacy' + assert second.proactive_intent_count == 0 + assert second.ready_intent_count == 0 + control = firestore.rows[('users', ENABLED_UID, 'task_intelligence_control', 'state')] + assert control['workflow_mode'] == TaskWorkflowMode.read.value + assert control['chat_first_ui_enabled'] is False + focused_goal = firestore.rows[('users', ENABLED_UID, 'goals', 'chat-first-e2e-goal-v1')] + non_focused_goal = firestore.rows[('users', ENABLED_UID, 'goals', 'chat-first-e2e-secondary-goal-v1')] + assert focused_goal['status'] == 'focused' + assert focused_goal['focus_rank'] == 0 + assert non_focused_goal['status'] == 'background' + assert non_focused_goal['focus_rank'] is None + assert non_focused_goal['is_active'] is True + task = firestore.rows[('users', ENABLED_UID, 'action_items', 'chat-first-e2e-task-v1')] + assert task['source'] == 'transcription:omi' + assert task['conversation_id'] == 'chat-first-e2e-capture-v1' + assert ('users', ENABLED_UID, 'conversations', 'chat-first-e2e-capture-v1') in firestore.rows + assert daily_opener['source'] == 'daily_opener' + assert daily_opener['blocks'] == [ + {'type': 'goalLink', 'goal_id': 'chat-first-e2e-goal-v1', 'summary': 'E2E fixture goal'}, + {'type': 'taskCard', 'task_id': 'chat-first-e2e-task-v1'}, + ] + + +def test_cold_start_case_uses_existing_intent_contract(firestore): + snapshot = fixture.prepare_fixture( + ENABLED_UID, + fixture_case=ChatFirstE2EFixtureCase.cold_start, + ) + + assert snapshot.expected_shell == 'chat_first' + assert snapshot.ready_intent_count == 1 + stored = next( + value + for path, value in firestore.rows.items() + if path[-2] == intents_db.INTENTS_COLLECTION and path[-1].startswith('cfi_') + ) + assert stored['source'] == 'cold_start_sparse' + assert stored['delivery_state'] == 'pending_kernel_receipt' + + +def test_question_case_starts_after_completed_rich_cold_start(firestore): + snapshot = fixture.prepare_fixture( + ENABLED_UID, + fixture_case=ChatFirstE2EFixtureCase.question, + ) + + assert snapshot.expected_shell == 'chat_first' + assert snapshot.proactive_intent_count == 2 + assert snapshot.ready_intent_count == 1 + assert snapshot.materialized_intent_count == 1 + intents = [ + value + for path, value in firestore.rows.items() + if path[-2] == intents_db.INTENTS_COLLECTION and path[-1].startswith('cfi_') + ] + completed_cold_start = next(intent for intent in intents if intent['source'] == 'cold_start_rich') + question = next(intent for intent in intents if intent['source'] == 'deferral_reraise') + assert completed_cold_start['delivery_state'] == 'delivered' + assert completed_cold_start['materialization_receipt_id'] + assert question['delivery_state'] == 'ready' + + +def test_unreachable_control_case_only_affects_the_prepared_local_fixture(firestore): + fixture.prepare_fixture( + ENABLED_UID, + fixture_case=ChatFirstE2EFixtureCase.unreachable_control, + ) + + assert fixture.is_control_unreachable(ENABLED_UID) + assert not fixture.is_control_unreachable(OUT_OF_COHORT_UID) + fixture.prepare_fixture(ENABLED_UID, fixture_case=ChatFirstE2EFixtureCase.enabled) + assert not fixture.is_control_unreachable(ENABLED_UID) + + +def test_advance_clock_makes_fixture_deferrals_due_without_changing_chat_clock(firestore): + fixture.prepare_fixture(ENABLED_UID, fixture_case=ChatFirstE2EFixtureCase.enabled) + now = datetime.now(timezone.utc) + deferred = ProactiveDeferral( + deferral_id='fixture-pending-deferral', + continuity_key='fixture-pending-deferral', + account_generation=1, + subject=ChatFirstSubject(kind='goal', id='chat-first-e2e-goal-v1'), + question=fixture._question(), + created_at=now, + due_at=now + timedelta(hours=24), + ) + path = ( + 'users', + ENABLED_UID, + intents_db.DEFERRALS_COLLECTION, + deferred.deferral_id, + ) + firestore.rows[path] = deferred.model_dump(mode='python') + + snapshot = fixture.advance_fixture_clock(ENABLED_UID, seconds=86400) + + assert snapshot.advanced_seconds == 86400 + assert snapshot.pending_deferral_count == 1 + assert firestore.rows[path]['due_at'] < datetime.now(timezone.utc) + + +def test_fixture_identity_and_cohort_are_fail_closed(firestore, monkeypatch): + with pytest.raises(fixture.ChatFirstE2EFixtureIdentityError): + fixture.prepare_fixture('regular-user', fixture_case=ChatFirstE2EFixtureCase.enabled) + with pytest.raises(fixture.ChatFirstE2EFixtureIdentityError): + fixture.prepare_fixture( + OUT_OF_COHORT_UID, + fixture_case=ChatFirstE2EFixtureCase.enabled, + ) + + monkeypatch.setattr(rollout, 'resolve_memory_system', lambda *args, **kwargs: MemorySystem.LEGACY) + enabled = rollout.resolve_task_intelligence_for_user( + uid=ENABLED_UID, + workflow_mode=TaskWorkflowMode.read, + account_generation=1, + ) + out_of_cohort = rollout.resolve_task_intelligence_for_user( + uid=OUT_OF_COHORT_UID, + workflow_mode=TaskWorkflowMode.read, + account_generation=1, + ) + assert enabled.intelligence_product_enabled is True + assert out_of_cohort.intelligence_product_enabled is False diff --git a/backend/tests/unit/test_chat_first_proactive_intents.py b/backend/tests/unit/test_chat_first_proactive_intents.py index 5c6933fcefe..c52fdc34c80 100644 --- a/backend/tests/unit/test_chat_first_proactive_intents.py +++ b/backend/tests/unit/test_chat_first_proactive_intents.py @@ -2,6 +2,7 @@ from copy import deepcopy from datetime import datetime, timedelta, timezone +from unittest.mock import patch import pytest @@ -520,5 +521,93 @@ def test_off_or_stale_control_rejects_intent_before_feature_records_are_read(fir assert not any(INTENTS_COLLECTION in path or DEFERRALS_COLLECTION in path for path in firestore.rows) +def test_malformed_control_or_proactive_state_fails_closed_without_a_fail_open_drop(firestore): + question = _question() + malformed_control_path = ('users', UID, 'task_intelligence_control', 'state') + firestore.rows[malformed_control_path]['unexpected_legacy_field'] = True + + with patch('database.read_boundary.record_fallback') as fallback: + with pytest.raises(intents_db.ChatFirstIntentGenerationMismatch, match='capability state is malformed'): + intents_db.create_intent( + UID, + source='capture_arrival', + continuity_key='malformed-control', + subject=question.subject, + blocks=[question], + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + fallback.assert_not_called() + assert not any(INTENTS_COLLECTION in path or DEFERRALS_COLLECTION in path for path in firestore.rows) + + +def test_malformed_intent_cannot_be_materialized_or_overwritten(firestore): + path = ('users', UID, intents_db.INTENTS_COLLECTION, 'malformed-intent') + firestore.rows[path] = { + 'account_generation': GENERATION, + 'unexpected_legacy_field': True, + } + original = deepcopy(firestore.rows[path]) + + with patch('database.read_boundary.record_fallback') as fallback: + with pytest.raises(intents_db.ChatFirstIntentGenerationMismatch, match='proactive intent is malformed'): + intents_db.acknowledge_materialization( + UID, + intent_id='malformed-intent', + receipt_id='kernel-receipt-1', + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + fallback.assert_not_called() + assert firestore.rows[path] == original + + +def test_malformed_deferral_cannot_be_accepted_or_overwritten(firestore): + question = _question() + continuity_key = 'malformed-deferral' + deferral_id = intents_db._stable_id('cfd', UID, GENERATION, continuity_key) + path = ('users', UID, intents_db.DEFERRALS_COLLECTION, deferral_id) + firestore.rows[path] = { + 'account_generation': GENERATION, + 'unexpected_legacy_field': True, + } + original = deepcopy(firestore.rows[path]) + + with patch('database.read_boundary.record_fallback') as fallback: + with pytest.raises(intents_db.ChatFirstIntentGenerationMismatch, match='deferral is malformed'): + intents_db.record_deferral( + UID, + continuity_key=continuity_key, + subject=question.subject, + question=question, + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + fallback.assert_not_called() + assert firestore.rows[path] == original + + +def test_malformed_budget_state_cannot_be_reset_to_an_enabled_default(firestore): + path = ('users', UID, intents_db.STATE_COLLECTION, intents_db.BUDGET_DOCUMENT) + firestore.rows[path] = { + 'account_generation': GENERATION, + 'unexpected_legacy_field': True, + } + original = deepcopy(firestore.rows[path]) + + with patch('database.read_boundary.record_fallback') as fallback: + with pytest.raises(intents_db.ChatFirstIntentGenerationMismatch, match='proactive budget state is malformed'): + intents_db.get_budget_state(UID, account_generation=GENERATION, now=NOW, firestore_client=firestore) + + fallback.assert_not_called() + assert firestore.rows[path] == original + + INTENTS_COLLECTION = intents_db.INTENTS_COLLECTION DEFERRALS_COLLECTION = intents_db.DEFERRALS_COLLECTION diff --git a/backend/tests/unit/test_what_matters_now_smoke_fixture.py b/backend/tests/unit/test_what_matters_now_smoke_fixture.py index 4e0bc885656..328a90bd144 100644 --- a/backend/tests/unit/test_what_matters_now_smoke_fixture.py +++ b/backend/tests/unit/test_what_matters_now_smoke_fixture.py @@ -1,4 +1,5 @@ from copy import deepcopy +from unittest.mock import patch import pytest @@ -88,6 +89,19 @@ def test_fixture_setup_preserves_differing_existing_control_and_fails_smoke(monk ] +def test_fixture_setup_treats_malformed_existing_control_as_differing_without_overwrite(monkeypatch): + malformed_control = {'workflow_mode': 'read', 'account_generation': 0, 'unexpected_legacy_field': True} + control_ref = _CreateOnlyControlReference(malformed_control) + monkeypatch.setattr(task_control_db, '_control_ref', lambda _uid: control_ref) + + with patch('database.read_boundary.record_fallback') as fallback: + with pytest.raises(task_control_db.DevelopmentSmokeFixtureConflictError, match='differing state'): + task_control_db.ensure_development_smoke_fixture(task_control_db.WHAT_MATTERS_NOW_SMOKE_UID, stage='dev') + + fallback.assert_called_once() + assert control_ref.payload == malformed_control + + def test_fixture_setup_fails_closed_without_a_development_runtime(monkeypatch): monkeypatch.setattr( task_control_db, diff --git a/backend/utils/task_intelligence/ARCHITECTURE.md b/backend/utils/task_intelligence/ARCHITECTURE.md new file mode 100644 index 00000000000..b497587df85 --- /dev/null +++ b/backend/utils/task_intelligence/ARCHITECTURE.md @@ -0,0 +1,59 @@ +# Task intelligence architecture map + +This package owns task-intelligence policy and orchestration. HTTP routes live +in `backend/routers/`; durable records, leases, and transaction boundaries live +in `backend/database/`; public request and stored-record contracts live in +`backend/models/`. Callers must resolve the user’s server-owned rollout before +performing feature work and must not substitute a client claim or a default-on +fallback. + +## Rollout and cohort authority + +`chat_first_eligibility.py` loads the persisted task-workflow control and is +the reusable, fail-closed authority for Chat-first ingress. It passes that +control's workflow mode and account generation to `rollout.py`, which resolves +canonical-memory cohort membership; the derived capability is enabled only for +the canonical read-mode task-intelligence cohort with the explicit UI flag on. +A control read, rollout, or memory-system error disables the feature. The +returned generation is part of the capability fence, so Chat-first stores, +providers, metrics, and intent creation must be downstream of that decision; +they must not substitute a client claim or cached enablement. + +## Capture and candidate lifecycle + +- `capture_policy.py` is the pure confidence and ownership policy used by every + capture adapter. +- `backend_capture.py` adapts backend payloads into that policy; `conversation_capture.py` + owns the legacy conversation extraction/reconciliation boundary. +- `candidate_service.py` owns candidate acceptance, rejection, expiry, and the + post-commit task-integration handoff. `staged_migration.py` migrates only the + legacy staged-task representation through that lifecycle. +- `task_links.py`, `workstream_association.py`, and `workstream_index.py` bind + validated tasks to canonical goals and workstreams. They may read resolvers + owned by the database layer but must not become alternate persistence owners. + +## Recommendations and proactive Chat-first behavior + +`recommendations.py` produces deterministic task/recommendation snapshots and +dedupe keys. `live_recommendation_judgment.py` is the injectable structured +LLM-judgment seam; its output is constrained by the deterministic snapshot. + +`proactive_engine.py` owns the eligibility- and generation-fenced proactive +intent paths. Its agent tier converts post-commit wake triggers into a +deterministic shortlist, then uses the injectable judge; the empty judge is the +safe default. Ordinary task completion never creates a follow-up by itself; a +meaningful, judged trigger may. Its closed deterministic tier persists +capture-arrival and daily-opener intents, and releases due deferrals before +agent judgment. A separate generation-bound cold-start path persists its +deterministic first-run intent. These functions persist intents only; the +desktop kernel remains the sole owner that materializes a visible Chat row. +`fixture_runner.py` provides deterministic fixture adapters for those policies +and must never be bound as production judgment. + +## Contract changes + +`contracts.py` validates the task-intelligence contract and writer manifests. +When adding a feature-specific writer or adapter, update its manifest/fixture +and tests in the same change. Keep raw user content out of rollout diagnostics, +intent metrics, and fixtures; feature-disabled paths must be inert before any +feature store or provider is touched. diff --git a/backend/utils/task_intelligence/chat_first_e2e_fixture.py b/backend/utils/task_intelligence/chat_first_e2e_fixture.py new file mode 100644 index 00000000000..9754f05587d --- /dev/null +++ b/backend/utils/task_intelligence/chat_first_e2e_fixture.py @@ -0,0 +1,503 @@ +"""Server-authoritative fixture state for the local Chat-first E2E bundle. + +This module deliberately does not alter the normal Chat-first routes. The +harness writes only two fixed local/offline accounts, uses the existing +canonical document builders and intent contracts, and returns no fixture text +or entity data. Its clock advance makes fixture deferrals due; production +materialization continues to read its normal wall clock. +""" + +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from typing import Any + +from config.chat_first_e2e_fixture import ( + CHAT_FIRST_E2E_ENABLED_PRINCIPAL, + CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL, + fixture_uid_for_principal, + is_chat_first_e2e_fixture_uid, + is_chat_first_e2e_harness_runtime, +) +from database._client import get_firestore_client +import database.action_items as action_items_db +import database.chat_first_intents as intents_db +import database.goals as goals_db +from models.chat_first import ( + ChatFirstSubject, + ColdStartSequence, + GoalLinkSpec, + ProactiveIntent, + QuestionCardSpec, + QuestionOption, + TaskCardSpec, +) +from models.chat_first_e2e import ( + ChatFirstE2EControlEndpointMode, + ChatFirstE2EExpectedShell, + ChatFirstE2EFixtureCase, + ChatFirstE2EFixtureSnapshot, +) +from models.goal import GoalStatus +from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode + +_STATE_COLLECTION = 'chat_first_e2e_harness' +_STATE_DOCUMENT = 'state' +_GOAL_ID = 'chat-first-e2e-goal-v1' +_SECONDARY_GOAL_ID = 'chat-first-e2e-secondary-goal-v1' +_TASK_ID = 'chat-first-e2e-task-v1' +_CAPTURE_ID = 'chat-first-e2e-capture-v1' +_QUESTION_CONTINUITY_KEY = 'chat-first-e2e-question-v1' +_COLD_START_CONTINUITY_KEY = 'cold-start:1' + + +class ChatFirstE2EFixtureUnavailable(RuntimeError): + """The fixture route is unavailable outside its local/offline boundary.""" + + +class ChatFirstE2EFixtureIdentityError(RuntimeError): + """An authenticated account is not one of the isolated fixture users.""" + + +class ChatFirstE2EFixtureNotPrepared(RuntimeError): + """An advance/snapshot request arrived before a fixture prepare call.""" + + +def _require_harness(uid: str) -> None: + if not is_chat_first_e2e_harness_runtime(): + raise ChatFirstE2EFixtureUnavailable('chat-first E2E harness is unavailable') + if not is_chat_first_e2e_fixture_uid(uid): + raise ChatFirstE2EFixtureIdentityError('chat-first E2E fixture account is required') + + +def fixture_uid_for_case(fixture_case: ChatFirstE2EFixtureCase) -> str | None: + """Resolve a case to the only account it may mutate.""" + + if fixture_case is ChatFirstE2EFixtureCase.out_of_cohort: + return fixture_uid_for_principal(CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL) + return fixture_uid_for_principal(CHAT_FIRST_E2E_ENABLED_PRINCIPAL) + + +def _state_ref(uid: str, *, firestore_client: Any): + return firestore_client.collection('users').document(uid).collection(_STATE_COLLECTION).document(_STATE_DOCUMENT) + + +def _user_ref(uid: str, *, firestore_client: Any): + return firestore_client.collection('users').document(uid) + + +def _entity_refs(uid: str, *, firestore_client: Any) -> dict[str, Any]: + user_ref = _user_ref(uid, firestore_client=firestore_client) + question_intent_id = intents_db._stable_id('cfi', uid, 1, 'deferral_reraise', _QUESTION_CONTINUITY_KEY) + cold_start_intent_id = intents_db._stable_id('cfi', uid, 1, 'cold_start', _COLD_START_CONTINUITY_KEY) + daily_opener_intent_id = intents_db._stable_id('cfi', uid, 1, 'daily_opener', 'daily:chat-first-e2e') + question_deferral_id = intents_db._stable_id('cfd', uid, 1, _QUESTION_CONTINUITY_KEY) + return { + 'control': user_ref.collection('task_intelligence_control').document('state'), + 'goal': user_ref.collection('goals').document(_GOAL_ID), + 'secondary_goal': user_ref.collection('goals').document(_SECONDARY_GOAL_ID), + 'task': user_ref.collection('action_items').document(_TASK_ID), + 'capture': user_ref.collection('conversations').document(_CAPTURE_ID), + 'question_intent': user_ref.collection(intents_db.INTENTS_COLLECTION).document(question_intent_id), + 'cold_start_intent': user_ref.collection(intents_db.INTENTS_COLLECTION).document(cold_start_intent_id), + 'daily_opener_intent': user_ref.collection(intents_db.INTENTS_COLLECTION).document(daily_opener_intent_id), + 'question_deferral': user_ref.collection(intents_db.DEFERRALS_COLLECTION).document(question_deferral_id), + 'budget': user_ref.collection(intents_db.STATE_COLLECTION).document(intents_db.BUDGET_DOCUMENT), + 'state': _state_ref(uid, firestore_client=firestore_client), + } + + +def _existing_feature_refs(uid: str, *, firestore_client: Any) -> list[Any]: + """List only the fixture account's replaceable proactive rows before reset.""" + + user_ref = _user_ref(uid, firestore_client=firestore_client) + refs: list[Any] = [] + for collection_name in (intents_db.INTENTS_COLLECTION, intents_db.DEFERRALS_COLLECTION): + refs.extend(document.reference for document in user_ref.collection(collection_name).stream()) + return refs + + +def _unique_document_refs(refs: list[Any]) -> list[Any]: + """Avoid sending duplicate deletes for deterministic rows in one commit.""" + + unique: list[Any] = [] + seen_paths: set[str] = set() + for ref in refs: + path = getattr(ref, 'path', None) + if not isinstance(path, str): + # Firestore document references expose ``path``. The fallback + # keeps the helper usable with minimal hermetic fakes. + unique.append(ref) + continue + if path not in seen_paths: + seen_paths.add(path) + unique.append(ref) + return unique + + +def _expected_shell(fixture_case: ChatFirstE2EFixtureCase) -> ChatFirstE2EExpectedShell: + if fixture_case in { + ChatFirstE2EFixtureCase.enabled, + ChatFirstE2EFixtureCase.question, + ChatFirstE2EFixtureCase.cold_start, + }: + return ChatFirstE2EExpectedShell.chat_first + return ChatFirstE2EExpectedShell.legacy + + +def _control_endpoint_mode(fixture_case: ChatFirstE2EFixtureCase) -> ChatFirstE2EControlEndpointMode: + if fixture_case is ChatFirstE2EFixtureCase.unreachable_control: + return ChatFirstE2EControlEndpointMode.unreachable + return ChatFirstE2EControlEndpointMode.reachable + + +def _control_for_case(fixture_case: ChatFirstE2EFixtureCase) -> TaskWorkflowControl: + return TaskWorkflowControl( + workflow_mode=TaskWorkflowMode.read, + account_generation=1, + chat_first_ui_enabled=fixture_case is not ChatFirstE2EFixtureCase.ui_flag_off, + ) + + +def _goal_payload(*, goal_id: str, title: str, focused: bool, now: datetime) -> dict[str, Any]: + payload = goals_db._new_goal_payload( + { + 'goal_id': goal_id, + 'title': title, + 'desired_outcome': 'Validate the Chat-first fixture loop', + 'status': GoalStatus.background.value, + 'source': 'user', + }, + goal_id=goal_id, + now=now, + account_generation=1, + ) + payload.update( + { + 'status': GoalStatus.focused.value if focused else GoalStatus.background.value, + 'focus_rank': 0 if focused else None, + 'is_active': True, + } + ) + return payload + + +def _task_payload(*, now: datetime) -> dict[str, Any]: + return action_items_db._prepare_action_item_for_write( + { + 'id': _TASK_ID, + 'description': 'E2E fixture task', + 'goal_id': _GOAL_ID, + 'completed': False, + 'owner': 'user', + # This is deliberately the one task provenance which the cohort + # archive can resolve. The resulting task-card badge exercises + # the production fail-closed capture-link policy instead of + # merely asserting its helper against a synthetic Swift value. + 'source': 'transcription:omi', + 'conversation_id': _CAPTURE_ID, + 'provenance': [], + 'sort_order': 0, + 'indent_level': 0, + 'created_at': now, + 'updated_at': now, + 'account_generation': 1, + } + ) + + +def _capture_payload(*, now: datetime) -> dict[str, Any]: + """The smallest persisted Omi capture shape accepted by the existing archive.""" + + return { + 'id': _CAPTURE_ID, + 'source': 'omi', + 'status': 'completed', + 'discarded': False, + 'created_at': now, + 'started_at': now - timedelta(minutes=20), + 'finished_at': now - timedelta(minutes=5), + 'structured': {'title': 'E2E fixture capture'}, + 'transcript_segments': [], + 'data_protection_level': 'standard', + } + + +def _question() -> QuestionCardSpec: + return QuestionCardSpec( + type='questionCard', + question_id='chat-first-e2e-question-v1', + text='Which fixture path should continue?', + subject=ChatFirstSubject(kind='goal', id=_GOAL_ID), + options=[ + QuestionOption(option_id='continue', label='Continue', prepared_answer='Continue'), + QuestionOption(option_id='later', label='Ask me later', prepared_answer='Ask me later', defer=True), + ], + ) + + +def _question_intent(uid: str, *, now: datetime) -> ProactiveIntent: + return ProactiveIntent( + intent_id=intents_db._stable_id('cfi', uid, 1, 'deferral_reraise', _QUESTION_CONTINUITY_KEY), + continuity_key=_QUESTION_CONTINUITY_KEY, + account_generation=1, + source='deferral_reraise', + subject=ChatFirstSubject(kind='goal', id=_GOAL_ID), + blocks=[_question()], + created_at=now, + ) + + +def _cold_start_intent(uid: str, *, now: datetime) -> ProactiveIntent: + sequence_id = _COLD_START_CONTINUITY_KEY + question = QuestionCardSpec( + type='questionCard', + question_id='chat-first-e2e-cold-start-question-v1', + text='What should Omi help with first?', + subject=ChatFirstSubject(kind='cold_start', id=sequence_id), + options=[QuestionOption(option_id='start', label='Start', prepared_answer='Start')], + cold_start_sequence=ColdStartSequence(sequence_id=sequence_id, step=1), + ) + return ProactiveIntent( + intent_id=intents_db._stable_id('cfi', uid, 1, 'cold_start', sequence_id), + continuity_key=sequence_id, + account_generation=1, + source='cold_start_sparse', + subject=question.subject, + blocks=[question], + delivery_state='pending_kernel_receipt', + created_at=now, + ) + + +def _daily_opener_intent(uid: str, *, now: datetime) -> ProactiveIntent: + """Build the same deterministic rich opener shape the normal route uses. + + The fixture owns only canonical entities and a server-owned intent; the + mounted desktop still materializes this row through its ordinary kernel + receipt path. This gives the cohesive flow a real Goal link and task card + without inserting a desktop-local card. + """ + + continuity_key = 'daily:chat-first-e2e' + return ProactiveIntent( + intent_id=intents_db._stable_id('cfi', uid, 1, 'daily_opener', continuity_key), + continuity_key=continuity_key, + account_generation=1, + source='daily_opener', + subject=ChatFirstSubject(kind='goal', id=_GOAL_ID), + blocks=[ + GoalLinkSpec(type='goalLink', goal_id=_GOAL_ID, summary='E2E fixture goal'), + TaskCardSpec(type='taskCard', task_id=_TASK_ID), + ], + created_at=now, + ) + + +def _completed_rich_cold_start_intent(uid: str, *, now: datetime) -> ProactiveIntent: + """Record the focused fixture's already-completed first-run exchange. + + The question/deferral flow starts after ordinary first-run materialization + has finished. Persisting the same deterministic cold-start row that the + real path would have delivered prevents an unrelated opener from taking + the Chat tail, while the question itself still uses the normal server + materialization and kernel-owned deferral path. + """ + + sequence_id = _COLD_START_CONTINUITY_KEY + return ProactiveIntent( + intent_id=intents_db._stable_id('cfi', uid, 1, 'cold_start', sequence_id), + continuity_key=sequence_id, + account_generation=1, + source='cold_start_rich', + subject=ChatFirstSubject(kind='goal', id=_GOAL_ID), + blocks=[ + GoalLinkSpec(type='goalLink', goal_id=_GOAL_ID, summary='E2E fixture goal'), + TaskCardSpec(type='taskCard', task_id=_TASK_ID), + ], + delivery_state='delivered', + created_at=now - timedelta(seconds=1), + delivered_at=now, + materialization_receipt_id='chat-first-e2e-completed-cold-start-v1', + ) + + +def _snapshot_from_rows( + uid: str, + *, + firestore_client: Any, + prepared_state: dict[str, Any] | None = None, +) -> ChatFirstE2EFixtureSnapshot: + refs = _entity_refs(uid, firestore_client=firestore_client) + state = prepared_state if prepared_state is not None else refs['state'].get().to_dict() + if not isinstance(state, dict): + raise ChatFirstE2EFixtureNotPrepared('chat-first E2E fixture is not prepared') + try: + fixture_case = ChatFirstE2EFixtureCase(state['fixture_case']) + fixture_revision = int(state['fixture_revision']) + advanced_seconds = int(state.get('advanced_seconds', 0)) + except (KeyError, TypeError, ValueError) as exc: + raise ChatFirstE2EFixtureNotPrepared('chat-first E2E fixture state is invalid') from exc + + intents = [] + for document in ( + _user_ref(uid, firestore_client=firestore_client).collection(intents_db.INTENTS_COLLECTION).stream() + ): + data = document.to_dict() + if isinstance(data, dict) and data.get('account_generation') == 1: + intents.append(ProactiveIntent.model_validate(data)) + pending_deferral_count = 0 + for document in ( + _user_ref(uid, firestore_client=firestore_client).collection(intents_db.DEFERRALS_COLLECTION).stream() + ): + data = document.to_dict() + if isinstance(data, dict) and data.get('account_generation') == 1 and data.get('state') == 'pending': + pending_deferral_count += 1 + return ChatFirstE2EFixtureSnapshot( + fixture_case=fixture_case, + fixture_revision=fixture_revision, + expected_shell=_expected_shell(fixture_case), + control_endpoint_mode=_control_endpoint_mode(fixture_case), + advanced_seconds=advanced_seconds, + materialized_intent_count=sum(intent.delivery_state == 'delivered' for intent in intents), + ready_intent_count=sum(intent.delivery_state in {'ready', 'pending_kernel_receipt'} for intent in intents), + proactive_intent_count=len(intents), + pending_deferral_count=pending_deferral_count, + ) + + +def prepare_fixture( + uid: str, + *, + fixture_case: ChatFirstE2EFixtureCase, + firestore_client: Any = None, +) -> ChatFirstE2EFixtureSnapshot: + """Atomically reset the fixed fixture rows and write one coherent scenario.""" + + _require_harness(uid) + expected_uid = fixture_uid_for_case(fixture_case) + if expected_uid is None or uid != expected_uid: + raise ChatFirstE2EFixtureIdentityError('fixture case must use its isolated account') + client = firestore_client or get_firestore_client() + refs = _entity_refs(uid, firestore_client=client) + now = datetime.now(timezone.utc) + prior_feature_refs = _existing_feature_refs(uid, firestore_client=client) + transaction = client.transaction() + state_snapshot = refs['state'].get(transaction=transaction) + existing_state = state_snapshot.to_dict() if state_snapshot.exists else {} + revision = int(existing_state.get('fixture_revision', 0)) + 1 if isinstance(existing_state, dict) else 1 + + # All fixture-owned surfaces are deterministic document IDs. The same + # transaction removes their prior contents before exposing the next case. + reset_refs = prior_feature_refs + [ + refs[ref_name] + for ref_name in ('question_intent', 'cold_start_intent', 'daily_opener_intent', 'question_deferral', 'budget') + ] + for ref in _unique_document_refs(reset_refs): + transaction.delete(ref) + transaction.set(refs['control'], _control_for_case(fixture_case).persisted_payload()) + transaction.set( + refs['goal'], + _goal_payload(goal_id=_GOAL_ID, title='E2E fixture goal', focused=True, now=now), + ) + transaction.set( + refs['secondary_goal'], + _goal_payload(goal_id=_SECONDARY_GOAL_ID, title='E2E fixture next goal', focused=False, now=now), + ) + transaction.set(refs['task'], _task_payload(now=now)) + transaction.set(refs['capture'], _capture_payload(now=now)) + if fixture_case is ChatFirstE2EFixtureCase.cold_start: + transaction.set(refs['cold_start_intent'], _cold_start_intent(uid, now=now).model_dump(mode='python')) + elif fixture_case is ChatFirstE2EFixtureCase.question: + transaction.set( + refs['cold_start_intent'], + _completed_rich_cold_start_intent(uid, now=now).model_dump(mode='python'), + ) + transaction.set(refs['question_intent'], _question_intent(uid, now=now).model_dump(mode='python')) + elif fixture_case is ChatFirstE2EFixtureCase.enabled: + transaction.set( + refs['daily_opener_intent'], + _daily_opener_intent(uid, now=now).model_dump(mode='python'), + ) + elif fixture_case is ChatFirstE2EFixtureCase.unreachable_control: + transaction.set(refs['question_intent'], _question_intent(uid, now=now).model_dump(mode='python')) + state = { + 'fixture_case': fixture_case.value, + 'fixture_revision': revision, + 'advanced_seconds': 0, + 'prepared_at': now, + } + transaction.set(refs['state'], state) + transaction.commit() + return _snapshot_from_rows(uid, firestore_client=client, prepared_state=state) + + +def advance_fixture_clock( + uid: str, + *, + seconds: int, + firestore_client: Any = None, +) -> ChatFirstE2EFixtureSnapshot: + """Advance fixture deferrals without adding a clock branch to normal Chat. + + The desktop still calls the real materialization endpoint. This harness + operation only moves its own pending deferrals to immediately due, so the + production wall-clock code releases them through the existing store. + """ + + _require_harness(uid) + client = firestore_client or get_firestore_client() + refs = _entity_refs(uid, firestore_client=client) + state_snapshot = refs['state'].get() + state = state_snapshot.to_dict() if state_snapshot.exists else None + if not isinstance(state, dict): + raise ChatFirstE2EFixtureNotPrepared('chat-first E2E fixture is not prepared') + now = datetime.now(timezone.utc) + pending_refs = [] + for document in _user_ref(uid, firestore_client=client).collection(intents_db.DEFERRALS_COLLECTION).stream(): + data = document.to_dict() + if isinstance(data, dict) and data.get('account_generation') == 1 and data.get('state') == 'pending': + pending_refs.append(document.reference) + transaction = client.transaction() + for ref in pending_refs: + transaction.update(ref, {'due_at': now - timedelta(seconds=1)}) + advanced_state = deepcopy(state) + advanced_state['advanced_seconds'] = int(advanced_state.get('advanced_seconds', 0)) + seconds + transaction.set(refs['state'], advanced_state) + transaction.commit() + return _snapshot_from_rows(uid, firestore_client=client, prepared_state=advanced_state) + + +def snapshot_fixture(uid: str, *, firestore_client: Any = None) -> ChatFirstE2EFixtureSnapshot: + """Read the harness's bounded outcomes without exposing fixture content.""" + + _require_harness(uid) + client = firestore_client or get_firestore_client() + return _snapshot_from_rows(uid, firestore_client=client) + + +def is_control_unreachable(uid: str, *, firestore_client: Any = None) -> bool: + """Return whether the real control route must simulate a local outage. + + This is intentionally an input to the normal control endpoint only for a + prepared fixture account in a local/offline process. It is not a derived + capability and it cannot be reached in any deployable server route table. + """ + + if not is_chat_first_e2e_fixture_uid(uid): + return False + client = firestore_client or get_firestore_client() + snapshot = _state_ref(uid, firestore_client=client).get() + state = snapshot.to_dict() if snapshot.exists else None + return isinstance(state, dict) and state.get('fixture_case') == ChatFirstE2EFixtureCase.unreachable_control.value + + +__all__ = [ + 'ChatFirstE2EFixtureIdentityError', + 'ChatFirstE2EFixtureNotPrepared', + 'ChatFirstE2EFixtureUnavailable', + 'advance_fixture_clock', + 'fixture_uid_for_case', + 'is_control_unreachable', + 'prepare_fixture', + 'snapshot_fixture', +] diff --git a/backend/utils/task_intelligence/rollout.py b/backend/utils/task_intelligence/rollout.py index 0b51c21722d..19dcac5be00 100644 --- a/backend/utils/task_intelligence/rollout.py +++ b/backend/utils/task_intelligence/rollout.py @@ -8,6 +8,7 @@ # LIFECYCLE: permanent from config.what_matters_now_smoke_fixture import is_development_smoke_fixture +from config.chat_first_e2e_fixture import is_chat_first_e2e_enabled_fixture from models.task_intelligence import TaskIntelligenceRolloutDecision, TaskWorkflowMode from utils.memory.memory_system import MemorySystem, resolve_memory_system @@ -66,8 +67,10 @@ def resolve_task_intelligence_for_user( ) -> TaskIntelligenceRolloutDecision: """Compose workflow mode with the authoritative canonical-memory selector.""" - memory_cohort_eligible = is_development_smoke_fixture(uid) or ( - resolve_memory_system(uid, db_client=db_client) == MemorySystem.CANONICAL + memory_cohort_eligible = ( + is_development_smoke_fixture(uid) + or is_chat_first_e2e_enabled_fixture(uid) + or (resolve_memory_system(uid, db_client=db_client) == MemorySystem.CANONICAL) ) return resolve_task_intelligence_rollout( uid=uid, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentBridge+ChatFirst.swift b/desktop/macos/Desktop/Sources/Chat/AgentBridge+ChatFirst.swift new file mode 100644 index 00000000000..cc1739f0d5f --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/AgentBridge+ChatFirst.swift @@ -0,0 +1,142 @@ +import Foundation + +/// The cohort-only projection and journal façade. Keeping it separate makes +/// the capability boundary visible without creating a second bridge or +/// transcript owner. +extension AgentBridge { + func resolveSurfaceSession( + _ surface: AgentSurfaceReference, + title: String? = nil, + creationProfile: AgentSessionCreationProfile? = nil, + chatFirstCapability: ChatFirstCapabilityProjection? = nil, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil + ) async throws -> AgentSurfaceSession { + let authorization = try resolveAuthorization(authorizationSnapshot) + try await start(authorizationSnapshot: authorization) + guard RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) else { + throw BridgeError.authMissing + } + return try await runtime.resolveSurfaceSession( + clientId: clientId, + surface: surface, + title: title, + creationProfile: creationProfile, + chatFirstCapability: chatFirstCapability, + authorizationSnapshot: authorization + ) + } + + func recordQuestionInteractionReply( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + questionID: String, + optionID: String, + controlGeneration: Int, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil + ) async throws -> AgentRuntimeProcess.QuestionInteractionReply { + let authorization = try resolveAuthorization( + authorizationSnapshot, + expectedOwnerID: ownerID) + try await start(authorizationSnapshot: authorization) + return try await runtime.recordQuestionInteractionReply( + clientId: clientId, + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + questionID: questionID, + optionID: optionID, + controlGeneration: controlGeneration, + authorizationSnapshot: authorization + ) + } + + func materializeChatFirstIntents( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + intents: [ChatFirstPromptIntent], + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil + ) async throws -> AgentRuntimeProcess.ChatFirstIntentsMaterialization { + let authorization = try resolveAuthorization( + authorizationSnapshot, + expectedOwnerID: ownerID) + try await start(authorizationSnapshot: authorization) + return try await runtime.materializeChatFirstIntents( + clientId: clientId, + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration, + intents: intents, + authorizationSnapshot: authorization + ) + } + + func listChatFirstMaterializationReceipts( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil + ) async throws -> ChatFirstPromptReceiptBatch { + let authorization = try resolveAuthorization( + authorizationSnapshot, + expectedOwnerID: ownerID) + try await start(authorizationSnapshot: authorization) + return try await runtime.listChatFirstMaterializationReceipts( + clientId: clientId, + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration, + authorizationSnapshot: authorization + ) + } + + @discardableResult + func acknowledgeChatFirstMaterializationReceipts( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + receipts: ChatFirstPromptReceiptBatch, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil + ) async throws -> Int { + let authorization = try resolveAuthorization( + authorizationSnapshot, + expectedOwnerID: ownerID) + try await start(authorizationSnapshot: authorization) + return try await runtime.acknowledgeChatFirstMaterializationReceipts( + clientId: clientId, + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration, + receipts: receipts, + authorizationSnapshot: authorization + ) + } + + func invokeChatFirstFixtureTaskCard( + ownerID: String, + sessionID: String, + producingTurnID: String, + controlGeneration: Int, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil + ) async throws -> AgentRuntimeProcess.ChatFirstHarnessExecutorReceipt { + let authorization = try resolveAuthorization( + authorizationSnapshot, + expectedOwnerID: ownerID) + try await start(authorizationSnapshot: authorization) + return try await runtime.invokeChatFirstFixtureTaskCard( + clientId: clientId, + ownerID: ownerID, + sessionID: sessionID, + producingTurnID: producingTurnID, + controlGeneration: controlGeneration, + authorizationSnapshot: authorization + ) + } +} diff --git a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift index 6ee85cae605..d561a0d10a7 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift @@ -707,8 +707,8 @@ actor AgentBridge { let harnessMode: String - private let clientId = UUID().uuidString - private let runtime: AgentRuntimeProcess + let clientId = UUID().uuidString + let runtime: AgentRuntimeProcess private var registered = false private var synchronizedRuntimeAuthorityEpoch: UInt64? private var synchronizedRuntimeAuthorityOwnerID: String? @@ -750,7 +750,7 @@ actor AgentBridge { return snapshot } - private func resolveAuthorization( + func resolveAuthorization( _ supplied: RuntimeOwnerAuthorizationSnapshot?, expectedOwnerID: String? = nil ) throws -> RuntimeOwnerAuthorizationSnapshot { @@ -809,7 +809,7 @@ actor AgentBridge { try await start(authorizationSnapshot: authorizationSnapshot) } - private func start( + func start( authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot ) async throws { try await runLifecycleOperation( @@ -1161,28 +1161,6 @@ actor AgentBridge { ) } - func resolveSurfaceSession( - _ surface: AgentSurfaceReference, - title: String? = nil, - creationProfile: AgentSessionCreationProfile? = nil, - chatFirstCapability: ChatFirstCapabilityProjection? = nil, - authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil - ) async throws -> AgentSurfaceSession { - let authorization = try resolveAuthorization(authorizationSnapshot) - try await start(authorizationSnapshot: authorization) - guard RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) else { - throw BridgeError.authMissing - } - return try await runtime.resolveSurfaceSession( - clientId: clientId, - surface: surface, - title: title, - creationProfile: creationProfile, - chatFirstCapability: chatFirstCapability, - authorizationSnapshot: authorization - ) - } - func migrateSessionExecutionProfile( sessionId: String, expectedProfileGeneration: Int, @@ -1362,99 +1340,6 @@ actor AgentBridge { ) } - func recordQuestionInteractionReply( - surface: AgentSurfaceReference, - ownerID: String, - sessionID: String, - questionID: String, - optionID: String, - controlGeneration: Int, - authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil - ) async throws -> AgentRuntimeProcess.QuestionInteractionReply { - let authorization = try resolveAuthorization( - authorizationSnapshot, - expectedOwnerID: ownerID) - try await start(authorizationSnapshot: authorization) - return try await runtime.recordQuestionInteractionReply( - clientId: clientId, - surface: surface, - ownerID: ownerID, - sessionID: sessionID, - questionID: questionID, - optionID: optionID, - controlGeneration: controlGeneration, - authorizationSnapshot: authorization - ) - } - - func materializeChatFirstIntents( - surface: AgentSurfaceReference, - ownerID: String, - sessionID: String, - controlGeneration: Int, - intents: [ChatFirstPromptIntent], - authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil - ) async throws -> AgentRuntimeProcess.ChatFirstIntentsMaterialization { - let authorization = try resolveAuthorization( - authorizationSnapshot, - expectedOwnerID: ownerID) - try await start(authorizationSnapshot: authorization) - return try await runtime.materializeChatFirstIntents( - clientId: clientId, - surface: surface, - ownerID: ownerID, - sessionID: sessionID, - controlGeneration: controlGeneration, - intents: intents, - authorizationSnapshot: authorization - ) - } - - func listChatFirstMaterializationReceipts( - surface: AgentSurfaceReference, - ownerID: String, - sessionID: String, - controlGeneration: Int, - authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil - ) async throws -> ChatFirstPromptReceiptBatch { - let authorization = try resolveAuthorization( - authorizationSnapshot, - expectedOwnerID: ownerID) - try await start(authorizationSnapshot: authorization) - return try await runtime.listChatFirstMaterializationReceipts( - clientId: clientId, - surface: surface, - ownerID: ownerID, - sessionID: sessionID, - controlGeneration: controlGeneration, - authorizationSnapshot: authorization - ) - } - - @discardableResult - func acknowledgeChatFirstMaterializationReceipts( - surface: AgentSurfaceReference, - ownerID: String, - sessionID: String, - controlGeneration: Int, - receipts: ChatFirstPromptReceiptBatch, - authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil - ) async throws -> Int { - let authorization = try resolveAuthorization( - authorizationSnapshot, - expectedOwnerID: ownerID) - try await start(authorizationSnapshot: authorization) - return try await runtime.acknowledgeChatFirstMaterializationReceipts( - clientId: clientId, - surface: surface, - ownerID: ownerID, - sessionID: sessionID, - controlGeneration: controlGeneration, - receipts: receipts, - authorizationSnapshot: authorization - ) - } - func updateJournalTurn( surface: AgentSurfaceReference, ownerID: String? = nil, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift index 3988f613d92..1aac60c27c6 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift @@ -306,6 +306,20 @@ enum AgentClient { ) } + func invokeChatFirstFixtureTaskCard( + ownerID: String, + sessionID: String, + producingTurnID: String, + controlGeneration: Int + ) async throws -> AgentRuntimeProcess.ChatFirstHarnessExecutorReceipt { + try await bridge.invokeChatFirstFixtureTaskCard( + ownerID: ownerID, + sessionID: sessionID, + producingTurnID: producingTurnID, + controlGeneration: controlGeneration + ) + } + func updateJournalTurn( surface: AgentSurfaceReference, ownerID: String? = nil, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess+ChatFirstJournal.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess+ChatFirstJournal.swift new file mode 100644 index 00000000000..defc25afec6 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess+ChatFirstJournal.swift @@ -0,0 +1,399 @@ +import Foundation + +/// Capability-scoped main-Chat journal operations. The kernel remains the sole +/// journal writer; this extension validates the Swift boundary and projects the +/// kernel receipts through the existing runtime actor. +extension AgentRuntimeProcess { + struct JournalOperationResult: Sendable { + let operation: String + let conversationId: String + let turn: KernelJournalTurn? + let turns: [KernelJournalTurn] + let clearedCount: Int + let highWaterTurnSeq: Int + let conversationGeneration: Int + let generationBaseTurnSeq: Int + let accepted: Bool? + let duplicate: Bool? + let continuityKey: String? + let suppressedByTailQuestion: Bool + let suppressedByStreamingTail: Bool + let materializationStoppedByTail: Bool + let materializationReceipts: [ChatFirstMaterializationReceipt] + let coldStartSequenceTerminalReceipts: [ChatFirstColdStartSequenceTerminalReceipt] + let acknowledgedReceiptCount: Int + + init( + operation: String, + conversationId: String, + turn: KernelJournalTurn?, + turns: [KernelJournalTurn], + clearedCount: Int, + highWaterTurnSeq: Int, + conversationGeneration: Int, + generationBaseTurnSeq: Int, + accepted: Bool? = nil, + duplicate: Bool? = nil, + continuityKey: String? = nil, + suppressedByTailQuestion: Bool = false, + suppressedByStreamingTail: Bool = false, + materializationStoppedByTail: Bool = false, + materializationReceipts: [ChatFirstMaterializationReceipt] = [], + coldStartSequenceTerminalReceipts: [ChatFirstColdStartSequenceTerminalReceipt] = [], + acknowledgedReceiptCount: Int = 0 + ) { + self.operation = operation + self.conversationId = conversationId + self.turn = turn + self.turns = turns + self.clearedCount = clearedCount + self.highWaterTurnSeq = highWaterTurnSeq + self.conversationGeneration = conversationGeneration + self.generationBaseTurnSeq = generationBaseTurnSeq + self.accepted = accepted + self.duplicate = duplicate + self.continuityKey = continuityKey + self.suppressedByTailQuestion = suppressedByTailQuestion + self.suppressedByStreamingTail = suppressedByStreamingTail + self.materializationStoppedByTail = materializationStoppedByTail + self.materializationReceipts = materializationReceipts + self.coldStartSequenceTerminalReceipts = coldStartSequenceTerminalReceipts + self.acknowledgedReceiptCount = acknowledgedReceiptCount + } + } + + struct QuestionInteractionReply: Sendable { + let accepted: Bool + let duplicate: Bool + let continuityKey: String + let parentTurn: KernelJournalTurn? + let userTurn: KernelJournalTurn + let assistantTurn: KernelJournalTurn + } + + struct ChatFirstIntentsMaterialization: Sendable { + let accepted: Bool + let stoppedByTail: Bool + let receipts: [ChatFirstMaterializationReceipt] + } + + /// Append server-validated structured blocks to exactly the assistant turn + /// produced by this capability's run/attempt. The Node kernel re-checks the + /// live capability and performs the sole journal mutation. + func appendChatFirstBlocks( + clientId: String, + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + runID: String, + attemptID: String, + capabilityRef: String, + controlGeneration: Int, + blocks: [[String: Any]], + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> KernelJournalTurn { + guard surface.surfaceKind == "main_chat", + controlGeneration >= 0, + !blocks.isEmpty, + blocks.count <= 8 + else { + throw BridgeError.agentError("Invalid chat-first journal append") + } + let result = try await journalOperation( + type: "append_chat_first_blocks", + operation: "append_chat_first_blocks", + clientId: clientId, + surface: surface, + ownerID: ownerID, + payload: [ + "sessionId": sessionID, + "runId": runID, + "attemptId": attemptID, + "capabilityRef": capabilityRef, + "controlGeneration": controlGeneration, + "blocks": blocks, + ], + authorizationSnapshot: authorizationSnapshot + ) + guard let turn = result.turn else { + throw BridgeError.agentError("Chat-first journal append returned no turn") + } + recordLifecycleJournalMutation(turn) + return turn + } + + /// The journal derives the stored question payload and only accepts the + /// current main-Chat tail. Swift cannot send an answer string or select an + /// arbitrary parent row through this operation. + func recordQuestionInteractionReply( + clientId: String, + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + questionID: String, + optionID: String, + controlGeneration: Int, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> QuestionInteractionReply { + guard surface.surfaceKind == "main_chat", + controlGeneration >= 0, + !sessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !questionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !optionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw BridgeError.agentError("Invalid question interaction") + } + let result = try await journalOperation( + type: "record_question_interaction_reply", + operation: "record_question_interaction_reply", + clientId: clientId, + surface: surface, + ownerID: ownerID, + payload: [ + "sessionId": sessionID, + "questionId": questionID, + "optionId": optionID, + "controlGeneration": controlGeneration, + ], + authorizationSnapshot: authorizationSnapshot + ) + guard result.accepted == true, + let continuityKey = result.continuityKey, + let userTurn = result.turns.first(where: { $0.role == "user" }), + let assistantTurn = result.turns.first(where: { $0.role == "assistant" }) + else { + throw BridgeError.agentError("Question is no longer actionable") + } + for turn in [result.turn, userTurn, assistantTurn] { + if let turn { recordLifecycleJournalMutation(turn) } + } + return QuestionInteractionReply( + accepted: true, + duplicate: result.duplicate == true, + continuityKey: continuityKey, + parentTurn: result.turn, + userTurn: userTurn, + assistantTurn: assistantTurn + ) + } + + /// Materialize one ordered server batch through the kernel, which owns the + /// canonical assistant rows, tail suppression, and receipt identities. + func materializeChatFirstIntents( + clientId: String, + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + intents: [ChatFirstPromptIntent], + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> ChatFirstIntentsMaterialization { + guard surface.surfaceKind == "main_chat", + controlGeneration >= 0, + !intents.isEmpty, + intents.count <= 8, + intents.allSatisfy({ $0.accountGeneration == controlGeneration && $0.kernelBlocks != nil }), + !sessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw BridgeError.agentError("Invalid chat-first materialization") + } + let result = try await journalOperation( + type: "materialize_chat_first_intents", + operation: "materialize_chat_first_intents", + clientId: clientId, + surface: surface, + ownerID: ownerID, + payload: [ + "sessionId": sessionID, + "controlGeneration": controlGeneration, + "intents": intents.compactMap { intent -> [String: Any]? in + guard let blocks = intent.kernelBlocks else { return nil } + return [ + "intentId": intent.intentID, + "continuityKey": intent.continuityKey, + "source": intent.source.rawValue, + "blocks": blocks, + ] as [String: Any] + }, + ], + authorizationSnapshot: authorizationSnapshot + ) + for turn in result.turns { + recordLifecycleJournalMutation(turn) + } + return ChatFirstIntentsMaterialization( + accepted: result.accepted == true, + stoppedByTail: result.materializationStoppedByTail, + receipts: result.materializationReceipts + ) + } + + func listChatFirstMaterializationReceipts( + clientId: String, + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> ChatFirstPromptReceiptBatch { + guard surface.surfaceKind == "main_chat", controlGeneration >= 0 else { + throw BridgeError.agentError("Invalid chat-first receipt listing") + } + let result = try await journalOperation( + type: "list_chat_first_materialization_receipts", + operation: "list_chat_first_materialization_receipts", + clientId: clientId, + surface: surface, + ownerID: ownerID, + payload: ["sessionId": sessionID, "controlGeneration": controlGeneration, "limit": 16], + authorizationSnapshot: authorizationSnapshot + ) + return ChatFirstPromptReceiptBatch( + materializationReceipts: result.materializationReceipts, + coldStartSequenceTerminalReceipts: result.coldStartSequenceTerminalReceipts + ) + } + + @discardableResult + func acknowledgeChatFirstMaterializationReceipts( + clientId: String, + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + receipts: ChatFirstPromptReceiptBatch, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> Int { + guard surface.surfaceKind == "main_chat", + controlGeneration >= 0, + receipts.materializationReceipts.count <= 16, + receipts.coldStartSequenceTerminalReceipts.count <= 16 + else { + throw BridgeError.agentError("Invalid chat-first receipt acknowledgement") + } + let result = try await journalOperation( + type: "acknowledge_chat_first_materialization_receipts", + operation: "acknowledge_chat_first_materialization_receipts", + clientId: clientId, + surface: surface, + ownerID: ownerID, + payload: [ + "sessionId": sessionID, + "controlGeneration": controlGeneration, + "receipts": receipts.materializationReceipts.map { + ["intentId": $0.intentID, "receiptId": $0.receiptID] + }, + "coldStartSequenceTerminalReceipts": receipts.coldStartSequenceTerminalReceipts.map { + [ + "sequenceId": $0.sequenceID, + "receiptId": $0.receiptID, + "terminalState": $0.terminalState.rawValue, + ] + }, + ], + authorizationSnapshot: authorizationSnapshot + ) + return result.acknowledgedReceiptCount + } + + nonisolated static func chatFirstMaterializationReceipts( + from payload: Any? + ) -> [ChatFirstMaterializationReceipt] { + guard let values = payload as? [[String: Any]] else { return [] } + return values.compactMap { value in + guard let intentID = value["intentId"] as? String, + !intentID.isEmpty, + let receiptID = value["receiptId"] as? String, + !receiptID.isEmpty + else { return nil } + return ChatFirstMaterializationReceipt(intentID: intentID, receiptID: receiptID) + } + } + + nonisolated static func chatFirstColdStartSequenceTerminalReceipts( + from payload: Any? + ) -> [ChatFirstColdStartSequenceTerminalReceipt] { + guard let values = payload as? [[String: Any]] else { return [] } + return values.compactMap { value in + guard let sequenceID = value["sequenceId"] as? String, + !sequenceID.isEmpty, + let receiptID = value["receiptId"] as? String, + !receiptID.isEmpty, + let rawState = value["terminalState"] as? String, + let terminalState = ChatFirstColdStartSequenceTerminalReceipt.TerminalState(rawValue: rawState) + else { return nil } + return ChatFirstColdStartSequenceTerminalReceipt( + sequenceID: sequenceID, + receiptID: receiptID, + terminalState: terminalState + ) + } + } + + func handleChatFirstDeferralDelivery(_ message: RuntimeMessage) { + guard let request = ChatFirstDeferralDeliveryRequest(payload: message.payload) else { + sendChatFirstDeferralDeliveryResult( + requestId: message.requestId, + clientId: message.clientId, + ownerID: message.payload["ownerId"] as? String, + continuityKey: message.payload["continuityKey"] as? String ?? "", + deliveryGeneration: message.payload["deliveryGeneration"] as? Int ?? 0, + payloadHash: message.payload["payloadHash"] as? String ?? "", + ok: false, + errorCode: "chat_first_deferral_malformed" + ) + return + } + Task { [weak self] in + do { + try await APIClient.shared.recordChatFirstDeferral(request) + await self?.sendChatFirstDeferralDeliveryResult( + requestId: message.requestId, + clientId: message.clientId, + ownerID: request.ownerID, + continuityKey: request.continuityKey, + deliveryGeneration: request.deliveryGeneration, + payloadHash: request.payloadHash, + ok: true, + errorCode: nil + ) + } catch { + await self?.sendChatFirstDeferralDeliveryResult( + requestId: message.requestId, + clientId: message.clientId, + ownerID: request.ownerID, + continuityKey: request.continuityKey, + deliveryGeneration: request.deliveryGeneration, + payloadHash: request.payloadHash, + ok: false, + errorCode: Self.boundedChatFirstDeferralErrorCode(for: error) + ) + } + } + } + + func sendChatFirstDeferralDeliveryResult( + requestId: String?, + clientId: String?, + ownerID: String?, + continuityKey: String, + deliveryGeneration: Int, + payloadHash: String, + ok: Bool, + errorCode: String? + ) { + var payload: [String: Any] = [ + "type": "chat_first_deferral_delivery_result", + "protocolVersion": 2, + "continuityKey": continuityKey, + "deliveryGeneration": deliveryGeneration, + "payloadHash": payloadHash, + "ok": ok, + ] + if let requestId { payload["requestId"] = requestId } + if let clientId { payload["clientId"] = clientId } + if let ownerID { payload["ownerId"] = ownerID } + if let errorCode { payload["errorCode"] = errorCode } + sendJson(payload) + } +} diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift index e11fcd36e44..392d4328f4e 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift @@ -244,6 +244,14 @@ final class DebugSuspendControl: @unchecked Sendable { } actor AgentRuntimeProcess { + /// Shape-only receipt from the local/offline E2E path that dispatches the + /// actual Chat-first block tool through the normal authorized-tool channel. + struct ChatFirstHarnessExecutorReceipt: Equatable, Sendable { + let executorInvoked: Bool + let validated: Bool + let journalBlockRendered: Bool + } + static let shared = AgentRuntimeProcess() nonisolated static let expectedProtocolVersion = 2 nonisolated static let requiredRuntimeCapabilities: Set = [ @@ -353,6 +361,7 @@ actor AgentRuntimeProcess { case externalSurfaceRunBeginResult case externalSurfaceToolResult case externalSurfaceRunCompleteResult + case chatFirstHarnessExecutorResult case ownerRuntimeRevoked case unknown(String) } @@ -414,6 +423,7 @@ actor AgentRuntimeProcess { case "external_surface_run_begin_result": return .externalSurfaceRunBeginResult case "external_surface_tool_result": return .externalSurfaceToolResult case "external_surface_run_complete_result": return .externalSurfaceRunCompleteResult + case "chat_first_harness_executor_result": return .chatFirstHarnessExecutorResult case "owner_runtime_revoked": return .ownerRuntimeRevoked default: return .unknown(type) } @@ -506,79 +516,6 @@ actor AgentRuntimeProcess { let timedOutAtUptime: TimeInterval } - struct JournalOperationResult: Sendable { - let operation: String - let conversationId: String - let turn: KernelJournalTurn? - let turns: [KernelJournalTurn] - let clearedCount: Int - let highWaterTurnSeq: Int - let conversationGeneration: Int - let generationBaseTurnSeq: Int - let accepted: Bool? - let duplicate: Bool? - let continuityKey: String? - let suppressedByTailQuestion: Bool - let suppressedByStreamingTail: Bool - let materializationStoppedByTail: Bool - let materializationReceipts: [ChatFirstMaterializationReceipt] - let coldStartSequenceTerminalReceipts: [ChatFirstColdStartSequenceTerminalReceipt] - let acknowledgedReceiptCount: Int - - init( - operation: String, - conversationId: String, - turn: KernelJournalTurn?, - turns: [KernelJournalTurn], - clearedCount: Int, - highWaterTurnSeq: Int, - conversationGeneration: Int, - generationBaseTurnSeq: Int, - accepted: Bool? = nil, - duplicate: Bool? = nil, - continuityKey: String? = nil, - suppressedByTailQuestion: Bool = false, - suppressedByStreamingTail: Bool = false, - materializationStoppedByTail: Bool = false, - materializationReceipts: [ChatFirstMaterializationReceipt] = [], - coldStartSequenceTerminalReceipts: [ChatFirstColdStartSequenceTerminalReceipt] = [], - acknowledgedReceiptCount: Int = 0 - ) { - self.operation = operation - self.conversationId = conversationId - self.turn = turn - self.turns = turns - self.clearedCount = clearedCount - self.highWaterTurnSeq = highWaterTurnSeq - self.conversationGeneration = conversationGeneration - self.generationBaseTurnSeq = generationBaseTurnSeq - self.accepted = accepted - self.duplicate = duplicate - self.continuityKey = continuityKey - self.suppressedByTailQuestion = suppressedByTailQuestion - self.suppressedByStreamingTail = suppressedByStreamingTail - self.materializationStoppedByTail = materializationStoppedByTail - self.materializationReceipts = materializationReceipts - self.coldStartSequenceTerminalReceipts = coldStartSequenceTerminalReceipts - self.acknowledgedReceiptCount = acknowledgedReceiptCount - } - } - - struct QuestionInteractionReply: Sendable { - let accepted: Bool - let duplicate: Bool - let continuityKey: String - let parentTurn: KernelJournalTurn? - let userTurn: KernelJournalTurn - let assistantTurn: KernelJournalTurn - } - - struct ChatFirstIntentsMaterialization: Sendable { - let accepted: Bool - let stoppedByTail: Bool - let receipts: [ChatFirstMaterializationReceipt] - } - typealias JournalTurnChangedHandler = @Sendable (KernelJournalTurn) -> Void typealias AuthorizedRealtimeToolHandler = @Sendable (AuthorizedToolExecution) async -> AuthorizedRealtimeToolExecutionResult @@ -1073,6 +1010,62 @@ actor AgentRuntimeProcess { return snapshot } + /// Local/offline-only E2E seam for the real Swift Chat-first block executor. + /// The caller supplies an already-resolved main-Chat session and immutable + /// server projection; Node derives and rechecks that projection from the + /// mounted session rather than accepting it from this message. + func invokeChatFirstFixtureTaskCard( + clientId: String, + ownerID: String, + sessionID: String, + producingTurnID: String, + controlGeneration: Int, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> ChatFirstHarnessExecutorReceipt { + let stage = ProcessInfo.processInfo.environment["OMI_ENV_STAGE"] + let isLocalOrOfflineStage = stage == "local" || stage == "offline" + guard AppBuild.allowsLocalAutomation, isLocalOrOfflineStage else { + throw BridgeError.agentError("Chat-first executor fixture is unavailable outside local/offline builds") + } + guard controlGeneration >= 0 else { + throw BridgeError.agentError("Chat-first executor fixture requires a valid control generation") + } + try assertAuthorization(authorizationSnapshot, expectedOwnerID: ownerID) + let requestID = UUID().uuidString + let result = try await kernelContractRequest( + payload: Self.chatFirstHarnessExecutorBeginWireMessage( + clientId: clientId, + requestId: requestID, + ownerID: ownerID, + sessionID: sessionID, + producingTurnID: producingTurnID, + controlGeneration: controlGeneration + ), + expectedKind: .chatFirstHarnessExecutorResult, + authorizationSnapshot: authorizationSnapshot + ) + guard + result["ownerId"] as? String == ownerID, + result["sessionId"] as? String == sessionID, + let runID = result["runId"] as? String, !runID.isEmpty, + let attemptID = result["attemptId"] as? String, !attemptID.isEmpty, + let ok = result["ok"] as? Bool, + let executorInvoked = result["executorInvoked"] as? Bool, + let validated = result["validated"] as? Bool, + let journalBlockRendered = result["journalBlockRendered"] as? Bool + else { + throw BridgeError.agentError("Kernel returned an invalid Chat-first executor fixture receipt") + } + guard ok == (executorInvoked && validated && journalBlockRendered) else { + throw BridgeError.agentError("Kernel returned an inconsistent Chat-first executor fixture receipt") + } + return ChatFirstHarnessExecutorReceipt( + executorInvoked: executorInvoked, + validated: validated, + journalBlockRendered: journalBlockRendered + ) + } + func setAuthorizedRealtimeToolHandler(_ handler: AuthorizedRealtimeToolHandler?) { authorizedRealtimeToolHandler = handler } @@ -1558,6 +1551,29 @@ actor AgentRuntimeProcess { return message } + static func chatFirstHarnessExecutorBeginWireMessage( + clientId: String, + requestId: String, + ownerID: String, + sessionID: String, + producingTurnID: String, + controlGeneration: Int + ) -> [String: Any] { + var message = protocolEnvelope( + type: "chat_first_harness_executor_begin", + clientId: clientId, + requestId: requestId, + ownerId: ownerID + ) + message["sessionId"] = sessionID + message["producingTurnId"] = producingTurnID + message["controlGeneration"] = controlGeneration + // This is deliberately the sole fixture payload accepted by Node. It is + // server-validated before the normal journal append can happen. + message["input"] = ["blocks": [["type": "taskCard", "taskId": "chat-first-e2e-task-v1"]]] + return message + } + static func importLegacyMainChatSessionsWireMessage( clientId: String, requestId: String, @@ -1987,226 +2003,10 @@ actor AgentRuntimeProcess { ).clearedCount } - /// Append server-validated structured blocks to exactly the assistant turn - /// produced by this capability's run/attempt. The Node kernel re-checks the - /// live capability and performs the sole journal mutation. - func appendChatFirstBlocks( - clientId: String, - surface: AgentSurfaceReference, - ownerID: String, - sessionID: String, - runID: String, - attemptID: String, - capabilityRef: String, - controlGeneration: Int, - blocks: [[String: Any]], - authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot - ) async throws -> KernelJournalTurn { - guard surface.surfaceKind == "main_chat", - controlGeneration >= 0, - !blocks.isEmpty, - blocks.count <= 8 - else { - throw BridgeError.agentError("Invalid chat-first journal append") - } - let result = try await journalOperation( - type: "append_chat_first_blocks", - operation: "append_chat_first_blocks", - clientId: clientId, - surface: surface, - ownerID: ownerID, - payload: [ - "sessionId": sessionID, - "runId": runID, - "attemptId": attemptID, - "capabilityRef": capabilityRef, - "controlGeneration": controlGeneration, - "blocks": blocks, - ], - authorizationSnapshot: authorizationSnapshot - ) - guard let turn = result.turn else { - throw BridgeError.agentError("Chat-first journal append returned no turn") - } - recordLifecycleJournalMutation(turn) - return turn - } - - /// The journal derives the stored question payload and only accepts the - /// current main-Chat tail. Swift cannot send an answer string or select an - /// arbitrary parent row through this operation. - func recordQuestionInteractionReply( - clientId: String, - surface: AgentSurfaceReference, - ownerID: String, - sessionID: String, - questionID: String, - optionID: String, - controlGeneration: Int, - authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot - ) async throws -> QuestionInteractionReply { - guard surface.surfaceKind == "main_chat", - controlGeneration >= 0, - !sessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !questionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !optionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw BridgeError.agentError("Invalid question interaction") - } - let result = try await journalOperation( - type: "record_question_interaction_reply", - operation: "record_question_interaction_reply", - clientId: clientId, - surface: surface, - ownerID: ownerID, - payload: [ - "sessionId": sessionID, - "questionId": questionID, - "optionId": optionID, - "controlGeneration": controlGeneration, - ], - authorizationSnapshot: authorizationSnapshot - ) - guard result.accepted == true, - let continuityKey = result.continuityKey, - let userTurn = result.turns.first(where: { $0.role == "user" }), - let assistantTurn = result.turns.first(where: { $0.role == "assistant" }) - else { - throw BridgeError.agentError("Question is no longer actionable") - } - for turn in [result.turn, userTurn, assistantTurn] { - if let turn { recordLifecycleJournalMutation(turn) } - } - return QuestionInteractionReply( - accepted: true, - duplicate: result.duplicate == true, - continuityKey: continuityKey, - parentTurn: result.turn, - userTurn: userTurn, - assistantTurn: assistantTurn - ) - } - - /// Materialize one ordered server batch through the kernel, which owns the - /// canonical assistant rows, tail suppression, and receipt identities. - func materializeChatFirstIntents( - clientId: String, - surface: AgentSurfaceReference, - ownerID: String, - sessionID: String, - controlGeneration: Int, - intents: [ChatFirstPromptIntent], - authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot - ) async throws -> ChatFirstIntentsMaterialization { - guard surface.surfaceKind == "main_chat", - controlGeneration >= 0, - !intents.isEmpty, - intents.count <= 8, - intents.allSatisfy({ $0.accountGeneration == controlGeneration && $0.kernelBlocks != nil }), - !sessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw BridgeError.agentError("Invalid chat-first materialization") - } - let result = try await journalOperation( - type: "materialize_chat_first_intents", - operation: "materialize_chat_first_intents", - clientId: clientId, - surface: surface, - ownerID: ownerID, - payload: [ - "sessionId": sessionID, - "controlGeneration": controlGeneration, - "intents": intents.compactMap { intent -> [String: Any]? in - guard let blocks = intent.kernelBlocks else { return nil } - return [ - "intentId": intent.intentID, - "continuityKey": intent.continuityKey, - "source": intent.source.rawValue, - "blocks": blocks, - ] as [String: Any] - }, - ], - authorizationSnapshot: authorizationSnapshot - ) - for turn in result.turns { - recordLifecycleJournalMutation(turn) - } - return ChatFirstIntentsMaterialization( - accepted: result.accepted == true, - stoppedByTail: result.materializationStoppedByTail, - receipts: result.materializationReceipts - ) - } - - func listChatFirstMaterializationReceipts( - clientId: String, - surface: AgentSurfaceReference, - ownerID: String, - sessionID: String, - controlGeneration: Int, - authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot - ) async throws -> ChatFirstPromptReceiptBatch { - guard surface.surfaceKind == "main_chat", controlGeneration >= 0 else { - throw BridgeError.agentError("Invalid chat-first receipt listing") - } - let result = try await journalOperation( - type: "list_chat_first_materialization_receipts", - operation: "list_chat_first_materialization_receipts", - clientId: clientId, - surface: surface, - ownerID: ownerID, - payload: ["sessionId": sessionID, "controlGeneration": controlGeneration, "limit": 16], - authorizationSnapshot: authorizationSnapshot - ) - return ChatFirstPromptReceiptBatch( - materializationReceipts: result.materializationReceipts, - coldStartSequenceTerminalReceipts: result.coldStartSequenceTerminalReceipts - ) - } - - @discardableResult - func acknowledgeChatFirstMaterializationReceipts( - clientId: String, - surface: AgentSurfaceReference, - ownerID: String, - sessionID: String, - controlGeneration: Int, - receipts: ChatFirstPromptReceiptBatch, - authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot - ) async throws -> Int { - guard surface.surfaceKind == "main_chat", - controlGeneration >= 0, - receipts.materializationReceipts.count <= 16, - receipts.coldStartSequenceTerminalReceipts.count <= 16 - else { - throw BridgeError.agentError("Invalid chat-first receipt acknowledgement") - } - let result = try await journalOperation( - type: "acknowledge_chat_first_materialization_receipts", - operation: "acknowledge_chat_first_materialization_receipts", - clientId: clientId, - surface: surface, - ownerID: ownerID, - payload: [ - "sessionId": sessionID, - "controlGeneration": controlGeneration, - "receipts": receipts.materializationReceipts.map { - ["intentId": $0.intentID, "receiptId": $0.receiptID] - }, - "coldStartSequenceTerminalReceipts": receipts.coldStartSequenceTerminalReceipts.map { - [ - "sequenceId": $0.sequenceID, - "receiptId": $0.receiptID, - "terminalState": $0.terminalState.rawValue, - ] - }, - ], - authorizationSnapshot: authorizationSnapshot - ) - return result.acknowledgedReceiptCount - } - - private func journalOperation( + /// Shared only with the Chat-first journal extension. Its callers must retain + /// the capability's main-Chat and generation checks before constructing a + /// kernel operation; this low-level transport does not grant authority. + func journalOperation( type: String, operation: String, clientId: String, @@ -2813,7 +2613,7 @@ actor AgentRuntimeProcess { env["OMI_AGENT_STATE_DIR"] = Self.defaultStateDirectory() env["OMI_AGENT_ARTIFACTS_DIR"] = Self.defaultArtifactsDirectory() #if DEBUG - if AppBuild.isNonProduction { + if AppBuild.allowsLocalAutomation { env["OMI_AGENT_ALLOW_CONTROL_ONLY"] = "1" } #endif @@ -3145,7 +2945,9 @@ actor AgentRuntimeProcess { /// A terminal kernel turn is a durable replay boundary. Feed the reducer at /// the runtime boundary rather than maintaining a second ad-hoc set in each /// chat or PTT surface. - private func recordLifecycleJournalMutation(_ turn: KernelJournalTurn) { + /// Shared only with the Chat-first journal extension so every journal-owned + /// mutation reaches the same lifecycle reducer boundary. + func recordLifecycleJournalMutation(_ turn: KernelJournalTurn) { _ = bridgeLifecycle.reduce( .kernelJournalWrite( turnID: turn.turnId, @@ -3455,7 +3257,8 @@ actor AgentRuntimeProcess { .sessionExecutionProfileMigrated, .contextSourceUpdated, .contextSnapshot, .legacyMainChatSessionsImported, .externalSurfaceRunBeginResult, .externalSurfaceToolResult, - .externalSurfaceRunCompleteResult, .ownerRuntimeRevoked: + .externalSurfaceRunCompleteResult, .chatFirstHarnessExecutorResult, + .ownerRuntimeRevoked: completeKernelContractRequest(message) case .result: @@ -3867,40 +3670,6 @@ actor AgentRuntimeProcess { )) } - private nonisolated static func chatFirstMaterializationReceipts( - from payload: Any? - ) -> [ChatFirstMaterializationReceipt] { - guard let values = payload as? [[String: Any]] else { return [] } - return values.compactMap { value in - guard let intentID = value["intentId"] as? String, - !intentID.isEmpty, - let receiptID = value["receiptId"] as? String, - !receiptID.isEmpty - else { return nil } - return ChatFirstMaterializationReceipt(intentID: intentID, receiptID: receiptID) - } - } - - private nonisolated static func chatFirstColdStartSequenceTerminalReceipts( - from payload: Any? - ) -> [ChatFirstColdStartSequenceTerminalReceipt] { - guard let values = payload as? [[String: Any]] else { return [] } - return values.compactMap { value in - guard let sequenceID = value["sequenceId"] as? String, - !sequenceID.isEmpty, - let receiptID = value["receiptId"] as? String, - !receiptID.isEmpty, - let rawState = value["terminalState"] as? String, - let terminalState = ChatFirstColdStartSequenceTerminalReceipt.TerminalState(rawValue: rawState) - else { return nil } - return ChatFirstColdStartSequenceTerminalReceipt( - sequenceID: sequenceID, - receiptID: receiptID, - terminalState: terminalState - ) - } - } - private func journalTurn(from message: RuntimeMessage) -> KernelJournalTurn? { guard let dictionary = message.payload["turn"] as? [String: Any] else { return nil } let surface = AgentSurfaceReference( @@ -3916,73 +3685,6 @@ actor AgentRuntimeProcess { ) } - private func handleChatFirstDeferralDelivery(_ message: RuntimeMessage) { - guard let request = ChatFirstDeferralDeliveryRequest(payload: message.payload) else { - sendChatFirstDeferralDeliveryResult( - requestId: message.requestId, - clientId: message.clientId, - ownerID: message.payload["ownerId"] as? String, - continuityKey: message.payload["continuityKey"] as? String ?? "", - deliveryGeneration: message.payload["deliveryGeneration"] as? Int ?? 0, - payloadHash: message.payload["payloadHash"] as? String ?? "", - ok: false, - errorCode: "chat_first_deferral_malformed" - ) - return - } - Task { [weak self] in - do { - try await APIClient.shared.recordChatFirstDeferral(request) - await self?.sendChatFirstDeferralDeliveryResult( - requestId: message.requestId, - clientId: message.clientId, - ownerID: request.ownerID, - continuityKey: request.continuityKey, - deliveryGeneration: request.deliveryGeneration, - payloadHash: request.payloadHash, - ok: true, - errorCode: nil - ) - } catch { - await self?.sendChatFirstDeferralDeliveryResult( - requestId: message.requestId, - clientId: message.clientId, - ownerID: request.ownerID, - continuityKey: request.continuityKey, - deliveryGeneration: request.deliveryGeneration, - payloadHash: request.payloadHash, - ok: false, - errorCode: Self.boundedChatFirstDeferralErrorCode(for: error) - ) - } - } - } - - private func sendChatFirstDeferralDeliveryResult( - requestId: String?, - clientId: String?, - ownerID: String?, - continuityKey: String, - deliveryGeneration: Int, - payloadHash: String, - ok: Bool, - errorCode: String? - ) { - var payload: [String: Any] = [ - "type": "chat_first_deferral_delivery_result", - "protocolVersion": 2, - "continuityKey": continuityKey, - "deliveryGeneration": deliveryGeneration, - "payloadHash": payloadHash, - "ok": ok, - ] - if let requestId { payload["requestId"] = requestId } - if let clientId { payload["clientId"] = clientId } - if let ownerID { payload["ownerId"] = ownerID } - if let errorCode { payload["errorCode"] = errorCode } - sendJson(payload) - } - private func handleJournalBackendSync(_ message: RuntimeMessage) { guard let request = KernelJournalBackendSyncDriver.Request(payload: message.payload) else { sendJournalBackendSyncResult( @@ -4313,7 +4015,7 @@ actor AgentRuntimeProcess { } @discardableResult - private func sendJson(_ dict: [String: Any]) -> Bool { + func sendJson(_ dict: [String: Any]) -> Bool { guard let stdinPipe else { return false } do { let data = try JSONSerialization.data(withJSONObject: dict) diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeStatusStore.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeStatusStore.swift index 3ae5a151b57..7d4a5a64ddc 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeStatusStore.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeStatusStore.swift @@ -337,7 +337,8 @@ final class AgentRuntimeStatusStore: ObservableObject { .sessionExecutionProfileMigrated, .contextSourceUpdated, .contextSnapshot, .legacyMainChatSessionsImported, .externalSurfaceRunBeginResult, .externalSurfaceToolResult, - .externalSurfaceRunCompleteResult, .ownerRuntimeRevoked, + .externalSurfaceRunCompleteResult, .chatFirstHarnessExecutorResult, + .ownerRuntimeRevoked, .unknown: break } diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstAutomationFixture.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstAutomationFixture.swift deleted file mode 100644 index bcb479730d8..00000000000 --- a/desktop/macos/Desktop/Sources/Chat/ChatFirstAutomationFixture.swift +++ /dev/null @@ -1,113 +0,0 @@ -import Foundation - -/// Deterministic, non-content fixture facts used by the named Chat-first -/// bundle. This is deliberately a contract probe, not a second production -/// state store: it exposes no IDs, titles, question text, prepared answers, -/// transcripts, or tool manifests. The harness validates actual raw manifests -/// out of process, where byte identity can be compared to the frozen fixture. -enum ChatFirstAutomationFixture { - enum Scenario: String, CaseIterable, Sendable { - case interactiveQuestion = "interactive_question" - case deferredQuestion = "deferred_question" - case mixedCapture = "mixed_capture" - case uiFlagOff = "ui_flag_off" - case outOfCohort = "out_of_cohort" - } - - struct Contract: Equatable, Sendable { - let validRichBlockCount: Int - let hasValidQuestion: Bool - let hasPreparedAnswer: Bool - let fakeClockEpochSeconds: Int - let deferralSeconds: Int - let captureSourceMode: String - let proactiveJudgeCalls: Int - let proactiveEmissions: Int - let materializationCount: Int - let rawManifestProofMode: String - let shellVariant: String - let chatFirstToolCount: Int - - var bridgeDetail: [String: String] { - [ - "valid_rich_block_count": "\(validRichBlockCount)", - "has_valid_question": hasValidQuestion ? "true" : "false", - "has_prepared_answer": hasPreparedAnswer ? "true" : "false", - "fake_clock_epoch_seconds": "\(fakeClockEpochSeconds)", - "deferral_seconds": "\(deferralSeconds)", - "capture_source_mode": captureSourceMode, - "proactive_judge_calls": "\(proactiveJudgeCalls)", - "proactive_emissions": "\(proactiveEmissions)", - "materialization_count": "\(materializationCount)", - "raw_manifest_proof_mode": rawManifestProofMode, - "shell_variant": shellVariant, - "chat_first_tool_count": "\(chatFirstToolCount)", - ] - } - } - - static func contract(for scenario: Scenario) -> Contract { - switch scenario { - case .interactiveQuestion: - return Contract( - validRichBlockCount: 1, - hasValidQuestion: true, - hasPreparedAnswer: true, - fakeClockEpochSeconds: 1_784_347_200, - deferralSeconds: 0, - captureSourceMode: "none", - proactiveJudgeCalls: 0, - proactiveEmissions: 0, - materializationCount: 0, - rawManifestProofMode: "external_raw_bytes_digest", - shellVariant: "chatFirst", - chatFirstToolCount: 2 - ) - case .deferredQuestion: - return Contract( - validRichBlockCount: 1, - hasValidQuestion: true, - hasPreparedAnswer: true, - fakeClockEpochSeconds: 1_784_347_200, - deferralSeconds: 86_400, - captureSourceMode: "none", - proactiveJudgeCalls: 0, - proactiveEmissions: 0, - materializationCount: 0, - rawManifestProofMode: "external_raw_bytes_digest", - shellVariant: "chatFirst", - chatFirstToolCount: 2 - ) - case .mixedCapture: - return Contract( - validRichBlockCount: 1, - hasValidQuestion: false, - hasPreparedAnswer: false, - fakeClockEpochSeconds: 1_784_347_200, - deferralSeconds: 0, - captureSourceMode: "mixed", - proactiveJudgeCalls: 0, - proactiveEmissions: 0, - materializationCount: 0, - rawManifestProofMode: "external_raw_bytes_digest", - shellVariant: "chatFirst", - chatFirstToolCount: 2 - ) - case .uiFlagOff, .outOfCohort: - return Contract( - validRichBlockCount: 0, - hasValidQuestion: false, - hasPreparedAnswer: false, - fakeClockEpochSeconds: 1_784_347_200, - deferralSeconds: 0, - captureSourceMode: "none", - proactiveJudgeCalls: 0, - proactiveEmissions: 0, - materializationCount: 0, - rawManifestProofMode: "external_raw_bytes_digest", - shellVariant: "legacy", - chatFirstToolCount: 0 - ) - } - } -} diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift index d6df49303d4..805ebd96cde 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift @@ -32,7 +32,8 @@ enum ChatFirstBlockWire { guard let blocks = input["blocks"] as? [[String: Any]], (1...8).contains(blocks.count) else { return nil } - return blocks.compactMap(backendBlock) + let convertedBlocks = blocks.compactMap(backendBlock) + return convertedBlocks.count == blocks.count ? convertedBlocks : nil } static func journalBlocks(from receipt: ChatFirstBlockValidationReceipt) -> [[String: Any]]? { diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift new file mode 100644 index 00000000000..137b10aa970 --- /dev/null +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Cohort-shell visibility proof for the non-production automation bridge. +/// The bridge reports success only after the target route has actually mounted. +extension DesktopAutomationBridge { + func waitForNavigationTarget( + _ payload: DesktopAutomationNavigationRequest + ) async throws -> DesktopAutomationSnapshot { + let expectedChatFirstRoute = ChatFirstRoute.automationVisibilityDestination(named: payload.target)?.stableName + let expectedLegacyTitle = legacyAutomationDestinationTitle(named: payload.target) + let deadline = Date().addingTimeInterval(5) + + while Date() < deadline { + let snapshot = await liveAutomationSnapshot() + if !snapshot.snapshotStale, + DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( + shellVariant: snapshot.shellVariant, + selectedTab: snapshot.selectedTab, + visibleChatFirstRoute: snapshot.visibleChatFirstRoute, + expectedChatFirstRoute: expectedChatFirstRoute, + expectedLegacyTitle: expectedLegacyTitle + ) + { + return snapshot + } + try await Task.sleep(nanoseconds: 50_000_000) + } + throw DesktopAutomationActionError.invalidParams("navigation_target_not_visible") + } + + private func legacyAutomationDestinationTitle(named target: String) -> String? { + switch target.lowercased().replacingOccurrences(of: "-", with: "_") { + case "dashboard", "home": return "Home" + case "conversations": return "Conversations" + case "chat": return "Chat" + case "memories": return "Memories" + case "tasks": return "Tasks" + case "focus": return "Focus" + case "insight": return "Insights" + case "rewind": return "Rewind" + case "apps", "integrations": return "Apps" + case "settings": return "Settings" + case "permissions": return "Permissions" + case "help": return "Help from Founder" + default: return nil + } + } +} + +/// Shared legacy and cohort visibility comparison retained separately from the +/// HTTP bridge so it has no access to rollout state beyond the sampled snapshot. +enum DesktopAutomationNavigationVisibilityPolicy { + static func isTargetVisible( + shellVariant: String?, + selectedTab: String?, + visibleChatFirstRoute: String?, + expectedChatFirstRoute: String?, + expectedLegacyTitle: String? + ) -> Bool { + switch shellVariant { + case "chat_first": + return expectedChatFirstRoute != nil && visibleChatFirstRoute == expectedChatFirstRoute + case "legacy": + return expectedLegacyTitle != nil && selectedTab == expectedLegacyTitle + default: + return false + } + } +} diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift index e09fb91d69b..249eeb41554 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift @@ -128,6 +128,10 @@ struct DesktopAutomationSnapshot: Codable, Sendable { var shellVariant: String? /// Stable typed route for the cohort shell. Nil for the legacy shell. var chatFirstRoute: String? + /// Set only by the mounted cohort destination after it has appeared. This + /// keeps a successful navigation response equivalent to the target being + /// visible, rather than merely accepted by the root reducer. + var visibleChatFirstRoute: String? /// Shape-only focus telemetry for route acknowledgement; entity IDs stay local. var pendingFocusKind: String? var acknowledgedFocusKind: String? @@ -445,6 +449,7 @@ final class DesktopAutomationStateStore { homeMode: nil, shellVariant: nil, chatFirstRoute: nil, + visibleChatFirstRoute: nil, pendingFocusKind: nil, acknowledgedFocusKind: nil, focusedEntityID: nil, @@ -535,7 +540,7 @@ func awaitWithTimeout( } } -private func liveAutomationSnapshot() async -> DesktopAutomationSnapshot { +func liveAutomationSnapshot() async -> DesktopAutomationSnapshot { // Bound the MainActor hop: if the main thread is wedged (blocking Keychain read // during sign-in), fall back to the last cached snapshot so `/state` still // answers instead of hanging the whole bridge. See awaitWithTimeout. @@ -732,27 +737,6 @@ final class DesktopAutomationActionRegistry { return nil } - if AppBuild.isNonProduction { - register( - name: "chat_first_fixture_contract", - summary: "Read one deterministic Chat-first non-production fixture contract without user content", - params: ["scenario"], - category: "read", - surfaces: ["main_chat"], - safety: "read_only", - sideEffects: [] - ) { params in - guard let scenario = ChatFirstAutomationFixture.Scenario( - rawValue: params["scenario"] ?? "" - ) else { - throw DesktopAutomationActionError.invalidParams( - "scenario must be interactive_question, deferred_question, mixed_capture, ui_flag_off, or out_of_cohort" - ) - } - return ChatFirstAutomationFixture.contract(for: scenario).bridgeDetail - } - } - // CHAT-05: read the free-tier monthly chat usage-limiter state so a harness can // prove the counter is deterministic without spending LLM calls. Read-only. register( @@ -3728,8 +3712,8 @@ final class DesktopAutomationBridge: @unchecked Sendable { let payload = try JSONDecoder().decode( DesktopAutomationNavigationRequest.self, from: request.body) try await dispatchNavigation(payload) + let snapshot = try await waitForNavigationTarget(payload) try await sleepForAutomationSettle(payload.settleMs) - let snapshot = await cachedAutomationSnapshot() return jsonResponse(DesktopAutomationResponse(ok: true, result: snapshot, error: nil)) } catch { return jsonResponse( diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift index b52049ebc4c..a6edef289b4 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift @@ -9,15 +9,18 @@ import OmiTheme struct CaptureArchivePage: View { @ObservedObject var navigation: ChatFirstShellNavigation let chatProvider: ChatProvider + let automationRuntime: ChatFirstAutomationRuntime? @StateObject private var repository: CaptureArchiveRepository @StateObject private var playback: CapturePlaybackController init( navigation: ChatFirstShellNavigation, - chatProvider: ChatProvider + chatProvider: ChatProvider, + automationRuntime: ChatFirstAutomationRuntime? = nil ) { self.navigation = navigation self.chatProvider = chatProvider + self.automationRuntime = automationRuntime _repository = StateObject(wrappedValue: CaptureArchiveRepository()) _playback = StateObject(wrappedValue: CapturePlaybackController()) } @@ -35,6 +38,8 @@ struct CaptureArchivePage: View { .background(OmiColors.backgroundPrimary) .task { await repository.loadInitial() } .task(id: pendingFocusToken) { await resolvePendingFocusIfNeeded() } + .onAppear { registerAutomationActions() } + .onDisappear { automationRuntime?.unregisterCapturePage() } .accessibilityIdentifier("chat-first-capture-archive") } @@ -359,6 +364,24 @@ struct CaptureArchivePage: View { _ = navigation.acknowledgeFocus(.capture(id: id, momentTs: momentTimestamp)) } + private func registerAutomationActions() { + automationRuntime?.registerCapturePage( + openCapture: { [repository, playback] in + guard let capture = repository.captures.first else { return false } + repository.select(capture) + guard let detail = await repository.loadDetail(id: capture.id) else { return false } + _ = await playback.prepare(for: detail) + return true + }, + discussCapture: { [navigation, chatProvider, repository] in + guard let capture = repository.selectedCapture else { return false } + navigation.discuss(.capture(id: capture.id, momentTimestamp: nil), using: chatProvider) + return true + }, + detailIsVisible: { [repository] in repository.selectedCapture != nil } + ) + } + private func canSeekMoment(_ segment: TranscriptSegment) -> Bool { guard case .readyAggregate(let artifact) = playback.resolution else { return false } return artifact.artifactOffset(forWallOffset: segment.start) != nil diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift new file mode 100644 index 00000000000..af08e34f406 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift @@ -0,0 +1,401 @@ +import Combine +import Foundation + +/// Non-production semantic actions for the live Chat-first shell. The runtime +/// owns no fixture data and makes no eligibility decision: it is installed only +/// after the root has consumed the server-owned capability sample. Individual +/// pages register their real handlers while mounted, so an action cannot claim +/// a result for an off-screen or fabricated surface. +@MainActor +final class ChatFirstAutomationRuntime: ObservableObject { + typealias AsyncBool = @MainActor () async -> Bool + typealias CaptureSnapshot = @MainActor () -> Bool + typealias AsyncQuestionSelection = @MainActor (QuestionSelection) async -> Bool + + enum QuestionSelection: String { + case first + case deferred = "defer" + } + + private let navigation: ChatFirstShellNavigation + private let goalsStore: CanonicalGoalsStore + private let tasksStore: TasksStore + private let chatProvider: ChatProvider + + private var setFocus: AsyncBool? + private var openRelatedTasks: AsyncBool? + private var toggleTask: AsyncBool? + private var openCapture: AsyncBool? + private var discussCapture: AsyncBool? + private var captureDetailIsVisible: CaptureSnapshot? + private var selectQuestionOption: AsyncQuestionSelection? + private var requestPromptMaterialization: AsyncBool? + + init( + navigation: ChatFirstShellNavigation, + goalsStore: CanonicalGoalsStore, + tasksStore: TasksStore, + chatProvider: ChatProvider + ) { + self.navigation = navigation + self.goalsStore = goalsStore + self.tasksStore = tasksStore + self.chatProvider = chatProvider + } + + func install() { + // The semantic fixture actions are part of the loopback bridge contract, + // not a generic non-production feature. Preview/release-style bundles + // must never register them, even if code elsewhere calls this runtime. + guard DesktopAutomationLaunchOptions.isEnabled else { return } + let registry = DesktopAutomationActionRegistry.shared + + registry.register( + name: "chat_first_runtime_snapshot", + summary: "Read shape-only state from the live Chat-first pages", + category: "read", + surfaces: ["main_chat", "goals", "tasks", "conversations"], + safety: "read_only", + sideEffects: [] + ) { [weak self] _ in + guard let self else { return nil } + return self.runtimeSnapshot() + } + + registry.register( + name: "chat_first_request_prompt_materialization", + summary: "Run the normal foreground materialization path from mounted main Chat", + params: ["timeoutMs"], + category: "coordinator", + surfaces: ["main_chat"], + safety: "chat_turn", + sideEffects: ["requests server-owned prompt materialization"] + ) { [weak self] params in + guard let self, let requestPromptMaterialization = self.requestPromptMaterialization else { + throw DesktopAutomationActionError.invalidParams("chat_first_main_chat_page_not_visible") + } + guard await requestPromptMaterialization() else { + return [ + "prompt_materialization_requested": "false", + "actionable_question_at_tail": "false", + ] + } + let timeoutMs = min(60_000, max(1_000, Int(params["timeoutMs"] ?? "") ?? 30_000)) + return [ + "prompt_materialization_requested": "true", + "actionable_question_at_tail": await waitForActionableQuestionTail(timeoutMs: timeoutMs) ? "true" : "false", + ] + } + + registry.register( + name: "chat_first_render_fixture_task_card", + summary: "Invoke the real authorized Chat-first block executor from mounted main Chat", + category: "chat", + surfaces: ["main_chat"], + safety: "chat_turn", + sideEffects: ["creates one local fixture journal turn", "validates and appends one fixture task card"] + ) { [weak self] _ in + guard let self, self.requestPromptMaterialization != nil else { + throw DesktopAutomationActionError.invalidParams("chat_first_main_chat_page_not_visible") + } + return await self.chatProvider.runChatFirstFixtureTaskCardProbe() + } + + registry.register( + name: "chat_first_set_focus", + summary: "Set focus through the currently visible Chat-first Goals page", + category: "coordinator", + surfaces: ["goals"], + safety: "server_mutation", + sideEffects: ["updates canonical goal focus"] + ) { [weak self] _ in + guard let self, let setFocus = self.setFocus else { + throw DesktopAutomationActionError.invalidParams("chat_first_goals_page_not_visible") + } + return ["focus_updated": await setFocus() ? "true" : "false"] + } + + registry.register( + name: "chat_first_open_related_tasks", + summary: "Open related Tasks through the currently visible focused Goal", + category: "coordinator", + surfaces: ["goals", "tasks"], + safety: "local_ui_state", + sideEffects: ["changes Chat-first route"] + ) { [weak self] _ in + guard let self, let openRelatedTasks = self.openRelatedTasks else { + throw DesktopAutomationActionError.invalidParams("chat_first_goal_detail_not_visible") + } + guard await openRelatedTasks() else { + return ["related_tasks_opened": "false"] + } + return ["related_tasks_opened": await self.waitForVisibleRoute(.tasks) ? "true" : "false"] + } + + registry.register( + name: "chat_first_toggle_task", + summary: "Toggle one visible Chat-first task through the shared Tasks store", + category: "coordinator", + surfaces: ["tasks", "main_chat"], + safety: "server_mutation", + sideEffects: ["updates task completion"] + ) { [weak self] _ in + guard let self, let toggleTask = self.toggleTask else { + throw DesktopAutomationActionError.invalidParams("chat_first_tasks_page_not_visible") + } + return ["task_reconciled": await toggleTask() ? "true" : "false"] + } + + registry.register( + name: "chat_first_open_capture", + summary: "Select an Omi-device capture through the visible archive page", + category: "coordinator", + surfaces: ["conversations"], + safety: "read_only", + sideEffects: ["selects a local capture detail"] + ) { [weak self] _ in + guard let self, let openCapture = self.openCapture else { + throw DesktopAutomationActionError.invalidParams("chat_first_capture_page_not_visible") + } + return ["capture_detail_opened": await openCapture() ? "true" : "false"] + } + + registry.register( + name: "chat_first_discuss_capture", + summary: "Start the ordinary main-Chat turn from the selected capture detail", + category: "chat", + surfaces: ["conversations", "main_chat"], + safety: "chat_turn", + sideEffects: ["creates one main-Chat turn"] + ) { [weak self] _ in + guard let self, let discussCapture = self.discussCapture else { + throw DesktopAutomationActionError.invalidParams("chat_first_capture_detail_not_visible") + } + let messageCount = self.chatProvider.messages.count + guard await discussCapture() else { + return ["capture_discussion_started": "false"] + } + let chatIsVisible = await self.waitForVisibleRoute(.chat) + let turnStarted = await self.waitForMainChatTurnStart(sinceMessageCount: messageCount) + return [ + "capture_discussion_started": chatIsVisible && turnStarted ? "true" : "false" + ] + } + + registry.register( + name: "chat_first_select_question_option", + summary: "Select a bounded actionable question option through the mounted main-Chat journal", + params: ["selection"], + category: "chat", + surfaces: ["main_chat"], + safety: "chat_turn", + sideEffects: ["creates one prepared main-Chat turn"] + ) { [weak self] params in + guard let self, let selectQuestionOption = self.selectQuestionOption else { + throw DesktopAutomationActionError.invalidParams("chat_first_main_chat_page_not_visible") + } + guard let selection = QuestionSelection( + rawValue: params["selection"] ?? QuestionSelection.first.rawValue + ) else { + throw DesktopAutomationActionError.invalidParams("selection must be first or defer") + } + return ["question_selection_started": await selectQuestionOption(selection) ? "true" : "false"] + } + } + + func uninstall() { + let registry = DesktopAutomationActionRegistry.shared + for name in [ + "chat_first_runtime_snapshot", + "chat_first_request_prompt_materialization", + "chat_first_render_fixture_task_card", + "chat_first_set_focus", + "chat_first_open_related_tasks", + "chat_first_toggle_task", + "chat_first_open_capture", + "chat_first_discuss_capture", + "chat_first_select_question_option", + ] { + registry.unregister(name) + } + unregisterGoalsPage() + unregisterTasksPage() + unregisterCapturePage() + unregisterChatPage() + } + + func registerGoalsPage(setFocus: @escaping AsyncBool, openRelatedTasks: @escaping AsyncBool) { + self.setFocus = setFocus + self.openRelatedTasks = openRelatedTasks + } + + func unregisterGoalsPage() { + setFocus = nil + openRelatedTasks = nil + } + + func registerTasksPage(toggleTask: @escaping AsyncBool) { + self.toggleTask = toggleTask + } + + func unregisterTasksPage() { + toggleTask = nil + } + + func registerCapturePage( + openCapture: @escaping AsyncBool, + discussCapture: @escaping AsyncBool, + detailIsVisible: @escaping CaptureSnapshot + ) { + self.openCapture = openCapture + self.discussCapture = discussCapture + captureDetailIsVisible = detailIsVisible + } + + func unregisterCapturePage() { + openCapture = nil + discussCapture = nil + captureDetailIsVisible = nil + } + + func registerChatPage(requestPromptMaterialization: @escaping AsyncBool) { + selectQuestionOption = { [weak self] selection in + guard let self else { return false } + return await self.selectActionableQuestionOption(selection: selection) + } + self.requestPromptMaterialization = requestPromptMaterialization + } + + func unregisterChatPage() { + selectQuestionOption = nil + requestPromptMaterialization = nil + } + + private func runtimeSnapshot() -> [String: String] { + [ + "route": navigation.route.stableName, + "route_visible": navigation.visibleRoute == navigation.route ? "true" : "false", + "focus_acknowledged": navigation.isFocusedEntityAcknowledged ? "true" : "false", + "focused_goal_available": goalsStore.primaryFocusedGoal == nil ? "false" : "true", + "visible_task_count": "\(tasksStore.tasks.filter { $0.deleted != true }.count)", + "completed_visible_task_count": "\(tasksStore.tasks.filter { $0.deleted != true && $0.completed }.count)", + "capture_detail_visible": captureDetailIsVisible?() == true ? "true" : "false", + "actionable_question_at_tail": actionableQuestionCard() ? "true" : "false", + "actionable_question_available": questionOptionIsAvailable(for: .first) ? "true" : "false", + "deferrable_question_available": questionOptionIsAvailable(for: .deferred) ? "true" : "false", + ] + } + + private func selectActionableQuestionOption(selection: QuestionSelection) async -> Bool { + guard let option = selectableQuestionOption(for: selection) else { return false } + AnalyticsManager.shared.chatFirst( + .question(lifecycle: selection == .deferred ? .deferred : .answered) + ) + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .questionCard, outcome: .acted, action: .select) + ) + // Yield to the bounded journal action before reporting success. Without + // this handoff, the next harness `wait_main_chat_idle` can read the *old* + // idle state before the real selection enters ChatProvider. We still do + // not await the model response here; the following idle/tail assertions + // own that terminal evidence. + Task { [chatProvider] in + await chatProvider.selectQuestionCardOption(questionID: option.questionID, optionID: option.optionID) + } + return await waitForQuestionSelectionToBegin(selection: selection) + } + + private func questionOptionIsAvailable(for selection: QuestionSelection) -> Bool { + selectQuestionOption != nil && selectableQuestionOption(for: selection) != nil + } + + private func actionableQuestionCard() -> Bool { + guard selectQuestionOption != nil, let tail = chatProvider.messages.last else { return false } + return tail.contentBlocks.contains { block in + guard case let .questionCard(_, questionID, _, _, _, _, selectedOptionID) = block else { return false } + return chatProvider.isQuestionCardActionable( + messageID: tail.id, + questionID: questionID, + selectedOptionID: selectedOptionID + ) + } + } + + private func waitForActionableQuestionTail(timeoutMs: Int) async -> Bool { + let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000) + while Date() < deadline { + if actionableQuestionCard() { return true } + try? await Task.sleep(nanoseconds: 100_000_000) + } + return actionableQuestionCard() + } + + private func waitForMainChatTurnStart(sinceMessageCount: Int) async -> Bool { + let deadline = Date().addingTimeInterval(5) + while Date() < deadline { + if chatProvider.isSending || chatProvider.messages.count > sinceMessageCount { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return chatProvider.isSending || chatProvider.messages.count > sinceMessageCount + } + + private func waitForQuestionSelectionToBegin( + selection: QuestionSelection, + timeoutMs: Int = 2_000 + ) async -> Bool { + let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000) + while Date() < deadline { + if chatProvider.isSending || !questionOptionIsAvailable(for: selection) { return true } + try? await Task.sleep(nanoseconds: 25_000_000) + } + return chatProvider.isSending || !questionOptionIsAvailable(for: selection) + } + + private func waitForVisibleRoute(_ route: ChatFirstRoute, timeoutMs: Int = 5_000) async -> Bool { + let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000) + while Date() < deadline { + if navigation.visibleRoute == route { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return navigation.visibleRoute == route + } + + private func selectableQuestionOption(for selection: QuestionSelection) -> (questionID: String, optionID: String)? { + guard let tail = chatProvider.messages.last else { return nil } + for block in tail.contentBlocks { + guard case let .questionCard(_, questionID, _, _, _, options, selectedOptionID) = block, + chatProvider.isQuestionCardActionable( + messageID: tail.id, + questionID: questionID, + selectedOptionID: selectedOptionID + ), + let optionID = ChatFirstQuestionAutomationSelectionPolicy.optionID( + in: options, + selection: selection + ) + else { continue } + return (questionID, optionID) + } + return nil + } +} + +/// Extracts only the selected opaque option identifier from the real question +/// card payload. It deliberately never returns the question text, option label, +/// prepared answer, subject, or an arbitrary caller-supplied option ID. +enum ChatFirstQuestionAutomationSelectionPolicy { + static func optionID( + in options: [[String: Any]], + selection: ChatFirstAutomationRuntime.QuestionSelection + ) -> String? { + let wantsDeferral = selection == .deferred + for option in options { + let isDeferral = option["defer"] as? Bool ?? false + guard isDeferral == wantsDeferral else { continue } + guard let optionID = option["optionId"] as? String else { continue } + let normalized = optionID.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalized.isEmpty { return normalized } + } + return nil + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift index 137af90d6c7..2d3370a5fc1 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift @@ -11,6 +11,7 @@ struct ChatFirstGoalsPage: View { @ObservedObject var goalsStore: CanonicalGoalsStore @ObservedObject var tasksStore: TasksStore let chatProvider: ChatProvider + let automationRuntime: ChatFirstAutomationRuntime? @State private var resolvingTaskIDs = Set() @@ -52,7 +53,9 @@ struct ChatFirstGoalsPage: View { .background(OmiColors.backgroundPrimary) .onAppear { Task { await refreshProjectionAndDetail() } + registerAutomationActions() } + .onDisappear { automationRuntime?.unregisterGoalsPage() } .onChange(of: navigation.pendingFocus) { _, _ in Task { await loadDetailForCurrentFocus() } } @@ -200,6 +203,21 @@ struct ChatFirstGoalsPage: View { } } } + + private func registerAutomationActions() { + automationRuntime?.registerGoalsPage( + setFocus: { [goalsStore] in + guard let goalID = goalsStore.otherActiveGoals.first?.goalId else { return false } + guard await goalsStore.setAsFocus(goalID: goalID) else { return false } + return await goalsStore.loadDetail(goalID: goalID) != nil + }, + openRelatedTasks: { [navigation, goalsStore] in + guard let goalID = goalsStore.selectedGoalDetail?.goal.goalId else { return false } + navigation.open(focus: .goal(id: goalID), destination: .tasks) + return true + } + ) + } } private struct ChatFirstFocusedGoalSection: View { diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift index 27d93193710..1e996408d7a 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift @@ -50,7 +50,7 @@ final class ChatFirstPromptMaterializationCoordinator: ObservableObject { /// available. A shell with another route never creates a proactive turn. func chatTranscriptFirstPageDidLoad() { didLoadTranscriptFirstPage = true - requestMaterialization(windowForeground: true) + _ = requestMaterialization(windowForeground: true) } /// Leaving the rich Chat route must immediately make this coordinator inert. @@ -65,11 +65,13 @@ final class ChatFirstPromptMaterializationCoordinator: ObservableObject { /// `ChatFirstShell` alone forwards app foreground events. This is never /// registered by the legacy shell, floating/notch UI, or a background task. - func mainWindowDidBecomeForeground() { + @discardableResult + func mainWindowDidBecomeForeground() -> Bool { requestMaterialization(windowForeground: true) } - private func requestMaterialization(windowForeground: Bool) { + @discardableResult + private func requestMaterialization(windowForeground: Bool) -> Bool { guard ChatFirstPromptMaterializationPolicy.shouldStart( hasChatFirstMainChatContext: driver?.materializationContext() != nil, transcriptFirstPageLoaded: didLoadTranscriptFirstPage, @@ -77,7 +79,7 @@ final class ChatFirstPromptMaterializationCoordinator: ObservableObject { lastAttemptAt: lastAttemptAt, now: now() ), let driver - else { return } + else { return false } lastAttemptAt = now() requestGeneration &+= 1 @@ -89,6 +91,7 @@ final class ChatFirstPromptMaterializationCoordinator: ObservableObject { self.requestTask = nil } } + return true } private func materialize( diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift index 23f6ffaad9f..59bcc8ab347 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift @@ -59,6 +59,27 @@ enum ChatFirstRoute: Hashable, Codable, Sendable { default: return nil } } + + /// Maps every legacy-compatible automation name to its mounted cohort route. + /// This is visibility-only: dispatch remains owned by `DesktopHomeView` so + /// callers retain the legacy adapter while the old shell is active. + static func automationVisibilityDestination(named target: String) -> ChatFirstRoute? { + if let primary = primaryAutomationDestination(named: target) { + return primary + } + let normalized = target.lowercased().replacingOccurrences(of: "-", with: "_") + switch normalized { + case "dashboard", "home": return .more(.dashboard) + case "focus": return .more(.focus) + case "insight": return .more(.insight) + case "rewind": return .more(.rewind) + case "apps", "integrations": return .more(.apps) + case "permissions": return .more(.permissions) + case "help": return .more(.help) + case "settings": return .more(.settings) + default: return nil + } + } } enum ChatFirstMorePage: String, CaseIterable, Codable, Hashable, Sendable { @@ -175,6 +196,10 @@ final class ChatFirstShellNavigation: ObservableObject { static let storageKey = "chatFirstShell.windowNavigation.v1" @Published private(set) var route: ChatFirstRoute + /// The destination currently mounted by SwiftUI. This is deliberately + /// separate from `route`: navigation commands are not complete until the + /// requested target has actually appeared. + @Published private(set) var visibleRoute: ChatFirstRoute? @Published private(set) var pendingFocus: ChatFirstPendingFocus? /// A related-entity link can intentionally land in a different primary /// destination (for example, a Goal's task list). This is transient like the @@ -209,6 +234,7 @@ final class ChatFirstShellNavigation: ObservableObject { } pendingFocus = nil pendingFocusDestination = nil + visibleRoute = nil lastAcknowledgedFocusKind = nil focusedEntityID = nil isFocusedEntityAcknowledged = false @@ -220,6 +246,7 @@ final class ChatFirstShellNavigation: ObservableObject { ) { guard destination.isPrimaryDestination else { return } route = destination + visibleRoute = nil clearFocus() persistNavigation() analytics(.routeEntered(route: analyticsRoute(destination), origin: origin)) @@ -227,6 +254,7 @@ final class ChatFirstShellNavigation: ObservableObject { func selectMore(_ page: ChatFirstMorePage) { route = .more(page) + visibleRoute = nil clearFocus() persistNavigation() analytics(.routeEntered(route: .more, origin: .more)) @@ -244,6 +272,7 @@ final class ChatFirstShellNavigation: ObservableObject { func open(focus: ChatFirstPendingFocus, destination: ChatFirstRoute) { guard destination.isPrimaryDestination else { return } route = destination + visibleRoute = nil pendingFocus = focus pendingFocusDestination = destination focusedEntityID = focus.entityID @@ -273,6 +302,14 @@ final class ChatFirstShellNavigation: ObservableObject { return true } + /// Called by the mounted destination, never by the navigation command. This + /// gives the non-production bridge an exact-target-visible acknowledgement + /// without persisting a second navigation state or emitting entity data. + func markRouteVisible(_ destination: ChatFirstRoute) { + guard route == destination else { return } + visibleRoute = destination + } + func toggleSidebar() { isSidebarCollapsed.toggle() persistNavigation() diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift index 5959c163996..94ab6c7c38b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift @@ -12,6 +12,31 @@ struct ChatFirstShell: View { @Binding var selectedSettingsSection: SettingsContentView.SettingsSection @Binding var highlightedSettingID: String? @StateObject private var promptMaterializationCoordinator = ChatFirstPromptMaterializationCoordinator() + @StateObject private var automationRuntime: ChatFirstAutomationRuntime + + init( + navigation: ChatFirstShellNavigation, + appState: AppState, + viewModelContainer: ViewModelContainer, + capability: ChatFirstCapabilityProjection, + selectedSettingsSection: Binding, + highlightedSettingID: Binding + ) { + self.navigation = navigation + self.appState = appState + self.viewModelContainer = viewModelContainer + self.capability = capability + _selectedSettingsSection = selectedSettingsSection + _highlightedSettingID = highlightedSettingID + _automationRuntime = StateObject( + wrappedValue: ChatFirstAutomationRuntime( + navigation: navigation, + goalsStore: viewModelContainer.canonicalGoalsStore, + tasksStore: viewModelContainer.tasksStore, + chatProvider: viewModelContainer.chatProvider + ) + ) + } var body: some View { HStack(spacing: 0) { @@ -35,8 +60,10 @@ struct ChatFirstShell: View { .onAppear { promptMaterializationCoordinator.activate(using: viewModelContainer.chatProvider) viewModelContainer.canonicalGoalsStore.activate(capability: capability) + automationRuntime.install() AnalyticsManager.shared.chatFirst(.routeEntered(route: .chat, origin: .shellLaunch)) } + .onDisappear { automationRuntime.uninstall() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in guard let window = NSApp.mainWindow, window.isKeyWindow, window.isVisible else { return } promptMaterializationCoordinator.mainWindowDidBecomeForeground() @@ -72,36 +99,53 @@ struct ChatFirstShell: View { ) ) .accessibilityIdentifier("chat-first-route-chat") + .onAppear { + navigation.markRouteVisible(.chat) + automationRuntime.registerChatPage( + requestPromptMaterialization: { + promptMaterializationCoordinator.mainWindowDidBecomeForeground() + } + ) + } + .onDisappear { automationRuntime.unregisterChatPage() } case .conversations: CaptureArchivePage( navigation: navigation, - chatProvider: viewModelContainer.chatProvider + chatProvider: viewModelContainer.chatProvider, + automationRuntime: automationRuntime ) .accessibilityIdentifier("chat-first-route-conversations") + .onAppear { navigation.markRouteVisible(.conversations) } case .tasks: ChatFirstTasksPage( navigation: navigation, tasksStore: viewModelContainer.tasksStore, - chatProvider: viewModelContainer.chatProvider + chatProvider: viewModelContainer.chatProvider, + automationRuntime: automationRuntime ) .accessibilityIdentifier("chat-first-route-tasks") + .onAppear { navigation.markRouteVisible(.tasks) } case .goals: ChatFirstGoalsPage( navigation: navigation, goalsStore: viewModelContainer.canonicalGoalsStore, tasksStore: viewModelContainer.tasksStore, - chatProvider: viewModelContainer.chatProvider + chatProvider: viewModelContainer.chatProvider, + automationRuntime: automationRuntime ) .accessibilityIdentifier("chat-first-route-goals") + .onAppear { navigation.markRouteVisible(.goals) } case .memories: MemoriesPage( viewModel: viewModelContainer.memoriesViewModel, graphViewModel: viewModelContainer.memoryGraphViewModel ) .accessibilityIdentifier("chat-first-route-memories") + .onAppear { navigation.markRouteVisible(.memories) } case .more(let page): moreDestination(page) .accessibilityIdentifier("chat-first-route-more-\(page.stableName)") + .onAppear { navigation.markRouteVisible(.more(page)) } } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift index a969707d56d..1ceb14b9f37 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift @@ -131,6 +131,7 @@ struct ChatFirstTasksPage: View { @ObservedObject var navigation: ChatFirstShellNavigation @ObservedObject var tasksStore: TasksStore let chatProvider: ChatProvider + let automationRuntime: ChatFirstAutomationRuntime? @State private var addDrafts: [ChatFirstTaskScheduleGroup: String] = [:] @State private var addingGroups: Set = [] @@ -139,11 +140,13 @@ struct ChatFirstTasksPage: View { init( navigation: ChatFirstShellNavigation, tasksStore: TasksStore, - chatProvider: ChatProvider + chatProvider: ChatProvider, + automationRuntime: ChatFirstAutomationRuntime? = nil ) { self.navigation = navigation self.tasksStore = tasksStore self.chatProvider = chatProvider + self.automationRuntime = automationRuntime } private var visibleTasks: [TaskActionItem] { @@ -187,9 +190,11 @@ struct ChatFirstTasksPage: View { .onAppear { tasksStore.isActive = true Task { await tasksStore.loadTasksIfNeeded() } + registerAutomationActions() } .onDisappear { tasksStore.isActive = false + automationRuntime?.unregisterTasksPage() } .accessibilityIdentifier("chat-first-tasks-page") } @@ -399,6 +404,25 @@ struct ChatFirstTasksPage: View { ) else { return } _ = navigation.acknowledgeFocus(focus) } + + private func registerAutomationActions() { + automationRuntime?.registerTasksPage( + toggleTask: { [tasksStore] in + guard let task = tasksStore.tasks.first(where: { $0.deleted != true && !$0.completed }) else { return false } + let intendedCompletion = !task.completed + AnalyticsManager.shared.chatFirst(.taskMutation(lifecycle: .attempt, mutation: .completion)) + await tasksStore.toggleTask(task) + let reconciled = tasksStore.tasks.first { $0.id == task.id && $0.deleted != true } + AnalyticsManager.shared.chatFirst( + .taskMutation( + lifecycle: reconciled?.completed == intendedCompletion ? .success : .rollback, + mutation: .completion + ) + ) + return reconciled?.completed == intendedCompletion + } + ) + } } private struct ChatFirstTaskRow: View { diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 3bba2b73c1f..69d78c07228 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -639,6 +639,7 @@ struct DesktopHomeView: View { homeMode: !usesChatFirstShell && onDashboard && !useLegacyHomeDesign ? (priorHomeMode ?? "hub") : nil, shellVariant: chatFirstCapabilitySample.variant.stableName, chatFirstRoute: chatFirstRoute?.stableName, + visibleChatFirstRoute: usesChatFirstShell ? chatFirstNavigation.visibleRoute?.stableName : nil, pendingFocusKind: chatFirstNavigation.pendingFocus?.stableName, acknowledgedFocusKind: chatFirstNavigation.lastAcknowledgedFocusKind, focusedEntityID: chatFirstNavigation.focusedEntityID, @@ -1130,6 +1131,7 @@ struct DesktopHomeView: View { updateStoreActivityForCurrentShell() reportAutomationState() } + .onChange(of: chatFirstNavigation.visibleRoute) { _, _ in reportAutomationState() } .onChange(of: chatFirstNavigation.isSidebarCollapsed) { _, _ in reportAutomationState() } .onChange(of: useLegacyHomeDesign) { _, newValue in OmiMotion.withGated(.easeInOut(duration: 0.2)) { diff --git a/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift b/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift new file mode 100644 index 00000000000..0bc3a3d851b --- /dev/null +++ b/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Capability-scoped block validation and journal append. It is intentionally +/// separate from the legacy desktop tool switch so server admission is visible +/// as a distinct, all-or-nothing boundary. +@MainActor +enum ChatFirstBlockToolExecutor { + static func execute( + _ args: [String: Any], + surface: AgentSurfaceReference?, + sessionID: String?, + runID: String?, + attemptID: String?, + capabilityRef: String?, + controlGeneration: Int?, + expectedOwnerID: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot?, + api: APIClient + ) async -> String { + guard let expectedOwnerID, + let authorizationSnapshot, + let surface, + surface.surfaceKind == "main_chat", + let sessionID, + let runID, + let attemptID, + let capabilityRef, + let controlGeneration, + controlGeneration >= 0, + let backendBlocks = ChatFirstBlockWire.backendBlocks(from: args) + else { + return #"{"ok":false,"error":{"code":"chat_first_invalid_authority"}}"# + } + guard ChatToolExecutor.isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { + return ChatToolExecutor.authorizedOwnerChangedResult() + } + + do { + let request = ChatFirstBlockValidationRequest( + controlGeneration: controlGeneration, + ownerFence: expectedOwnerID, + runID: runID, + attemptID: attemptID, + blocks: backendBlocks.map(OmiAnyCodable.init) + ) + let receipt: ChatFirstBlockValidationReceipt = try await api.post( + "v1/chat-first/blocks/validate", + body: request, + expectedOwnerId: expectedOwnerID, + authorizationSnapshot: authorizationSnapshot + ) + guard ChatToolExecutor.isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { + return ChatToolExecutor.authorizedOwnerChangedResult() + } + guard let journalBlocks = ChatFirstBlockWire.journalBlocks(from: receipt) else { + return #"{"ok":false,"error":{"code":"chat_first_blocks_rejected"}}"# + } + _ = try await AgentRuntimeProcess.shared.appendChatFirstBlocks( + clientId: "chat-first-render", + surface: surface, + ownerID: expectedOwnerID, + sessionID: sessionID, + runID: runID, + attemptID: attemptID, + capabilityRef: capabilityRef, + controlGeneration: controlGeneration, + blocks: journalBlocks, + authorizationSnapshot: authorizationSnapshot + ) + return #"{"ok":true,"rendered":#(journalBlocks.count)}"# + } catch { + guard ChatToolExecutor.isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { + return ChatToolExecutor.authorizedOwnerChangedResult() + } + logError("Chat-first block rendering failed", error: error) + return #"{"ok":false,"error":{"code":"chat_first_blocks_unavailable"}}"# + } + } +} diff --git a/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift b/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift index b92f16fa9e5..f17690454d5 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift @@ -246,7 +246,7 @@ class ChatToolExecutor { expectedOwnerID: expectedOwnerID) case .renderChatBlocks: - return await executeRenderChatBlocks( + return await ChatFirstBlockToolExecutor.execute( toolCall.arguments, surface: originatingSurfaceRef, sessionID: originatingSessionID, @@ -1638,81 +1638,6 @@ class ChatToolExecutor { } } - /// The server admits the canonical references as one all-or-nothing receipt; - /// only then does the kernel append the receipt to the producing assistant - /// turn. This path deliberately never calls the legacy Python chat writer. - private static func executeRenderChatBlocks( - _ args: [String: Any], - surface: AgentSurfaceReference?, - sessionID: String?, - runID: String?, - attemptID: String?, - capabilityRef: String?, - controlGeneration: Int?, - expectedOwnerID: String?, - authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot?, - api: APIClient - ) async -> String { - guard let expectedOwnerID, - let authorizationSnapshot, - let surface, - surface.surfaceKind == "main_chat", - let sessionID, - let runID, - let attemptID, - let capabilityRef, - let controlGeneration, - controlGeneration >= 0, - let backendBlocks = ChatFirstBlockWire.backendBlocks(from: args) - else { - return #"{"ok":false,"error":{"code":"chat_first_invalid_authority"}}"# - } - guard isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { - return authorizedOwnerChangedResult() - } - - do { - let request = ChatFirstBlockValidationRequest( - controlGeneration: controlGeneration, - ownerFence: expectedOwnerID, - runID: runID, - attemptID: attemptID, - blocks: backendBlocks.map(OmiAnyCodable.init) - ) - let receipt: ChatFirstBlockValidationReceipt = try await api.post( - "v1/chat-first/blocks/validate", - body: request, - expectedOwnerId: expectedOwnerID, - authorizationSnapshot: authorizationSnapshot - ) - guard isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { - return authorizedOwnerChangedResult() - } - guard let journalBlocks = ChatFirstBlockWire.journalBlocks(from: receipt) else { - return #"{"ok":false,"error":{"code":"chat_first_blocks_rejected"}}"# - } - _ = try await AgentRuntimeProcess.shared.appendChatFirstBlocks( - clientId: "chat-first-render", - surface: surface, - ownerID: expectedOwnerID, - sessionID: sessionID, - runID: runID, - attemptID: attemptID, - capabilityRef: capabilityRef, - controlGeneration: controlGeneration, - blocks: journalBlocks, - authorizationSnapshot: authorizationSnapshot - ) - return #"{"ok":true,"rendered":#(journalBlocks.count)}"# - } catch { - guard isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { - return authorizedOwnerChangedResult() - } - logError("Chat-first block rendering failed", error: error) - return #"{"ok":false,"error":{"code":"chat_first_blocks_unavailable"}}"# - } - } - // MARK: - Onboarding Tools /// Request a specific macOS permission diff --git a/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift b/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift index 58b324a4576..05af5ef4455 100644 --- a/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift +++ b/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift @@ -135,7 +135,7 @@ final class AgentRuntimeProcessTests: XCTestCase { func testRuntimeHandshakeRejectsStaleV2RuntimeWithoutRequiredCapability() throws { let valid = try XCTUnwrap( AgentRuntimeProcess.RuntimeMessage.parse( - #"{"type":"init","protocolVersion":2,"sessionId":"","agentControlTools":[],"runtimeVersion":"1.0.0","runtimeCapabilities":["journal_import_remote_turn","runtime_adapter_availability"]}"# + #"{"type":"init","protocolVersion":2,"sessionId":"","agentControlTools":[],"runtimeVersion":"1.0.0","runtimeCapabilities":["journal_import_remote_turn","runtime_adapter_availability","chat_first_capability_projection"]}"# )) let handshake = try AgentRuntimeProcess.validateRuntimeHandshake(valid) XCTAssertEqual(handshake.protocolVersion, AgentRuntimeProcess.expectedProtocolVersion) @@ -149,7 +149,7 @@ final class AgentRuntimeProcessTests: XCTestCase { let wrongProtocol = try XCTUnwrap( AgentRuntimeProcess.RuntimeMessage.parse( - #"{"type":"init","protocolVersion":1,"sessionId":"","agentControlTools":[],"runtimeVersion":"1.0.0","runtimeCapabilities":["journal_import_remote_turn"]}"# + #"{"type":"init","protocolVersion":1,"sessionId":"","agentControlTools":[],"runtimeVersion":"1.0.0","runtimeCapabilities":["journal_import_remote_turn","runtime_adapter_availability","chat_first_capability_projection"]}"# )) XCTAssertThrowsError(try AgentRuntimeProcess.validateRuntimeHandshake(wrongProtocol)) } @@ -356,7 +356,7 @@ final class AgentRuntimeProcessTests: XCTestCase { let bridgeSource = try sourceFile("Chat/AgentBridge.swift") let startRange = try XCTUnwrap( bridgeSource.range( - of: "private func start(\n authorizationSnapshot:")) + of: "func start(\n authorizationSnapshot:")) let restartRange = try XCTUnwrap( bridgeSource.range( of: "\n func restart() async throws", diff --git a/desktop/macos/Desktop/Tests/AuthorizedToolExecutionTests.swift b/desktop/macos/Desktop/Tests/AuthorizedToolExecutionTests.swift index dd8a20d5735..3c856e7438e 100644 --- a/desktop/macos/Desktop/Tests/AuthorizedToolExecutionTests.swift +++ b/desktop/macos/Desktop/Tests/AuthorizedToolExecutionTests.swift @@ -23,6 +23,39 @@ final class AuthorizedToolExecutionTests: XCTestCase { XCTAssertEqual(command.canonicalToolName, "semantic_search") } + func testChatFirstToolRequiresMainChatCapabilityAndDynamicManifest() throws { + let command = try AuthorizedToolExecution.parse( + payload( + toolName: "render_chat_blocks", + overrides: [ + "manifestDigest": GeneratedToolExecutors.chatFirstManifestDigest, + "surfaceKind": "main_chat", + "chatFirstControlGeneration": 7, + ]), + currentOwnerID: "owner-1") + + XCTAssertEqual(command.canonicalToolName, "render_chat_blocks") + XCTAssertEqual(command.chatFirstControlGeneration, 7) + + XCTAssertThrowsError( + try AuthorizedToolExecution.parse( + payload( + toolName: "render_chat_blocks", + overrides: [ + "manifestDigest": GeneratedToolExecutors.chatFirstManifestDigest, + "surfaceKind": "main_chat", + ]), + currentOwnerID: "owner-1")) { error in + XCTAssertEqual(error as? AuthorizedToolExecution.Rejection, .invalidChatFirstCapability) + } + XCTAssertThrowsError( + try AuthorizedToolExecution.parse( + payload(overrides: ["chatFirstControlGeneration": 7]), + currentOwnerID: "owner-1")) { error in + XCTAssertEqual(error as? AuthorizedToolExecution.Rejection, .invalidChatFirstCapability) + } + } + func testWrongOwnerAndManifestFailClosed() { XCTAssertThrowsError( try AuthorizedToolExecution.parse(payload(), currentOwnerID: "owner-other") @@ -278,6 +311,7 @@ final class AuthorizedToolExecutionTests: XCTestCase { "manifestDigest": GeneratedToolExecutors.manifestDigest, "daemonBootEpoch": "boot-1", "executionGeneration": 3, + "capabilityRef": "capability-1", "toolName": toolName, "input": input, "inputHash": try! AuthorizedToolExecution.inputHash(for: input), diff --git a/desktop/macos/Desktop/Tests/ChatFirstAnalyticsTests.swift b/desktop/macos/Desktop/Tests/ChatFirstAnalyticsTests.swift index b71b82c0857..4f80a177578 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstAnalyticsTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstAnalyticsTests.swift @@ -65,28 +65,6 @@ final class ChatFirstAnalyticsTests: XCTestCase { XCTAssertTrue(Set(payload.properties.keys).isDisjoint(with: prohibitedKeys)) } - func testChatFirstInteractionSurfacesUseAnalyticsManagerAsTheirOnlyTelemetryPath() throws { - // omi-test-quality: source-inspection -- static contract: direct telemetry - // calls could bypass the closed mapper without a runtime-observable result. - let desktopDirectory = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - let sources = [ - "Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift", - "Sources/MainWindow/ChatFirst/ChatFirstRoute.swift", - "Sources/MainWindow/ChatFirst/ChatFirstShell.swift", - "Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift", - "Sources/MainWindow/Components/ChatBubble.swift", - "Sources/MainWindow/DesktopHomeView.swift", - ] - - for path in sources { - let source = try String(contentsOf: desktopDirectory.appendingPathComponent(path), encoding: .utf8) - XCTAssertFalse(source.contains("PostHogManager.shared.track"), "\(path) bypasses AnalyticsManager") - XCTAssertFalse(source.contains("PostHogManager.shared.capture"), "\(path) bypasses AnalyticsManager") - } - } - func testCapabilityGenerationIsBucketedAndTaskResultHasNoTransportDimension() { XCTAssertEqual(ChatFirstAnalyticsEvent.CapabilityGenerationBucket.bucket(for: nil), .none) XCTAssertEqual(ChatFirstAnalyticsEvent.CapabilityGenerationBucket.bucket(for: 0), .zeroToNine) @@ -107,52 +85,4 @@ final class ChatFirstAnalyticsTests: XCTestCase { ) } - func testNonProductionFixtureContractUsesOnlyDeterministicShapeFacts() { - let interactive = ChatFirstAutomationFixture.contract(for: .interactiveQuestion) - XCTAssertEqual(interactive.validRichBlockCount, 1) - XCTAssertTrue(interactive.hasValidQuestion) - XCTAssertTrue(interactive.hasPreparedAnswer) - XCTAssertEqual(interactive.proactiveJudgeCalls, 0) - XCTAssertEqual(interactive.materializationCount, 0) - XCTAssertEqual(interactive.rawManifestProofMode, "external_raw_bytes_digest") - XCTAssertEqual(interactive.shellVariant, "chatFirst") - XCTAssertEqual(interactive.chatFirstToolCount, 2) - - let deferred = ChatFirstAutomationFixture.contract(for: .deferredQuestion) - XCTAssertEqual(deferred.deferralSeconds, 86_400) - XCTAssertEqual(deferred.fakeClockEpochSeconds, interactive.fakeClockEpochSeconds) - - let capture = ChatFirstAutomationFixture.contract(for: .mixedCapture) - XCTAssertEqual(capture.captureSourceMode, "mixed") - XCTAssertFalse(capture.hasPreparedAnswer) - XCTAssertTrue( - Set(capture.bridgeDetail.keys).isDisjoint(with: ["text", "answer", "title", "id", "transcript", "url", "error"]) - ) - - for scenario in [ChatFirstAutomationFixture.Scenario.uiFlagOff, .outOfCohort] { - let disabled = ChatFirstAutomationFixture.contract(for: scenario) - XCTAssertEqual(disabled.shellVariant, "legacy") - XCTAssertEqual(disabled.chatFirstToolCount, 0) - XCTAssertEqual(disabled.validRichBlockCount, 0) - XCTAssertEqual(disabled.materializationCount, 0) - XCTAssertEqual(disabled.proactiveJudgeCalls, 0) - XCTAssertEqual(disabled.proactiveEmissions, 0) - } - } - - @MainActor - func testFixtureBridgeActionIsDiscoverableWithoutContentParameters() { - let registry = DesktopAutomationActionRegistry.shared - registry.registerBuiltins() - let descriptor = registry.descriptors().first { $0.name == "chat_first_fixture_contract" } - - guard AppBuild.isNonProduction else { - XCTAssertNil(descriptor) - return - } - XCTAssertEqual(descriptor?.params, ["scenario"]) - XCTAssertEqual(descriptor?.category, "read") - XCTAssertEqual(descriptor?.safety, "read_only") - XCTAssertEqual(descriptor?.sideEffects, []) - } } diff --git a/desktop/macos/Desktop/Tests/ChatFirstAutomationRuntimeTests.swift b/desktop/macos/Desktop/Tests/ChatFirstAutomationRuntimeTests.swift new file mode 100644 index 00000000000..54cfaf44169 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstAutomationRuntimeTests.swift @@ -0,0 +1,37 @@ +import XCTest + +@testable import Omi_Computer + +final class ChatFirstAutomationRuntimeTests: XCTestCase { + func testQuestionAutomationSelectsOnlyBoundedFirstOrDeferralOptions() { + let options: [[String: Any]] = [ + ["optionId": "later", "defer": true], + ["optionId": "continue", "defer": false], + ["optionId": " ", "defer": false], + ] + + XCTAssertEqual( + ChatFirstQuestionAutomationSelectionPolicy.optionID(in: options, selection: .first), + "continue" + ) + XCTAssertEqual( + ChatFirstQuestionAutomationSelectionPolicy.optionID(in: options, selection: .deferred), + "later" + ) + } + + func testQuestionAutomationNeverFallsBackToCallerSuppliedOrMalformedOptionID() { + XCTAssertNil( + ChatFirstQuestionAutomationSelectionPolicy.optionID( + in: [["optionId": " ", "defer": true]], + selection: .deferred + ) + ) + XCTAssertNil( + ChatFirstQuestionAutomationSelectionPolicy.optionID( + in: [["optionId": "continue", "defer": false]], + selection: .deferred + ) + ) + } +} diff --git a/desktop/macos/Desktop/Tests/ChatFirstLegacyGoalIsolationTests.swift b/desktop/macos/Desktop/Tests/ChatFirstLegacyGoalIsolationTests.swift deleted file mode 100644 index 699ec75dae7..00000000000 --- a/desktop/macos/Desktop/Tests/ChatFirstLegacyGoalIsolationTests.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation -import XCTest - -@testable import Omi_Computer - -final class ChatFirstLegacyGoalIsolationTests: XCTestCase { - func testChatFirstSourcesDoNotReferenceLegacyGoalModels() throws { - // omi-test-quality: source-inspection -- static contract: cohort-only goal - // UI must never add a legacy-goal dependency, which behavior tests cannot - // observe after a forbidden fallback has already been compiled in. - let testsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - let chatFirstDirectory = testsDirectory - .deletingLastPathComponent() - .appendingPathComponent("Sources/MainWindow/ChatFirst") - let forbiddenSymbols = ["GoalStorage", "GoalRecord", "GoalsHistoryPage"] - let files = FileManager.default.enumerator( - at: chatFirstDirectory, - includingPropertiesForKeys: [.isRegularFileKey] - )?.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" } ?? [] - - for file in files { - let source = try String(contentsOf: file, encoding: .utf8) - for symbol in forbiddenSymbols { - XCTAssertFalse(source.contains(symbol), "\(file.lastPathComponent) must not depend on \(symbol)") - } - } - } -} diff --git a/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift index 302a520600a..bda3dc4a09e 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift @@ -3,6 +3,36 @@ import XCTest @testable import Omi_Computer final class ChatFirstRichBlockTests: XCTestCase { + func testBlockWireRejectsTheEntireToolPayloadWhenAnyBlockIsMalformed() { + let converted = ChatFirstBlockWire.backendBlocks( + from: [ + "blocks": [ + ["type": "taskCard", "taskId": "task-1"], + ["type": "taskCard"], + ] + ] + ) + + XCTAssertNil(converted, "render_chat_blocks must fail closed instead of rendering a valid subset") + } + + func testBlockWirePreservesEveryValidatedToolBlock() throws { + let converted = try XCTUnwrap( + ChatFirstBlockWire.backendBlocks( + from: [ + "blocks": [ + ["type": "taskCard", "taskId": "task-1"], + ["type": "goalLink", "goalId": "goal-1", "summary": "Ship the plan"], + ] + ] + ) + ) + + XCTAssertEqual(converted.count, 2) + XCTAssertEqual(converted[0]["task_id"] as? String, "task-1") + XCTAssertEqual(converted[1]["goal_id"] as? String, "goal-1") + } + func testCodecRoundTripsEveryChatFirstBlock() throws { let blocks: [ChatContentBlock] = [ .questionCard( diff --git a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift index 2666735df82..cc50749ea0a 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift @@ -78,6 +78,28 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertFalse(restored.isFocusedEntityAcknowledged) } + func testRouteIsNotVisibleUntilTheMountedDestinationAcknowledgesIt() { + let suiteName = "ChatFirstShellTests.visible-route.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let navigation = ChatFirstShellNavigation(defaults: defaults) + XCTAssertNil(navigation.visibleRoute) + + navigation.selectPrimary(.goals) + XCTAssertEqual(navigation.route, .goals) + XCTAssertNil(navigation.visibleRoute) + + navigation.markRouteVisible(.tasks) + XCTAssertNil(navigation.visibleRoute) + navigation.markRouteVisible(.goals) + XCTAssertEqual(navigation.visibleRoute, .goals) + + navigation.open(focus: .task(id: "task-1")) + XCTAssertEqual(navigation.route, .tasks) + XCTAssertNil(navigation.visibleRoute) + } + func testDirectAndLegacyNavigationClearFocusAndMapToTypedRoutes() { let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! @@ -149,6 +171,40 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertNil(ChatFirstRoute.primaryAutomationDestination(named: "settings")) } + func testAutomationNavigationVisibilityAcceptsTheMountedShellForSharedNames() { + XCTAssertEqual(ChatFirstRoute.automationVisibilityDestination(named: "settings"), .more(.settings)) + XCTAssertEqual(ChatFirstRoute.automationVisibilityDestination(named: "help"), .more(.help)) + XCTAssertEqual(ChatFirstRoute.automationVisibilityDestination(named: "home"), .more(.dashboard)) + + XCTAssertTrue( + DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( + shellVariant: "chat_first", + selectedTab: nil, + visibleChatFirstRoute: "tasks", + expectedChatFirstRoute: "tasks", + expectedLegacyTitle: "Tasks" + ) + ) + XCTAssertTrue( + DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( + shellVariant: "legacy", + selectedTab: "Tasks", + visibleChatFirstRoute: nil, + expectedChatFirstRoute: "tasks", + expectedLegacyTitle: "Tasks" + ) + ) + XCTAssertFalse( + DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( + shellVariant: "loading", + selectedTab: "Tasks", + visibleChatFirstRoute: nil, + expectedChatFirstRoute: "tasks", + expectedLegacyTitle: "Tasks" + ) + ) + } + func testProjectionGatePassesOnlyEnabledMainChatForSampledOwner() throws { var gate = ChatFirstMainChatProjectionGate() XCTAssertFalse(gate.isConfigured(for: "owner-a")) diff --git a/desktop/macos/agent/src/index.ts b/desktop/macos/agent/src/index.ts index bd723127a3c..55cf3ed8bcd 100644 --- a/desktop/macos/agent/src/index.ts +++ b/desktop/macos/agent/src/index.ts @@ -68,6 +68,7 @@ import type { JournalBackendDeleteResultMessage, JournalBackendReconcileResultMessage, ChatFirstDeferralDeliveryResultMessage, + ChatFirstHarnessExecutorBeginMessage, RefreshOwnerMessage, RevokeOwnerRuntimeMessage, RefreshTokenMessage, @@ -299,6 +300,41 @@ const pendingExternalToolCalls = new Map< } >(); +/** + * This exists solely for the local/offline desktop E2E fixture. Unlike the + * external-surface bridge, it cannot accept a user/model-selected tool or + * capability: the kernel derives the one permitted capability from an + * already-mounted Main Chat session. + */ +const pendingChatFirstHarnessExecutors = new Map< + string, + { + requestId: string; + clientId: string; + invocation: AuthorizedRunToolInvocation; + timeout: ReturnType; + } +>(); + +const CHAT_FIRST_HARNESS_TASK_ID = "chat-first-e2e-task-v1"; + +function isLocalChatFirstExecutorHarnessEnabled(): boolean { + return (process.env.OMI_ENV_STAGE === "local" || process.env.OMI_ENV_STAGE === "offline") + && process.env.OMI_AGENT_ALLOW_CONTROL_ONLY === "1"; +} + +function isBoundedChatFirstHarnessInput(input: unknown): input is Record { + if (!input || typeof input !== "object" || Array.isArray(input)) return false; + const outer = input as Record; + if (Object.keys(outer).length !== 1 || !Array.isArray(outer.blocks) || outer.blocks.length !== 1) return false; + const block = outer.blocks[0]; + if (!block || typeof block !== "object" || Array.isArray(block)) return false; + const taskCard = block as Record; + return Object.keys(taskCard).length === 2 + && taskCard.type === "taskCard" + && taskCard.taskId === CHAT_FIRST_HARNESS_TASK_ID; +} + const TERMINAL_RUN_TOOL_EVENTS = new Set([ "run.succeeded", "run.failed", @@ -362,6 +398,99 @@ function finalizeRelayResult( /** Resolve a pending tool call with a result from Swift */ function resolveToolCall(msg: AuthorizedToolExecutionResultMessage): void { const key = toolCallPendingKey(msg); + const chatFirstHarness = pendingChatFirstHarnessExecutors.get(key); + if (chatFirstHarness) { + pendingChatFirstHarnessExecutors.delete(key); + clearTimeout(chatFirstHarness.timeout); + const invocation = chatFirstHarness.invocation; + const identityMatches = msg.ownerId === invocation.ownerId + && msg.sessionId === invocation.sessionId + && msg.runId === invocation.runId + && msg.attemptId === invocation.attemptId + && msg.profileGeneration === invocation.profileGeneration + && msg.manifestVersion === invocation.manifestVersion + && msg.manifestDigest === invocation.manifestDigest + && msg.daemonBootEpoch === invocation.daemonBootEpoch + && msg.executionGeneration === invocation.executionGeneration + && msg.inputHash === invocation.inputHash; + let validated = false; + try { + if (!runtimeKernel) throw new Error("Agent runtime kernel is not ready"); + if (!identityMatches) { + runtimeKernel.markRunToolInvocationOutcomeUnknown(invocation, "chat_first_e2e_result_mismatch"); + } else { + runtimeKernel.completeRunToolInvocation({ + invocationId: invocation.invocationId, + ownerId: invocation.ownerId, + sessionId: invocation.sessionId, + runId: invocation.runId, + attemptId: invocation.attemptId, + profileGeneration: invocation.profileGeneration, + manifestVersion: invocation.manifestVersion, + manifestDigest: invocation.manifestDigest, + daemonBootEpoch: invocation.daemonBootEpoch, + executionGeneration: invocation.executionGeneration, + inputHash: invocation.inputHash, + capabilityRef: invocation.capabilityRef, + activeOwnerId: currentOwnerId, + outcome: msg.outcome, + result: msg.result, + }); + const result = JSON.parse(msg.result) as Record; + validated = msg.outcome === "succeeded" && result.ok === true && result.rendered === 1; + } + runtimeKernel.completeChatFirstHarnessExecutor({ + ownerId: invocation.ownerId, + sessionId: invocation.sessionId, + runId: invocation.runId, + attemptId: invocation.attemptId, + succeeded: validated, + }); + send({ + type: "chat_first_harness_executor_result", + requestId: chatFirstHarness.requestId, + clientId: chatFirstHarness.clientId, + ownerId: invocation.ownerId, + sessionId: invocation.sessionId, + runId: invocation.runId, + attemptId: invocation.attemptId, + ok: validated, + executorInvoked: true, + validated, + journalBlockRendered: validated, + ...(validated ? {} : { error: { code: "chat_first_e2e_executor_failed", message: "The executor did not render the fixture block" } }), + }); + } catch (error) { + logErr(`Rejected Chat-first E2E executor result invocation=${msg.invocationId}: ${error}`); + try { + runtimeKernel?.markRunToolInvocationOutcomeUnknown(invocation, "chat_first_e2e_result_rejected"); + runtimeKernel?.completeChatFirstHarnessExecutor({ + ownerId: invocation.ownerId, + sessionId: invocation.sessionId, + runId: invocation.runId, + attemptId: invocation.attemptId, + succeeded: false, + }); + } catch (terminalizeError) { + logErr(`Failed to terminalize rejected Chat-first E2E executor: ${terminalizeError}`); + } + send({ + type: "chat_first_harness_executor_result", + requestId: chatFirstHarness.requestId, + clientId: chatFirstHarness.clientId, + ownerId: invocation.ownerId, + sessionId: invocation.sessionId, + runId: invocation.runId, + attemptId: invocation.attemptId, + ok: false, + executorInvoked: true, + validated: false, + journalBlockRendered: false, + error: externalAuthorityError(error, "chat_first_e2e_result_rejected"), + }); + } + return; + } const pending = pendingToolCalls.get(key); if (pending) { try { @@ -488,6 +617,69 @@ function registerPendingExternalToolCall( return pending; } +function finishPendingChatFirstHarnessExecutor( + pending: { + requestId: string; + clientId: string; + invocation: AuthorizedRunToolInvocation; + timeout: ReturnType; + }, + errorCode: string, + message: string, +): void { + try { + runtimeKernel?.markRunToolInvocationOutcomeUnknown(pending.invocation, errorCode); + runtimeKernel?.completeChatFirstHarnessExecutor({ + ownerId: pending.invocation.ownerId, + sessionId: pending.invocation.sessionId, + runId: pending.invocation.runId, + attemptId: pending.invocation.attemptId, + succeeded: false, + }); + } catch (error) { + logErr(`Failed to terminalize Chat-first E2E executor: ${error}`); + } + send({ + type: "chat_first_harness_executor_result", + requestId: pending.requestId, + clientId: pending.clientId, + ownerId: pending.invocation.ownerId, + sessionId: pending.invocation.sessionId, + runId: pending.invocation.runId, + attemptId: pending.invocation.attemptId, + ok: false, + executorInvoked: true, + validated: false, + journalBlockRendered: false, + error: { code: errorCode, message }, + }); +} + +function registerPendingChatFirstHarnessExecutor(input: { + requestId: string; + clientId: string; + invocation: AuthorizedRunToolInvocation; +}): void { + const key = toolCallPendingKey(input.invocation); + if (pendingChatFirstHarnessExecutors.has(key) || pendingExternalToolCalls.has(key) || pendingToolCalls.has(key)) { + throw Object.assign(new Error("Duplicate tool invocation"), { code: "invocation_replayed" }); + } + const pending = { + ...input, + timeout: setTimeout(() => { + const active = pendingChatFirstHarnessExecutors.get(key); + if (!active) return; + pendingChatFirstHarnessExecutors.delete(key); + finishPendingChatFirstHarnessExecutor( + active, + "swift_tool_timeout", + "Timed out waiting for the authorized Chat-first block executor", + ); + }, 120_000), + }; + pendingChatFirstHarnessExecutors.set(key, pending); +} + function cancelPendingExternalToolCallsForAttempt(input: { ownerId: string; runId: string; @@ -556,6 +748,12 @@ function rejectPendingToolCallsForOwner( error: { code: errorCode, message }, }); } + for (const [key, pending] of pendingChatFirstHarnessExecutors) { + if (pending.invocation.ownerId !== ownerId) continue; + pendingChatFirstHarnessExecutors.delete(key); + clearTimeout(pending.timeout); + finishPendingChatFirstHarnessExecutor(pending, errorCode, message); + } } /** The broker terminalizes the ledger before subscribers see terminal events. */ @@ -595,6 +793,16 @@ function rejectPendingToolCallsForKernelEvent(event: AgentEvent): void { error: { code: errorCode, message: "Run tool authority ended before Swift returned a result" }, }); } + for (const [key, pending] of pendingChatFirstHarnessExecutors) { + if (!matches(pending.invocation)) continue; + pendingChatFirstHarnessExecutors.delete(key); + clearTimeout(pending.timeout); + finishPendingChatFirstHarnessExecutor( + pending, + errorCode, + "Run tool authority ended before Swift returned a result", + ); + } } function resolveClientToolCalls(client: Socket, result: string): void { @@ -1870,6 +2078,80 @@ async function main(): Promise { break; } + case "chat_first_harness_executor_begin": { + const request = msg as ChatFirstHarnessExecutorBeginMessage; + const requestId = request.requestId?.trim(); + const clientId = request.clientId?.trim(); + try { + if (!isLocalChatFirstExecutorHarnessEnabled()) { + throw new Error("Chat-first executor harness is available only to local/offline debug runtime"); + } + if (!requestId || !clientId) { + throw new Error("Chat-first executor harness requires requestId and clientId"); + } + if (!isBoundedChatFirstHarnessInput(request.input)) { + throw new Error("Chat-first executor harness accepts only the static fixture task card"); + } + const ownerId = resolveActiveOwner(request.ownerId); + const authorized = kernel.beginChatFirstHarnessExecutor({ + ownerId, + sessionId: request.sessionId, + producingTurnId: request.producingTurnId, + controlGeneration: request.controlGeneration, + clientId, + requestId, + toolInput: request.input, + }); + registerPendingChatFirstHarnessExecutor({ requestId, clientId, invocation: authorized }); + send({ + type: "authorized_tool_execution", + invocationId: authorized.invocationId, + ownerId: authorized.ownerId, + sessionId: authorized.sessionId, + runId: authorized.runId, + attemptId: authorized.attemptId, + profileGeneration: authorized.profileGeneration, + manifestVersion: authorized.manifestVersion, + manifestDigest: authorized.manifestDigest, + daemonBootEpoch: authorized.daemonBootEpoch, + executionGeneration: authorized.executionGeneration, + capabilityRef: authorized.capabilityRef, + toolName: authorized.canonicalToolName, + input: request.input, + inputHash: authorized.inputHash, + effectClass: authorized.effectClass, + retryPolicy: authorized.retryPolicy, + surfaceKind: authorized.surfaceKind, + externalRefKind: authorized.externalRefKind, + externalRefId: authorized.externalRefId, + originatingUserText: authorized.originatingUserText, + precedingAssistantText: authorized.precedingAssistantText, + runMode: authorized.runMode, + chatMode: authorized.chatMode, + ...(authorized.canonicalToolName === "render_chat_blocks" + && authorized.chatFirstControlGeneration !== null + ? { chatFirstControlGeneration: authorized.chatFirstControlGeneration } + : {}), + }); + } catch (error) { + send({ + type: "chat_first_harness_executor_result", + requestId: requestId ?? "", + clientId: clientId ?? "", + ownerId: request.ownerId ?? "", + sessionId: request.sessionId ?? "", + runId: "", + attemptId: "", + ok: false, + executorInvoked: false, + validated: false, + journalBlockRendered: false, + error: externalAuthorityError(error, "chat_first_e2e_executor_rejected"), + }); + } + break; + } + case "authorized_tool_execution_result": resolveToolCall(msg); break; diff --git a/desktop/macos/agent/src/protocol.ts b/desktop/macos/agent/src/protocol.ts index 53ac40dfdee..67b6417f240 100644 --- a/desktop/macos/agent/src/protocol.ts +++ b/desktop/macos/agent/src/protocol.ts @@ -527,6 +527,20 @@ export interface RefreshOwnerMessage { ownerId: string; } +/** + * Local/offline-only E2E probe for the real Chat-first Swift tool executor. + * The kernel derives capability from the already-resolved session; this message + * cannot carry or manufacture a rollout projection. + */ +export interface ChatFirstHarnessExecutorBeginMessage extends ProtocolEnvelope { + type: "chat_first_harness_executor_begin"; + ownerId: string; + sessionId: string; + producingTurnId: string; + controlGeneration: number; + input: Record; +} + export type InboundMessage = | QueryMessage | AuthorizedToolExecutionResultMessage @@ -563,6 +577,7 @@ export type InboundMessage = | JournalBackendDeleteResultMessage | JournalBackendReconcileResultMessage | ChatFirstDeferralDeliveryResultMessage + | ChatFirstHarnessExecutorBeginMessage | RefreshTokenMessage | RefreshOwnerMessage; @@ -692,6 +707,20 @@ export interface ExternalSurfaceRunCompleteResultMessage extends OutboundEnvelop error?: ExternalAuthorityError; } +/** Shape-only completion for the local/offline Chat-first executor probe. */ +export interface ChatFirstHarnessExecutorResultMessage extends OutboundEnvelope { + type: "chat_first_harness_executor_result"; + ownerId: string; + sessionId: string; + runId: string; + attemptId: string; + ok: boolean; + executorInvoked: boolean; + validated: boolean; + journalBlockRendered: boolean; + error?: ExternalAuthorityError; +} + export interface OwnerRuntimeRevokedMessage extends OutboundEnvelope { type: "owner_runtime_revoked"; ownerId: string; @@ -1106,6 +1135,7 @@ export type OutboundMessage = | ExternalSurfaceRunBeginResultMessage | ExternalSurfaceToolResultMessage | ExternalSurfaceRunCompleteResultMessage + | ChatFirstHarnessExecutorResultMessage | OwnerRuntimeRevokedMessage | ControlToolResultMessage | DefaultExecutionProfileConfiguredMessage @@ -1143,6 +1173,7 @@ export type OutboundMessageDraft = | DraftEnvelope | DraftEnvelope | DraftEnvelope + | DraftEnvelope | DraftEnvelope | DraftEnvelope | DraftEnvelope diff --git a/desktop/macos/agent/src/runtime/kernel-sessions.ts b/desktop/macos/agent/src/runtime/kernel-sessions.ts index 29ffdf30a67..a1ee6a21735 100644 --- a/desktop/macos/agent/src/runtime/kernel-sessions.ts +++ b/desktop/macos/agent/src/runtime/kernel-sessions.ts @@ -143,6 +143,8 @@ import { type EnsureAgentSpawnJournalInput, type EnsureAgentSpawnJournalResult, } from "./agent-spawn-journal.js"; +import { conversationIdForSession } from "./conversation-turns.js"; +import type { AuthorizedRunToolInvocation } from "./run-tool-capability.js"; export class KernelSessions extends KernelArtifacts { /** Process-local only: never back this with SQLite or a user preference. */ @@ -212,6 +214,124 @@ export class KernelSessions extends KernelArtifacts { ); } + /** + * Local/offline E2E admission for the real `render_chat_blocks` executor. + * Session ownership and the ephemeral rollout projection both belong to this + * session layer, so the probe cannot manufacture either one from its input. + */ + beginChatFirstHarnessExecutor(input: { + ownerId: string; + sessionId: string; + producingTurnId: string; + controlGeneration: number; + clientId: string; + requestId: string; + toolInput: Record; + }): AuthorizedRunToolInvocation { + const session = this.ownedSession(input.sessionId, input.ownerId); + if (session.surfaceKind !== "main_chat") { + throw new Error("Chat-first E2E executor requires an existing main Chat session"); + } + const admitted = this.contextSnapshot(input.sessionId, input.ownerId, "main_chat"); + if ( + admitted.capabilities.chatFirstUi !== true + || admitted.capabilities.chatFirstControlGeneration !== input.controlGeneration + || !admitted.capabilities.allowedToolNames.includes("render_chat_blocks") + ) { + throw new Error("Chat-first E2E executor requires the mounted server-derived capability"); + } + const accepted = this.createAcceptedRun({ + sessionId: input.sessionId, + ownerId: input.ownerId, + surfaceKind: "main_chat", + clientId: input.clientId, + requestId: input.requestId, + producingTurnId: input.producingTurnId, + prompt: "Local Chat-first executor probe", + mode: "ask", + admittedContextSnapshot: admitted, + }); + const conversationId = conversationIdForSession(this.store, accepted.session.sessionId); + if (!conversationId) throw new Error("Chat-first E2E executor requires a canonical Chat conversation"); + const attempt = this.createAttempt({ + runId: accepted.run.runId, + attemptNo: 1, + adapterId: accepted.session.defaultAdapterId, + retryReason: null, + resumeFromAttemptId: null, + producingTurn: { + ownerId: accepted.session.ownerId, + sessionId: accepted.session.sessionId, + conversationId, + turnId: input.producingTurnId, + }, + }); + const capability = this.toolCapabilities.register({ + ownerId: accepted.session.ownerId, + sessionId: accepted.session.sessionId, + runId: accepted.run.runId, + attemptId: attempt.attemptId, + }); + const invocation = this.toolCapabilities.authorize({ + capabilityRef: capability.capabilityRef, + invocationId: `chat_first_e2e_${accepted.run.runId}`, + runId: accepted.run.runId, + attemptId: attempt.attemptId, + toolName: "render_chat_blocks", + toolInput: input.toolInput, + activeOwnerId: input.ownerId, + }); + if ( + invocation.canonicalToolName !== "render_chat_blocks" + || invocation.surfaceKind !== "main_chat" + || invocation.chatFirstUi !== true + || invocation.chatFirstControlGeneration !== input.controlGeneration + ) { + throw new Error("Chat-first E2E executor did not receive the authorized main-Chat tool"); + } + this.markRunToolInvocationDispatched(invocation); + return invocation; + } + + completeChatFirstHarnessExecutor(input: { + ownerId: string; + sessionId: string; + runId: string; + attemptId: string; + succeeded: boolean; + }): void { + const session = this.ownedSession(input.sessionId, input.ownerId); + const run = this.readRun(input.runId); + const attempt = this.readAttempt(input.attemptId); + if ( + session.surfaceKind !== "main_chat" + || run.sessionId !== session.sessionId + || attempt.runId !== run.runId + || this.readLatestAttempt(run.runId).attemptId !== attempt.attemptId + ) { + throw new Error("Chat-first E2E executor completion does not own the active run"); + } + const pendingInvocations = Number(this.store.getRow( + `SELECT COUNT(*) AS count FROM tool_invocation_ledger + WHERE run_id = ? AND attempt_id = ? AND status IN ('prepared', 'dispatched')`, + [input.runId, input.attemptId], + ).count); + if (pendingInvocations > 0) { + throw new Error("Chat-first E2E executor cannot finish while its tool invocation is pending"); + } + this.withTransaction(() => { + this.finishAttemptAndRun({ + sessionId: input.sessionId, + runId: input.runId, + attemptId: input.attemptId, + status: input.succeeded ? "succeeded" : "failed", + finalText: null, + errorCode: input.succeeded ? null : "chat_first_e2e_executor_failed", + errorMessage: input.succeeded ? null : "Chat-first E2E executor failed", + }); + }); + } + contextSnapshotForExactSurface( ownerId: string, surface: { surfaceKind: string; externalRefKind: string; externalRefId: string }, diff --git a/desktop/macos/agent/tests/sqlite-store.test.ts b/desktop/macos/agent/tests/sqlite-store.test.ts index d4ef16b9a6e..545f1313240 100644 --- a/desktop/macos/agent/tests/sqlite-store.test.ts +++ b/desktop/macos/agent/tests/sqlite-store.test.ts @@ -21,13 +21,19 @@ describe("SqliteAgentStore", () => { store.migrate(); store.migrate(); - expect(store.getRow("SELECT COUNT(*) AS count FROM schema_migrations").count).toBe(27); + expect(store.getRow("SELECT COUNT(*) AS count FROM schema_migrations").count).toBe(30); + expect(store.allRows("SELECT version FROM schema_migrations ORDER BY version")).toEqual( + Array.from({ length: 30 }, (_, index) => ({ version: index + 1 })), + ); expect(tableNames(store)).toEqual([ "adapter_bindings", "artifacts", "backend_conversation_delete_outbox", "backend_reconcile_state", "backend_turn_outbox", + "chat_first_cold_start_sequence_receipts", + "chat_first_deferral_outbox", + "chat_first_materialization_receipts", "cleared_backend_turn_claims", "completion_delta_checkpoints", "context_owner_snapshot_state", @@ -1203,6 +1209,15 @@ describe("SqliteAgentStore", () => { expect(execStatements.some((statement) => statement.includes("CREATE TABLE IF NOT EXISTS desktop_memory_candidates"))).toBe(true); expect(execStatements.some((statement) => statement.includes("CREATE TABLE IF NOT EXISTS desktop_context_access_log"))).toBe(true); expect(execStatements.some((statement) => statement.includes("CREATE TABLE IF NOT EXISTS desktop_attention_overrides"))).toBe(true); + expect(execStatements.some((statement) => statement.includes("CREATE TABLE chat_first_deferral_outbox"))).toBe( + true, + ); + expect( + execStatements.some((statement) => statement.includes("CREATE TABLE chat_first_materialization_receipts")), + ).toBe(true); + expect( + execStatements.some((statement) => statement.includes("CREATE TABLE chat_first_cold_start_sequence_receipts")), + ).toBe(true); }); it("stores no legacy_default grant rows in a fresh database", () => { diff --git a/desktop/macos/agent/tests/workstream-continuity.test.ts b/desktop/macos/agent/tests/workstream-continuity.test.ts index ee336f0504e..9c8e8c327e9 100644 --- a/desktop/macos/agent/tests/workstream-continuity.test.ts +++ b/desktop/macos/agent/tests/workstream-continuity.test.ts @@ -119,7 +119,7 @@ describe("workstream continuity", () => { delivery_status: "blocked", status: "rejected", }); - expect(store.getRow("SELECT COUNT(*) AS count FROM schema_migrations").count).toBe(27); + expect(store.getRow("SELECT COUNT(*) AS count FROM schema_migrations").count).toBe(30); store.close(); }); diff --git a/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml b/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml new file mode 100644 index 00000000000..429a3276a65 --- /dev/null +++ b/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml @@ -0,0 +1,62 @@ +version: 2 +name: chat-first-capability-isolation +tier: manual +description: "Manual local/offline capability-off assertion. Run it once for each isolated fixture case in the three-launch matrix below." +app: non-prod +# A harness invocation owns one automation port, hence one named app. It cannot +# switch apps mid-run; prepare, launch, and run this *single-case* assertion +# separately for every row below (each command pair has a distinct named bundle +# and port): +# +# make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=ui_flag_off +# OMI_AUTOMATION_PORT=47851 make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e-flag-off DESKTOP_USER=omi-chat-first-e2e-enabled +# python3 desktop/macos/scripts/omi-harness run desktop/macos/e2e/flows/chat-first-capability-isolation.yaml --lane bridge --port 47851 --bundle-id com.omi.omi-chat-first-e2e-flag-off +# +# make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=out_of_cohort +# OMI_AUTOMATION_PORT=47852 make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e-out-of-cohort DESKTOP_USER=omi-chat-first-e2e-out-of-cohort +# python3 desktop/macos/scripts/omi-harness run desktop/macos/e2e/flows/chat-first-capability-isolation.yaml --lane bridge --port 47852 --bundle-id com.omi.omi-chat-first-e2e-out-of-cohort +# +# make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=unreachable_control +# OMI_AUTOMATION_PORT=47853 make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e-unreachable-control DESKTOP_USER=omi-chat-first-e2e-enabled +# python3 desktop/macos/scripts/omi-harness run desktop/macos/e2e/flows/chat-first-capability-isolation.yaml --lane bridge --port 47853 --bundle-id com.omi.omi-chat-first-e2e-unreachable-control +# +# The fixture is server-owned and authenticates to Firebase Auth emulator +# itself; none of these steps set a desktop capability or fabricate agent state. +# +# The no-leak contract is deliberately not simulated here. It is owned by the +# behavioral seams that can inspect it without adding a client-side override: +# - desktop/macos/agent/tests/chat-first-capability-projection.test.ts proves +# dynamic tools are absent/un-authorizable and tools/list bytes equal legacy. +# - backend/tests/unit/test_chat_first_blocks.py proves block validation does +# no entity work while disabled. +# - backend/tests/unit/test_chat_first_proactive_router.py and +# backend/tests/unit/test_chat_first_proactive_engine.py prove disabled +# materialization and proactive work do no feature-store/provider/metric work. +covers: + # Each case below drives this root's real sampled-control failure/disabled path. + - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift + - desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift + - desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift + # S1 traverses the legacy half of the exact-route visibility wait; the + # cohort flow covers the Chat-first half without fabricating rollout state. + - desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift +preconditions: + - automation_bridge_ready + - provider_mode_offline + - seeded_memory_scenario_happy_path + - one_fixture_case_prepared_by_the_real_make_helper + - its_fresh_named_non_production_bundle_launched_on_the_matching_port + +steps: + - id: S1 + name: Navigate the prepared capability-off bundle to its actual legacy Chat surface + bridge.navigate: + target: chat + activateApp: false + wait: + state.shellVariant: legacy + + - id: S2 + name: Assert the sampled server capability remained off and the legacy Chat shell stayed usable + state.expect: + state.shellVariant: legacy diff --git a/desktop/macos/e2e/flows/chat-first-cohesive.yaml b/desktop/macos/e2e/flows/chat-first-cohesive.yaml new file mode 100644 index 00000000000..93a90686532 --- /dev/null +++ b/desktop/macos/e2e/flows/chat-first-cohesive.yaml @@ -0,0 +1,206 @@ +version: 2 +name: chat-first-cohesive +tier: manual +description: "Manual local/offline fixture flow for the mounted Chat-first goal-link, focus, task, capture, and ordinary Chat response path." +app: non-prod +# Setup is server-owned and must precede this flow: +# PROVIDER_MODE=offline make dev-up +# make seed-memory-scenario SCENARIO=happy_path +# make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=enabled +# make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-chat-first-e2e-enabled +# Run with --lane ui --bundle-id com.omi.omi-chat-first-e2e for AX steps. +covers: + - desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift + # S1a exercises the real Node -> authorized Swift tool dispatch, backend + # validation, and journal append; it is not a local card-insertion mapping. + - desktop/macos/agent/src/protocol.ts + - desktop/macos/agent/src/runtime/kernel-core.ts + - desktop/macos/agent/src/index.ts + - desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift + - desktop/macos/Desktop/Sources/Chat/AgentBridge+ChatFirst.swift + - desktop/macos/Desktop/Sources/Chat/AgentClient.swift + - desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift + - desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift + - desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift + - desktop/macos/Desktop/Sources/Providers/ChatProvider.swift + # S8 drives the Tasks-page closure, including its bounded attempt/terminal telemetry. + - desktop/macos/Desktop/Sources/Chat/ChatFirstAnalytics.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift + # S2/S3 prove the enabled main Chat rendered and operated the rich-block context. + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift + # S2 and S12 activate the real task card's Omi-capture badge. The local + # fixture uses only transcription:omi provenance, so the policy's + # fail-closed branch is part of the mounted path rather than a paper cover. + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstCaptureLinkPolicy.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift + # S3-S6 load canonical goals, acknowledge focus, and mutate focused-goal state. + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift + # S12/S13 load the Omi-only archive, select detail, and prepare its playback through typed audio APIs. + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlayback.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlaybackAPI.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift +preconditions: + - automation_bridge_ready + - named_non_production_bundle_ui_lane + - provider_mode_offline + - chat_first_e2e_fixture_case_enabled_prepared_by_make_helper + - desktop_user_omi_chat_first_e2e_enabled + - chat_first_daily_opener_materialized_in_mounted_main_chat + +steps: + - id: S1 + name: Navigate to the mounted Chat surface that owns the real rich goal link + bridge.navigate: + target: chat + activateApp: true + settleMs: 750 + + - id: S1a + name: Dispatch the fixture task card through the real authorized Chat-first block executor + bridge.action: + name: chat_first_render_fixture_task_card + expect: + result.detail.executor_invoked: "true" + result.detail.validated: "true" + result.detail.journal_block_rendered: "true" + + - id: S1b + name: Verify the executor-rendered fixture task card is visible through Accessibility + ax.expect: + identifiers_visible: + - chat-first-task-chat-first-e2e-task-v1-toggle + + - id: S2 + name: Verify the materialized rich Goal link and Omi-capture task badge are exposed through Accessibility + ax.expect: + identifiers_visible: + - chat-first-goal-chat-first-e2e-goal-v1-open + - chat-first-task-chat-first-e2e-task-v1-capture-chat-first-e2e-capture-v1 + + - id: S3 + name: Open the Goal through its real mounted rich-link control + ax.activate: + identifier: chat-first-goal-chat-first-e2e-goal-v1-open + timeout_seconds: 10 + wait: + state.shellVariant: chat_first + state.visibleChatFirstRoute: goals + state.isFocusedEntityAcknowledged: true + + - id: S4 + name: Confirm the Goal destination acknowledged the focused entity + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: goals + result.detail.route_visible: "true" + result.detail.focus_acknowledged: "true" + result.detail.focused_goal_available: "true" + + - id: S5 + name: Set the server-seeded alternate active Goal as focus through the mounted Goals page + bridge.action: + name: chat_first_set_focus + expect: + result.detail.focus_updated: "true" + + - id: S6 + name: Open the selected Goal's related Tasks through the mounted Goals page + bridge.action: + name: chat_first_open_related_tasks + expect: + result.detail.related_tasks_opened: "true" + + - id: S7 + name: Confirm the mounted Tasks page acknowledged the related Goal focus + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: tasks + result.detail.route_visible: "true" + result.detail.focus_acknowledged: "true" + + - id: S8 + name: Toggle and reconcile the seeded task through the shared Tasks store + bridge.action: + name: chat_first_toggle_task + expect: + result.detail.task_reconciled: "true" + + - id: S9 + name: Confirm the actual Tasks owner retained the reconciled completion count + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.completed_visible_task_count: "1" + + - id: S10 + name: Return to mounted Chat and preserve the shared-store reconciliation + bridge.navigate: + target: chat + activateApp: false + settleMs: 750 + + - id: S11 + name: Confirm Chat sees the same reconciled Tasks-store shape + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: chat + result.detail.route_visible: "true" + result.detail.completed_visible_task_count: "1" + + - id: S12 + name: Open the fixture's Omi capture through the real mounted task-card badge + ax.activate: + identifier: chat-first-task-chat-first-e2e-task-v1-capture-chat-first-e2e-capture-v1 + timeout_seconds: 10 + wait: + state.shellVariant: chat_first + state.visibleChatFirstRoute: conversations + state.isFocusedEntityAcknowledged: true + + - id: S13 + name: Confirm the task-originated capture detail is mounted without returning capture content + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: conversations + result.detail.route_visible: "true" + result.detail.capture_detail_visible: "true" + + - id: S14 + name: Discuss the selected capture through the normal main-Chat owner + bridge.action: + name: chat_first_discuss_capture + expect: + result.detail.capture_discussion_started: "true" + + - id: S15 + name: Wait for the resulting ordinary main-Chat response to terminalize + bridge.action: + name: wait_main_chat_idle + params: + timeoutMs: "120000" + expect: + result.detail.idle: "true" + + - id: S16 + name: Confirm the response returned to visibly mounted Chat without exposing transcript content + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: chat + result.detail.route_visible: "true" + result.detail.completed_visible_task_count: "1" + + - id: S17 + name: Confirm the bridge recorded no route failure + log.expect: + absent: + - "DesktopAutomationBridge: failed" + - "navigation_target_not_visible" diff --git a/desktop/macos/e2e/flows/chat-first-cold-start.yaml b/desktop/macos/e2e/flows/chat-first-cold-start.yaml new file mode 100644 index 00000000000..61f01a4c423 --- /dev/null +++ b/desktop/macos/e2e/flows/chat-first-cold-start.yaml @@ -0,0 +1,86 @@ +version: 2 +name: chat-first-cold-start +tier: manual +description: "Manual local/offline flow for the server-owned sparse cold-start materialization and terminal answer path." +app: non-prod +# Setup is server-owned and must precede this flow: +# PROVIDER_MODE=offline make dev-up +# make seed-memory-scenario SCENARIO=happy_path +# make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=cold_start +# make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-chat-first-e2e-enabled +# Run with --lane ui --bundle-id com.omi.omi-chat-first-e2e for AX steps. +covers: + # This flow reaches the focused facade's receipt listing/materialization and + # bounded question-reply operations. It does not claim appendChatFirstBlocks; + # that privileged rendered-block contract stays in unit/authorized-tool tests. + - desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess+ChatFirstJournal.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift + - desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +preconditions: + - automation_bridge_ready + - named_non_production_bundle_ui_lane + - provider_mode_offline + - chat_first_e2e_fixture_case_cold_start_prepared_by_make_helper + - desktop_user_omi_chat_first_e2e_enabled + - chat_first_prompt_materialized_in_mounted_main_chat + +steps: + - id: S1 + name: Navigate to the mounted Chat page after sparse cold-start materialization + bridge.navigate: + target: chat + activateApp: true + settleMs: 1000 + + - id: S2 + name: Confirm the current Chat tail is a real actionable non-deferrable cold-start question + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: chat + result.detail.route_visible: "true" + result.detail.actionable_question_at_tail: "true" + result.detail.actionable_question_available: "true" + result.detail.deferrable_question_available: "false" + + - id: S3 + name: Verify the stable local-only cold-start card and its answer control are exposed through Accessibility + ax.expect: + identifiers_visible: + - chat-first-question-chat-first-e2e-cold-start-question-v1 + - chat-first-question-chat-first-e2e-cold-start-question-v1-option-start + + - id: S4 + name: Select the bounded cold-start answer through the normal main-Chat journal + bridge.action: + name: chat_first_select_question_option + params: + selection: first + expect: + result.detail.question_selection_started: "true" + + - id: S5 + name: Wait for the ordinary main-Chat answer turn to terminalize + bridge.action: + name: wait_main_chat_idle + params: + timeoutMs: "120000" + expect: + result.detail.idle: "true" + + - id: S6 + name: Confirm the materialized cold-start sequence reached its terminal answered state + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: chat + result.detail.route_visible: "true" + result.detail.actionable_question_at_tail: "false" + result.detail.actionable_question_available: "false" + result.detail.deferrable_question_available: "false" + + - id: S7 + name: Read only the bounded server-owned fixture counters after the UI terminal state + do: "Run `make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=snapshot CHAT_FIRST_E2E_CASE=cold_start` and inspect only fixture case, expected shell, and bounded count fields; do not inspect transcript, question, answer, goal, capture, or entity payloads." diff --git a/desktop/macos/e2e/flows/chat-first-question-deferral.yaml b/desktop/macos/e2e/flows/chat-first-question-deferral.yaml new file mode 100644 index 00000000000..3e0aa5859a1 --- /dev/null +++ b/desktop/macos/e2e/flows/chat-first-question-deferral.yaml @@ -0,0 +1,149 @@ +version: 2 +name: chat-first-question-deferral +tier: manual +description: "Manual local/offline flow for a server-materialized question card, its real deferral, and a normal foreground re-raise." +app: non-prod +# Setup is server-owned and must precede this flow: +# PROVIDER_MODE=offline make dev-up +# make seed-memory-scenario SCENARIO=happy_path +# make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=question +# make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-chat-first-e2e-enabled +# Run with --lane ui --bundle-id com.omi.omi-chat-first-e2e for AX steps. +covers: + # S4 and S8 use the enabled main-Chat bridge facade for the owned question + # reply and materialization/receipt operations; no page-local journal path + # is involved. + - desktop/macos/Desktop/Sources/Chat/AgentBridge+ChatFirst.swift + # This flow reaches the focused facade's bounded reply/deferral, receipt + # listing/acknowledgement, and foreground materialization operations. It does + # not claim appendChatFirstBlocks; that privileged rendered-block contract + # stays in unit/authorized-tool tests. + - desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess+ChatFirstJournal.swift + # This is the user journey for model-validated question cards; the adapter's + # all-or-nothing wire contract is behaviorally tested in ChatFirstRichBlockTests. + - desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift + # S8 drives the coordinator's normal server fetch/ack/materialize runner. + - desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift + - desktop/macos/Desktop/Sources/Providers/ChatProvider.swift + - desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift +preconditions: + - automation_bridge_ready + - named_non_production_bundle_ui_lane + - provider_mode_offline + - chat_first_e2e_fixture_case_question_prepared_by_make_helper + - desktop_user_omi_chat_first_e2e_enabled + - chat_first_prompt_materialized_in_mounted_main_chat + +steps: + - id: S1 + name: Navigate to the mounted Chat page after server materialization + bridge.navigate: + target: chat + activateApp: true + settleMs: 1000 + + - id: S2 + name: Confirm the current Chat tail has a real actionable and deferrable question + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: chat + result.detail.route_visible: "true" + result.detail.actionable_question_at_tail: "true" + result.detail.actionable_question_available: "true" + result.detail.deferrable_question_available: "true" + + - id: S3 + name: Verify the stable local-only question and deferral controls are exposed through Accessibility + ax.expect: + identifiers_visible: + - chat-first-question-chat-first-e2e-question-v1 + - chat-first-question-chat-first-e2e-question-v1-option-later + + - id: S4 + name: Select the bounded deferral option through the normal main-Chat journal + bridge.action: + name: chat_first_select_question_option + params: + selection: defer + expect: + result.detail.question_selection_started: "true" + + - id: S5 + name: Wait for the ordinary main-Chat deferral turn to settle + bridge.action: + name: wait_main_chat_idle + params: + timeoutMs: "120000" + expect: + result.detail.idle: "true" + + - id: S6 + name: Confirm the consumed question retired from the current Chat tail + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: chat + result.detail.route_visible: "true" + result.detail.actionable_question_at_tail: "false" + result.detail.deferrable_question_available: "false" + + - id: S7 + name: Advance only the server-owned fixture clock, then wait for the normal foreground debounce window + do: "Run `make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=snapshot CHAT_FIRST_E2E_CASE=question` and inspect only `pending_deferral_count`; run `make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=advance CHAT_FIRST_E2E_CASE=question CHAT_FIRST_E2E_SECONDS=86400`; then allow the documented 60-second foreground debounce to expire. Do not inspect fixture, transcript, question, answer, or entity content." + + - id: S8 + name: Trigger only the mounted normal foreground materialization coordinator and wait for its tail result + bridge.action: + name: chat_first_request_prompt_materialization + params: + timeoutMs: "60000" + expect: + result.detail.prompt_materialization_requested: "true" + result.detail.actionable_question_at_tail: "true" + + - id: S9 + name: Verify the same stable local-only question controls reappeared at the actionable tail + ax.expect: + identifiers_visible: + - chat-first-question-chat-first-e2e-question-v1 + - chat-first-question-chat-first-e2e-question-v1-option-later + + - id: S10 + name: Confirm the re-raised tail again offers both bounded choices + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.actionable_question_at_tail: "true" + result.detail.actionable_question_available: "true" + result.detail.deferrable_question_available: "true" + + - id: S11 + name: Answer the re-raised question through its bounded non-deferral selection + bridge.action: + name: chat_first_select_question_option + params: + selection: first + expect: + result.detail.question_selection_started: "true" + + - id: S12 + name: Wait for the ordinary main-Chat answer turn to settle + bridge.action: + name: wait_main_chat_idle + params: + timeoutMs: "120000" + expect: + result.detail.idle: "true" + + - id: S13 + name: Confirm the answered re-raise retired from the current Chat tail + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.actionable_question_at_tail: "false" + result.detail.actionable_question_available: "false" + result.detail.deferrable_question_available: "false" diff --git a/desktop/macos/e2e/flows/chat-first.yaml b/desktop/macos/e2e/flows/chat-first.yaml new file mode 100644 index 00000000000..bcbee68e921 --- /dev/null +++ b/desktop/macos/e2e/flows/chat-first.yaml @@ -0,0 +1,132 @@ +version: 2 +name: chat-first +tier: 2 +description: "Cohort-gated mounted-shell route contract. It never fabricates Chat-first data or proves agent manifests." +app: non-prod +covers: + - desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift + # Every bridge.navigate step waits for the requested Chat-first route to + # mount through the split visibility policy before returning success. + - desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift + # S1 observes the shell only after its server-sampled capability projection admits it. + - desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift + - desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift + - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +preconditions: + - automation_bridge_ready + - auth_ready + - chat_first_cohort_named_bundle + +steps: + - id: S1 + name: Navigate the enabled shell to its canonical Chat route + bridge.navigate: + target: chat + activateApp: false + wait: + state.shellVariant: chat_first + state.chatFirstRoute: chat + + - id: S2 + name: Read mounted Chat-first runtime state without fixture content + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: chat + result.detail.route_visible: "true" + + - id: S3 + name: Assert stable sidebar accessibility order and VoiceOver labels + ax.expect: + identifiers_visible: + - chat-first-sidebar-chat + - chat-first-sidebar-conversations + - chat-first-sidebar-tasks + - chat-first-sidebar-goals + - chat-first-sidebar-memories + focus_order: + - chat-first-sidebar-chat + - chat-first-sidebar-conversations + - chat-first-sidebar-tasks + - chat-first-sidebar-goals + - chat-first-sidebar-memories + voiceover_labels: + chat-first-sidebar-chat: Chat + chat-first-sidebar-conversations: Conversations + chat-first-sidebar-tasks: Tasks + chat-first-sidebar-goals: Goals + chat-first-sidebar-memories: Memories + + - id: S4 + name: Activate Goals by its stable accessibility identifier + ax.activate: + identifier: chat-first-sidebar-goals + + - id: S5 + name: Navigate from Chat to canonical Goals + bridge.navigate: + target: goals + activateApp: false + wait: + state.shellVariant: chat_first + state.chatFirstRoute: goals + + - id: S6 + name: Prove the mounted Goals surface is visible to the semantic runtime + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: goals + result.detail.route_visible: "true" + + - id: S7 + name: Navigate from Goals to the shared Tasks surface + bridge.navigate: + target: tasks + activateApp: false + wait: + state.shellVariant: chat_first + state.chatFirstRoute: tasks + + - id: S8 + name: Prove the mounted Tasks surface is visible to the semantic runtime + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: tasks + result.detail.route_visible: "true" + + - id: S9 + name: Navigate from Tasks to the Omi-device capture archive + bridge.navigate: + target: conversations + activateApp: false + wait: + state.shellVariant: chat_first + state.chatFirstRoute: conversations + + - id: S10 + name: Prove the mounted Conversations surface is visible to the semantic runtime + bridge.action: + name: chat_first_runtime_snapshot + expect: + result.detail.route: conversations + result.detail.route_visible: "true" + + - id: S11 + name: Return to the canonical Chat route + bridge.navigate: + target: chat + activateApp: false + wait: + state.shellVariant: chat_first + state.chatFirstRoute: chat + + - id: S12 + name: Assert the bridge did not report a route failure + log.expect: + absent: + - "DesktopAutomationBridge: failed" + - "unsupported_route" diff --git a/desktop/macos/e2e/harness.md b/desktop/macos/e2e/harness.md index cf8495e73a8..e067a3a5aa5 100644 --- a/desktop/macos/e2e/harness.md +++ b/desktop/macos/e2e/harness.md @@ -70,9 +70,38 @@ Supported step types: - `state.expect` - `trace.expect` - `log.expect` +- `ax.activate` - `ax.expect` - `power.sample` +`ax.activate` is UI-lane-only and activates a SwiftUI/AppKit control by its stable +accessibility identifier — never a coordinate. AX steps require a named +non-production bundle id (`com.omi.omi-*`); the harness refuses production and +unnamed bundle ids. `ax.expect` supports `identifiers_visible`, ordered +`focus_order` assertions over the interactive AX tree, and exact +`voiceover_labels` keyed by stable identifier. Use these only for static, +user-facing controls; expected labels must never contain user content. + +```yaml +- name: Verify chat-first sidebar accessibility contract + ax.expect: + identifiers_visible: + - chat-first-sidebar-chat + - chat-first-sidebar-goals + focus_order: + - chat-first-sidebar-chat + - chat-first-sidebar-goals + voiceover_labels: + chat-first-sidebar-chat: Chat + chat-first-sidebar-goals: Goals + +- name: Open Goals by stable accessibility identifier + ax.activate: + identifier: chat-first-sidebar-goals + wait: + state.chatFirstRoute: goals +``` + Use `wait:` after bridge steps when a state or trace must settle before the next step. Keep automated checks to facts the harness can measure directly: state, traces, logs, timing, and AX text. The harness does not score screenshot quality; @@ -84,10 +113,11 @@ include `surfaces`, `safety`, `sideEffects`, and `examples`; use a matching sema `bridge.action` first when `preferSemantic` is true, especially for read-only probes, local captures, and deterministic visual fixtures. -Bridge routes return as soon as the app has accepted the command. Prefer `wait:` -for readiness checks so benchmark timing separates command latency from UI settle -latency. If a flow genuinely needs a fixed pause, pass `settleMs:` on -`bridge.navigate` payloads, `/conversation/open` payloads, or as an action param. +Bridge navigation returns only after its requested destination is mounted; the +Chat-first shell additionally requires the exact route-visible acknowledgement. +Use `wait:` for data, traces, or other readiness checks that follow navigation. +If a flow genuinely needs a fixed pause, pass `settleMs:` on `bridge.navigate` +payloads, `/conversation/open` payloads, or as an action param. Use `visual.action_sequence` for fast animations. It posts the bridge action and captures frames concurrently, which avoids missing transitions that finish before @@ -117,3 +147,38 @@ cheap enough for agents to run repeatedly while optimizing idle UI power. interval_ms: 250 max_avg_cpu_percent: 5 ``` + +## Chat-first local fixture flows + +`chat-first-cohesive.yaml`, `chat-first-question-deferral.yaml`, +`chat-first-cold-start.yaml`, and `chat-first-capability-isolation.yaml` are +manual flows. They require the server-owned local fixture; the desktop bridge +never manufactures eligibility, goals, tasks, captures, prompts, question +options, or rollout state. + +```bash +PROVIDER_MODE=offline make dev-up +make seed-memory-scenario SCENARIO=happy_path +make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=enabled +make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-chat-first-e2e-enabled +``` + +Use `CHAT_FIRST_E2E_CASE=question` for the question-deferral flow. It begins +after a fixture-owned completed rich cold-start receipt, so the real +server-materialized question remains the actionable Chat tail. + +The fixture helper authenticates through Firebase Auth emulator credentials and +checks the emulator-assigned UID against the harness manifest. Its read-back and +clock advance responses contain bounded state and counts only; never add a +desktop capability override or print fixture content from these flows. + +`chat-first-capability-isolation.yaml` is a three-launch matrix: run the same +single-case flow once after preparing and launching each `ui_flag_off`, +`out_of_cohort`, and `unreachable_control` case in its own `omi-*` bundle and +automation port. The exact three command pairs live in the flow header. A +harness run cannot switch named bundles midway through a flow, so do not treat +one serial run as proof of all three accounts. Each run asserts the real +`legacy` shell can still open Chat. The separate behavioral tests named in that +flow own the byte-equivalent legacy tool manifest and the no-rich-block, +no-materialization, and no-proactive-work guarantees; do not add a bridge +fixture or client capability override merely to duplicate those assertions. diff --git a/desktop/macos/scripts/desktop-flow-lint.py b/desktop/macos/scripts/desktop-flow-lint.py index f011c71105e..b6ec61cadc2 100755 --- a/desktop/macos/scripts/desktop-flow-lint.py +++ b/desktop/macos/scripts/desktop-flow-lint.py @@ -26,6 +26,7 @@ VIEW_MODEL_ACTION_SOURCES = ( DESKTOP_DIR / "Desktop/Sources/MainWindow/Pages/TasksPage.swift", DESKTOP_DIR / "Desktop/Sources/MainWindow/Pages/MemoriesPage.swift", + DESKTOP_DIR / "Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift", ) TYPED_STEP_KEYS = { @@ -36,6 +37,7 @@ "state.expect", "log.expect", "trace.expect", + "ax.activate", "ax.expect", "power.sample", } @@ -69,6 +71,50 @@ def collect_bridge_action_names(step: dict) -> list[str]: return names +def stable_identifier(value: object) -> bool: + return isinstance(value, str) and bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", value)) + + +def lint_ax_step(path: Path, step: dict) -> list[str]: + errors: list[str] = [] + if "ax.activate" in step: + payload = step.get("ax.activate") or {} + if not isinstance(payload, dict) or not stable_identifier(payload.get("identifier")): + errors.append(f"{path.name}: ax.activate requires a stable identifier") + elif payload.get("action", "click") not in {"click", "press"}: + errors.append(f"{path.name}: ax.activate action must be click or press") + + if "ax.expect" in step: + payload = step.get("ax.expect") or {} + if not isinstance(payload, dict): + errors.append(f"{path.name}: ax.expect must be a mapping") + return errors + for key in ("identifiers_visible", "focus_order"): + value = payload.get(key) + if value is None: + continue + values = [value] if isinstance(value, str) else value + if not isinstance(values, list) or not all(stable_identifier(item) for item in values): + errors.append(f"{path.name}: ax.expect {key} must contain stable identifiers") + focus_order = payload.get("focus_order") + if ( + isinstance(focus_order, list) + and all(stable_identifier(item) for item in focus_order) + and len(set(focus_order)) != len(focus_order) + ): + errors.append(f"{path.name}: ax.expect focus_order must not repeat an identifier") + labels = payload.get("voiceover_labels") + if labels is not None and ( + not isinstance(labels, dict) + or not all( + stable_identifier(identifier) and isinstance(label, str) and label + for identifier, label in labels.items() + ) + ): + errors.append(f"{path.name}: ax.expect voiceover_labels must map stable identifiers to non-empty labels") + return errors + + def is_typed_flow(flow: dict, steps: list) -> bool: if flow.get("tier") == MANUAL_TIER: return False @@ -115,6 +161,7 @@ def lint_flow(path: Path, actions: set[str]) -> list[str]: if "do" in step: errors.append(f"{path.name}: typed flow must not contain do steps") continue + errors.extend(lint_ax_step(path, step)) for name in collect_bridge_action_names(step): if name not in actions: errors.append(f"{path.name}: unknown bridge action {name!r}") diff --git a/desktop/macos/scripts/omi-harness b/desktop/macos/scripts/omi-harness index 00dfbaa83f5..052bf15f5c0 100755 --- a/desktop/macos/scripts/omi-harness +++ b/desktop/macos/scripts/omi-harness @@ -21,7 +21,6 @@ from datetime import datetime, timezone from pathlib import Path import typing - SCHEMA_VERSION = 2 MIN_COMPATIBLE_FLOW_VERSION = 1 SCRIPT_DIR = Path(__file__).resolve().parent @@ -29,6 +28,8 @@ DESKTOP_DIR = SCRIPT_DIR.parent DEFAULT_PORT = int(os.environ.get("OMI_AUTOMATION_PORT", "47777")) DEFAULT_RUN_ROOT = DESKTOP_DIR / ".harness/runs" POLL_INTERVAL_SECONDS = 0.02 +NAMED_NON_PRODUCTION_BUNDLE_PREFIX = "com.omi.omi-" +STABLE_ACCESSIBILITY_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*") @dataclass @@ -115,7 +116,9 @@ def automation_port_from_base_url(base_url: str) -> int: return int(base_url.rsplit(":", 1)[1]) -def request_json(base_url: str, method: str, route: str, body: dict[str, typing.Any] | None = None) -> dict[str, typing.Any]: +def request_json( + base_url: str, method: str, route: str, body: dict[str, typing.Any] | None = None +) -> dict[str, typing.Any]: data = None headers = {"Accept": "application/json"} token = automation_token(automation_port_from_base_url(base_url)) @@ -281,7 +284,9 @@ def expectation_mismatches(data: dict[str, typing.Any], expectations: dict[str, return mismatches -def wait_for_state(ctx: HarnessContext, expectations: dict[str, typing.Any], timeout: float = 5.0) -> tuple[bool, dict[str, typing.Any]]: +def wait_for_state( + ctx: HarnessContext, expectations: dict[str, typing.Any], timeout: float = 5.0 +) -> tuple[bool, dict[str, typing.Any]]: deadline = time.monotonic() + timeout latest: dict[str, typing.Any] = {} while time.monotonic() < deadline: @@ -292,7 +297,9 @@ def wait_for_state(ctx: HarnessContext, expectations: dict[str, typing.Any], tim return False, latest -def wait_for_trace(ctx: HarnessContext, expectations: dict[str, typing.Any], timeout: float = 5.0) -> tuple[bool, list[dict[str, typing.Any]]]: +def wait_for_trace( + ctx: HarnessContext, expectations: dict[str, typing.Any], timeout: float = 5.0 +) -> tuple[bool, list[dict[str, typing.Any]]]: deadline = time.monotonic() + timeout traces: list[dict[str, typing.Any]] = [] while time.monotonic() < deadline: @@ -324,7 +331,12 @@ def collect_logs(ctx: HarnessContext) -> dict[str, typing.Any]: out = ctx.run_dir / "logs.txt" out.write_text(text, encoding="utf-8") error_count = len(re.findall(r"\b(error|failed|exception|crash)\b", text, flags=re.IGNORECASE)) - return {"path": str(out), "available": ctx.log_path.exists(), "bytes": len(text.encode("utf-8")), "error_count": error_count} + return { + "path": str(out), + "available": ctx.log_path.exists(), + "bytes": len(text.encode("utf-8")), + "error_count": error_count, + } def _ancestor_pids() -> set[int]: @@ -422,7 +434,9 @@ def read_process_sample(pid: int) -> dict[str, typing.Any] | None: } -def sample_process_power(ctx: HarnessContext, spec: dict[str, typing.Any], path: Path) -> tuple[bool, str | None, dict[str, typing.Any]]: +def sample_process_power( + ctx: HarnessContext, spec: dict[str, typing.Any], path: Path +) -> tuple[bool, str | None, dict[str, typing.Any]]: pid, match = resolve_sample_pid(ctx, spec) duration_ms = max(250, int(spec.get("duration_ms", 5000))) interval_ms = max(100, int(spec.get("interval_ms", 250))) @@ -477,7 +491,12 @@ def step_prefix(index: int, step: dict[str, typing.Any]) -> str: def run_agent_swift(ctx: HarnessContext, args: list[str]) -> subprocess.CompletedProcess[str]: if not ctx.bundle_id: - raise RuntimeError("ax.expect requires --bundle-id") + raise RuntimeError("AX steps require --bundle-id") + if not ctx.bundle_id.startswith(NAMED_NON_PRODUCTION_BUNDLE_PREFIX): + raise RuntimeError( + "AX steps require a named non-production bundle id beginning with " + f"{NAMED_NON_PRODUCTION_BUNDLE_PREFIX!r}; refusing {ctx.bundle_id!r}" + ) connect = subprocess.run( ["agent-swift", "connect", "--bundle-id", ctx.bundle_id], check=False, @@ -488,7 +507,9 @@ def run_agent_swift(ctx: HarnessContext, args: list[str]) -> subprocess.Complete ) if connect.returncode != 0: return connect - return subprocess.run(["agent-swift", *args], check=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=20) + return subprocess.run( + ["agent-swift", *args], check=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=20 + ) def action_payload(spec: dict[str, typing.Any]) -> dict[str, typing.Any]: @@ -543,6 +564,61 @@ def assert_trace(ctx: HarnessContext, spec: dict[str, typing.Any], path: Path) - return False, f"no trace matched: expectations={expectations}, mismatches={reasons}" +def ax_snapshot_elements(snapshot: typing.Any) -> list[dict[str, typing.Any]]: + """Return AX element dictionaries in the traversal order reported by agent-swift.""" + elements: list[dict[str, typing.Any]] = [] + + def visit(value: typing.Any) -> None: + if isinstance(value, dict): + if isinstance(value.get("identifier"), str): + elements.append(value) + for child in value.values(): + visit(child) + elif isinstance(value, list): + for child in value: + visit(child) + + visit(snapshot) + return elements + + +def ax_accessibility_label(element: dict[str, typing.Any]) -> str | None: + """Read the label agent-swift exposes for a VoiceOver-visible element.""" + for key in ("label", "title", "text", "name"): + value = element.get(key) + if isinstance(value, str) and value: + return value + attributes = element.get("attrs") + if isinstance(attributes, dict): + for key in ("AXLabel", "AXTitle", "AXDescription"): + value = attributes.get(key) + if isinstance(value, str) and value: + return value + return None + + +def ax_elements_by_identifier(snapshot: typing.Any) -> dict[str, dict[str, typing.Any]]: + """Index the first element for each stable AX identifier without changing its order.""" + elements: dict[str, dict[str, typing.Any]] = {} + for element in ax_snapshot_elements(snapshot): + identifier = element["identifier"] + if identifier not in elements: + elements[identifier] = element + return elements + + +def is_stable_accessibility_identifier(value: typing.Any) -> bool: + return isinstance(value, str) and bool(STABLE_ACCESSIBILITY_IDENTIFIER.fullmatch(value)) + + +def _string_list(spec: typing.Any, field: str) -> tuple[list[str] | None, str | None]: + if isinstance(spec, str): + spec = [spec] + if not isinstance(spec, list) or not all(is_stable_accessibility_identifier(item) for item in spec): + return None, f"ax.expect {field} requires a stable identifier or list of stable identifiers" + return spec, None + + def assert_ax(ctx: HarnessContext, spec: dict[str, typing.Any], path: Path) -> tuple[bool, str | None]: if ctx.lane != "ui": return True, "skipped outside ui lane" @@ -551,12 +627,78 @@ def assert_ax(ctx: HarnessContext, spec: dict[str, typing.Any], path: Path) -> t if result.returncode != 0: return False, "agent-swift snapshot failed" text = result.stdout + try: + snapshot = json.loads(text) + except json.JSONDecodeError: + return False, "agent-swift snapshot returned invalid JSON" visible = spec.get("text_visible", []) if isinstance(visible, str): visible = [visible] missing = [item for item in visible if item not in text] if missing: return False, f"AX snapshot missing text: {missing}" + + identifiers = ax_elements_by_identifier(snapshot) + visible_ids, error = _string_list(spec.get("identifiers_visible", []), "identifiers_visible") + if error: + return False, error + missing_ids = [identifier for identifier in visible_ids or [] if identifier not in identifiers] + if missing_ids: + return False, f"AX snapshot missing stable identifier(s): {missing_ids}" + + focus_order, error = _string_list(spec.get("focus_order", []), "focus_order") + if error: + return False, error + if focus_order: + if len(set(focus_order)) != len(focus_order): + return False, "ax.expect focus_order must not repeat an identifier" + actual_order = [str(element["identifier"]) for element in ax_snapshot_elements(snapshot)] + expected_index = 0 + for identifier in actual_order: + if identifier == focus_order[expected_index]: + expected_index += 1 + if expected_index == len(focus_order): + break + if expected_index != len(focus_order): + missing_or_reordered = focus_order[expected_index:] + return False, ( + "AX keyboard focus order did not contain the expected stable-id subsequence: " + f"missing or reordered {missing_or_reordered}; actual={actual_order}" + ) + + voiceover_labels = spec.get("voiceover_labels", {}) + if not isinstance(voiceover_labels, dict) or not all( + is_stable_accessibility_identifier(identifier) and isinstance(label, str) and label + for identifier, label in voiceover_labels.items() + ): + return False, "ax.expect voiceover_labels requires a mapping of stable identifiers to non-empty labels" + # AX labels can contain user data. Keep terminal/run-summary failures to + # stable identifiers; the local artifact remains available for intentional + # inspection under the harness's non-production boundary. + label_mismatches = [ + identifier + for identifier, expected in voiceover_labels.items() + if identifier not in identifiers or ax_accessibility_label(identifiers[identifier]) != expected + ] + if label_mismatches: + return False, f"AX VoiceOver label assertions failed for stable identifier(s): {label_mismatches}" + return True, None + + +def activate_ax(ctx: HarnessContext, spec: dict[str, typing.Any], path: Path) -> tuple[bool, str | None]: + """Activate one AX element through its stable accessibility identifier, never coordinates.""" + if ctx.lane != "ui": + return True, "skipped outside ui lane" + identifier = spec.get("identifier") + if not is_stable_accessibility_identifier(identifier): + return False, "ax.activate requires a stable identifier" + action = spec.get("action", "click") + if action not in {"click", "press"}: + return False, "ax.activate action must be click or press" + result = run_agent_swift(ctx, ["find", "identifier", identifier, action]) + path.write_text(result.stdout, encoding="utf-8") + if result.returncode != 0: + return False, f"agent-swift failed to {action} identifier {identifier!r}" return True, None @@ -656,7 +798,9 @@ def execute_step(ctx: HarnessContext, index: int, step: dict[str, typing.Any]) - options = dict(step["visual.action_sequence"] or {}) action_name = str(options.get("action") or "") if not action_name: - result.update(operation="visual.action_sequence", ok=False, error="visual.action_sequence requires action") + result.update( + operation="visual.action_sequence", ok=False, error="visual.action_sequence requires action" + ) else: frames = max(1, int(options.get("frames", 8))) interval_ms = max(1, int(options.get("interval_ms", 16))) @@ -738,6 +882,16 @@ def execute_step(ctx: HarnessContext, index: int, step: dict[str, typing.Any]) - if error: result["error"] = error + elif "ax.activate" in step: + path = ctx.steps_dir / f"{prefix}-ax-activate.json" + ok, error = activate_ax(ctx, dict(step["ax.activate"] or {}), path) + result.update(operation="ax.activate", ok=ok) + result["artifacts"]["ax"] = str(path) + if error and ok: + result["warnings"].append(error) + elif error: + result["error"] = error + elif "ax.expect" in step: path = ctx.steps_dir / f"{prefix}-ax.json" ok, error = assert_ax(ctx, dict(step["ax.expect"] or {}), path) @@ -987,8 +1141,7 @@ def repeat_flow(args: argparse.Namespace) -> int: values.append(float(step.get("duration_ms") or 0)) if values: print( - f"| {name} | {statistics.median(values):.2f} ms | " - f"{min(values):.2f} ms | {max(values):.2f} ms |" + f"| {name} | {statistics.median(values):.2f} ms | " f"{min(values):.2f} ms | {max(values):.2f} ms |" ) return exit_code @@ -1033,7 +1186,10 @@ def comparable_steps(left: dict[str, typing.Any], right: dict[str, typing.Any]) "delta_ms": None, "delta_percent": None, "speedup": None, - "ok": [before_step.get("ok") if before_step else None, after_step.get("ok") if after_step else None], + "ok": [ + before_step.get("ok") if before_step else None, + after_step.get("ok") if after_step else None, + ], } ) continue @@ -1143,6 +1299,7 @@ def add_run_args(parser: argparse.ArgumentParser) -> None: help="explicitly allow compatible flows older than the current harness schema", ) + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) diff --git a/desktop/macos/tests/test-omi-harness.sh b/desktop/macos/tests/test-omi-harness.sh index 3b990d35689..5ccbc21b50b 100644 --- a/desktop/macos/tests/test-omi-harness.sh +++ b/desktop/macos/tests/test-omi-harness.sh @@ -78,6 +78,130 @@ except RuntimeError: pass else: raise AssertionError("relative health log path must fail loudly") + +from pathlib import Path +import tempfile + +snapshot = { + "elements": [ + {"identifier": "chat-first-sidebar-chat", "label": "Chat"}, + {"identifier": "chat-first-sidebar-goals", "title": "Goals"}, + {"identifier": "chat-first-sidebar-tasks", "attrs": {"AXLabel": "Tasks"}}, + ] +} +snapshot_json = module.json.dumps(snapshot) +commands = [] +def fake_agent_swift(_ctx, args): + commands.append(args) + stdout = snapshot_json if args[:2] == ["snapshot", "-i"] else "activated" + return module.subprocess.CompletedProcess(args, 0, stdout) + +real_agent_swift = module.run_agent_swift +module.run_agent_swift = fake_agent_swift +with tempfile.TemporaryDirectory() as directory: + artifacts = Path(directory) + ctx = module.HarnessContext( + base_url="http://127.0.0.1:9", + flow_path=Path("flow.yaml"), + run_dir=artifacts, + steps_dir=artifacts, + lane="ui", + log_path=artifacts / "missing.log", + log_start=0, + bundle_id="com.omi.omi-chat-first-e2e", + process_match=None, + ) + ok, error = module.assert_ax( + ctx, + { + "identifiers_visible": ["chat-first-sidebar-chat", "chat-first-sidebar-goals"], + "focus_order": [ + "chat-first-sidebar-chat", + "chat-first-sidebar-goals", + "chat-first-sidebar-tasks", + ], + "voiceover_labels": { + "chat-first-sidebar-chat": "Chat", + "chat-first-sidebar-goals": "Goals", + "chat-first-sidebar-tasks": "Tasks", + }, + }, + artifacts / "ax.json", + ) + assert ok, error + ok, error = module.activate_ax(ctx, {"identifier": "chat-first-sidebar-goals"}, artifacts / "activate.json") + assert ok, error + assert commands[-1] == ["find", "identifier", "chat-first-sidebar-goals", "click"] + ok, error = module.activate_ax(ctx, {"identifier": "not a stable id"}, artifacts / "activate-invalid.json") + assert not ok and "stable identifier" in error + ok, error = module.assert_ax( + ctx, + {"focus_order": ["chat-first-sidebar-goals", "chat-first-sidebar-chat"]}, + artifacts / "ax-reordered.json", + ) + assert not ok and "keyboard focus order" in error + ok, error = module.assert_ax( + ctx, + {"voiceover_labels": {"chat-first-sidebar-goals": "unexpected label"}}, + artifacts / "ax-label-mismatch.json", + ) + assert not ok and "chat-first-sidebar-goals" in error + assert "unexpected label" not in error and "Goals" not in error + + production_ctx = module.HarnessContext( + base_url=ctx.base_url, + flow_path=ctx.flow_path, + run_dir=ctx.run_dir, + steps_dir=ctx.steps_dir, + lane=ctx.lane, + log_path=ctx.log_path, + log_start=ctx.log_start, + bundle_id="com.omi.computer-macos", + process_match=None, + ) + module.run_agent_swift = real_agent_swift + try: + module.run_agent_swift(production_ctx, ["snapshot", "-i", "--json"]) + except RuntimeError as exc: + assert "named non-production" in str(exc) + else: + raise AssertionError("production bundle was not rejected") + +assert module.NAMED_NON_PRODUCTION_BUNDLE_PREFIX == "com.omi.omi-" +PY + +python3 - "$MACOS_DIR/scripts/desktop-flow-lint.py" <<'PY' +import importlib.machinery +import importlib.util +import sys +from pathlib import Path + +path = sys.argv[1] +loader = importlib.machinery.SourceFileLoader("desktop_flow_lint", path) +spec = importlib.util.spec_from_loader(loader.name, loader) +module = importlib.util.module_from_spec(spec) +sys.modules[loader.name] = module +loader.exec_module(module) + +assert "ax.activate" in module.TYPED_STEP_KEYS +assert "chat_first_runtime_snapshot" in module.registered_actions() +assert module.lint_ax_step( + Path("chat-first.yaml"), + { + "ax.activate": {"identifier": "chat-first-sidebar-goals"}, + "ax.expect": { + "focus_order": ["chat-first-sidebar-chat", "chat-first-sidebar-goals"], + "voiceover_labels": {"chat-first-sidebar-chat": "Chat"}, + }, + }, +) == [] +errors = module.lint_ax_step(Path("chat-first.yaml"), {"ax.activate": {"identifier": "not a stable id"}}) +assert errors and "stable identifier" in errors[0] +errors = module.lint_ax_step( + Path("chat-first.yaml"), + {"ax.expect": {"focus_order": ["chat-first-sidebar-chat", "chat-first-sidebar-chat"]}}, +) +assert errors and "must not repeat" in errors[0] PY write_flow() { diff --git a/scripts/dev-harness/MEMORY_SCENARIOS.md b/scripts/dev-harness/MEMORY_SCENARIOS.md index 18a2621495a..12f8f9664f7 100644 --- a/scripts/dev-harness/MEMORY_SCENARIOS.md +++ b/scripts/dev-harness/MEMORY_SCENARIOS.md @@ -8,7 +8,7 @@ They are **LOCAL_EMULATOR_DEV** artifacts only: - `activation_eligible` is hard-coded as `false`. - `watermark` is hard-coded as `NOT_ACTIVATION_EVIDENCE`. - Fixture definitions cannot choose evidence labels and must not be used as dev-cloud proof. -- Synthetic users include `local_default_user`, `alice`, and `bob`; no production UIDs, tokens, credentials, or copied user data are present. +- Synthetic users include `local_default_user`, `alice`, `bob`, and two Chat-first E2E-only principals; no production UIDs, tokens, credentials, or copied user data are present. Commands: @@ -17,7 +17,7 @@ make list-memory-scenarios make seed-memory-scenario SCENARIO=happy_path make dev-status make dev-summary -make desktop-run-local USER=alice +make desktop-run-local DESKTOP_USER=alice make reset-memory-scenario SCENARIO=happy_path ``` @@ -26,3 +26,26 @@ Before `make desktop-run-local` signs into the Auth emulator, it resets only the `make dev-summary` writes an optional `LOCAL_EMULATOR_DEV` session summary with `activation_eligible=false`, provider mode, local endpoints, no prod/dev-cloud activation implication, and placeholder fields for write-attempt instrumentation plus protected-collection before/after digests when live emulator instrumentation is not available. If local Firestore/Auth emulators are not reachable, seed/reset commands still validate fixtures and emit a dry-run manifest under the sentinel-owned local harness state root. They do not fake live emulator writes. + +## Chat-first E2E fixture + +The named Chat-first bundle is exercised through real Firebase Auth emulator +credentials. The local harness resolves the emulator-assigned `localId` from +`canonical-auth-uids.json`; it never uses a fixed UID or a production auth +bypass. Start the local backend and live emulators, seed the scenario, prepare +the fixture using its logical principal, then launch the named bundle: + +```bash +PROVIDER_MODE=offline make dev-up +make seed-memory-scenario SCENARIO=happy_path +make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=enabled +make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-chat-first-e2e-enabled +``` + +The same local-only command snapshots or advances an existing fixture while +preserving the authenticated principal boundary: + +```bash +make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=snapshot CHAT_FIRST_E2E_CASE=enabled +make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=advance CHAT_FIRST_E2E_CASE=enabled CHAT_FIRST_E2E_SECONDS=86400 +``` diff --git a/scripts/dev-harness/chat-first-e2e-fixture.sh b/scripts/dev-harness/chat-first-e2e-fixture.sh new file mode 100644 index 00000000000..80b526b4e2e --- /dev/null +++ b/scripts/dev-harness/chat-first-e2e-fixture.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Drive the Chat-first E2E fixture through a real Firebase Auth emulator token. +# This is intentionally a local-harness command, never an authentication bypass. +# shellcheck source=_source_local_dev_env.sh +source "$(dirname "$0")/_source_local_dev_env.sh" +cd "$(dirname "$0")/../.." + +ACTION="${1:?usage: chat-first-e2e-fixture.sh [seconds]}" +FIXTURE_CASE="${2:?usage: chat-first-e2e-fixture.sh [seconds]}" +SECONDS="${3:-86400}" + +PYTHON_BIN="${PYTHON:-backend/venv/bin/python}" +if [ ! -x "$PYTHON_BIN" ]; then + PYTHON_BIN="python3" +fi + +PYTHONPATH="scripts/dev-harness${PYTHONPATH:+:$PYTHONPATH}" "$PYTHON_BIN" - "$ACTION" "$FIXTURE_CASE" "$SECONDS" <<'PY' +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import urlparse + +from dev_harness import config, safety +from dev_harness.cli import _current_scenario_manifest, _scenario_users_from_seed_manifest + +action, fixture_case, raw_seconds = sys.argv[1:] +valid_actions = {'prepare', 'snapshot', 'advance'} +valid_cases = {'enabled', 'question', 'ui_flag_off', 'out_of_cohort', 'unreachable_control', 'cold_start'} +if action not in valid_actions or fixture_case not in valid_cases: + raise SystemExit( + 'usage: chat-first-e2e-fixture.sh ' + ' [seconds]' + ) +if os.environ.get('OMI_ENV_STAGE') not in {'local', 'offline'}: + raise SystemExit('Chat-first E2E fixture is local/offline only') + +repo = Path.cwd() +cfg = config.load_config(repo, create_layout=False) +if not cfg.layout.sentinel_path.is_file(): + raise SystemExit('Local harness sentinel is missing; run PROVIDER_MODE=offline make dev-up first') +safety.read_and_validate_sentinel(cfg.layout.state_root, repo_root=repo, instance=cfg.instance) +if not safety.is_loopback_host(urlparse(cfg.backend_url).netloc): + raise SystemExit(f'Refusing non-loopback backend URL: {cfg.backend_url}') + +scenario = _current_scenario_manifest(cfg) +seeded_users = _scenario_users_from_seed_manifest(cfg) +if not scenario or not seeded_users: + raise SystemExit('No live memory scenario is seeded; run make seed-memory-scenario SCENARIO=happy_path') + +principal = 'omi-chat-first-e2e-out-of-cohort' if fixture_case == 'out_of_cohort' else 'omi-chat-first-e2e-enabled' +if principal not in seeded_users: + raise SystemExit(f'Fixture principal {principal!r} is absent from the seeded scenario') + +auth_manifest_path = cfg.layout.state_root / 'manifests' / 'canonical-auth-uids.json' +try: + auth_manifest = json.loads(auth_manifest_path.read_text(encoding='utf-8')) + expected_local_id = auth_manifest['users'][principal] +except (OSError, KeyError, TypeError, json.JSONDecodeError): + raise SystemExit('Live Auth UID manifest is missing; re-run make seed-memory-scenario SCENARIO=happy_path') from None +if not isinstance(expected_local_id, str) or not expected_local_id: + raise SystemExit('Auth UID manifest contains no fixture Auth emulator identity') + +seed_manifests = sorted((cfg.layout.state_root / 'manifests').glob('memory-scenario-*-seed.json')) +if not seed_manifests: + raise SystemExit('Seed manifest is missing; re-run make seed-memory-scenario SCENARIO=happy_path') +seed_manifest = json.loads(max(seed_manifests, key=lambda path: path.stat().st_mtime).read_text(encoding='utf-8')) +credentials = next( + ( + op.get('payload') + for op in seed_manifest.get('operations', []) + if isinstance(op, dict) + and op.get('kind') == 'auth' + and op.get('action') == 'upsert' + and isinstance(op.get('payload'), dict) + and op['payload'].get('localId') == principal + ), + None, +) +if not isinstance(credentials, dict): + raise SystemExit(f'Seed manifest has no credentials for {principal!r}') +email, password = credentials.get('email'), credentials.get('password') +if not isinstance(email, str) or not isinstance(password, str): + raise SystemExit(f'Seed manifest credentials for {principal!r} are invalid') + + +def request_json(method: str, url: str, payload: dict[str, object] | None = None, token: str | None = None): + body = json.dumps(payload).encode('utf-8') if payload is not None else None + headers = {'Content-Type': 'application/json'} if body else {} + if token: + headers['Authorization'] = f'Bearer {token}' + request = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=10) as response: + return response.status, json.loads(response.read().decode('utf-8')) + except urllib.error.HTTPError as error: + return error.code, json.loads(error.read().decode('utf-8')) + + +auth_status, auth_body = request_json( + 'POST', + f'http://{cfg.auth_host}/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=local-dev-harness', + {'email': email, 'password': password, 'returnSecureToken': True}, +) +if auth_status >= 400 or not isinstance(auth_body, dict): + raise SystemExit(f'Firebase Auth emulator sign-in failed: HTTP {auth_status}') +if auth_body.get('localId') != expected_local_id: + raise SystemExit('Firebase Auth emulator localId does not match the harness manifest') +token = auth_body.get('idToken') +if not isinstance(token, str) or not token: + raise SystemExit('Firebase Auth emulator returned no ID token') + +if action == 'prepare': + method, endpoint, payload = 'POST', '/prepare', {'fixture_case': fixture_case} +elif action == 'snapshot': + method, endpoint, payload = 'GET', '/snapshot', None +else: + try: + seconds = int(raw_seconds) + except ValueError: + raise SystemExit('advance seconds must be an integer') from None + if seconds <= 0: + raise SystemExit('advance seconds must be positive') + method, endpoint, payload = 'POST', '/advance-clock', {'seconds': seconds} + +status, response = request_json(method, f'{cfg.backend_url}/v1/dev-harness/chat-first{endpoint}', payload, token) +if status >= 400: + raise SystemExit(f'Chat-first fixture {action} failed: HTTP {status}') +if not isinstance(response, dict): + raise SystemExit('Chat-first fixture returned an invalid response') + +# The API intentionally returns only bounded state/counters, never fixture IDs, +# titles, or raw conversation content. Keep command output equally constrained. +print(json.dumps(response, sort_keys=True)) +PY diff --git a/scripts/dev-harness/dev_harness/memory_scenarios.py b/scripts/dev-harness/dev_harness/memory_scenarios.py index 191c99aeed3..f736cae0957 100644 --- a/scripts/dev-harness/dev_harness/memory_scenarios.py +++ b/scripts/dev-harness/dev_harness/memory_scenarios.py @@ -32,6 +32,8 @@ DEFAULT_LOCAL_USER_ID = "local_default_user" ALICE_USER_ID = "alice" BOB_USER_ID = "bob" +CHAT_FIRST_E2E_ENABLED_USER_ID = "omi-chat-first-e2e-enabled" +CHAT_FIRST_E2E_OUT_OF_COHORT_USER_ID = "omi-chat-first-e2e-out-of-cohort" # Short-term seeds must stay visible across long local-dev sessions. SHORT_TERM_EXPIRES_AT = "2027-12-31T23:59:59Z" SYNTHETIC_SOURCE_VERSION = "memory-local-synthetic-source-1" @@ -177,7 +179,13 @@ def _user(uid: str, name: str) -> ScenarioUser: ) -USERS = (_user(DEFAULT_LOCAL_USER_ID, "Default"), _user(ALICE_USER_ID, "Alice"), _user(BOB_USER_ID, "Bob")) +USERS = ( + _user(DEFAULT_LOCAL_USER_ID, "Default"), + _user(ALICE_USER_ID, "Alice"), + _user(BOB_USER_ID, "Bob"), + _user(CHAT_FIRST_E2E_ENABLED_USER_ID, "Chat-first E2E Enabled"), + _user(CHAT_FIRST_E2E_OUT_OF_COHORT_USER_ID, "Chat-first E2E Out Of Cohort"), +) def _auth_seed(users: Sequence[ScenarioUser]) -> tuple[Mapping[str, object], ...]: @@ -1024,7 +1032,13 @@ def validate_scenario(scenario: MemoryScenario) -> None: if scenario.scenario_id not in SCENARIOS: raise ValueError("Scenario ID must be registered") user_ids = {user.uid for user in scenario.users} - required = {DEFAULT_LOCAL_USER_ID, ALICE_USER_ID, BOB_USER_ID} + required = { + DEFAULT_LOCAL_USER_ID, + ALICE_USER_ID, + BOB_USER_ID, + CHAT_FIRST_E2E_ENABLED_USER_ID, + CHAT_FIRST_E2E_OUT_OF_COHORT_USER_ID, + } if not required.issubset(user_ids): raise ValueError(f"Scenario users must include {sorted(required)}") if scenario.selected_user not in user_ids: diff --git a/scripts/dev-harness/tests/test_memory_scenarios.py b/scripts/dev-harness/tests/test_memory_scenarios.py index d85842fca52..b16fd4f87c9 100644 --- a/scripts/dev-harness/tests/test_memory_scenarios.py +++ b/scripts/dev-harness/tests/test_memory_scenarios.py @@ -38,7 +38,13 @@ def test_all_memory_scenarios_import_and_validate() -> None: }.issubset(names) happy = memory_scenarios.get_scenario("happy_path") - assert {user.uid for user in happy.users} >= {"local_default_user", "alice", "bob"} + assert {user.uid for user in happy.users} >= { + "local_default_user", + "alice", + "bob", + "omi-chat-first-e2e-enabled", + "omi-chat-first-e2e-out-of-cohort", + } assert happy.selected_user == "alice" assert happy.report_metadata.evidence_class == "LOCAL_EMULATOR_DEV" assert happy.report_metadata.activation_eligible is False From 1a91cbd20a706676e334ceaf35dfb093122bfe94 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Thu, 16 Jul 2026 02:26:48 -0400 Subject: [PATCH 17/28] fix(chat): prepare local fixture with write batch --- .../tests/unit/test_chat_first_e2e_fixture.py | 8 ++-- .../chat_first_e2e_fixture.py | 42 ++++++++++--------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/backend/tests/unit/test_chat_first_e2e_fixture.py b/backend/tests/unit/test_chat_first_e2e_fixture.py index d0d03a2f37f..5a8c1500851 100644 --- a/backend/tests/unit/test_chat_first_e2e_fixture.py +++ b/backend/tests/unit/test_chat_first_e2e_fixture.py @@ -57,6 +57,8 @@ def collection(self, name): return _Collection(self._database, (*self._path, name)) def get(self, transaction=None): + if transaction is not None: + raise AssertionError('fixture state must be read before opening its write-only transaction') return _Snapshot(self._database, self._path) @@ -77,7 +79,7 @@ def stream(self): ] -class _Transaction: +class _WriteBatch: def __init__(self, database): self._database = database self._operations = [] @@ -108,8 +110,8 @@ def __init__(self): def collection(self, name): return _Collection(self, (name,)) - def transaction(self): - return _Transaction(self) + def batch(self): + return _WriteBatch(self) @pytest.fixture diff --git a/backend/utils/task_intelligence/chat_first_e2e_fixture.py b/backend/utils/task_intelligence/chat_first_e2e_fixture.py index 9754f05587d..6343bab1341 100644 --- a/backend/utils/task_intelligence/chat_first_e2e_fixture.py +++ b/backend/utils/task_intelligence/chat_first_e2e_fixture.py @@ -381,53 +381,55 @@ def prepare_fixture( refs = _entity_refs(uid, firestore_client=client) now = datetime.now(timezone.utc) prior_feature_refs = _existing_feature_refs(uid, firestore_client=client) - transaction = client.transaction() - state_snapshot = refs['state'].get(transaction=transaction) + # Read the fixture revision before opening its write batch. This avoids + # passing an inactive Firestore transaction to DocumentReference.get(). + state_snapshot = refs['state'].get() existing_state = state_snapshot.to_dict() if state_snapshot.exists else {} revision = int(existing_state.get('fixture_revision', 0)) + 1 if isinstance(existing_state, dict) else 1 + batch = client.batch() # All fixture-owned surfaces are deterministic document IDs. The same - # transaction removes their prior contents before exposing the next case. + # batch removes their prior contents before exposing the next case. reset_refs = prior_feature_refs + [ refs[ref_name] for ref_name in ('question_intent', 'cold_start_intent', 'daily_opener_intent', 'question_deferral', 'budget') ] for ref in _unique_document_refs(reset_refs): - transaction.delete(ref) - transaction.set(refs['control'], _control_for_case(fixture_case).persisted_payload()) - transaction.set( + batch.delete(ref) + batch.set(refs['control'], _control_for_case(fixture_case).persisted_payload()) + batch.set( refs['goal'], _goal_payload(goal_id=_GOAL_ID, title='E2E fixture goal', focused=True, now=now), ) - transaction.set( + batch.set( refs['secondary_goal'], _goal_payload(goal_id=_SECONDARY_GOAL_ID, title='E2E fixture next goal', focused=False, now=now), ) - transaction.set(refs['task'], _task_payload(now=now)) - transaction.set(refs['capture'], _capture_payload(now=now)) + batch.set(refs['task'], _task_payload(now=now)) + batch.set(refs['capture'], _capture_payload(now=now)) if fixture_case is ChatFirstE2EFixtureCase.cold_start: - transaction.set(refs['cold_start_intent'], _cold_start_intent(uid, now=now).model_dump(mode='python')) + batch.set(refs['cold_start_intent'], _cold_start_intent(uid, now=now).model_dump(mode='python')) elif fixture_case is ChatFirstE2EFixtureCase.question: - transaction.set( + batch.set( refs['cold_start_intent'], _completed_rich_cold_start_intent(uid, now=now).model_dump(mode='python'), ) - transaction.set(refs['question_intent'], _question_intent(uid, now=now).model_dump(mode='python')) + batch.set(refs['question_intent'], _question_intent(uid, now=now).model_dump(mode='python')) elif fixture_case is ChatFirstE2EFixtureCase.enabled: - transaction.set( + batch.set( refs['daily_opener_intent'], _daily_opener_intent(uid, now=now).model_dump(mode='python'), ) elif fixture_case is ChatFirstE2EFixtureCase.unreachable_control: - transaction.set(refs['question_intent'], _question_intent(uid, now=now).model_dump(mode='python')) + batch.set(refs['question_intent'], _question_intent(uid, now=now).model_dump(mode='python')) state = { 'fixture_case': fixture_case.value, 'fixture_revision': revision, 'advanced_seconds': 0, 'prepared_at': now, } - transaction.set(refs['state'], state) - transaction.commit() + batch.set(refs['state'], state) + batch.commit() return _snapshot_from_rows(uid, firestore_client=client, prepared_state=state) @@ -457,13 +459,13 @@ def advance_fixture_clock( data = document.to_dict() if isinstance(data, dict) and data.get('account_generation') == 1 and data.get('state') == 'pending': pending_refs.append(document.reference) - transaction = client.transaction() + batch = client.batch() for ref in pending_refs: - transaction.update(ref, {'due_at': now - timedelta(seconds=1)}) + batch.update(ref, {'due_at': now - timedelta(seconds=1)}) advanced_state = deepcopy(state) advanced_state['advanced_seconds'] = int(advanced_state.get('advanced_seconds', 0)) + seconds - transaction.set(refs['state'], advanced_state) - transaction.commit() + batch.set(refs['state'], advanced_state) + batch.commit() return _snapshot_from_rows(uid, firestore_client=client, prepared_state=advanced_state) From 1e557f5932eea1f4f8a1cf8606988079051daa9b Mon Sep 17 00:00:00 2001 From: David Zhang Date: Fri, 17 Jul 2026 02:58:41 -0400 Subject: [PATCH 18/28] fix(memory): unify canonical cohort entitlement Failure-Class: none --- backend/.env.template | 6 +- backend/AGENTS.md | 2 +- backend/config/canonical_memory_cohort.py | 33 +++++ backend/config/chat_first_e2e_fixture.py | 23 ++- backend/database/candidates.py | 42 ++---- backend/database/chat_first_intents.py | 35 ++--- backend/database/goals.py | 14 +- backend/database/recurrence_inbox.py | 22 ++- backend/database/task_recommendations.py | 52 ++++--- backend/database/workstreams.py | 24 ++-- backend/docs/task_intelligence_baseline.md | 5 + backend/models/chat_first_e2e.py | 1 - backend/models/task_intelligence.py | 26 ++-- backend/routers/candidates.py | 53 +++++-- backend/routers/canonical_task_access.py | 22 +++ backend/routers/goals.py | 15 +- backend/routers/memories.py | 54 +++---- backend/routers/staged_tasks.py | 31 ++-- backend/routers/workstreams.py | 24 ++-- ...activate_task_intelligence_dogfood_user.py | 46 +----- .../p1_3_v3_control_reader_emulator_test.py | 12 +- .../unit/canonical_cohort_test_helpers.py | 12 +- ...activate_task_intelligence_dogfood_user.py | 68 +-------- backend/tests/unit/test_candidates_router.py | 136 +++++++++++++----- backend/tests/unit/test_chat_first_blocks.py | 4 +- .../tests/unit/test_chat_first_e2e_fixture.py | 16 +-- .../unit/test_chat_first_proactive_intents.py | 29 +++- .../unit/test_chat_first_proactive_router.py | 6 +- .../tests/unit/test_memory_service_parity.py | 4 +- .../unit/test_task_intelligence_rollout.py | 60 ++++---- .../tests/unit/test_task_recommendations.py | 35 +++-- .../unit/test_v3_control_state_adapter.py | 58 ++++---- .../unit/test_v3_production_runtime_wiring.py | 55 +++---- .../tests/unit/test_workstream_association.py | 19 +-- .../unit/test_workstream_router_contract.py | 37 +++++ backend/utils/memory/ARCHITECTURE.md | 6 +- backend/utils/memory/canonical_activation.py | 36 +---- backend/utils/memory/memory_service.py | 42 +++++- backend/utils/memory/memory_system.py | 40 ++---- .../utils/memory/v3/control_state_adapter.py | 35 ++--- backend/utils/memory/v3/production_runtime.py | 55 ++----- .../chat_first_e2e_fixture.py | 1 - .../chat_first_eligibility.py | 2 +- .../task_intelligence/conversation_capture.py | 124 +++------------- backend/utils/task_intelligence/rollout.py | 83 +++++------ .../workstream_association.py | 48 +------ .../chat-first-capability-isolation.yaml | 10 +- .../macos/e2e/flows/chat-first-cohesive.yaml | 2 +- .../e2e/flows/chat-first-cold-start.yaml | 2 +- .../flows/chat-first-question-deferral.yaml | 2 +- desktop/macos/e2e/harness.md | 8 +- .../backend/canonical_memory_architecture.md | 9 +- scripts/dev-harness/MEMORY_SCENARIOS.md | 2 +- scripts/dev-harness/chat-first-e2e-fixture.sh | 10 +- .../dev_harness/memory_scenarios.py | 65 ++++++++- .../tests/test_memory_scenarios.py | 19 +-- 56 files changed, 857 insertions(+), 825 deletions(-) create mode 100644 backend/config/canonical_memory_cohort.py create mode 100644 backend/routers/canonical_task_access.py diff --git a/backend/.env.template b/backend/.env.template index 09439dfce7b..2ee50312114 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -1,10 +1,12 @@ # Stage templates (local harness / cloud dev): see backend/.env.{local-dev,offline,dev,prod}.template # Copy one to backend/.env., symlink backend/.env, set OMI_ENV_STAGE. See .envrc.example. -# Memory rollout (canonical MEMORY_* env) +# Canonical-memory maintenance controls. They never select a user, task +# system, or Chat-first shell: those are selected only by the code-owned +# CANONICAL_MEMORY_USERS whitelist in config/canonical_memory_cohort.py. +# Keep these values aligned with maintenance-job readiness requirements. MEMORY_MODE=off MEMORY_ENABLED_USERS= -# Canonical cohort: edit CANONICAL_MEMORY_USERS in utils/memory/memory_system.py (not env). MEMORY_CANONICAL_PROMOTION_CRON_ENABLED=false MEMORY_CANONICAL_PROMOTION_CRON_INTERVAL_HOURS=1 MEMORY_V3_GET_ENABLED=false diff --git a/backend/AGENTS.md b/backend/AGENTS.md index a34d66cfdbe..265edf79737 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -156,7 +156,7 @@ Helm charts: `backend/charts/{agent-proxy,agent-vm-reaper,backend-listen,backend - **nllb-translation** (`nllb_translation/`) — GPU translation service. Called by backend when `HOSTED_TRANSLATION_API_URL` is set and NLLB is selected. - **backend-sync** (`main.py`, same image as backend) — Cloud Run admission service for `/v2/sync-local-files`. The server classifies whole batches: recordings no more than six hours old enter `sync-jobs` (fresh), while older or untrusted batches enter `sync-backfill` and the scale-to-zero **backend-sync-backfill** worker. Fresh keeps its bounded inline fallback; backfill never falls into fresh/inline capacity. Backfill defaults to one in-flight job per UID, four processed speech hours per UID/day, 555 processed speech hours globally/day, a 30-day lookback, and four queue workers. Live fair-use reads only `realtime + sync_fresh`; `sync_backfill` is separately metered. A 45-day Firestore content ledger protects transcription and usage side effects across job expiry and re-upload. Audio playback merges (`/v1/sync/audio/*`) follow the same pattern via queue `audio-merge` building 30-day MP3 artifacts under `playback/` (`AUDIO_MERGE_DISPATCH_MODE`) — per-part files plus one dense per-conversation `conversation.mp3` whose spans manifest + audio_files fingerprint are stamped on the conversation doc (`conversation_audio`); a fingerprint mismatch after late chunks re-enqueues the build. In production, account deletion requires `ACCOUNT_DELETION_DISPATCH_MODE=cloud_tasks` and complete Cloud Tasks bindings to enqueue opaque job IDs to queue `account-deletion`, which posts `/v1/users/account-deletion-wipes/run`; startup rejects inline or incomplete configuration, reconciliation only re-dispatches tasks so the OIDC handler is the sole wipe executor, and the post-deploy queue-drain window accepts the former sync OIDC audience only for legacy UID payloads. API success is returned only after the deletion marker is persisted and the wipe task is durably enqueued. - **notifications-job** (`modal/job.py`) — Cron job, reads Firestore/Redis, sends push notifications and runs X connector sync. It has no canonical maintenance flags or Typesense secrets; its deploy workflow removes only those retired bindings and preserves unrelated notification/X-sync env. -- **memory-maintenance-job** (`modal/memory_maintenance_job.py`) — Cloud Run Job and sole host for canonical ST→LT maintenance (TTL → consolidation → promotion). Manual deploy via `.github/workflows/gcp_memory_maintenance_job.yml`; auto-dev on push to `main` via `gcp_memory_maintenance_job_auto_dev.yml`. Enablement is a multi-var contract (`MEMORY_MODE`, `MEMORY_ENABLED_USERS`, cron/fast-track/consolidation flags) enforced by `backend/scripts/validate-backend-runtime-env.py`; prod stays `MEMORY_MODE=off` until Gate 3. +- **memory-maintenance-job** (`modal/memory_maintenance_job.py`) — Cloud Run Job and sole host for canonical ST→LT maintenance (TTL → consolidation → promotion). Manual deploy via `.github/workflows/gcp_memory_maintenance_job.yml`; auto-dev on push to `main` via `gcp_memory_maintenance_job_auto_dev.yml`. Its multi-var runtime contract (`MEMORY_MODE`, `MEMORY_ENABLED_USERS`, cron/fast-track/consolidation flags) controls maintenance readiness only; user selection for canonical memory, tasks, and Chat-first is exclusively `CANONICAL_MEMORY_USERS` in `config/canonical_memory_cohort.py`. - **monitoring** (`backend/charts/monitoring/`) — Prometheus, Grafana, Loki, Alloy, alerts, and HPA metric adapters for backend services. - **agent-vm-reaper** (`backend/charts/agent-vm-reaper/`) — CronJob that deletes stale `omi-agent-*` GCE VMs left by desktop agent sandboxes. - **backend-secrets** (`backend/charts/backend-secrets/`) — ExternalSecret and SecretStore resources that sync backend runtime secrets into GKE namespaces. diff --git a/backend/config/canonical_memory_cohort.py b/backend/config/canonical_memory_cohort.py new file mode 100644 index 00000000000..621bf240aee --- /dev/null +++ b/backend/config/canonical_memory_cohort.py @@ -0,0 +1,33 @@ +"""Code-owned canonical-memory entitlement selector. + +This deliberately dependency-free module is importable from database, utility, +and router layers. It is the one source of truth for canonical memory, task +intelligence, and Chat-first cohort membership. +""" + +# This fixed Auth-emulator UID is a local-only E2E account. Its presence here +# deliberately makes the harness use the same membership predicate as every +# real account; the paired disabled fixture UID is intentionally absent. +LOCAL_CHAT_FIRST_E2E_ENABLED_UID = 'omi-local-emulator-chat-first-enabled-v1' + +CANONICAL_MEMORY_USERS: frozenset[str] = frozenset( + { + "vi7SA9ckQCe4ccobWNxlbdcNdC23", # david.d.zhang@gmail.com (prod Firebase: based-hardware) + LOCAL_CHAT_FIRST_E2E_ENABLED_UID, + # Next dogfood (re-enable soon): + # "viUv7GtdoHXbK1UBCDlPuTDuPgJ2", # kodjima33@gmail.com (prod Firebase: based-hardware) + } +) + + +def is_canonical_memory_user(uid: str) -> bool: + """Return whether ``uid`` belongs to the sole canonical entitlement cohort.""" + + return bool(uid) and uid in CANONICAL_MEMORY_USERS + + +__all__ = [ + 'CANONICAL_MEMORY_USERS', + 'LOCAL_CHAT_FIRST_E2E_ENABLED_UID', + 'is_canonical_memory_user', +] diff --git a/backend/config/chat_first_e2e_fixture.py b/backend/config/chat_first_e2e_fixture.py index ba82c978874..4995e9293a2 100644 --- a/backend/config/chat_first_e2e_fixture.py +++ b/backend/config/chat_first_e2e_fixture.py @@ -1,17 +1,19 @@ """Firebase Auth emulator identities and runtime guard for Chat-first E2E. -The Firebase Auth emulator assigns the authenticated ``localId``. It does not -reliably honour a caller-provided ``localId`` during seed, so fixture access -resolves two logical synthetic principals through the dev-harness manifest -instead of relying on a production-like auth bypass or hard-coded UID. +The local harness seeds fixed Firebase Auth emulator UIDs through the Admin +API. The enabled UID is deliberately listed in the canonical-memory cohort, +so fixture capability uses the normal product entitlement rather than a test +bypass. """ import json import os from pathlib import Path -CHAT_FIRST_E2E_ENABLED_PRINCIPAL = 'omi-chat-first-e2e-enabled' -CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL = 'omi-chat-first-e2e-out-of-cohort' +from config.canonical_memory_cohort import LOCAL_CHAT_FIRST_E2E_ENABLED_UID + +CHAT_FIRST_E2E_ENABLED_PRINCIPAL = LOCAL_CHAT_FIRST_E2E_ENABLED_UID +CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL = 'omi-local-emulator-chat-first-disabled-v1' _LOCAL_E2E_STAGES = frozenset({'local', 'offline'}) _AUTH_UID_MANIFEST_NAME = 'canonical-auth-uids.json' @@ -61,14 +63,6 @@ def fixture_uid_for_principal(principal: str, *, state_root: str | None = None) return _fixture_auth_uids(state_root=state_root).get(principal) -def is_chat_first_e2e_enabled_fixture(uid: str, *, stage: str | None = None) -> bool: - """Return whether one local-only fixture identity is in the test cohort.""" - - return is_chat_first_e2e_harness_runtime(stage=stage) and uid == fixture_uid_for_principal( - CHAT_FIRST_E2E_ENABLED_PRINCIPAL - ) - - def is_chat_first_e2e_fixture_uid(uid: str, *, stage: str | None = None) -> bool: """Return whether ``uid`` is one of the two isolated E2E accounts.""" @@ -79,7 +73,6 @@ def is_chat_first_e2e_fixture_uid(uid: str, *, stage: str | None = None) -> bool 'CHAT_FIRST_E2E_ENABLED_PRINCIPAL', 'CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL', 'fixture_uid_for_principal', - 'is_chat_first_e2e_enabled_fixture', 'is_chat_first_e2e_fixture_uid', 'is_chat_first_e2e_harness_runtime', ] diff --git a/backend/database/candidates.py b/backend/database/candidates.py index a0c38dcbcaa..2a550598a58 100644 --- a/backend/database/candidates.py +++ b/backend/database/candidates.py @@ -9,6 +9,8 @@ from google.cloud import firestore from google.cloud.firestore_v1 import FieldFilter + +from config.canonical_memory_cohort import is_canonical_memory_user import database.action_items as action_items_db from database._client import db from database.read_boundary import parse_snapshot_or_none, parse_snapshot_strict, parse_snapshots @@ -21,7 +23,7 @@ CandidateStatus, CandidateSubjectKind, ) -from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode +from models.task_intelligence import TaskWorkflowControl CANDIDATES_COLLECTION = 'candidates' ACTION_ITEMS_COLLECTION = 'action_items' @@ -133,14 +135,14 @@ def _task_control_ref(uid: str): ) -def _validate_write_control(snapshot: Any, *, account_generation: int) -> None: +def _validate_write_control(snapshot: Any, *, uid: str, account_generation: int) -> None: + if not is_canonical_memory_user(uid): + raise CandidateConflictError('canonical task intelligence is not enabled') control = TaskWorkflowControl() if snapshot.exists: control = parse_snapshot_strict(TaskWorkflowControl, snapshot) if control.account_generation != account_generation: raise CandidateGenerationMismatchError('account generation mismatch') - if control.workflow_mode not in {TaskWorkflowMode.write, TaskWorkflowMode.read}: - raise CandidateConflictError('Candidate writes are not enabled') def _snapshot_dict(snapshot: Any) -> dict[str, Any]: @@ -398,7 +400,7 @@ def create_candidate( @firestore.transactional def apply(write_transaction): control_snapshot = _task_control_ref(uid).get(transaction=write_transaction) - _validate_write_control(control_snapshot, account_generation=account_generation) + _validate_write_control(control_snapshot, uid=uid, account_generation=account_generation) alias_snapshot = alias_ref.get(transaction=write_transaction) if alias_snapshot.exists: @@ -635,7 +637,7 @@ def resolve_task_candidate( @firestore.transactional def apply(write_transaction): control_snapshot = _task_control_ref(uid).get(transaction=write_transaction) - _validate_write_control(control_snapshot, account_generation=account_generation) + _validate_write_control(control_snapshot, uid=uid, account_generation=account_generation) snapshot = candidate_ref.get(transaction=write_transaction) if not snapshot.exists: raise CandidateNotFoundError(candidate_id) @@ -782,15 +784,7 @@ def apply(write_transaction): }, ) return None - if control.workflow_mode not in {TaskWorkflowMode.write, TaskWorkflowMode.read}: - write_transaction.update( - outbox_ref, - { - 'status': 'suppressed', - 'resolution_reason': 'candidate_writes_disabled', - 'updated_at': claim_time, - }, - ) + if not is_canonical_memory_user(uid): return None if payload.get('status') in {'completed', 'suppressed'}: return None @@ -847,15 +841,7 @@ def apply(write_transaction): }, ) return False - if control.workflow_mode not in {TaskWorkflowMode.write, TaskWorkflowMode.read}: - write_transaction.update( - outbox_ref, - { - 'status': 'suppressed', - 'resolution_reason': 'candidate_writes_disabled', - 'updated_at': completion_time, - }, - ) + if not is_canonical_memory_user(uid): return False if payload.get('status') != 'processing' or payload.get('lease_token') != lease_token: return False @@ -908,7 +894,7 @@ def resolve_candidate_without_mutation( @firestore.transactional def apply(write_transaction): control_snapshot = _task_control_ref(uid).get(transaction=write_transaction) - _validate_write_control(control_snapshot, account_generation=account_generation) + _validate_write_control(control_snapshot, uid=uid, account_generation=account_generation) snapshot = candidate_ref.get(transaction=write_transaction) if not snapshot.exists: raise CandidateNotFoundError(candidate_id) @@ -967,7 +953,7 @@ def reconcile_migrated_candidate( @firestore.transactional def apply(write_transaction): control_snapshot = _task_control_ref(uid).get(transaction=write_transaction) - _validate_write_control(control_snapshot, account_generation=account_generation) + _validate_write_control(control_snapshot, uid=uid, account_generation=account_generation) snapshot = candidate_ref.get(transaction=write_transaction) if not snapshot.exists: raise CandidateNotFoundError(candidate_id) @@ -1035,7 +1021,7 @@ def claim_candidate_for_legacy_promotion( @firestore.transactional def apply(write_transaction): control_snapshot = _task_control_ref(uid).get(transaction=write_transaction) - _validate_write_control(control_snapshot, account_generation=account_generation) + _validate_write_control(control_snapshot, uid=uid, account_generation=account_generation) candidate_snapshot = candidate_ref.get(transaction=write_transaction) if not candidate_snapshot.exists: raise CandidateNotFoundError(candidate_id) @@ -1109,7 +1095,7 @@ def begin_candidate_legacy_promotion( @firestore.transactional def apply(write_transaction): control_snapshot = _task_control_ref(uid).get(transaction=write_transaction) - _validate_write_control(control_snapshot, account_generation=account_generation) + _validate_write_control(control_snapshot, uid=uid, account_generation=account_generation) candidate_snapshot = candidate_ref.get(transaction=write_transaction) if not candidate_snapshot.exists: raise CandidateNotFoundError(candidate_id) diff --git a/backend/database/chat_first_intents.py b/backend/database/chat_first_intents.py index 4f6d818da48..581233a1838 100644 --- a/backend/database/chat_first_intents.py +++ b/backend/database/chat_first_intents.py @@ -7,6 +7,7 @@ from google.cloud import firestore +from config.canonical_memory_cohort import is_canonical_memory_user from database._client import get_firestore_client from database.read_boundary import MalformedDocError, parse_snapshot_strict from models.chat_first import ( @@ -21,7 +22,7 @@ QuestionCardSpec, ) from models.proactive_budget import account_materialization, budget_allows, normalized_budget_state, reserve_budget -from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode +from models.task_intelligence import TaskWorkflowControl INTENTS_COLLECTION = 'chat_first_proactive_intents' DEFERRALS_COLLECTION = 'chat_first_deferrals' @@ -93,18 +94,16 @@ def _stable_id(prefix: str, *parts: object) -> str: return f'{prefix}_{hashlib.sha256(raw).hexdigest()[:32]}' -def _require_control(snapshot: Any, account_generation: int) -> None: +def _require_control(snapshot: Any, *, uid: str, account_generation: int) -> None: + if not is_canonical_memory_user(uid): + raise ChatFirstIntentGenerationMismatch('chat-first capability changed') control = TaskWorkflowControl() if snapshot.exists: try: control = parse_snapshot_strict(TaskWorkflowControl, snapshot) except MalformedDocError as error: raise ChatFirstIntentGenerationMismatch('chat-first capability state is malformed') from error - if ( - control.workflow_mode != TaskWorkflowMode.read - or control.account_generation != account_generation - or not control.chat_first_ui_enabled - ): + if control.account_generation != account_generation: raise ChatFirstIntentGenerationMismatch('chat-first capability changed') @@ -141,7 +140,11 @@ def _deferral_from_snapshot(snapshot: Any) -> ProactiveDeferral: def _require_current_control(uid: str, *, account_generation: int, firestore_client: Any) -> None: """Fence read-only entry points before they inspect feature-specific rows.""" - _require_control(_control_ref(uid, firestore_client=firestore_client).get(), account_generation) + _require_control( + _control_ref(uid, firestore_client=firestore_client).get(), + uid=uid, + account_generation=account_generation, + ) def _intent_payload(intent: ProactiveIntent) -> dict[str, Any]: @@ -189,7 +192,7 @@ def admit_agent_judgment( @firestore.transactional def apply(write_transaction: Any) -> AgentJudgmentAdmission: control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _require_control(control_snapshot, account_generation) + _require_control(control_snapshot, uid=uid, account_generation=account_generation) existing_snapshot = intent_ref.get(transaction=write_transaction) if existing_snapshot.exists: existing = _intent_from_snapshot(existing_snapshot) @@ -240,7 +243,7 @@ def release_agent_judgment_admission( @firestore.transactional def apply(write_transaction: Any) -> None: control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _require_control(control_snapshot, account_generation) + _require_control(control_snapshot, uid=uid, account_generation=account_generation) intent_snapshot = intent_ref.get(transaction=write_transaction) budget_snapshot = budget_ref.get(transaction=write_transaction) if intent_snapshot.exists: @@ -287,7 +290,7 @@ def create_intent( @firestore.transactional def apply(write_transaction: Any) -> tuple[ProactiveIntent, bool]: control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _require_control(control_snapshot, account_generation) + _require_control(control_snapshot, uid=uid, account_generation=account_generation) existing_snapshot = intent_ref.get(transaction=write_transaction) budget_snapshot = ( budget_ref.get(transaction=write_transaction) @@ -361,7 +364,7 @@ def get_or_create_cold_start_intent( @firestore.transactional def apply(write_transaction: Any) -> tuple[ProactiveIntent, bool]: control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _require_control(control_snapshot, account_generation) + _require_control(control_snapshot, uid=uid, account_generation=account_generation) existing_snapshot = intent_ref.get(transaction=write_transaction) if existing_snapshot.exists: existing = _intent_from_snapshot(existing_snapshot) @@ -431,7 +434,7 @@ def acknowledge_sparse_cold_start_sequence_terminal( @firestore.transactional def apply(write_transaction: Any) -> ProactiveIntent: control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _require_control(control_snapshot, account_generation) + _require_control(control_snapshot, uid=uid, account_generation=account_generation) snapshot = intent_ref.get(transaction=write_transaction) if not snapshot.exists: raise ProactiveIntentNotReady('cold-start intent is not ready') @@ -527,7 +530,7 @@ def acknowledge_materialization( @firestore.transactional def apply(write_transaction: Any) -> ProactiveIntent: control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _require_control(control_snapshot, account_generation) + _require_control(control_snapshot, uid=uid, account_generation=account_generation) intent_snapshot = intent_ref.get(transaction=write_transaction) if not intent_snapshot.exists: raise ProactiveIntentNotReady('proactive intent is not ready') @@ -589,7 +592,7 @@ def record_deferral( @firestore.transactional def apply(write_transaction: Any) -> tuple[DeferralReceipt, bool]: control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _require_control(control_snapshot, account_generation) + _require_control(control_snapshot, uid=uid, account_generation=account_generation) existing_snapshot = ref.get(transaction=write_transaction) if existing_snapshot.exists: existing = _deferral_from_snapshot(existing_snapshot) @@ -674,7 +677,7 @@ def _release_deferral_transaction( @firestore.transactional def apply(write_transaction: Any) -> ProactiveIntent | None: control_snapshot = _control_ref(uid, firestore_client=firestore_client).get(transaction=write_transaction) - _require_control(control_snapshot, account_generation) + _require_control(control_snapshot, uid=uid, account_generation=account_generation) deferral_snapshot = deferral_ref.get(transaction=write_transaction) intent_snapshot = intent_ref.get(transaction=write_transaction) if not deferral_snapshot.exists: diff --git a/backend/database/goals.py b/backend/database/goals.py index 0d7afeb65ee..0f2b482bf4c 100644 --- a/backend/database/goals.py +++ b/backend/database/goals.py @@ -8,6 +8,8 @@ from uuid import uuid4 from google.cloud import firestore + +from config.canonical_memory_cohort import is_canonical_memory_user from google.cloud.firestore_v1 import FieldFilter from pydantic import ValidationError @@ -23,7 +25,7 @@ GoalStatus, GoalType, ) -from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode +from models.task_intelligence import TaskWorkflowControl logger = logging.getLogger(__name__) @@ -84,14 +86,14 @@ def _goal_control_ref(uid: str, *, firestore_client: Any): ) -def _validate_canonical_write(snapshot: Any, *, account_generation: int) -> None: +def _validate_canonical_write(snapshot: Any, *, uid: str, account_generation: int) -> None: + if not is_canonical_memory_user(uid): + raise GoalConflictError('canonical task intelligence is not enabled') control = TaskWorkflowControl() if snapshot.exists: control = parse_snapshot_strict(TaskWorkflowControl, snapshot) if control.account_generation != account_generation: raise GoalConflictError('account generation mismatch') - if control.workflow_mode not in {TaskWorkflowMode.write, TaskWorkflowMode.read}: - raise GoalConflictError('canonical goal writes are disabled') def _goal_mutation_receipt_ref( @@ -124,7 +126,7 @@ def _begin_goal_mutation( firestore_client: Any, ) -> tuple[Any, Optional[dict[str, Any]], str]: control_snapshot = _goal_control_ref(uid, firestore_client=firestore_client).get(transaction=write_transaction) - _validate_canonical_write(control_snapshot, account_generation=account_generation) + _validate_canonical_write(control_snapshot, uid=uid, account_generation=account_generation) receipt_ref = _goal_mutation_receipt_ref( uid, operation=operation, @@ -745,7 +747,7 @@ def _append_goal_progress_event( def apply(write_transaction): if account_generation is not None: control_snapshot = _goal_control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _validate_canonical_write(control_snapshot, account_generation=account_generation) + _validate_canonical_write(control_snapshot, uid=uid, account_generation=account_generation) goal_snapshot = goal_ref.get(transaction=write_transaction) if not goal_snapshot.exists: raise GoalNotFoundError(goal_id) diff --git a/backend/database/recurrence_inbox.py b/backend/database/recurrence_inbox.py index f9fefedde5f..755d175f298 100644 --- a/backend/database/recurrence_inbox.py +++ b/backend/database/recurrence_inbox.py @@ -5,6 +5,8 @@ from typing import Any from google.cloud import firestore + +from config.canonical_memory_cohort import is_canonical_memory_user from google.cloud.firestore_v1.base_query import FieldFilter from database._client import db as default_db @@ -15,7 +17,7 @@ RecurrenceInboxStatus, RecurrenceOutcomeKind, ) -from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode +from models.task_intelligence import TaskWorkflowControl RECURRENCE_INBOX_COLLECTION = 'task_recurrence_inbox' TASK_INTELLIGENCE_CONTROL_COLLECTION = 'task_intelligence_control' @@ -55,7 +57,9 @@ def _control_ref(uid: str, *, firestore_client: Any = None): ) -def _validate_generation(snapshot: Any, account_generation: int) -> None: +def _validate_generation(snapshot: Any, *, uid: str, account_generation: int) -> None: + if not is_canonical_memory_user(uid): + raise RecurrenceGenerationMismatchError('canonical task intelligence is not enabled') if not snapshot.exists: control = TaskWorkflowControl() else: @@ -65,8 +69,6 @@ def _validate_generation(snapshot: Any, account_generation: int) -> None: raise RecurrenceGenerationMismatchError('task workflow control is malformed') from error if control.account_generation != account_generation: raise RecurrenceGenerationMismatchError('account generation mismatch') - if control.workflow_mode not in {TaskWorkflowMode.write, TaskWorkflowMode.read}: - raise RecurrenceGenerationMismatchError('task workflow mode changed') def _from_snapshot(snapshot: Any) -> RecurrenceInboxReceipt: @@ -97,7 +99,9 @@ def enqueue_recurrence_signal( @firestore.transactional def apply(write_transaction): _validate_generation( - _control_ref(uid, firestore_client=client).get(transaction=write_transaction), account_generation + _control_ref(uid, firestore_client=client).get(transaction=write_transaction), + uid=uid, + account_generation=account_generation, ) snapshot = ref.get(transaction=write_transaction) if snapshot.exists: @@ -155,7 +159,9 @@ def complete_recurrence_receipt( @firestore.transactional def apply(write_transaction): _validate_generation( - _control_ref(uid, firestore_client=client).get(transaction=write_transaction), account_generation + _control_ref(uid, firestore_client=client).get(transaction=write_transaction), + uid=uid, + account_generation=account_generation, ) snapshot = ref.get(transaction=write_transaction) if not snapshot.exists or _from_snapshot(snapshot).account_generation != account_generation: @@ -189,7 +195,9 @@ def retry_recurrence_receipt( @firestore.transactional def apply(write_transaction): _validate_generation( - _control_ref(uid, firestore_client=client).get(transaction=write_transaction), account_generation + _control_ref(uid, firestore_client=client).get(transaction=write_transaction), + uid=uid, + account_generation=account_generation, ) snapshot = ref.get(transaction=write_transaction) if not snapshot.exists or _from_snapshot(snapshot).account_generation != account_generation: diff --git a/backend/database/task_recommendations.py b/backend/database/task_recommendations.py index 5bf441c139d..1bc008a7933 100644 --- a/backend/database/task_recommendations.py +++ b/backend/database/task_recommendations.py @@ -7,6 +7,8 @@ from typing import Any, Optional, cast from google.cloud import firestore + +from config.canonical_memory_cohort import is_canonical_memory_user from google.cloud.firestore_v1 import FieldFilter from pydantic import ValidationError @@ -26,7 +28,7 @@ SnapshotReceipt, WhatMattersNowProjection, ) -from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode +from models.task_intelligence import TaskWorkflowControl from utils.observability.fallback import record_fallback logger = logging.getLogger(__name__) @@ -87,17 +89,17 @@ def _control_ref(uid: str, *, firestore_client: Any = None): def _validate_generation( snapshot: Any, - account_generation: int, *, - allowed_modes: set[TaskWorkflowMode] | None = None, + uid: str, + account_generation: int, ) -> None: + if not is_canonical_memory_user(uid): + raise RecommendationGenerationMismatchError('canonical task intelligence is not enabled') control = TaskWorkflowControl() if snapshot.exists: control = parse_snapshot_strict(TaskWorkflowControl, snapshot) if control.account_generation != account_generation: raise RecommendationGenerationMismatchError('account generation mismatch') - if control.workflow_mode not in (allowed_modes or {TaskWorkflowMode.read}): - raise RecommendationGenerationMismatchError('task intelligence mode changed') def _without_generation(payload: dict[str, Any]) -> dict[str, Any]: @@ -190,7 +192,9 @@ def create_intervention( @firestore.transactional def apply(write_transaction): _validate_generation( - _control_ref(uid, firestore_client=client).get(transaction=write_transaction), account_generation + _control_ref(uid, firestore_client=client).get(transaction=write_transaction), + uid=uid, + account_generation=account_generation, ) existing = ref.get(transaction=write_transaction) if not existing.exists: @@ -233,7 +237,9 @@ def save_projection( @firestore.transactional def publish(write_transaction: Any) -> tuple[WhatMattersNowProjection, bool]: _validate_generation( - _control_ref(uid, firestore_client=client).get(transaction=write_transaction), account_generation + _control_ref(uid, firestore_client=client).get(transaction=write_transaction), + uid=uid, + account_generation=account_generation, ) current_snapshot = projection_ref.get(transaction=write_transaction) if current_snapshot.exists: @@ -328,7 +334,9 @@ def get_projection( @firestore.transactional def read(read_transaction): _validate_generation( - _control_ref(uid, firestore_client=client).get(transaction=read_transaction), account_generation + _control_ref(uid, firestore_client=client).get(transaction=read_transaction), + uid=uid, + account_generation=account_generation, ) return ref.get(transaction=read_transaction) @@ -384,7 +392,9 @@ def get_decisions( @firestore.transactional def read(read_transaction): _validate_generation( - _control_ref(uid, firestore_client=client).get(transaction=read_transaction), account_generation + _control_ref(uid, firestore_client=client).get(transaction=read_transaction), + uid=uid, + account_generation=account_generation, ) return ref.get(transaction=read_transaction) @@ -441,7 +451,9 @@ def get_evaluation_projection( @firestore.transactional def read(read_transaction): _validate_generation( - _control_ref(uid, firestore_client=client).get(transaction=read_transaction), account_generation + _control_ref(uid, firestore_client=client).get(transaction=read_transaction), + uid=uid, + account_generation=account_generation, ) return ref.get(transaction=read_transaction) @@ -489,7 +501,9 @@ def create_feedback( def apply(write_transaction): nonlocal attribution_chain_id, dedupe_key, record, payload _validate_generation( - _control_ref(uid, firestore_client=client).get(transaction=write_transaction), account_generation + _control_ref(uid, firestore_client=client).get(transaction=write_transaction), + uid=uid, + account_generation=account_generation, ) if request.intervention_id is not None: intervention_ref = user_ref.collection(INTERVENTIONS_COLLECTION).document(request.intervention_id) @@ -601,7 +615,9 @@ def link_feedback_completion_candidate( @firestore.transactional def apply(write_transaction): _validate_generation( - _control_ref(uid, firestore_client=client).get(transaction=write_transaction), account_generation + _control_ref(uid, firestore_client=client).get(transaction=write_transaction), + uid=uid, + account_generation=account_generation, ) snapshot = ref.get(transaction=write_transaction) payload = _snapshot_dict(snapshot) @@ -711,7 +727,9 @@ def create_outcome( @firestore.transactional def apply(write_transaction): _validate_generation( - _control_ref(uid, firestore_client=client).get(transaction=write_transaction), account_generation + _control_ref(uid, firestore_client=client).get(transaction=write_transaction), + uid=uid, + account_generation=account_generation, ) existing = ref.get(transaction=write_transaction) if not existing.exists: @@ -758,8 +776,8 @@ def replace_context_snapshot( def apply(write_transaction): _validate_generation( _control_ref(uid, firestore_client=client).get(transaction=write_transaction), - account_generation, - allowed_modes={TaskWorkflowMode.shadow, TaskWorkflowMode.write, TaskWorkflowMode.read}, + uid=uid, + account_generation=account_generation, ) prior_receipt = receipt_ref.get(transaction=write_transaction) if prior_receipt.exists: @@ -865,8 +883,8 @@ def replace_open_loop_snapshot( def apply(write_transaction): _validate_generation( _control_ref(uid, firestore_client=client).get(transaction=write_transaction), - account_generation, - allowed_modes={TaskWorkflowMode.shadow, TaskWorkflowMode.write, TaskWorkflowMode.read}, + uid=uid, + account_generation=account_generation, ) prior_receipt = receipt_ref.get(transaction=write_transaction) if prior_receipt.exists: diff --git a/backend/database/workstreams.py b/backend/database/workstreams.py index 9fca5500041..675a0f103b0 100644 --- a/backend/database/workstreams.py +++ b/backend/database/workstreams.py @@ -6,6 +6,8 @@ from typing import Any, Optional, cast from google.cloud import firestore + +from config.canonical_memory_cohort import is_canonical_memory_user from google.cloud.firestore_v1 import FieldFilter import database.goals as goals_db from database._client import get_firestore_client @@ -13,7 +15,7 @@ from models.action_item import ActionItemResponse, TaskOwner, TaskPriority, TaskStatus from models.candidate import CandidateRecord, CandidateResolutionReceipt, CandidateStatus, CandidateSubjectKind from models.goal import GoalResponse, GoalStatus -from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode +from models.task_intelligence import TaskWorkflowControl from models.workstream import ( ArtifactDescriptor, ArtifactDescriptorCreate, @@ -150,7 +152,7 @@ def _begin_mutation( firestore_client: Any, ) -> tuple[Any, Optional[dict[str, Any]], str]: control_snapshot = _control_ref(uid, firestore_client=firestore_client).get(transaction=write_transaction) - _validate_control(control_snapshot, account_generation=account_generation) + _validate_control(control_snapshot, uid=uid, account_generation=account_generation) receipt_ref = _mutation_receipt_ref( uid, operation=operation, @@ -185,14 +187,14 @@ def _finish_mutation( ) -def _validate_control(snapshot: Any, *, account_generation: int) -> None: +def _validate_control(snapshot: Any, *, uid: str, account_generation: int) -> None: + if not is_canonical_memory_user(uid): + raise WorkstreamConflictError('canonical task intelligence is not enabled') control = TaskWorkflowControl() if snapshot.exists: control = parse_snapshot_strict(TaskWorkflowControl, snapshot) if control.account_generation != account_generation: raise WorkstreamGenerationMismatchError('account generation mismatch') - if control.workflow_mode not in {TaskWorkflowMode.write, TaskWorkflowMode.read}: - raise WorkstreamConflictError('canonical workflow writes are disabled') def _assert_workstream_generation(snapshot: Any, *, account_generation: int) -> None: @@ -463,7 +465,7 @@ def append_workstream_event( @firestore.transactional def apply(write_transaction): control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _validate_control(control_snapshot, account_generation=account_generation) + _validate_control(control_snapshot, uid=uid, account_generation=account_generation) workstream_snapshot = workstream_ref.get(transaction=write_transaction) if not workstream_snapshot.exists: raise WorkstreamNotFoundError(workstream_id) @@ -861,7 +863,7 @@ def resolve_workstream_candidate( @firestore.transactional def apply(write_transaction): control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _validate_control(control_snapshot, account_generation=account_generation) + _validate_control(control_snapshot, uid=uid, account_generation=account_generation) candidate_snapshot = candidate_ref.get(transaction=write_transaction) if not candidate_snapshot.exists: raise WorkstreamNotFoundError(candidate.candidate_id) @@ -994,7 +996,7 @@ def resolve_work_intent( @firestore.transactional def apply(write_transaction): control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _validate_control(control_snapshot, account_generation=account_generation) + _validate_control(control_snapshot, uid=uid, account_generation=account_generation) receipt_snapshot = receipt_ref.get(transaction=write_transaction) if receipt_snapshot.exists: stored = _snapshot_dict(receipt_snapshot) @@ -1196,7 +1198,7 @@ def import_task_goal_links( @firestore.transactional def reserve(write_transaction): control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _validate_control(control_snapshot, account_generation=account_generation) + _validate_control(control_snapshot, uid=uid, account_generation=account_generation) receipt_snapshot = receipt_ref.get(transaction=write_transaction) if receipt_snapshot.exists: receipt = _snapshot_dict(receipt_snapshot) @@ -1227,7 +1229,7 @@ def reserve(write_transaction): @firestore.transactional def apply(write_transaction): control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _validate_control(control_snapshot, account_generation=account_generation) + _validate_control(control_snapshot, uid=uid, account_generation=account_generation) receipt_snapshot = receipt_ref.get(transaction=write_transaction) if not receipt_snapshot.exists: raise WorkstreamConflictError('migration receipt disappeared') @@ -1300,7 +1302,7 @@ def apply(write_transaction): @firestore.transactional def complete(write_transaction): control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) - _validate_control(control_snapshot, account_generation=account_generation) + _validate_control(control_snapshot, uid=uid, account_generation=account_generation) receipt_snapshot = receipt_ref.get(transaction=write_transaction) if not receipt_snapshot.exists: raise WorkstreamConflictError('migration receipt disappeared') diff --git a/backend/docs/task_intelligence_baseline.md b/backend/docs/task_intelligence_baseline.md index 5e253b9270a..0c56cc9bf1f 100644 --- a/backend/docs/task_intelligence_baseline.md +++ b/backend/docs/task_intelligence_baseline.md @@ -2,6 +2,11 @@ This file records the pre-implementation boundaries that the Ticket 01 fixtures and source manifest protect. +Current entitlement boundary: the code-owned `CANONICAL_MEMORY_USERS` whitelist in +`config/canonical_memory_cohort.py` is the only selector for canonical memory, +task intelligence, and Chat-first. Persisted workflow/UI fields and memory +environment variables are generation/readiness metadata, never product gates. + - Backend conversation extraction can write directly to `action_items`; desktop screen extraction stages and ranks locally. - Desktop task discovery is coupled to proactive-task notification enablement. - The backend action-item contract drops desktop-only provenance, confidence, goal, recurrence, and agent fields. diff --git a/backend/models/chat_first_e2e.py b/backend/models/chat_first_e2e.py index 656ecfa5472..f2b64f42bbe 100644 --- a/backend/models/chat_first_e2e.py +++ b/backend/models/chat_first_e2e.py @@ -8,7 +8,6 @@ class ChatFirstE2EFixtureCase(str, Enum): enabled = 'enabled' question = 'question' - ui_flag_off = 'ui_flag_off' out_of_cohort = 'out_of_cohort' unreachable_control = 'unreachable_control' cold_start = 'cold_start' diff --git a/backend/models/task_intelligence.py b/backend/models/task_intelligence.py index 7c53ec6319f..64e5ae6345d 100644 --- a/backend/models/task_intelligence.py +++ b/backend/models/task_intelligence.py @@ -92,30 +92,36 @@ class TaskIntelligenceRolloutDecision(BaseModel): class TaskWorkflowControl(BaseModel): - """Per-user task-intelligence control plus its fail-closed API capability. - - ``chat_first_ui_enabled`` is the persisted, operator-owned rollout input. - ``chat_first_ui`` is a response-only derived capability: it is composed in - the candidates router from the explicit flag and the authoritative - canonical-memory/task-intelligence rollout decision. Persistence must use - :meth:`persisted_payload` so the derived response value can never become a - local or stale source of truth. + """Persisted workflow metadata plus the derived Chat-first capability. + + ``workflow_mode`` remains readable for legacy records and operational + history. It is not an entitlement. ``chat_first_ui`` is derived only from + the canonical-memory selector; persistence excludes it so clients cannot + turn a sampled response into later authority. """ model_config = ConfigDict(extra='forbid', frozen=True) workflow_mode: TaskWorkflowMode = TaskWorkflowMode.off account_generation: int = Field(default=0, ge=0) - chat_first_ui_enabled: bool = Field(default=False, exclude=True) chat_first_ui: bool = False + @model_validator(mode='before') + @classmethod + def strip_retired_chat_first_flag(cls, value): + """Accept historic control documents without retaining a dormant gate.""" + + if isinstance(value, dict): + value = dict(value) + value.pop('chat_first_ui_enabled', None) + return value + def persisted_payload(self) -> dict[str, object]: """Return the exact Firestore control-record shape, excluding derived API state.""" return { 'workflow_mode': self.workflow_mode.value, 'account_generation': self.account_generation, - 'chat_first_ui_enabled': self.chat_first_ui_enabled, } diff --git a/backend/routers/candidates.py b/backend/routers/candidates.py index 0890b1c8c4c..ccccd4322e2 100644 --- a/backend/routers/candidates.py +++ b/backend/routers/candidates.py @@ -26,7 +26,11 @@ from utils.task_intelligence import candidate_service from utils.task_intelligence.capture_policy import MINIMUM_CAPTURE_CONFIDENCE from utils.task_intelligence.recommendations import candidate_recommendation_dedupe_key -from utils.task_intelligence.rollout import resolve_chat_first_ui, resolve_task_intelligence_for_user +from utils.task_intelligence.rollout import ( + effective_task_workflow_control, + resolve_chat_first_ui, + resolve_task_intelligence_for_user, +) from utils.task_intelligence import chat_first_e2e_fixture from utils.task_intelligence.task_links import TaskLinkValidationError from utils.task_intelligence.staged_migration import migrate_staged_tasks @@ -42,10 +46,15 @@ def _require_candidate_write_control(uid: str, account_generation: int) -> None: control = task_control_db.get_task_workflow_control(uid) + rollout = resolve_task_intelligence_for_user( + uid=uid, + workflow_mode=control.workflow_mode, + account_generation=control.account_generation, + ) + if not rollout.intelligence_product_enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Not found') if control.account_generation != account_generation: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Account generation mismatch') - if control.workflow_mode not in {TaskWorkflowMode.write, TaskWorkflowMode.read}: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Candidate writes are not enabled') def _raise_store_error(exc: candidates_db.CandidateStoreError) -> None: @@ -184,8 +193,8 @@ def list_candidates( surface: Optional[Literal['suggested']] = Query(default=None), uid: str = Depends(auth.get_current_user_uid), ): + rollout = _require_suggested_rollout(uid) if surface == 'suggested': - rollout = _require_suggested_rollout(uid) records = candidates_db.list_candidates( uid, status=None, @@ -211,11 +220,10 @@ def list_candidates( ), has_more=False, ) - control = task_control_db.get_task_workflow_control(uid) records = candidates_db.list_candidates( uid, status=candidate_status, - account_generation=control.account_generation, + account_generation=rollout.account_generation, limit=limit + 1, offset=offset, ) @@ -228,7 +236,19 @@ def migrate_staged_candidates( uid: str = Depends(auth.get_current_user_uid), ): control = task_control_db.get_task_workflow_control(uid) - return migrate_staged_tasks(uid, control, after_id=request.after_id, limit=request.limit) + rollout = resolve_task_intelligence_for_user( + uid=uid, + workflow_mode=control.workflow_mode, + account_generation=control.account_generation, + ) + if not rollout.intelligence_product_enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Not found') + return migrate_staged_tasks( + uid, + effective_task_workflow_control(control, rollout), + after_id=request.after_id, + limit=request.limit, + ) @router.get('/v1/candidates/control', response_model=TaskWorkflowControl, tags=['candidates']) @@ -251,13 +271,22 @@ def get_candidate_workflow_control(uid: str = Depends(auth.get_current_user_uid) workflow_mode=control.workflow_mode, account_generation=control.account_generation, ) - chat_first_ui = resolve_chat_first_ui(rollout, control.chat_first_ui_enabled) + chat_first_ui = resolve_chat_first_ui(rollout) except Exception: # Cohort resolution is intentionally fail-closed: a backend outage or # stale selector keeps this user in the existing shell. - chat_first_ui = False + return control.model_copy( + update={ + 'workflow_mode': TaskWorkflowMode.off, + 'chat_first_ui': False, + } + ) - return control.model_copy(update={'chat_first_ui': chat_first_ui}) + # Desktop samples both fields as one generation-bound projection. Preserve + # the raw generation, but never let a stale workflow record select the + # legacy shell for an enrolled account. + effective_control = effective_task_workflow_control(control, rollout) + return effective_control.model_copy(update={'chat_first_ui': chat_first_ui}) @router.post('/v1/candidates/integrations/drain', tags=['candidates']) @@ -278,9 +307,9 @@ def drain_candidate_integrations( @router.get('/v1/candidates/{candidate_id}', response_model=CandidateRecord, tags=['candidates']) def get_candidate(candidate_id: str, uid: str = Depends(auth.get_current_user_uid)): + rollout = _require_suggested_rollout(uid) candidate = candidates_db.get_candidate(uid, candidate_id) - control = task_control_db.get_task_workflow_control(uid) - if candidate is None or candidate.account_generation != control.account_generation: + if candidate is None or candidate.account_generation != rollout.account_generation: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Candidate not found') return candidate diff --git a/backend/routers/canonical_task_access.py b/backend/routers/canonical_task_access.py new file mode 100644 index 00000000000..ba2efc06e44 --- /dev/null +++ b/backend/routers/canonical_task_access.py @@ -0,0 +1,22 @@ +"""Shared FastAPI dependency for canonical task-system routes.""" + +from fastapi import Depends, HTTPException, status + +from config.canonical_memory_cohort import is_canonical_memory_user +from utils.other import endpoints as auth + + +def require_canonical_task_user(uid: str = Depends(auth.get_current_user_uid)) -> str: + """Authorize task-system access exclusively through canonical membership. + + Task workflow controls supply generation fences after this check; they must + not select the product surface. Keeping the entitlement at router entry + also prevents non-enrolled users from reaching canonical stores. + """ + + if not is_canonical_memory_user(uid): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Not found') + return uid + + +__all__ = ['require_canonical_task_user'] diff --git a/backend/routers/goals.py b/backend/routers/goals.py index a0c110d655f..40fec29c9a8 100644 --- a/backend/routers/goals.py +++ b/backend/routers/goals.py @@ -33,6 +33,7 @@ ) from models.workstream import GoalDetailProjection import database.workstreams as workstreams_db +from routers.canonical_task_access import require_canonical_task_user router = APIRouter() IdempotencyHeader = Annotated[str, Header(alias='Idempotency-Key', min_length=1, max_length=256)] @@ -76,7 +77,7 @@ def create_canonical_goal( goal: GoalCreate, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> dict: """Create a generation-scoped canonical goal with safe retry semantics.""" @@ -107,7 +108,7 @@ def focus_goal( request: GoalFocusRequest, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> dict: try: goal = goals_db.focus_goal( @@ -129,7 +130,7 @@ def unfocus_goal( goal_id: str, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> dict: try: return normalize_goal_response( @@ -151,7 +152,7 @@ def transition_goal_lifecycle( request: GoalLifecycleRequest, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> dict: try: goal = goals_db.transition_goal_lifecycle( @@ -169,7 +170,7 @@ def transition_goal_lifecycle( @router.get('/v1/goals/{goal_id}/detail', tags=['goals'], response_model=GoalDetailProjection) -def get_goal_detail(goal_id: str, uid: str = Depends(auth.get_current_user_uid)) -> GoalDetailProjection: +def get_goal_detail(goal_id: str, uid: str = Depends(require_canonical_task_user)) -> GoalDetailProjection: try: return workstreams_db.get_goal_detail(uid, goal_id) except workstreams_db.WorkstreamNotFoundError as exc: @@ -182,7 +183,7 @@ def append_goal_progress_event( request: GoalProgressEventCreate, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> GoalProgressEvent: try: return goals_db.append_goal_progress_event( @@ -201,7 +202,7 @@ def append_goal_progress_event( def list_goal_progress_events( goal_id: str, limit: int = Query(100, ge=1, le=500), - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> list[GoalProgressEvent]: return goals_db.list_goal_progress_events(uid, goal_id, limit=limit) diff --git a/backend/routers/memories.py b/backend/routers/memories.py index 247f8ed0d45..cb756275c08 100644 --- a/backend/routers/memories.py +++ b/backend/routers/memories.py @@ -119,12 +119,11 @@ class V3GetRuntime: def get_v3_get_runtime(uid: str = Depends(auth.get_current_user_uid)): """Return the production/default runtime bundle for GET `/v3/memories`. - Default production behavior is still disabled. Server-owned configuration can - only enter memory when all of these are true: `MEMORY_MODE` is not off, - `MEMORY_ENABLED_USERS` contains the authenticated uid, the persisted - control state is read-mode, and global/read-convergence gates allow the - composed service to proceed. Client headers, query params, request bodies, - and persisted user docs alone cannot activate memory. + The code-owned canonical-memory selector chooses the cohort. Enrolled + accounts then require a valid, generation-matched projection/control state; + any unavailable or stale state fails closed instead of returning legacy + memory. Client headers, query params, request bodies, and persisted user + docs alone cannot activate canonical memory. """ return build_v3_production_runtime(uid=uid, db_client=getattr(db_client_module, 'db', None)) @@ -648,26 +647,31 @@ def get_memories( _validate_device_scope_request(scope_request.device_scope, scope_request.client_device_id) _set_device_scope_capability_header(response, supported=True) _set_canonical_lifecycle_exposure_header(response, exposed=True) - # Clamp pagination parameters so the canonical branch (which bypasses - # _legacy_get_memories clamping) never receives values that would - # slice the visible list incorrectly — e.g. limit=-1 returning nearly - # the entire list or negative offsets producing inconsistent pages. + # Clamp pagination parameters before handing an eligible account to a + # canonical reader. clamped_offset = max(0, offset) - clamped_limit = max(1, min(limit, 5000)) - # Preserve the historical first-page load for the mobile MemoriesProvider, - # which calls getMemoriesResult() with its default limit and has no - # load-more path. Legacy users get this expansion via _legacy_get_memories; - # canonical users must get the same first-page behavior so accounts with - # more than 100 memories do not silently see only the newest 100. - if clamped_offset == 0: - clamped_limit = 5000 - return MemoryService(db_client=db_client).read( - uid, - limit=clamped_limit, - offset=clamped_offset, - device_scope_request=scope_request, - include_pending_processing=True, - ) + # `/v3/memories` is the generation-bound V3 projection contract. The + # canonical selector must not bypass that contract through the general + # memory-service reader: an unavailable or stale projection is a 503, + # never a legacy/general-memory fallback. Device-scoped reads remain on + # the canonical service path because V3's compatibility projection has + # no device-scope representation. + if scope_request.device_scope != 'all': + # Preserve the historical first-page expansion for this separate + # canonical service path. + clamped_limit = max(1, min(limit, 5000)) + if clamped_offset == 0: + clamped_limit = 5000 + return MemoryService(db_client=db_client).read( + uid, + limit=clamped_limit, + offset=clamped_offset, + device_scope_request=scope_request, + include_pending_processing=True, + ) + # V3's compositional contract limits a page to 500 items. + limit = max(1, min(limit, 500)) + offset = clamped_offset if memory_runtime.source_decision != 'memory_read': return _legacy_memories_response(_legacy_get_memories(uid, limit, offset)) diff --git a/backend/routers/staged_tasks.py b/backend/routers/staged_tasks.py index 9aed3ad15fd..dafc52c2e61 100644 --- a/backend/routers/staged_tasks.py +++ b/backend/routers/staged_tasks.py @@ -23,12 +23,25 @@ from utils.other import endpoints as auth from utils.observability.fallback import record_fallback from utils.task_intelligence import candidate_service +from utils.task_intelligence.rollout import effective_task_workflow_control, resolve_task_intelligence_for_user from utils.task_intelligence.staged_migration import migrate_staged_tasks, proposal_from_legacy_staged router = APIRouter() logger = logging.getLogger(__name__) +def _effective_control(uid: str): + """Project raw workflow metadata through the sole canonical entitlement.""" + + control = task_control_db.get_task_workflow_control(uid) + rollout = resolve_task_intelligence_for_user( + uid=uid, + workflow_mode=control.workflow_mode, + account_generation=control.account_generation, + ) + return effective_task_workflow_control(control, rollout) + + def _candidate_as_staged(candidate: CandidateRecord) -> dict: task_change = candidate.task_change return { @@ -266,7 +279,7 @@ def create_staged_task( request: CreateStagedTaskRequest, uid: str = Depends(auth.get_current_user_uid), ): - control = task_control_db.get_task_workflow_control(uid) + control = _effective_control(uid) if control.workflow_mode == TaskWorkflowMode.read: digest = hashlib.sha256(request.description.strip().lower().encode('utf-8')).hexdigest()[:24] synthetic_row = { @@ -315,7 +328,7 @@ def get_staged_tasks( offset: int = Query(0, ge=0), uid: str = Depends(auth.get_current_user_uid), ): - control = task_control_db.get_task_workflow_control(uid) + control = _effective_control(uid) if control.workflow_mode == TaskWorkflowMode.read: candidates = _pending_staged_candidates(uid, account_generation=control.account_generation) return { @@ -332,7 +345,7 @@ def get_staged_tasks( @router.delete('/v1/staged-tasks', tags=['staged-tasks']) def clear_staged_tasks(uid: str = Depends(auth.get_current_user_uid)): - control = task_control_db.get_task_workflow_control(uid) + control = _effective_control(uid) if control.workflow_mode == TaskWorkflowMode.read: candidates = _pending_staged_candidates(uid, account_generation=control.account_generation) for candidate in candidates: @@ -367,7 +380,7 @@ def delete_staged_task( task_id: str, uid: str = Depends(auth.get_current_user_uid), ): - control = task_control_db.get_task_workflow_control(uid) + control = _effective_control(uid) if control.workflow_mode == TaskWorkflowMode.read: candidate = candidates_db.get_candidate(uid, task_id) if ( @@ -408,7 +421,7 @@ def batch_update_staged_scores( request: BatchUpdateScoresRequest, uid: str = Depends(auth.get_current_user_uid), ): - if task_control_db.get_task_workflow_control(uid).workflow_mode == TaskWorkflowMode.read: + if _effective_control(uid).workflow_mode == TaskWorkflowMode.read: return {'status': 'ok'} staged_tasks_db.batch_update_staged_scores(uid, [s.model_dump() for s in request.scores]) return {'status': 'ok'} @@ -416,7 +429,7 @@ def batch_update_staged_scores( @router.post('/v1/staged-tasks/promote', tags=['staged-tasks'], response_model=PromoteStagedTaskResponse) def promote_staged_task(uid: str = Depends(auth.get_current_user_uid)): - control = task_control_db.get_task_workflow_control(uid) + control = _effective_control(uid) if control.workflow_mode == TaskWorkflowMode.read: return {'promoted': False, 'reason': 'Score-based promotion is disabled', 'promoted_task': None} claim_token: str | None = None @@ -484,7 +497,7 @@ def promote_staged_task(uid: str = Depends(auth.get_current_user_uid)): @router.post('/v1/staged-tasks/{task_id}/promote', tags=['staged-tasks']) def promote_staged_task_by_id(task_id: str, uid: str = Depends(auth.get_current_user_uid)): - control = task_control_db.get_task_workflow_control(uid) + control = _effective_control(uid) if control.workflow_mode == TaskWorkflowMode.read: candidate = candidates_db.get_candidate(uid, task_id) if ( @@ -566,7 +579,7 @@ def promote_staged_task_by_id(task_id: str, uid: str = Depends(auth.get_current_ @router.post('/v1/staged-tasks/migrate', tags=['staged-tasks'], response_model=StatusResponse) def migrate_ai_tasks(uid: str = Depends(auth.get_current_user_uid)): - control = task_control_db.get_task_workflow_control(uid) + control = _effective_control(uid) if control.workflow_mode == TaskWorkflowMode.read: return {'status': 'canonical read mode; no legacy migration performed'} result = staged_tasks_db.migrate_ai_tasks(uid) @@ -581,7 +594,7 @@ def migrate_ai_tasks(uid: str = Depends(auth.get_current_user_uid)): response_model=MigrateConversationItemsResponse, ) def migrate_conversation_items(uid: str = Depends(auth.get_current_user_uid)): - control = task_control_db.get_task_workflow_control(uid) + control = _effective_control(uid) if control.workflow_mode == TaskWorkflowMode.read: return {'status': 'ok', 'migrated': 0, 'deleted': 0} result = staged_tasks_db.migrate_conversation_items_to_staged(uid) diff --git a/backend/routers/workstreams.py b/backend/routers/workstreams.py index bd595a2ae4c..a505dcc5574 100644 --- a/backend/routers/workstreams.py +++ b/backend/routers/workstreams.py @@ -21,7 +21,7 @@ WorkstreamEventCreate, WorkstreamUpdate, ) -from utils.other import endpoints as auth +from routers.canonical_task_access import require_canonical_task_user from utils.task_intelligence.workstream_index import refresh_workstream_association_index router = APIRouter() @@ -45,7 +45,7 @@ def resolve_work_intent( request: WorkIntentRequest, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> WorkIntentReceipt: """Idempotent backend operation behind the “Work on this with Omi” affordance.""" @@ -65,7 +65,7 @@ def resolve_work_intent( @router.get('/v1/workstreams/{workstream_id}', tags=['tasks'], response_model=WorkstreamDetailProjection) def get_workstream_detail( workstream_id: str, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> WorkstreamDetailProjection: try: return workstreams_db.get_workstream_detail(uid, workstream_id) @@ -79,7 +79,7 @@ def update_workstream( request: WorkstreamUpdate, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> Workstream: try: workstream = workstreams_db.update_workstream( @@ -101,7 +101,7 @@ def append_workstream_event( request: WorkstreamEventCreate, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> WorkstreamEvent: try: return workstreams_db.append_workstream_event( @@ -120,7 +120,7 @@ def list_workstream_events( workstream_id: str, after_sequence: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> list[WorkstreamEvent]: return workstreams_db.list_workstream_events( uid, @@ -136,7 +136,7 @@ def create_artifact_descriptor( request: ArtifactDescriptorCreate, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> ArtifactDescriptor: try: return workstreams_db.create_artifact_descriptor( @@ -161,7 +161,7 @@ def transition_artifact_status( request: ArtifactStatusTransitionRequest, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> ArtifactDescriptor: try: return workstreams_db.transition_artifact_status( @@ -180,7 +180,7 @@ def transition_artifact_status( def list_artifact_descriptors( workstream_id: str, limit: int = Query(100, ge=1, le=500), - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> list[ArtifactDescriptor]: return workstreams_db.list_artifact_descriptors(uid, workstream_id, limit=limit) @@ -196,7 +196,7 @@ def upsert_continuation_checkpoint( request: ContinuationCheckpointUpsert, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> ContinuationCheckpoint: if request.runtime_id != runtime_id: raise HTTPException(status_code=422, detail='runtime_id path and body must match') @@ -219,7 +219,7 @@ def upsert_continuation_checkpoint( ) def list_continuation_checkpoints( workstream_id: str, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> list[ContinuationCheckpoint]: return workstreams_db.list_continuation_checkpoints(uid, workstream_id) @@ -229,7 +229,7 @@ def import_task_goal_links( request: TaskGoalLinkImportRequest, idempotency_key: IdempotencyHeader, account_generation: AccountGenerationHeader, - uid: str = Depends(auth.get_current_user_uid), + uid: str = Depends(require_canonical_task_user), ) -> TaskGoalLinkImportReport: try: return workstreams_db.import_task_goal_links( diff --git a/backend/scripts/activate_task_intelligence_dogfood_user.py b/backend/scripts/activate_task_intelligence_dogfood_user.py index 5caaccdeea9..56e7d1ce953 100644 --- a/backend/scripts/activate_task_intelligence_dogfood_user.py +++ b/backend/scripts/activate_task_intelligence_dogfood_user.py @@ -203,16 +203,11 @@ def read_control_with_gcloud_user_token( def build_activation_plan( uid: str, current: TaskWorkflowControl, - *, - chat_first_ui_enabled: bool | None = None, ) -> ActivationPlan: require_dogfood_uid(uid) target = TaskWorkflowControl( workflow_mode=TaskWorkflowMode.read, account_generation=current.account_generation, - chat_first_ui_enabled=( - current.chat_first_ui_enabled if chat_first_ui_enabled is None else chat_first_ui_enabled - ), ) return ActivationPlan( uid=uid, @@ -228,7 +223,6 @@ def apply_activation( *, uid: str, expected_account_generation: int, - chat_first_ui_enabled: bool, rpc_timeout_seconds: float = DEFAULT_FIRESTORE_RPC_TIMEOUT_SECONDS, ) -> bool: """Write the `read` control only if the observed generation remains current. @@ -257,7 +251,6 @@ def activate(transaction) -> bool: target = TaskWorkflowControl( workflow_mode=TaskWorkflowMode.read, account_generation=current.account_generation, - chat_first_ui_enabled=chat_first_ui_enabled, ) if current == target: return False @@ -271,7 +264,6 @@ def _firestore_control_fields(control: TaskWorkflowControl) -> dict[str, dict[st return { 'workflow_mode': {'stringValue': control.workflow_mode.value}, 'account_generation': {'integerValue': str(control.account_generation)}, - 'chat_first_ui_enabled': {'booleanValue': control.chat_first_ui_enabled}, } @@ -280,7 +272,6 @@ def apply_activation_with_gcloud_user_token( firestore_project: str, uid: str, expected_account_generation: int, - chat_first_ui_enabled: bool, current: FirestoreControlSnapshot, rpc_timeout_seconds: float, http_open=None, @@ -295,7 +286,6 @@ def apply_activation_with_gcloud_user_token( target = TaskWorkflowControl( workflow_mode=TaskWorkflowMode.read, account_generation=current.control.account_generation, - chat_first_ui_enabled=chat_first_ui_enabled, ) if current.control == target: return False @@ -309,7 +299,7 @@ def apply_activation_with_gcloud_user_token( query = urlencode( { **precondition, - 'updateMask.fieldPaths': ['workflow_mode', 'account_generation', 'chat_first_ui_enabled'], + 'updateMask.fieldPaths': ['workflow_mode', 'account_generation'], }, doseq=True, ) @@ -355,24 +345,15 @@ def build_report( 'It writes only users/{uid}/task_intelligence_control/state when --apply is supplied.', 'It does not change the canonical-memory cohort, runtime environment, or any other user.', 'The transaction preserves account_generation and rejects a stale expected generation.', - 'The Chat-first UI flag is an explicit per-user input and must be confirmed exactly on apply.', + 'Chat-first derives only from the code-owned canonical-memory cohort; this tool has no UI flag.', 'The gcloud-user source obtains a short-lived token without including it in output or reports.', ], } -def parse_bool(value: str) -> bool: - normalized = value.strip().lower() - if normalized == 'true': - return True - if normalized == 'false': - return False - raise argparse.ArgumentTypeError('must be true or false') - - def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description='Dry-run/apply Smart Tasks read mode and Chat-first UI for the approved dogfood UID.' + description='Dry-run/apply Smart Tasks read-mode metadata for the approved dogfood UID.' ) parser.add_argument('--uid', default=TASK_INTELLIGENCE_DOGFOOD_UID) parser.add_argument('--firestore-project', help='Required for --inspect-existing or --apply.') @@ -396,16 +377,6 @@ def parse_args() -> argparse.Namespace: '--confirm-workflow-mode', help='Required with --apply; must exactly equal read.', ) - parser.add_argument( - '--chat-first-ui-enabled', - type=parse_bool, - help='Requested per-user Chat-first UI flag; required with --apply and accepted as true or false.', - ) - parser.add_argument( - '--confirm-chat-first-ui-enabled', - type=parse_bool, - help='Required with --apply; must exactly match --chat-first-ui-enabled.', - ) return parser.parse_args() @@ -423,12 +394,6 @@ def main() -> int: raise SystemExit('--confirm-workflow-mode must exactly equal read when --apply is used') if args.expected_account_generation is None: raise SystemExit('--expected-account-generation is required when --apply is used') - if args.chat_first_ui_enabled is None: - raise SystemExit('--chat-first-ui-enabled is required when --apply is used') - if args.confirm_chat_first_ui_enabled != args.chat_first_ui_enabled: - raise SystemExit( - '--confirm-chat-first-ui-enabled must exactly match --chat-first-ui-enabled when --apply is used' - ) if (args.inspect_existing or args.apply) and not args.firestore_project: raise SystemExit('--firestore-project is required for inspection or apply') @@ -452,7 +417,7 @@ def main() -> int: f'expected {args.expected_account_generation}, found {current.account_generation}' ) - plan = build_activation_plan(args.uid, current, chat_first_ui_enabled=args.chat_first_ui_enabled) + plan = build_activation_plan(args.uid, current) applied = None verified_control = None if args.apply: @@ -463,7 +428,6 @@ def main() -> int: firestore_project=args.firestore_project, uid=args.uid, expected_account_generation=args.expected_account_generation, - chat_first_ui_enabled=args.chat_first_ui_enabled, current=current_snapshot, rpc_timeout_seconds=args.rpc_timeout_seconds, ) @@ -477,7 +441,6 @@ def main() -> int: db_client, uid=args.uid, expected_account_generation=args.expected_account_generation, - chat_first_ui_enabled=args.chat_first_ui_enabled, rpc_timeout_seconds=args.rpc_timeout_seconds, ) verified_control = read_control( @@ -488,7 +451,6 @@ def main() -> int: if verified_control != TaskWorkflowControl( workflow_mode=TaskWorkflowMode.read, account_generation=current.account_generation, - chat_first_ui_enabled=args.chat_first_ui_enabled, ): raise RuntimeError('post-apply control verification did not match the requested control') print( diff --git a/backend/scripts/p1_3_v3_control_reader_emulator_test.py b/backend/scripts/p1_3_v3_control_reader_emulator_test.py index 4557456ce13..708ed8f937a 100644 --- a/backend/scripts/p1_3_v3_control_reader_emulator_test.py +++ b/backend/scripts/p1_3_v3_control_reader_emulator_test.py @@ -17,7 +17,7 @@ from google.cloud import firestore -from config.memory_rollout import MemoryRolloutMode, MemoryRolloutConfig +from config.memory_rollout import MemoryRolloutMode from database.memory_collections import MemoryCollections from utils.memory.v3.control_reader_contract import ( V3ControlDecisionReason, @@ -28,7 +28,9 @@ from utils.memory.v3.control_state_adapter import read_v3_control PROJECT_ID = os.environ.get("GCLOUD_PROJECT") or os.environ.get("FIREBASE_PROJECT") or "demo-memory" -UID = "memory-v3-control-reader-emulator-user" +# This is the code-owned canonical entitlement; the emulator remains isolated +# by FIRESTORE_EMULATOR_HOST and never contacts the real account. +UID = "vi7SA9ckQCe4ccobWNxlbdcNdC23" CONTROL_PATH = MemoryCollections(uid=UID).memory_control_state GLOBAL_READ_GATE_PATH = "memory_control/global_read_gate" WRITE_CONVERGENCE_GATE_PATH = "memory_control/write_convergence_gate" @@ -101,11 +103,7 @@ def _write_fixture(db: firestore.Client, case: ProofCase) -> None: def _assert_case(db: firestore.Client, case: ProofCase) -> None: _write_fixture(db, case) - result = read_v3_control( - uid=UID, - db_client=db, - rollout_config=MemoryRolloutConfig(enabled_users={UID}, mode=MemoryRolloutMode.read), - ) + result = read_v3_control(uid=UID, db_client=db) decision = decide_v3_control_route(case.request, result) assert result.source_path == CONTROL_PATH, case.case_id assert decision.route_family == case.route_family, (case.case_id, decision) diff --git a/backend/tests/unit/canonical_cohort_test_helpers.py b/backend/tests/unit/canonical_cohort_test_helpers.py index 855b5a10fbd..dc4e9238fe1 100644 --- a/backend/tests/unit/canonical_cohort_test_helpers.py +++ b/backend/tests/unit/canonical_cohort_test_helpers.py @@ -7,18 +7,16 @@ def set_canonical_cohort(monkeypatch, *uids: str) -> None: - """Inject canonical test uids and matching env activation.""" + """Inject canonical test uids into the one code-owned selector.""" + cohort_config = importlib.import_module("config.canonical_memory_cohort") memory_system_mod = importlib.import_module("utils.memory.memory_system") + monkeypatch.setattr(cohort_config, "CANONICAL_MEMORY_USERS", frozenset(uids)) monkeypatch.setattr(memory_system_mod, "CANONICAL_MEMORY_USERS", frozenset(uids)) for module_name, module in list(sys.modules.items()): if module_name.startswith("utils.memory.") and hasattr(module, "resolve_memory_system"): monkeypatch.setattr(module, "resolve_memory_system", memory_system_mod.resolve_memory_system) - if uids: - monkeypatch.setenv("MEMORY_MODE", "read") - monkeypatch.setenv("MEMORY_ENABLED_USERS", ",".join(uids)) - else: - monkeypatch.delenv("MEMORY_MODE", raising=False) - monkeypatch.delenv("MEMORY_ENABLED_USERS", raising=False) + monkeypatch.delenv("MEMORY_MODE", raising=False) + monkeypatch.delenv("MEMORY_ENABLED_USERS", raising=False) def clear_canonical_cohort(monkeypatch) -> None: diff --git a/backend/tests/unit/test_activate_task_intelligence_dogfood_user.py b/backend/tests/unit/test_activate_task_intelligence_dogfood_user.py index 1bbf92b3dc8..5f549eb8aa6 100644 --- a/backend/tests/unit/test_activate_task_intelligence_dogfood_user.py +++ b/backend/tests/unit/test_activate_task_intelligence_dogfood_user.py @@ -94,14 +94,10 @@ def test_missing_control_plans_explicit_read_at_default_generation(script): db = _Db() current = script.read_control(db, uid=script.TASK_INTELLIGENCE_DOGFOOD_UID) - plan = script.build_activation_plan( - script.TASK_INTELLIGENCE_DOGFOOD_UID, - current, - chat_first_ui_enabled=True, - ) + plan = script.build_activation_plan(script.TASK_INTELLIGENCE_DOGFOOD_UID, current) - assert plan.current_control == {'workflow_mode': 'off', 'account_generation': 0, 'chat_first_ui_enabled': False} - assert plan.target_control == {'workflow_mode': 'read', 'account_generation': 0, 'chat_first_ui_enabled': True} + assert plan.current_control == {'workflow_mode': 'off', 'account_generation': 0} + assert plan.target_control == {'workflow_mode': 'read', 'account_generation': 0} assert plan.canonical_memory_whitelisted is True assert db.read_timeouts == [script.DEFAULT_FIRESTORE_RPC_TIMEOUT_SECONDS] @@ -116,14 +112,12 @@ def test_activation_preserves_existing_generation_and_is_idempotent(monkeypatch) db, uid=script.TASK_INTELLIGENCE_DOGFOOD_UID, expected_account_generation=7, - chat_first_ui_enabled=True, ) - assert db.docs[path] == {'workflow_mode': 'read', 'account_generation': 7, 'chat_first_ui_enabled': True} + assert db.docs[path] == {'workflow_mode': 'read', 'account_generation': 7} assert not script.apply_activation( db, uid=script.TASK_INTELLIGENCE_DOGFOOD_UID, expected_account_generation=7, - chat_first_ui_enabled=True, ) assert len(db.writes) == 1 @@ -139,7 +133,6 @@ def test_activation_rejects_stale_generation_without_a_write(monkeypatch): db, uid=script.TASK_INTELLIGENCE_DOGFOOD_UID, expected_account_generation=2, - chat_first_ui_enabled=True, ) assert db.writes == [] @@ -153,52 +146,6 @@ def test_tool_rejects_any_non_dogfood_uid(): script.build_activation_plan('another-user', script.TaskWorkflowControl()) -def test_apply_requires_an_explicit_chat_first_ui_value(monkeypatch): - script = load_script() - monkeypatch.setattr( - sys, - 'argv', - [ - str(SCRIPT), - '--apply', - '--confirm-uid', - script.TASK_INTELLIGENCE_DOGFOOD_UID, - '--confirm-workflow-mode', - 'read', - '--expected-account-generation', - '0', - ], - ) - - with pytest.raises(SystemExit, match='--chat-first-ui-enabled is required'): - script.main() - - -def test_apply_requires_an_exact_chat_first_ui_confirmation(monkeypatch): - script = load_script() - monkeypatch.setattr( - sys, - 'argv', - [ - str(SCRIPT), - '--apply', - '--confirm-uid', - script.TASK_INTELLIGENCE_DOGFOOD_UID, - '--confirm-workflow-mode', - 'read', - '--expected-account-generation', - '0', - '--chat-first-ui-enabled', - 'true', - '--confirm-chat-first-ui-enabled', - 'false', - ], - ) - - with pytest.raises(SystemExit, match='must exactly match'): - script.main() - - def test_gcloud_user_transport_uses_current_document_precondition_and_never_reports_token(): script = load_script() requests = [] @@ -221,15 +168,12 @@ def http_open(request, timeout): http_open=http_open, access_token='short-lived-token', ) - assert snapshot.control == script.TaskWorkflowControl( - workflow_mode='off', account_generation=7, chat_first_ui_enabled=False - ) + assert snapshot.control == script.TaskWorkflowControl(workflow_mode='off', account_generation=7) assert script.apply_activation_with_gcloud_user_token( firestore_project='based-hardware', uid=script.TASK_INTELLIGENCE_DOGFOOD_UID, expected_account_generation=7, - chat_first_ui_enabled=True, current=snapshot, rpc_timeout_seconds=5, http_open=http_open, @@ -242,5 +186,5 @@ def http_open(request, timeout): assert 'currentDocument.updateTime=2026-07-12T00%3A00%3A00.000000Z' in patch_request.full_url assert b'"workflow_mode": {"stringValue": "read"}' in patch_request.data assert b'"account_generation": {"integerValue": "7"}' in patch_request.data - assert b'"chat_first_ui_enabled": {"booleanValue": true}' in patch_request.data + assert b'chat_first_ui_enabled' not in patch_request.data assert b'short-lived-token' not in patch_request.data diff --git a/backend/tests/unit/test_candidates_router.py b/backend/tests/unit/test_candidates_router.py index dbd32548e54..152f8a77397 100644 --- a/backend/tests/unit/test_candidates_router.py +++ b/backend/tests/unit/test_candidates_router.py @@ -10,6 +10,12 @@ from models.candidate import CandidateCreate, CandidateRecord, CandidateResolutionReceipt, CandidateStatus from models.task_intelligence import TaskWorkflowControl import routers.candidates as candidates_router +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort + + +@pytest.fixture(autouse=True) +def _canonical_candidate_test_user(monkeypatch): + set_canonical_cohort(monkeypatch, 'user-1') def _proposal(**overrides): @@ -92,14 +98,33 @@ def _workflow_control_client() -> TestClient: return TestClient(app) -def test_candidate_workflow_control_exposes_composed_chat_first_ui_capability(monkeypatch): - control = TaskWorkflowControl(workflow_mode='read', account_generation=8, chat_first_ui_enabled=True) - monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', lambda uid: control) - monkeypatch.setattr( - candidates_router, - 'resolve_task_intelligence_for_user', - lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), +@pytest.mark.parametrize('workflow_mode', ['off', 'read']) +@pytest.mark.parametrize('legacy_ui_flag', [False, True]) +@pytest.mark.parametrize( + 'env_values', + [ + {}, + {'MEMORY_MODE': 'off'}, + {'MEMORY_MODE': 'read', 'MEMORY_ENABLED_USERS': ''}, + {'MEMORY_MODE': 'read', 'MEMORY_ENABLED_USERS': 'someone-else'}, + ], +) +def test_candidate_workflow_control_uses_whitelist_only_across_legacy_gates( + monkeypatch, workflow_mode, legacy_ui_flag, env_values +): + set_canonical_cohort(monkeypatch, 'user-1') + for key in ('MEMORY_MODE', 'MEMORY_ENABLED_USERS'): + monkeypatch.delenv(key, raising=False) + for key, value in env_values.items(): + monkeypatch.setenv(key, value) + control = TaskWorkflowControl.model_validate( + { + 'workflow_mode': workflow_mode, + 'account_generation': 8, + 'chat_first_ui_enabled': legacy_ui_flag, + } ) + monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', lambda uid: control) response = _workflow_control_client().get('/v1/candidates/control') @@ -111,8 +136,8 @@ def test_candidate_workflow_control_exposes_composed_chat_first_ui_capability(mo } -def test_candidate_workflow_control_fails_closed_when_chat_first_composition_raises(monkeypatch): - control = TaskWorkflowControl(workflow_mode='read', account_generation=8, chat_first_ui_enabled=True) +def test_candidate_workflow_control_fails_closed_when_the_selector_raises(monkeypatch): + control = TaskWorkflowControl(workflow_mode='read', account_generation=8) monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', lambda uid: control) monkeypatch.setattr( candidates_router, @@ -124,6 +149,7 @@ def test_candidate_workflow_control_fails_closed_when_chat_first_composition_rai assert response.status_code == 200 assert response.json()['chat_first_ui'] is False + assert response.json()['workflow_mode'] == 'off' assert 'chat_first_ui_enabled' not in response.json() @@ -148,22 +174,58 @@ def unavailable(_uid): } -def test_candidate_workflow_control_defaults_chat_first_ui_off_when_the_flag_is_missing(monkeypatch): - control = TaskWorkflowControl(workflow_mode='read', account_generation=8) - monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', lambda uid: control) +def test_noncanonical_user_cannot_read_canonical_candidates(monkeypatch): + set_canonical_cohort(monkeypatch) monkeypatch.setattr( - candidates_router, - 'resolve_task_intelligence_for_user', - lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), + candidates_router.task_control_db, + 'get_task_workflow_control', + lambda _uid: TaskWorkflowControl(workflow_mode='read', account_generation=8), + ) + monkeypatch.setattr( + candidates_router.candidates_db, + 'list_candidates', + lambda *args, **kwargs: pytest.fail('noncanonical list must not touch the Candidate store'), + ) + monkeypatch.setattr( + candidates_router.candidates_db, + 'get_candidate', + lambda *args, **kwargs: pytest.fail('noncanonical get must not touch the Candidate store'), ) + client = _workflow_control_client() + + assert client.get('/v1/candidates').status_code == 404 + assert client.get('/v1/candidates/candidate-1').status_code == 404 + + +def test_candidate_workflow_control_ignores_a_missing_legacy_ui_flag(monkeypatch): + set_canonical_cohort(monkeypatch, 'user-1') + control = TaskWorkflowControl(workflow_mode='read', account_generation=8) + monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', lambda uid: control) response = _workflow_control_client().get('/v1/candidates/control') assert response.status_code == 200 - assert response.json()['chat_first_ui'] is False + assert response.json()['chat_first_ui'] is True assert 'chat_first_ui_enabled' not in response.json() +def test_candidate_workflow_control_keeps_non_whitelisted_users_legacy(monkeypatch): + set_canonical_cohort(monkeypatch, 'someone-else') + control = TaskWorkflowControl.model_validate( + {'workflow_mode': 'read', 'account_generation': 8, 'chat_first_ui_enabled': True} + ) + monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', lambda uid: control) + + response = _workflow_control_client().get('/v1/candidates/control') + + assert response.status_code == 200 + assert response.json() == { + 'workflow_mode': 'off', + 'account_generation': 8, + 'chat_first_ui': False, + } + + def test_candidate_workflow_control_e2e_fixture_uses_real_transport_failure(monkeypatch): monkeypatch.setattr(candidates_router.chat_first_e2e_fixture, 'is_control_unreachable', lambda uid: True) monkeypatch.setattr( @@ -246,12 +308,6 @@ def test_create_and_list_candidate_router_forward_idempotency_and_pagination(mon 'get_task_workflow_control', lambda uid: TaskWorkflowControl(workflow_mode='read', account_generation=3), ) - monkeypatch.setattr( - candidates_router, - 'resolve_task_intelligence_for_user', - lambda **kwargs: pytest.fail('generic Candidate listing must not use the product rollout gate'), - ) - created = candidates_router.create_candidate( _proposal(), idempotency_key='request-1', @@ -729,27 +785,41 @@ def test_accept_reject_and_expire_return_stable_receipts(monkeypatch): ) -@pytest.mark.parametrize( - ('control', 'generation'), - [ - (TaskWorkflowControl(workflow_mode='off', account_generation=3), 3), - (TaskWorkflowControl(workflow_mode='shadow', account_generation=3), 3), - (TaskWorkflowControl(workflow_mode='read', account_generation=4), 3), - ], -) -def test_candidate_router_rejects_disabled_or_stale_writes(monkeypatch, control, generation): +@pytest.mark.parametrize('workflow_mode', ['off', 'shadow', 'read']) +def test_candidate_router_old_workflow_modes_do_not_disable_whitelisted_writes(monkeypatch, workflow_mode): + control = TaskWorkflowControl(workflow_mode=workflow_mode, account_generation=3) + monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', lambda uid: control) + monkeypatch.setattr( + candidates_router.candidate_service, + 'create_candidate', + lambda *args, **kwargs: _record(), + ) + + assert ( + candidates_router.create_candidate( + _proposal(), + idempotency_key='request-1', + account_generation=3, + uid='user-1', + ).candidate_id + == 'candidate-1' + ) + + +def test_candidate_router_rejects_stale_writes(monkeypatch): + control = TaskWorkflowControl(workflow_mode='read', account_generation=4) monkeypatch.setattr(candidates_router.task_control_db, 'get_task_workflow_control', lambda uid: control) monkeypatch.setattr( candidates_router.candidate_service, 'create_candidate', - lambda *args, **kwargs: pytest.fail('disabled or stale writes must not reach persistence'), + lambda *args, **kwargs: pytest.fail('stale writes must not reach persistence'), ) with pytest.raises(HTTPException) as error: candidates_router.create_candidate( _proposal(), idempotency_key='request-1', - account_generation=generation, + account_generation=3, uid='user-1', ) diff --git a/backend/tests/unit/test_chat_first_blocks.py b/backend/tests/unit/test_chat_first_blocks.py index e168f456b34..a60212b938d 100644 --- a/backend/tests/unit/test_chat_first_blocks.py +++ b/backend/tests/unit/test_chat_first_blocks.py @@ -5,6 +5,7 @@ from models.task_intelligence import TaskWorkflowControl import routers.chat_first as chat_first_router +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort def _client() -> TestClient: @@ -15,6 +16,7 @@ def _client() -> TestClient: def _enable_chat_first(monkeypatch, *, generation: int = 7) -> None: + set_canonical_cohort(monkeypatch, 'user-1') monkeypatch.setattr( chat_first_router.task_control_db, 'get_task_workflow_control', @@ -91,7 +93,7 @@ def test_chat_first_validate_fails_closed_before_entity_resolution_outside_the_c monkeypatch.setattr( chat_first_router, 'resolve_task_intelligence_for_user', - lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), + lambda **kwargs: SimpleNamespace(intelligence_product_enabled=False), ) monkeypatch.setattr( chat_first_router.action_items_db, diff --git a/backend/tests/unit/test_chat_first_e2e_fixture.py b/backend/tests/unit/test_chat_first_e2e_fixture.py index 5a8c1500851..f59a902be9e 100644 --- a/backend/tests/unit/test_chat_first_e2e_fixture.py +++ b/backend/tests/unit/test_chat_first_e2e_fixture.py @@ -12,7 +12,6 @@ CHAT_FIRST_E2E_ENABLED_PRINCIPAL, CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL, fixture_uid_for_principal, - is_chat_first_e2e_enabled_fixture, is_chat_first_e2e_fixture_uid, is_chat_first_e2e_harness_runtime, ) @@ -21,6 +20,7 @@ from models.chat_first_e2e import ChatFirstE2EFixtureCase from models.task_intelligence import TaskWorkflowMode from utils.memory.memory_system import MemorySystem +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort import utils.task_intelligence.chat_first_e2e_fixture as fixture import utils.task_intelligence.rollout as rollout import routers.chat_first_e2e as fixture_router @@ -116,6 +116,7 @@ def batch(self): @pytest.fixture def firestore(monkeypatch, tmp_path): + set_canonical_cohort(monkeypatch, ENABLED_UID) monkeypatch.setenv('OMI_ENV_STAGE', 'local') manifest_dir = tmp_path / 'manifests' manifest_dir.mkdir() @@ -154,11 +155,9 @@ def test_harness_runtime_and_fixture_identities_are_local_offline_only(monkeypat for stage in ('local', 'offline'): assert is_chat_first_e2e_harness_runtime(stage=stage) assert fixture_uid_for_principal(CHAT_FIRST_E2E_ENABLED_PRINCIPAL) == ENABLED_UID - assert is_chat_first_e2e_enabled_fixture(ENABLED_UID, stage=stage) assert is_chat_first_e2e_fixture_uid(OUT_OF_COHORT_UID, stage=stage) for stage in ('dev', 'prod', ''): assert not is_chat_first_e2e_harness_runtime(stage=stage) - assert not is_chat_first_e2e_enabled_fixture(ENABLED_UID, stage=stage) assert not is_chat_first_e2e_fixture_uid(OUT_OF_COHORT_UID, stage=stage) monkeypatch.delenv('OMI_ENV_STAGE', raising=False) assert not is_chat_first_e2e_harness_runtime() @@ -169,7 +168,6 @@ def test_fixture_identity_is_fail_closed_without_live_auth_uid_manifest(monkeypa monkeypatch.delenv('OMI_HARNESS_STATE_ROOT', raising=False) assert fixture_uid_for_principal(CHAT_FIRST_E2E_ENABLED_PRINCIPAL) is None - assert not is_chat_first_e2e_enabled_fixture(ENABLED_UID) assert not is_chat_first_e2e_fixture_uid(OUT_OF_COHORT_UID) @@ -202,18 +200,18 @@ def test_prepare_resets_fixed_canonical_fixture_rows_atomically(firestore): ) second = fixture.prepare_fixture( ENABLED_UID, - fixture_case=ChatFirstE2EFixtureCase.ui_flag_off, + fixture_case=ChatFirstE2EFixtureCase.unreachable_control, ) assert first.expected_shell == 'chat_first' assert first.proactive_intent_count == 1 assert second.fixture_revision == 2 assert second.expected_shell == 'legacy' - assert second.proactive_intent_count == 0 - assert second.ready_intent_count == 0 + assert second.proactive_intent_count == 1 + assert second.ready_intent_count == 1 control = firestore.rows[('users', ENABLED_UID, 'task_intelligence_control', 'state')] assert control['workflow_mode'] == TaskWorkflowMode.read.value - assert control['chat_first_ui_enabled'] is False + assert 'chat_first_ui_enabled' not in control focused_goal = firestore.rows[('users', ENABLED_UID, 'goals', 'chat-first-e2e-goal-v1')] non_focused_goal = firestore.rows[('users', ENABLED_UID, 'goals', 'chat-first-e2e-secondary-goal-v1')] assert focused_goal['status'] == 'focused' @@ -330,5 +328,5 @@ def test_fixture_identity_and_cohort_are_fail_closed(firestore, monkeypatch): workflow_mode=TaskWorkflowMode.read, account_generation=1, ) - assert enabled.intelligence_product_enabled is True + assert enabled.intelligence_product_enabled is False assert out_of_cohort.intelligence_product_enabled is False diff --git a/backend/tests/unit/test_chat_first_proactive_intents.py b/backend/tests/unit/test_chat_first_proactive_intents.py index c52fdc34c80..95c60ca051c 100644 --- a/backend/tests/unit/test_chat_first_proactive_intents.py +++ b/backend/tests/unit/test_chat_first_proactive_intents.py @@ -7,6 +7,7 @@ import pytest import database.chat_first_intents as intents_db +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort from models.chat_first import ( CaptureLinkSpec, ChatFirstSubject, @@ -97,6 +98,7 @@ def transaction(self): @pytest.fixture def firestore(monkeypatch): + set_canonical_cohort(monkeypatch, UID) monkeypatch.setattr( intents_db.firestore, 'transactional', lambda function: lambda transaction: function(transaction) ) @@ -498,19 +500,36 @@ def test_deferral_releases_once_verbatim_when_due_or_subject_changes(firestore): assert subject_change[0].blocks == [task_question] -def test_off_or_stale_control_rejects_intent_before_feature_records_are_read(firestore): +def test_workflow_mode_cannot_suppress_intent_but_stale_generation_still_rejects(firestore): firestore.rows[('users', UID, 'task_intelligence_control', 'state')] = TaskWorkflowControl( - workflow_mode=TaskWorkflowMode.read, + workflow_mode=TaskWorkflowMode.off, account_generation=GENERATION, - chat_first_ui_enabled=False, ).persisted_payload() question = _question() + intent, created = intents_db.create_intent( + UID, + source='agent_judgment', + continuity_key='workflow-mode-is-metadata', + subject=question.subject, + blocks=[question], + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + assert created is True + assert intent.account_generation == GENERATION + firestore.rows[('users', UID, 'task_intelligence_control', 'state')] = TaskWorkflowControl( + workflow_mode=TaskWorkflowMode.off, + account_generation=GENERATION + 1, + ).persisted_payload() + with pytest.raises(intents_db.ChatFirstIntentGenerationMismatch): intents_db.create_intent( UID, source='agent_judgment', - continuity_key='off', + continuity_key='stale-generation', subject=question.subject, blocks=[question], account_generation=GENERATION, @@ -518,7 +537,7 @@ def test_off_or_stale_control_rejects_intent_before_feature_records_are_read(fir firestore_client=firestore, ) - assert not any(INTENTS_COLLECTION in path or DEFERRALS_COLLECTION in path for path in firestore.rows) + assert sum(INTENTS_COLLECTION in path for path in firestore.rows) == 1 def test_malformed_control_or_proactive_state_fails_closed_without_a_fail_open_drop(firestore): diff --git a/backend/tests/unit/test_chat_first_proactive_router.py b/backend/tests/unit/test_chat_first_proactive_router.py index 03ec1096a5c..04377dc038a 100644 --- a/backend/tests/unit/test_chat_first_proactive_router.py +++ b/backend/tests/unit/test_chat_first_proactive_router.py @@ -9,6 +9,7 @@ from models.chat_first import ChatFirstSubject, ProactiveIntent, QuestionCardSpec, QuestionOption from models.task_intelligence import TaskWorkflowControl import routers.chat_first as chat_first_router +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort def _client() -> TestClient: @@ -31,6 +32,7 @@ def _request(*, generation: int = 7, owner_fence: str = 'user-1', receipts=None, def _enable_chat_first(monkeypatch, *, generation: int = 7) -> None: + set_canonical_cohort(monkeypatch, 'user-1') monkeypatch.setattr( chat_first_router.task_control_db, 'get_task_workflow_control', @@ -64,7 +66,7 @@ def test_materialize_capability_off_does_zero_feature_store_or_metric_work(monke monkeypatch.setattr( chat_first_router, 'resolve_task_intelligence_for_user', - lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), + lambda **kwargs: SimpleNamespace(intelligence_product_enabled=False), ) for name in ( 'acknowledge_materialization', @@ -196,7 +198,7 @@ def test_deferral_receiver_is_capability_gated_before_its_store(monkeypatch): monkeypatch.setattr( chat_first_router, 'resolve_task_intelligence_for_user', - lambda **kwargs: SimpleNamespace(intelligence_product_enabled=True), + lambda **kwargs: SimpleNamespace(intelligence_product_enabled=False), ) monkeypatch.setattr( chat_first_router.chat_first_intents_db, diff --git a/backend/tests/unit/test_memory_service_parity.py b/backend/tests/unit/test_memory_service_parity.py index 957a033ca8b..b7211e14962 100644 --- a/backend/tests/unit/test_memory_service_parity.py +++ b/backend/tests/unit/test_memory_service_parity.py @@ -190,7 +190,7 @@ def test_empty_whitelist_is_global_kill_switch(self, monkeypatch): class TestMemoryServiceParity: - def test_canonical_write_decision_malformed_rollout_fails_closed_for_code_cohort(self, monkeypatch): + def test_canonical_write_decision_ignores_malformed_legacy_rollout_env_for_code_cohort(self, monkeypatch): from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort from utils.memory.canonical_activation import canonical_write_decision @@ -202,7 +202,7 @@ def test_canonical_write_decision_malformed_rollout_fails_closed_for_code_cohort assert decision.enabled is False assert decision.memory_system == MemorySystem.CANONICAL assert decision.fail_closed is True - assert decision.reason == "invalid_rollout_config" + assert decision.reason == "missing_control_doc" def test_canonical_write_decision_missing_db_fails_closed_for_code_cohort(self, monkeypatch): from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort diff --git a/backend/tests/unit/test_task_intelligence_rollout.py b/backend/tests/unit/test_task_intelligence_rollout.py index a373806f272..ecfbb38102c 100644 --- a/backend/tests/unit/test_task_intelligence_rollout.py +++ b/backend/tests/unit/test_task_intelligence_rollout.py @@ -1,7 +1,6 @@ import pytest from models.task_intelligence import TaskWorkflowMode -from config.what_matters_now_smoke_fixture import WHAT_MATTERS_NOW_SMOKE_UID from utils.task_intelligence import rollout as rollout_module from utils.task_intelligence.rollout import ( resolve_chat_first_ui, @@ -13,7 +12,7 @@ @pytest.mark.parametrize('mode', list(TaskWorkflowMode)) @pytest.mark.parametrize('memory_eligible', [False, True]) -def test_rollout_matrix_keeps_workflow_and_memory_axes_independent(mode, memory_eligible): +def test_rollout_matrix_uses_memory_membership_as_the_only_eligibility_input(mode, memory_eligible): decision = resolve_task_intelligence_rollout( uid='user-1', workflow_mode=mode, memory_cohort_eligible=memory_eligible, account_generation=7 ) @@ -21,26 +20,23 @@ def test_rollout_matrix_keeps_workflow_and_memory_axes_independent(mode, memory_ assert decision.workflow_mode == mode assert decision.memory_cohort_eligible is memory_eligible assert decision.account_generation == 7 - assert decision.legacy_reads_authoritative is (mode != TaskWorkflowMode.read) - assert decision.legacy_writes_enabled is (mode != TaskWorkflowMode.read) - assert decision.canonical_sidecar_writes_enabled is (mode in {TaskWorkflowMode.write, TaskWorkflowMode.read}) - assert decision.canonical_reads_authoritative is (mode == TaskWorkflowMode.read) - assert decision.compatibility_projection_required is (mode == TaskWorkflowMode.read) - assert decision.intelligence_evaluation_enabled is (memory_eligible and mode != TaskWorkflowMode.off) - assert decision.intelligence_product_enabled is (memory_eligible and mode == TaskWorkflowMode.read) + assert decision.legacy_reads_authoritative is (not memory_eligible) + assert decision.legacy_writes_enabled is (not memory_eligible) + assert decision.canonical_sidecar_writes_enabled is memory_eligible + assert decision.canonical_reads_authoritative is memory_eligible + assert decision.compatibility_projection_required is memory_eligible + assert decision.intelligence_evaluation_enabled is memory_eligible + assert decision.intelligence_product_enabled is memory_eligible @pytest.mark.parametrize('mode', list(TaskWorkflowMode)) @pytest.mark.parametrize('memory_eligible', [False, True]) -@pytest.mark.parametrize('ui_flag_enabled', [False, True]) -def test_chat_first_ui_requires_the_complete_read_mode_canonical_cohort(mode, memory_eligible, ui_flag_enabled): +def test_chat_first_ui_uses_the_same_canonical_membership_decision(mode, memory_eligible): rollout = resolve_task_intelligence_rollout( uid='user-1', workflow_mode=mode, memory_cohort_eligible=memory_eligible, account_generation=7 ) - assert resolve_chat_first_ui(rollout, ui_flag_enabled) is ( - mode == TaskWorkflowMode.read and memory_eligible and ui_flag_enabled - ) + assert resolve_chat_first_ui(rollout) is memory_eligible def test_production_resolver_uses_authoritative_memory_selector(monkeypatch): @@ -67,36 +63,34 @@ def test_production_resolver_fails_closed_for_legacy_memory_user(monkeypatch): decision = resolve_task_intelligence_for_user(uid='user-1', workflow_mode='read') - assert decision.canonical_reads_authoritative is True + assert decision.canonical_reads_authoritative is False assert decision.memory_cohort_eligible is False assert decision.intelligence_evaluation_enabled is False assert decision.intelligence_product_enabled is False -def test_explicit_dev_runtime_allows_only_the_code_owned_smoke_fixture(monkeypatch): - monkeypatch.setenv('OMI_ENV_STAGE', 'dev') +def test_noncanonical_dev_fixture_cannot_bypass_the_code_owned_cohort(monkeypatch): monkeypatch.setattr(rollout_module, 'resolve_memory_system', lambda uid, db_client=None: MemorySystem.LEGACY) - decision = resolve_task_intelligence_for_user(uid=WHAT_MATTERS_NOW_SMOKE_UID, workflow_mode='read') + decision = resolve_task_intelligence_for_user(uid='fixture-user', workflow_mode='read') - assert decision.memory_cohort_eligible is True - assert decision.intelligence_product_enabled is True - other = resolve_task_intelligence_for_user(uid='another-user', workflow_mode='read') - assert other.intelligence_product_enabled is False + assert decision.memory_cohort_eligible is False + assert decision.intelligence_product_enabled is False -@pytest.mark.parametrize('stage', ['', 'local', 'prod']) -def test_smoke_fixture_rollout_fails_closed_outside_explicit_dev_runtime(monkeypatch, stage): - if stage: - monkeypatch.setenv('OMI_ENV_STAGE', stage) - else: - monkeypatch.delenv('OMI_ENV_STAGE', raising=False) - monkeypatch.setattr(rollout_module, 'resolve_memory_system', lambda uid, db_client=None: MemorySystem.LEGACY) - - decision = resolve_task_intelligence_for_user(uid=WHAT_MATTERS_NOW_SMOKE_UID, workflow_mode='read') +def test_local_chat_first_harness_uses_the_same_static_membership_predicate(): + from config.canonical_memory_cohort import ( + LOCAL_CHAT_FIRST_E2E_ENABLED_UID, + is_canonical_memory_user, + ) + from config.chat_first_e2e_fixture import ( + CHAT_FIRST_E2E_ENABLED_PRINCIPAL, + CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL, + ) - assert decision.memory_cohort_eligible is False - assert decision.intelligence_product_enabled is False + assert CHAT_FIRST_E2E_ENABLED_PRINCIPAL == LOCAL_CHAT_FIRST_E2E_ENABLED_UID + assert is_canonical_memory_user(CHAT_FIRST_E2E_ENABLED_PRINCIPAL) is True + assert is_canonical_memory_user(CHAT_FIRST_E2E_OUT_OF_COHORT_PRINCIPAL) is False def test_rollout_rejects_invalid_identity_and_generation(): diff --git a/backend/tests/unit/test_task_recommendations.py b/backend/tests/unit/test_task_recommendations.py index 5aa345776ef..ec86016bb84 100644 --- a/backend/tests/unit/test_task_recommendations.py +++ b/backend/tests/unit/test_task_recommendations.py @@ -10,6 +10,7 @@ from pydantic import ValidationError import database.task_recommendations as recommendation_db +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort from models.action_item import EvidenceKind, EvidenceRef, EvidenceScope from models.task_intelligence import ( TaskIntelligenceFeedbackAction, @@ -1379,7 +1380,8 @@ def test_database_module_has_attribution_join_and_no_raw_content_fields(): ) -def test_firestore_feedback_replay_heals_override_and_outcomes_require_known_chain(fake_firestore): +def test_firestore_feedback_replay_heals_override_and_outcomes_require_known_chain(fake_firestore, monkeypatch): + set_canonical_cohort(monkeypatch, 'u1') fake_db = fake_firestore intervention, created = recommendation_db.create_intervention( 'u1', @@ -1465,7 +1467,8 @@ def test_firestore_feedback_replay_heals_override_and_outcomes_require_known_cha ) -def test_firestore_generation_fences_reads_identities_snapshots_and_publication(fake_firestore): +def test_firestore_generation_fences_reads_identities_snapshots_and_publication(fake_firestore, monkeypatch): + set_canonical_cohort(monkeypatch, 'u1') fake_db = fake_firestore control_path = ( 'users', @@ -1580,24 +1583,28 @@ def set_generation(generation: int) -> None: workflow_mode=TaskWorkflowMode.off, account_generation=8, ).model_dump(mode='python') - with pytest.raises(recommendation_db.RecommendationGenerationMismatchError, match='mode changed'): + persisted_mode_projection = stale_projection.model_copy( + update={ + 'evaluation_id': 'generation-8-evaluation', + 'output_version': 'generation-8-output', + 'material_version': 'generation-8-material', + } + ) + assert ( recommendation_db.save_projection( 'u1', device_scope='device-1', - projection=stale_projection.model_copy( - update={ - 'evaluation_id': 'generation-8-evaluation', - 'output_version': 'generation-8-output', - 'material_version': 'generation-8-material', - } - ), + projection=persisted_mode_projection, decisions=[], account_generation=8, firestore_client=fake_db, ) + == persisted_mode_projection + ) -def test_firestore_snapshot_replacement_expiry_and_cross_device_isolation(fake_firestore): +def test_firestore_snapshot_replacement_expiry_and_cross_device_isolation(fake_firestore, monkeypatch): + set_canonical_cohort(monkeypatch, 'u1') fake_db = fake_firestore first = NormalizedContextSnapshot( device_id='device-1', @@ -1646,7 +1653,8 @@ def test_firestore_snapshot_replacement_expiry_and_cross_device_isolation(fake_f ) -def test_firestore_projection_persists_stable_intervention_and_debug_trace(fake_firestore): +def test_firestore_projection_persists_stable_intervention_and_debug_trace(fake_firestore, monkeypatch): + set_canonical_cohort(monkeypatch, 'u1') fake_db = fake_firestore subject = fixture_subject('task-1', {'capture_confidence': 1, 'has_concrete_next_action': True}) projection = WhatMattersNowProjection( @@ -1750,7 +1758,8 @@ def test_firestore_projection_persists_stable_intervention_and_debug_trace(fake_ assert len(decision_paths) == 2 -def test_firestore_same_material_publication_returns_one_winner(fake_firestore): +def test_firestore_same_material_publication_returns_one_winner(fake_firestore, monkeypatch): + set_canonical_cohort(monkeypatch, 'u1') first = WhatMattersNowProjection( evaluation_id='evaluation-same', output_version='output-first', diff --git a/backend/tests/unit/test_v3_control_state_adapter.py b/backend/tests/unit/test_v3_control_state_adapter.py index 0b6ce546e8d..4210cbb9820 100644 --- a/backend/tests/unit/test_v3_control_state_adapter.py +++ b/backend/tests/unit/test_v3_control_state_adapter.py @@ -1,5 +1,7 @@ from dataclasses import dataclass +import pytest + from config.memory_rollout import MemoryRolloutMode, MemoryRolloutConfig from database.memory_collections import MemoryCollections from utils.memory.default_read_rollout import DEFAULT_READ_ROLLOUT_TIMEOUT_SECONDS @@ -9,7 +11,8 @@ V3ControlRouteFamily, decide_v3_control_route, ) -from utils.memory.v3.control_state_adapter import read_v3_control, resolve_v3_effective_mode +from utils.memory.v3.control_state_adapter import read_v3_control +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort @dataclass @@ -92,16 +95,21 @@ def _ready_docs(uid='uid-a', control_doc=None): } +@pytest.fixture(autouse=True) +def _canonical_uid(monkeypatch): + set_canonical_cohort(monkeypatch, 'uid-a') + + def test_non_enrolled_returns_legacy_eligibility_without_firestore_read(): db = FakeDb() - result = read_v3_control(uid='uid-a', db_client=db, rollout_config=_config(enabled_users=())) + result = read_v3_control(uid='uid-other', db_client=db) assert result.cohort_enrolled is False - assert result.source_path == 'users/uid-a/memory_control/state' + assert result.source_path == 'users/uid-other/memory_control/state' assert result.state is None assert db.document_calls == [] decision = decide_v3_control_route( - V3ControlReaderRequest('uid-a', 50, False, False), + V3ControlReaderRequest('uid-other', 50, False, False), result, ) assert decision.route_family == V3ControlRouteFamily.LEGACY_PRIMARY @@ -110,7 +118,7 @@ def test_non_enrolled_returns_legacy_eligibility_without_firestore_read(): def test_enrolled_reads_exact_control_doc_once_with_existing_timeout_and_no_memory_items_touch(): db = FakeDb(_ready_docs()) - result = read_v3_control(uid='uid-a', db_client=db, rollout_config=_config()) + result = read_v3_control(uid='uid-a', db_client=db) assert result.cohort_enrolled is True assert result.source_path == 'users/uid-a/memory_control/state' @@ -120,26 +128,17 @@ def test_enrolled_reads_exact_control_doc_once_with_existing_timeout_and_no_memo assert all('memory_items' not in path for path in db.document_calls) -def test_off_shadow_write_map_to_legacy_authoritative_and_do_not_read_global_gates(): +def test_persisted_legacy_modes_do_not_reclassify_an_enrolled_user(): for mode in (MemoryRolloutMode.off, MemoryRolloutMode.shadow, MemoryRolloutMode.write): - db = FakeDb({MemoryCollections(uid='uid-a').memory_control_state: _doc(mode=mode.value)}) - result = read_v3_control(uid='uid-a', db_client=db, rollout_config=_config(mode=MemoryRolloutMode.read)) + db = FakeDb(_ready_docs(control_doc=_doc(mode=mode.value))) + result = read_v3_control(uid='uid-a', db_client=db) - assert result.state.effective_mode == mode - assert db.document_calls == ['users/uid-a/memory_control/state'] + assert result.state.effective_mode == MemoryRolloutMode.read decision = decide_v3_control_route(V3ControlReaderRequest('uid-a', 50, False, False), result) - assert decision.route_family == V3ControlRouteFamily.LEGACY_PRIMARY - assert decision.reason == V3ControlDecisionReason.ROLLOUT_LEGACY_AUTHORITATIVE + assert decision.route_family == V3ControlRouteFamily.MEMORY_PROJECTION assert decision.fallback_to_legacy_allowed is False -def test_global_ceiling_never_elevates_lower_persisted_mode_and_caps_higher_persisted_mode(): - assert resolve_v3_effective_mode(MemoryRolloutMode.read, MemoryRolloutMode.write) == MemoryRolloutMode.write - assert resolve_v3_effective_mode(MemoryRolloutMode.write, MemoryRolloutMode.read) == MemoryRolloutMode.write - assert resolve_v3_effective_mode('shadow', 'read') == MemoryRolloutMode.shadow - assert resolve_v3_effective_mode('off', 'read') == MemoryRolloutMode.off - - def test_omi_chat_grants_only_enable_default_memory_and_archive_is_strict_boolean(): control_doc = _doc( grants={ @@ -150,7 +149,7 @@ def test_omi_chat_grants_only_enable_default_memory_and_archive_is_strict_boolea } ) db = FakeDb(_ready_docs(control_doc=control_doc)) - result = read_v3_control(uid='uid-a', db_client=db, rollout_config=_config()) + result = read_v3_control(uid='uid-a', db_client=db) assert result.state.default_memory_grant is False assert result.state.archive_allowed is False @@ -159,15 +158,16 @@ def test_omi_chat_grants_only_enable_default_memory_and_archive_is_strict_boolea assert decision.http_status == 403 -def test_global_read_and_convergence_docs_are_read_only_for_effective_read_mode(): +def test_global_read_and_convergence_docs_are_read_for_every_enrolled_control_mode(): read_db = FakeDb(_ready_docs()) - read_v3_control(uid='uid-a', db_client=read_db, rollout_config=_config()) + read_v3_control(uid='uid-a', db_client=read_db) assert 'memory_control/global_read_gate' in read_db.document_calls assert 'memory_control/write_convergence_gate' in read_db.document_calls - write_db = FakeDb({MemoryCollections(uid='uid-a').memory_control_state: _doc(mode='write')}) - read_v3_control(uid='uid-a', db_client=write_db, rollout_config=_config()) - assert write_db.document_calls == ['users/uid-a/memory_control/state'] + write_db = FakeDb(_ready_docs(control_doc=_doc(mode='write'))) + read_v3_control(uid='uid-a', db_client=write_db) + assert 'memory_control/global_read_gate' in write_db.document_calls + assert 'memory_control/write_convergence_gate' in write_db.document_calls def test_missing_malformed_uid_mismatch_unsupported_schema_and_transport_failures_are_typed(): @@ -196,7 +196,7 @@ def test_missing_malformed_uid_mismatch_unsupported_schema_and_transport_failure ] for db, reason in cases: - result = read_v3_control(uid='uid-a', db_client=db, rollout_config=_config()) + result = read_v3_control(uid='uid-a', db_client=db) decision = decide_v3_control_route(V3ControlReaderRequest('uid-a', 50, False, False), result) assert decision.route_family == V3ControlRouteFamily.FAIL_CLOSED assert decision.reason == reason @@ -205,7 +205,7 @@ def test_missing_malformed_uid_mismatch_unsupported_schema_and_transport_failure def test_mode_epoch_one_with_account_generation_fifty_is_valid_and_not_compared(): db = FakeDb(_ready_docs(control_doc=_doc(mode_epoch=1, cutover_epoch=1, account_generation=50))) - result = read_v3_control(uid='uid-a', db_client=db, rollout_config=_config()) + result = read_v3_control(uid='uid-a', db_client=db) decision = decide_v3_control_route(V3ControlReaderRequest('uid-a', 50, False, False), result) assert result.state.mode_epoch == 1 @@ -215,7 +215,7 @@ def test_mode_epoch_one_with_account_generation_fifty_is_valid_and_not_compared( def test_archive_defaults_false_strict_boolean_and_archive_denial_is_403(): db = FakeDb(_ready_docs(control_doc=_doc(grants={'omi_chat': {'default_memory': True}}))) - result = read_v3_control(uid='uid-a', db_client=db, rollout_config=_config()) + result = read_v3_control(uid='uid-a', db_client=db) assert result.state.archive_allowed is False decision = decide_v3_control_route(V3ControlReaderRequest('uid-a', 50, False, False, True), result) @@ -225,7 +225,7 @@ def test_archive_defaults_false_strict_boolean_and_archive_denial_is_403(): def test_stale_short_term_is_absent_from_adapter_state(): db = FakeDb(_ready_docs()) - result = read_v3_control(uid='uid-a', db_client=db, rollout_config=_config()) + result = read_v3_control(uid='uid-a', db_client=db) assert result.state is not None assert not hasattr(result.state, 'short_term_freshness_default_visible') diff --git a/backend/tests/unit/test_v3_production_runtime_wiring.py b/backend/tests/unit/test_v3_production_runtime_wiring.py index 93930300503..89d651d8136 100644 --- a/backend/tests/unit/test_v3_production_runtime_wiring.py +++ b/backend/tests/unit/test_v3_production_runtime_wiring.py @@ -20,6 +20,7 @@ compose_v3_get, ) from utils.memory.v3.production_runtime import build_v3_production_runtime +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort @dataclass @@ -87,6 +88,11 @@ def collection(self, path): return FakeQuery(self, path) +@pytest.fixture(autouse=True) +def _canonical_uid(monkeypatch): + set_canonical_cohort(monkeypatch, 'uid-a') + + def _control_doc(uid='uid-a'): return { 'uid': uid, @@ -240,7 +246,7 @@ def legacy_get(uid, limit, offset): @pytest.mark.slow -def test_real_router_uses_actual_builder_and_does_zero_db_reads_while_v3_gate_off(monkeypatch): +def test_real_router_ignores_v3_env_gate_for_an_enrolled_user(monkeypatch): monkeypatch.setenv('MEMORY_MODE', 'read') monkeypatch.setenv('MEMORY_ENABLED_USERS', 'uid-a') monkeypatch.delenv('MEMORY_V3_GET_ENABLED', raising=False) @@ -252,14 +258,14 @@ def test_real_router_uses_actual_builder_and_does_zero_db_reads_while_v3_gate_of assert response.status_code == 200 body = response.json() - assert body[0]['id'] == 'legacy-id' - assert 'layer' not in body[0] - assert 'memory_tier' not in body[0] - assert response.headers['x-omi-memory-canonical-lifecycle-exposed'] == 'false' - assert response.headers['x-omi-memory-device-scope-supported'] == 'false' - assert legacy_calls == [{'uid': 'uid-a', 'limit': 5000, 'offset': 0}] - assert db.reads == [] - assert db.streams == [] + assert body[0]['id'] == 'm1' + assert body[0]['layer'] == 'long_term' + assert response.headers['x-omi-memory-canonical-lifecycle-exposed'] == 'true' + assert response.headers['x-omi-memory-device-scope-supported'] == 'true' + assert legacy_calls == [] + # The composed reader asks for the requested page plus its ten-row scan + # cushion, then the projection store asks for one cursor sentinel. + assert db.streams == [('users/uid-a/v3_compatibility_projection_items', 14)] @pytest.mark.slow @@ -322,7 +328,7 @@ def test_real_router_cursor_read_requires_cursor_secret_and_never_calls_legacy(m assert db.writes == [] -def test_default_env_stays_disabled_and_does_not_read_firestore(monkeypatch): +def test_absent_memory_env_still_builds_an_enrolled_runtime(monkeypatch): monkeypatch.delenv('MEMORY_MODE', raising=False) monkeypatch.delenv('MEMORY_ENABLED_USERS', raising=False) monkeypatch.delenv('MEMORY_V3_GET_ENABLED', raising=False) @@ -330,12 +336,11 @@ def test_default_env_stays_disabled_and_does_not_read_firestore(monkeypatch): runtime = build_v3_production_runtime(uid='uid-a', db_client=db) - assert runtime.enabled is False - assert runtime.source_decision == 'disabled' - assert db.reads == [] + assert runtime.enabled is True + assert runtime.source_decision == 'memory_read' -def test_route_specific_gate_is_default_false_even_when_global_memory_env_is_read(monkeypatch): +def test_memory_env_cannot_disable_an_enrolled_runtime(monkeypatch): monkeypatch.setenv('MEMORY_MODE', 'read') monkeypatch.setenv('MEMORY_ENABLED_USERS', 'uid-a') monkeypatch.delenv('MEMORY_V3_GET_ENABLED', raising=False) @@ -343,12 +348,11 @@ def test_route_specific_gate_is_default_false_even_when_global_memory_env_is_rea runtime = build_v3_production_runtime(uid='uid-a', db_client=db) - assert runtime.enabled is False - assert runtime.source_decision == 'disabled' - assert db.reads == [] + assert runtime.enabled is True + assert runtime.source_decision == 'memory_read' -def test_route_specific_gate_requires_exact_true(monkeypatch): +def test_non_boolean_v3_env_cannot_disable_an_enrolled_runtime(monkeypatch): monkeypatch.setenv('MEMORY_MODE', 'read') monkeypatch.setenv('MEMORY_ENABLED_USERS', 'uid-a') monkeypatch.setenv('MEMORY_V3_GET_ENABLED', '1') @@ -356,12 +360,11 @@ def test_route_specific_gate_requires_exact_true(monkeypatch): runtime = build_v3_production_runtime(uid='uid-a', db_client=db) - assert runtime.enabled is False - assert runtime.source_decision == 'disabled' - assert db.reads == [] + assert runtime.enabled is True + assert runtime.source_decision == 'memory_read' -def test_non_read_modes_and_malformed_mode_do_not_enable_runtime(monkeypatch): +def test_memory_mode_values_cannot_disable_an_enrolled_runtime(monkeypatch): for mode in ('shadow', 'write', 'unknown'): monkeypatch.setenv('MEMORY_MODE', mode) monkeypatch.setenv('MEMORY_ENABLED_USERS', 'uid-a') @@ -370,12 +373,12 @@ def test_non_read_modes_and_malformed_mode_do_not_enable_runtime(monkeypatch): runtime = build_v3_production_runtime(uid='uid-a', db_client=db) - assert runtime.enabled is False - assert runtime.source_decision == 'disabled' - assert db.reads == [] + assert runtime.enabled is True + assert runtime.source_decision == 'memory_read' def test_non_enrolled_runtime_is_legacy_primary_without_firestore_read(monkeypatch): + set_canonical_cohort(monkeypatch, 'other-user') monkeypatch.setenv('MEMORY_MODE', 'read') monkeypatch.setenv('MEMORY_V3_GET_ENABLED', 'true') monkeypatch.setenv('MEMORY_ENABLED_USERS', 'other-user') @@ -383,7 +386,7 @@ def test_non_enrolled_runtime_is_legacy_primary_without_firestore_read(monkeypat runtime = build_v3_production_runtime(uid='uid-a', db_client=db) - assert runtime.enabled is True + assert runtime.enabled is False assert runtime.source_decision == 'legacy_primary' assert db.reads == [] diff --git a/backend/tests/unit/test_workstream_association.py b/backend/tests/unit/test_workstream_association.py index b03d19ec835..5385503b578 100644 --- a/backend/tests/unit/test_workstream_association.py +++ b/backend/tests/unit/test_workstream_association.py @@ -204,7 +204,7 @@ def test_material_event_uses_minimized_adjudicator_summary_not_memory_content(en assert appended[0][1]['required_status'] == WorkstreamStatus.open -def test_shadow_association_adjudicates_but_does_not_append(enabled, monkeypatch): +def test_persisted_shadow_mode_cannot_suppress_canonical_association(enabled, monkeypatch): monkeypatch.setattr( association.workstreams_db, 'get_task_workflow_control', @@ -230,11 +230,12 @@ def test_shadow_association_adjudicates_but_does_not_append(enabled, monkeypatch reason=AssociationReason.selected, event_summary='Revise the draft for the changed pricing assumption.', ), - append_event=lambda *args, **kwargs: appended.append('event'), + append_event=lambda *args, **kwargs: appended.append('event') or SimpleNamespace(event_id='event-1'), ) - assert outcome.outcome == AssociationOutcomeKind.would_append - assert appended == [] + assert outcome.outcome == AssociationOutcomeKind.appended + assert outcome.event_id == 'event-1' + assert appended == ['event'] def test_verbatim_material_summary_fails_closed_without_append(enabled): @@ -388,7 +389,7 @@ def create(uid, proposal, *, idempotency_key, account_generation): assert len(created) == 1 -def test_recurrence_shadow_evaluates_without_candidate_mutation(monkeypatch): +def test_persisted_shadow_mode_cannot_suppress_canonical_recurrence_candidate(monkeypatch): monkeypatch.setattr(association, 'resolve_memory_system', lambda uid, db_client=None: MemorySystem.CANONICAL) monkeypatch.setattr( association.workstreams_db, @@ -404,12 +405,14 @@ def test_recurrence_shadow_evaluates_without_candidate_mutation(monkeypatch): 'uid-1', _recurrence_signal(distinct_days=3), account_generation=7, - create_candidate=lambda *args, **kwargs: calls.append('candidate'), + create_candidate=lambda *args, **kwargs: calls.append('candidate') + or SimpleNamespace(candidate_id='candidate-1'), ) - assert result.outcome == RecurrenceOutcomeKind.would_create + assert result.outcome == RecurrenceOutcomeKind.candidate_created + assert result.candidate_id == 'candidate-1' assert result.idempotency_key - assert calls == [] + assert calls == ['candidate'] def test_durable_recurrence_inbox_retries_failures_and_continues(enabled, monkeypatch): diff --git a/backend/tests/unit/test_workstream_router_contract.py b/backend/tests/unit/test_workstream_router_contract.py index e30b685fa52..143dfea9638 100644 --- a/backend/tests/unit/test_workstream_router_contract.py +++ b/backend/tests/unit/test_workstream_router_contract.py @@ -8,6 +8,7 @@ import pytest from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient from starlette.requests import Request import routers.goals as goals_router @@ -19,6 +20,14 @@ from config.what_matters_now_smoke_fixture import WHAT_MATTERS_NOW_SMOKE_UID +def _canonical_task_router_client() -> TestClient: + app = FastAPI() + app.include_router(goals_router.router) + app.include_router(workstreams_router.router) + app.dependency_overrides[goals_router.auth.get_current_user_uid] = lambda: 'not-enrolled' + return TestClient(app) + + def test_openapi_exposes_intent_and_thread_resources_without_manual_workstream_create(): app = FastAPI() app.include_router(goals_router.router) @@ -367,3 +376,31 @@ def resolve(uid, request, **kwargs): assert captured['idempotency_key'] == 'click-1' assert captured['account_generation'] == 7 assert refreshed == [('u1', 'w1')] + + +@pytest.mark.parametrize( + ('path', 'store', 'store_method'), + [ + ('/v1/goals/goal-1/detail', goals_router.workstreams_db, 'get_goal_detail'), + ('/v1/goals/goal-1/progress-events', goals_router.goals_db, 'list_goal_progress_events'), + ('/v1/workstreams/workstream-1', workstreams_router.workstreams_db, 'get_workstream_detail'), + ('/v1/workstreams/workstream-1/events', workstreams_router.workstreams_db, 'list_workstream_events'), + ('/v1/workstreams/workstream-1/artifacts', workstreams_router.workstreams_db, 'list_artifact_descriptors'), + ( + '/v1/workstreams/workstream-1/checkpoints', + workstreams_router.workstreams_db, + 'list_continuation_checkpoints', + ), + ], +) +def test_noncanonical_task_reads_are_hidden_before_store_access(monkeypatch, path, store, store_method): + monkeypatch.setattr( + store, + store_method, + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('canonical store must not be read')), + ) + + response = _canonical_task_router_client().get(path) + + assert response.status_code == 404 + assert response.json() == {'detail': 'Not found'} diff --git a/backend/utils/memory/ARCHITECTURE.md b/backend/utils/memory/ARCHITECTURE.md index 76a08499647..2d6df85f778 100644 --- a/backend/utils/memory/ARCHITECTURE.md +++ b/backend/utils/memory/ARCHITECTURE.md @@ -82,9 +82,9 @@ Consolidation and promotion mutate authoritative state only through `memory_appl `backend/config/memory_rollout.py` owns the runtime contract: -- `MEMORY_MODE` sets `off`, `shadow`, `write`, or `read`; composed GET requires `read`. -- `MEMORY_ENABLED_USERS` is the environment cohort. `memory_system.py` also requires membership in the code-reviewed `CANONICAL_MEMORY_USERS` cohort for direct canonical routing. -- `MEMORY_V3_GET_ENABLED` enables construction of the composed GET runtime. +- `CANONICAL_MEMORY_USERS` in `config/canonical_memory_cohort.py` is the one product entitlement: it selects canonical memory, task intelligence, and Chat-first together. +- `MEMORY_MODE`, `MEMORY_ENABLED_USERS`, and `MEMORY_V3_GET_ENABLED` remain maintenance/readiness metadata only. Request routing must never use them to select or suppress a user. +- An enrolled account whose canonical projection is unavailable fails closed; it never falls back to legacy memory. - `MEMORY_V3_CURSOR_SECRET`, `MEMORY_V3_CURSOR_TTL_SECONDS`, `MEMORY_V3_CURSOR_POLICY_VERSION`, and `MEMORY_V3_CURSOR_SECRET_VERSION` bind cursor behavior. - Persisted control state additionally gates rollout stage, default-memory grant, global reads, write convergence, account/projection generations, and projection readiness. - `MEMORY_CANONICAL_PROMOTION_CRON_ENABLED` and `MEMORY_CANONICAL_PROMOTION_CRON_INTERVAL_HOURS` gate scheduled maintenance; `MEMORY_CANONICAL_PROMOTION_FAST_TRACK_ENABLED` gates user-asserted fast promotion. diff --git a/backend/utils/memory/canonical_activation.py b/backend/utils/memory/canonical_activation.py index f73ca8034d8..336a410a433 100644 --- a/backend/utils/memory/canonical_activation.py +++ b/backend/utils/memory/canonical_activation.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from typing import Any -from config.memory_rollout import MemoryRolloutConfig, MemoryRolloutMode from utils.memory.memory_system import MemorySystem, list_canonical_cohort_uids from utils.memory.memory_system_pin import pin_memory_system from utils.memory.v3.account_generation_source import read_memory_v3_trusted_account_generation @@ -47,35 +46,11 @@ def canonical_write_decision(uid: str, *, db_client: Any) -> CanonicalWriteDecis reason="missing_db_client", ) - try: - rollout_config = MemoryRolloutConfig.from_env() - except ValueError: - if uid in set(list_canonical_cohort_uids()): - return CanonicalWriteDecision( - enabled=False, - memory_system=MemorySystem.CANONICAL, - fail_closed=True, - reason="invalid_rollout_config", - ) - return CanonicalWriteDecision( - enabled=False, - memory_system=MemorySystem.LEGACY, - fail_closed=False, - reason="invalid_rollout_config", - ) - memory_system = pin_memory_system(uid, db_client=db_client) if memory_system != MemorySystem.CANONICAL: return CanonicalWriteDecision(enabled=False, memory_system=memory_system, reason="not_canonical") - if rollout_config.mode not in {MemoryRolloutMode.write, MemoryRolloutMode.read}: - return CanonicalWriteDecision( - enabled=False, - memory_system=memory_system, - fail_closed=True, - reason=f"mode_{rollout_config.mode.value}_not_writable", - ) - control = read_v3_control(uid=uid, db_client=db_client, rollout_config=rollout_config) + control = read_v3_control(uid=uid, db_client=db_client) if not control.cohort_enrolled or control.state is None: reason = control.read_error_reason or "missing_state" logger.info("canonical_write disabled uid=%s reason=%s", uid, reason) @@ -119,14 +94,7 @@ def canonical_read_enabled( if pin_memory_system(uid, db_client=db_client) != MemorySystem.CANONICAL: return False - try: - rollout_config = MemoryRolloutConfig.from_env() - except ValueError: - return False - if rollout_config.mode != MemoryRolloutMode.read: - return False - - control = read_v3_control(uid=uid, db_client=db_client, rollout_config=rollout_config) + control = read_v3_control(uid=uid, db_client=db_client) trusted_generation = read_memory_v3_trusted_account_generation(uid=uid, db_client=db_client) effective_env = env if env is not None else os.environ decision = decide_v3_control_route( diff --git a/backend/utils/memory/memory_service.py b/backend/utils/memory/memory_service.py index 61482d72281..85bb58ba4b6 100644 --- a/backend/utils/memory/memory_service.py +++ b/backend/utils/memory/memory_service.py @@ -29,7 +29,7 @@ from utils.memory.required_promotion import required_processing_payload from utils.client_device import DeviceScopeRequest from utils.memory.canonical_activation import canonical_read_enabled, canonical_write_decision -from utils.memory.memory_system import MemorySystem +from utils.memory.memory_system import MemorySystem, resolve_memory_system from utils.memory.default_read_rollout import guard_legacy_memory_write from utils.memory.memory_api_contract import MemoryApiExposure, memory_api_payload, memory_write_payload from utils.retrieval.hybrid import rrf_rerank @@ -69,6 +69,18 @@ def _canonical_external_write_enabled_or_fail_closed(uid: str, db_client: Any) - return False +def _read_backend_or_fail_closed( + uid: str, *, db_client: Any, legacy: "LegacyMemoryBackend", canonical: "CanonicalMemoryBackend" +): + """Choose a read backend without ever reclassifying an enrolled user as legacy.""" + + if resolve_memory_system(uid, db_client=db_client) != MemorySystem.CANONICAL: + return legacy + if canonical_read_enabled(uid, db_client=db_client): + return canonical + raise HTTPException(status_code=503, detail={"reason": "canonical_memory_not_ready", "memory_system": "canonical"}) + + def resolve_external_memory_write_context( uid: str, *, @@ -120,7 +132,11 @@ def _legacy_memorydb(value: MemoryDB | Dict[str, Any]) -> MemoryDB: def fetch_memory_dict(uid: str, memory_id: str, *, db_client: Any) -> MemoryPayload: """Fetch one memory by id with canonical/legacy routing and locked-memory paywall.""" - if canonical_read_enabled(uid, db_client=db_client): + if resolve_memory_system(uid, db_client=db_client) == MemorySystem.CANONICAL: + if not canonical_read_enabled(uid, db_client=db_client): + raise HTTPException( + status_code=503, detail={"reason": "canonical_memory_not_ready", "memory_system": "canonical"} + ) item = read_canonical_memory_item(uid, memory_id, db_client=db_client) if item is None: raise HTTPException(status_code=404, detail="Memory not found") @@ -467,7 +483,12 @@ def read( device_scope_request: Optional[DeviceScopeRequest] = None, include_pending_processing: bool = False, ) -> List[MemoryDB]: - backend = self._canonical if canonical_read_enabled(uid, db_client=self._db_client) else self._legacy + backend = _read_backend_or_fail_closed( + uid, + db_client=self._db_client, + legacy=self._legacy, + canonical=self._canonical, + ) return backend.read( uid, limit=limit, @@ -484,7 +505,12 @@ def search( limit: int = 5, device_scope_request: Optional[DeviceScopeRequest] = None, ) -> List[MemorySearchMatch]: - backend = self._canonical if canonical_read_enabled(uid, db_client=self._db_client) else self._legacy + backend = _read_backend_or_fail_closed( + uid, + db_client=self._db_client, + legacy=self._legacy, + canonical=self._canonical, + ) return backend.search( uid, query, @@ -494,7 +520,13 @@ def search( def search_mcp(self, uid: str, query: str, *, limit: int = 5) -> List[McpSearchPayload]: """MCP-shaped search results (legacy parity filters + RRF, or canonical keyword).""" - if canonical_read_enabled(uid, db_client=self._db_client): + backend = _read_backend_or_fail_closed( + uid, + db_client=self._db_client, + legacy=self._legacy, + canonical=self._canonical, + ) + if backend is self._canonical: return _canonical_search_memories_mcp(uid, query, limit=limit, db_client=self._db_client) return _legacy_search_memories_mcp(uid, query, limit=limit) diff --git a/backend/utils/memory/memory_system.py b/backend/utils/memory/memory_system.py index 98eab5c8675..33725ffc022 100644 --- a/backend/utils/memory/memory_system.py +++ b/backend/utils/memory/memory_system.py @@ -7,20 +7,14 @@ from enum import Enum from typing import Any -from config.memory_rollout import MemoryRolloutConfig +from config import canonical_memory_cohort MEMORY_SYSTEM_FIELD = "memory_system" # Code-as-config canonical cohort whitelist (reviewable, diff-able, test-guarded). # Add Firebase UIDs here to enroll users in the canonical memory path. # Everyone not listed resolves to LEGACY. -CANONICAL_MEMORY_USERS: frozenset[str] = frozenset( - { - "vi7SA9ckQCe4ccobWNxlbdcNdC23", # david.d.zhang@gmail.com (prod Firebase: based-hardware) - # Next dogfood (re-enable soon): - # "viUv7GtdoHXbK1UBCDlPuTDuPgJ2", # kodjima33@gmail.com (prod Firebase: based-hardware) - } -) +CANONICAL_MEMORY_USERS = canonical_memory_cohort.CANONICAL_MEMORY_USERS class MemorySystem(str, Enum): @@ -30,7 +24,7 @@ class MemorySystem(str, Enum): def _canonical_cohort_uids() -> frozenset[str]: """Return the code-defined canonical cohort set.""" - return CANONICAL_MEMORY_USERS + return canonical_memory_cohort.CANONICAL_MEMORY_USERS def list_canonical_cohort_uids() -> list[str]: @@ -41,32 +35,14 @@ def list_canonical_cohort_uids() -> list[str]: def resolve_memory_system(uid: str, *, db_client: Any = None) -> MemorySystem: """Return the server-owned memory cohort for ``uid``. - Precedence (authoritative): - 1. ``CANONICAL_MEMORY_USERS`` in this module — code-reviewed cohort membership. - 2. ``MEMORY_ENABLED_USERS`` + ``MEMORY_MODE`` — environment-specific activation. - 3. Absence from either list, or ``MEMORY_MODE=off`` → ``MemorySystem.LEGACY``. + ``CANONICAL_MEMORY_USERS`` is the sole entitlement selector. Runtime rollout + configuration and persisted control records may supply readiness and + concurrency fences after this selector has chosen ``CANONICAL``; they must + never reinterpret an enrolled account as ``LEGACY``. A stale persisted ``memory_control/state.memory_system=canonical`` does **not** override whitelist removal — clearing the code whitelist is the global kill-switch (everyone legacy). - - UID membership alone never activates canonical memory. This keeps the same - branch deployable to dev or prod while requiring each GCP/Firebase - environment to opt in explicitly through its own runtime env. """ del db_client # reserved for callers/tests; cohort is code-defined today - if not uid: - return MemorySystem.LEGACY - - if uid not in _canonical_cohort_uids(): - return MemorySystem.LEGACY - - try: - rollout = MemoryRolloutConfig.from_env() - except ValueError: - return MemorySystem.LEGACY - - if rollout.mode.value == "off" or uid not in rollout.enabled_users: - return MemorySystem.LEGACY - - return MemorySystem.CANONICAL + return MemorySystem.CANONICAL if canonical_memory_cohort.is_canonical_memory_user(uid) else MemorySystem.LEGACY diff --git a/backend/utils/memory/v3/control_state_adapter.py b/backend/utils/memory/v3/control_state_adapter.py index 472ea2cce60..9361025121b 100644 --- a/backend/utils/memory/v3/control_state_adapter.py +++ b/backend/utils/memory/v3/control_state_adapter.py @@ -8,7 +8,6 @@ from typing import Any, cast from config.memory_rollout import ( - MemoryRolloutConfig, MemoryRolloutMode, MemoryRolloutState, decide_memory_rollout_capabilities, @@ -22,6 +21,7 @@ read_write_convergence_gate, ) from utils.memory.memory_read_rollout_core import extract_consumer_grants +from utils.memory.memory_system import MemorySystem, resolve_memory_system from utils.memory.v3.control_reader_contract import ( V3ControlDecisionReason, V3ControlReadResult, @@ -29,12 +29,6 @@ ) V3_DEFAULT_CONSUMER = 'omi_chat' -_MODE_RANK = { - MemoryRolloutMode.off: 0, - MemoryRolloutMode.shadow: 1, - MemoryRolloutMode.write: 2, - MemoryRolloutMode.read: 3, -} _READ_ERROR_REASON_MAP = { 'malformed_rollout_state': V3ControlDecisionReason.MALFORMED_CONTROL_DOC, 'rollout_read_failed': V3ControlDecisionReason.CONTROL_READ_FAILED, @@ -45,18 +39,6 @@ def _mode(value: MemoryRolloutMode | str) -> MemoryRolloutMode: return value if isinstance(value, MemoryRolloutMode) else MemoryRolloutMode(value) -def resolve_v3_effective_mode( - configured_mode: MemoryRolloutMode | str, persisted_mode: MemoryRolloutMode | str -) -> MemoryRolloutMode: - """Return the lower-ranked mode; global config is a ceiling, not an elevator.""" - - configured = _mode(configured_mode) - persisted = _mode(persisted_mode) - if _MODE_RANK[configured] <= _MODE_RANK[persisted]: - return configured - return persisted - - def _read_error_reason(reason: str | None) -> V3ControlDecisionReason: return _READ_ERROR_REASON_MAP.get(str(reason or ''), V3ControlDecisionReason.CONTROL_READ_FAILED) @@ -87,19 +69,17 @@ def _rollout_state_from_data(*, uid: str, data: dict[str, Any]) -> MemoryRollout ) -def read_v3_control( - *, uid: str, db_client: Any, rollout_config: MemoryRolloutConfig, consumer: str = V3_DEFAULT_CONSUMER -) -> V3ControlReadResult: +def read_v3_control(*, uid: str, db_client: Any, consumer: str = V3_DEFAULT_CONSUMER) -> V3ControlReadResult: """Read canonical memory rollout state and derive the `/v3` control contract. - Non-enrolled users are identified solely by rollout cohort membership and do + Non-enrolled users are identified solely by the canonical-memory selector and do not trigger a Firestore read. Enrolled users must have a persisted control doc; missing or unreadable state fails closed and is not reinterpreted as non-enrollment. """ source_path = MemoryCollections(uid=uid).memory_control_state - if uid not in rollout_config.enabled_users: + if resolve_memory_system(uid, db_client=db_client) != MemorySystem.CANONICAL: return V3ControlReadResult(cohort_enrolled=False, source_path=source_path) source_path, data, read_error = read_rollout_state_doc(uid=uid, db_client=db_client) @@ -137,10 +117,13 @@ def read_v3_control( ) try: - configured_mode = _mode(getattr(rollout_config, 'mode', MemoryRolloutMode.off)) persisted = _rollout_state_from_data(uid=uid, data=payload) persisted_mode = _mode(persisted.mode) - effective_mode = resolve_v3_effective_mode(configured_mode, persisted_mode) + # The persisted mode records rollout history. It cannot downgrade a + # code-enrolled account to the legacy system; readiness remains guarded + # by the persisted stage gates and projection state below. + configured_mode = MemoryRolloutMode.read + effective_mode = MemoryRolloutMode.read capabilities = decide_memory_rollout_capabilities(uid, effective_mode, persisted) default_memory_grant, archive_allowed = _consumer_grants(payload, consumer) except (TypeError, ValueError, AttributeError): diff --git a/backend/utils/memory/v3/production_runtime.py b/backend/utils/memory/v3/production_runtime.py index 9f869d97b78..4641a2d9057 100644 --- a/backend/utils/memory/v3/production_runtime.py +++ b/backend/utils/memory/v3/production_runtime.py @@ -10,14 +10,6 @@ from datetime import datetime, timezone from typing import Any, Literal, Mapping, Protocol, TypeAlias, cast -from config.memory_rollout import ( - MemoryRolloutMode, - MemoryRolloutConfig, - parse_enabled_users, - rollout_enabled_users_env_raw, - rollout_mode_env_value, - rollout_v3_get_enabled_env_value, -) from database import memory_compatibility_projection as projection_db from utils.memory.v3.account_generation_source import read_memory_v3_trusted_account_generation from utils.memory.v3.account_generation_source import V3TrustedAccountGenerationResult @@ -49,6 +41,7 @@ parse_v3_cursor, ) from utils.memory.v3.projection_reader_contract import V3ProjectionCursor, V3ProjectionPage, V3ProjectionReadRequest +from utils.memory.memory_system import MemorySystem, resolve_memory_system V3GetSourceDecision: TypeAlias = Literal['disabled', 'legacy_primary', 'memory_read'] MemoryDbItem: TypeAlias = dict[str, Any] @@ -89,7 +82,6 @@ class V3GetRuntime: class _RuntimeConfig: uid: str db_client: object - rollout_config: MemoryRolloutConfig cursor_secret: bytes | None cursor_policy_version: str cursor_secret_version: str @@ -118,7 +110,6 @@ def decide_dependency(self, request: V3ComposedRequest, budget_ms: int) -> V3Com control = read_v3_control( uid=self.config.uid, db_client=self.config.db_client, - rollout_config=self.config.rollout_config, ) if not control.cohort_enrolled: self._last_control = control @@ -372,54 +363,24 @@ def _cursor_ttl_from_env(env: EnvMapping) -> int: return _DEFAULT_CURSOR_TTL_SECONDS -def _v3_get_route_enabled(env: EnvMapping) -> bool: - return rollout_v3_get_enabled_env_value(env) - - -def _runtime_enabled(rollout_config: MemoryRolloutConfig) -> bool: - return rollout_config.mode == MemoryRolloutMode.read and bool(rollout_config.enabled_users) - - -def _rollout_config_from_env(env: EnvMapping) -> MemoryRolloutConfig: - try: - mode = MemoryRolloutMode(rollout_mode_env_value(env)) - except (TypeError, ValueError): - return MemoryRolloutConfig() - return MemoryRolloutConfig( - enabled_users=parse_enabled_users(rollout_enabled_users_env_raw(env)), - mode=mode, - ) - - -def _source_decision_for_uid( - *, uid: str, db_client: object, rollout_config: MemoryRolloutConfig -) -> V3GetSourceDecision: - if not _runtime_enabled(rollout_config): - return 'disabled' - control = read_v3_control(uid=uid, db_client=db_client, rollout_config=rollout_config) - if not control.cohort_enrolled: +def _source_decision_for_uid(*, uid: str, db_client: object) -> V3GetSourceDecision: + if resolve_memory_system(uid, db_client=db_client) != MemorySystem.CANONICAL: return 'legacy_primary' - if control.state is not None and control.state.effective_mode != MemoryRolloutMode.read: + control = read_v3_control(uid=uid, db_client=db_client) + if not control.cohort_enrolled: return 'legacy_primary' return 'memory_read' def build_v3_production_runtime(*, uid: str, db_client: object, env: EnvMapping | None = None) -> V3GetRuntime: effective_env = env if env is not None else os.environ - if not _v3_get_route_enabled(effective_env): - return V3GetRuntime(enabled=False, source_decision='disabled') - rollout_config = _rollout_config_from_env(effective_env) - if not _runtime_enabled(rollout_config): - return V3GetRuntime(enabled=False, source_decision='disabled') - - source_decision = _source_decision_for_uid(uid=uid, db_client=db_client, rollout_config=rollout_config) + source_decision = _source_decision_for_uid(uid=uid, db_client=db_client) if source_decision == 'legacy_primary': - return V3GetRuntime(enabled=True, source_decision='legacy_primary') + return V3GetRuntime(enabled=False, source_decision='legacy_primary') config = _RuntimeConfig( uid=uid, db_client=db_client, - rollout_config=rollout_config, cursor_secret=_cursor_secret_from_env(effective_env), cursor_policy_version=effective_env.get('MEMORY_V3_CURSOR_POLICY_VERSION') or _DEFAULT_CURSOR_POLICY_VERSION, cursor_secret_version=effective_env.get('MEMORY_V3_CURSOR_SECRET_VERSION') or _DEFAULT_CURSOR_SECRET_VERSION, @@ -431,7 +392,7 @@ def build_v3_production_runtime(*, uid: str, db_client: object, env: EnvMapping source_decision='memory_read', service=compose_v3_get, adapters=adapters.as_composed_adapters(), - source_selector='server_side_rollout_config_and_control_state', + source_selector='canonical_memory_selector_and_control_state', control_reader=read_v3_control, projection_reader=_projection_reader, cursor_codec='v3_hmac_cursor', diff --git a/backend/utils/task_intelligence/chat_first_e2e_fixture.py b/backend/utils/task_intelligence/chat_first_e2e_fixture.py index 6343bab1341..f688dd22dcc 100644 --- a/backend/utils/task_intelligence/chat_first_e2e_fixture.py +++ b/backend/utils/task_intelligence/chat_first_e2e_fixture.py @@ -154,7 +154,6 @@ def _control_for_case(fixture_case: ChatFirstE2EFixtureCase) -> TaskWorkflowCont return TaskWorkflowControl( workflow_mode=TaskWorkflowMode.read, account_generation=1, - chat_first_ui_enabled=fixture_case is not ChatFirstE2EFixtureCase.ui_flag_off, ) diff --git a/backend/utils/task_intelligence/chat_first_eligibility.py b/backend/utils/task_intelligence/chat_first_eligibility.py index aed32dfb3c1..df66204967f 100644 --- a/backend/utils/task_intelligence/chat_first_eligibility.py +++ b/backend/utils/task_intelligence/chat_first_eligibility.py @@ -36,7 +36,7 @@ def resolve_chat_first_eligibility( workflow_mode=control.workflow_mode, account_generation=control.account_generation, ) - if not resolve_chat_first_ui(rollout, control.chat_first_ui_enabled): + if not resolve_chat_first_ui(rollout): return ChatFirstEligibility(enabled=False) return ChatFirstEligibility(enabled=True, account_generation=control.account_generation) except Exception: diff --git a/backend/utils/task_intelligence/conversation_capture.py b/backend/utils/task_intelligence/conversation_capture.py index ee71e65e6da..3b3747ba739 100644 --- a/backend/utils/task_intelligence/conversation_capture.py +++ b/backend/utils/task_intelligence/conversation_capture.py @@ -9,18 +9,16 @@ from typing import Any, Sequence import database.action_items as action_items_db -import database.candidates as candidates_db import database.task_intelligence_control as task_control_db from models.action_item import EvidenceKind, EvidenceRef, EvidenceScope, TaskCreatePayload, TaskOwner -from models.candidate import CandidateAction, CandidateCreate, CandidateStatus -from models.task_intelligence import TaskWorkflowMode +from models.candidate import CandidateAction from utils.task_intelligence import candidate_service from utils.task_intelligence.backend_capture import BackendCaptureSignals, adapt_backend_capture +from utils.memory.memory_system import MemorySystem, resolve_memory_system def capture_enabled(uid: str) -> bool: - control = task_control_db.get_task_workflow_control(uid) - return control.workflow_mode in {TaskWorkflowMode.shadow, TaskWorkflowMode.write, TaskWorkflowMode.read} + return resolve_memory_system(uid) == MemorySystem.CANONICAL def _concrete_deliverable(action_item: Any) -> bool: @@ -93,96 +91,41 @@ def canonical_fields(action_item: Any, conversation_id: str) -> dict[str, Any]: def process_before_legacy(uid: str, conversation_id: str, action_items: Sequence[Any]) -> bool: """Capture proposals before the legacy writer; return true when legacy is bypassed.""" control = task_control_db.get_task_workflow_control(uid) - if control.workflow_mode == TaskWorkflowMode.read: - for action_item, semantic_key, occurrence in _semantic_occurrences(action_items): - decision = _capture_decision(action_item, conversation_id) - if decision.candidate is None: - continue - candidate = candidate_service.create_candidate( - uid, - decision.candidate, - idempotency_key=_idempotency_key(conversation_id, semantic_key, occurrence), - account_generation=control.account_generation, - ) - if decision.policy.outcome in {'auto_accept_silent', 'create_direct'}: - candidate_service.accept_candidate( - uid, - candidate.candidate_id, - account_generation=control.account_generation, - ) - return True - if control.workflow_mode == TaskWorkflowMode.shadow: - for action_item in action_items: - _capture_decision(action_item, conversation_id) - return False - - -def reconcile_after_legacy( - uid: str, - conversation_id: str, - action_items: Sequence[Any], - task_ids: Sequence[str], -) -> None: - control = task_control_db.get_task_workflow_control(uid) - if control.workflow_mode != TaskWorkflowMode.write: - return - semantic_items = _semantic_occurrences(action_items) - for (action_item, semantic_key, occurrence), task_id in zip(semantic_items, task_ids): + if not capture_enabled(uid): + return False + for action_item, semantic_key, occurrence in _semantic_occurrences(action_items): decision = _capture_decision(action_item, conversation_id) if decision.candidate is None: continue - if decision.candidate.proposed_action != CandidateAction.create: - candidate_service.create_candidate( - uid, - decision.candidate, - idempotency_key=_idempotency_key( - conversation_id, - semantic_key, - occurrence, - purpose='judgment', - ), - account_generation=control.account_generation, - ) - projection = _legacy_create_projection(decision.candidate, action_item) candidate = candidate_service.create_candidate( uid, - projection, - idempotency_key=_idempotency_key( - conversation_id, - semantic_key, - occurrence, - purpose='legacy_projection', - ), + decision.candidate, + idempotency_key=_idempotency_key(conversation_id, semantic_key, occurrence), account_generation=control.account_generation, ) - if candidate.status == CandidateStatus.pending: - candidates_db.reconcile_migrated_candidate( + if decision.policy.outcome in {'auto_accept_silent', 'create_direct'}: + candidate_service.accept_candidate( uid, candidate.candidate_id, - status=CandidateStatus.accepted, account_generation=control.account_generation, - result_task_id=task_id, - reason='legacy_write_projection', ) + return True + + +def reconcile_after_legacy( + uid: str, + conversation_id: str, + action_items: Sequence[Any], + task_ids: Sequence[str], +) -> None: + # Enrolled users take the canonical path before the legacy writer. Legacy + # users keep the existing writer untouched, so no post-write sidecar exists. + return None def legacy_document_ids(uid: str, conversation_id: str, action_items: Sequence[Any]) -> list[str] | None: """Return order-independent write-mode IDs derived from each item's semantic content.""" - control = task_control_db.get_task_workflow_control(uid) - if control.workflow_mode != TaskWorkflowMode.write: - return None - task_ids: list[str] = [] - for _action_item, semantic_key, occurrence in _semantic_occurrences(action_items): - task_ids.append( - candidates_db.task_id_for_conversation_item( - uid, - control.account_generation, - conversation_id, - semantic_key, - occurrence, - ) - ) - return task_ids + return None def legacy_replacement_map( @@ -211,27 +154,6 @@ def legacy_replacement_map( return replacements -def _legacy_create_projection(candidate: CandidateCreate, action_item: Any) -> CandidateCreate: - return CandidateCreate.model_validate( - { - 'subject_kind': 'task', - 'proposed_action': 'create', - 'task_change': { - 'description': action_item.description, - 'owner': getattr(action_item, 'capture_owner', None) or TaskOwner.unknown, - 'due_at': action_item.due_at, - 'due_confidence': 1.0 if action_item.due_at else None, - }, - 'capture_confidence': candidate.capture_confidence, - 'ownership_confidence': candidate.ownership_confidence, - 'goal_id': candidate.goal_id, - 'workstream_id': candidate.workstream_id, - 'evidence_refs': candidate.evidence_refs, - 'source_surface': 'conversation_legacy_projection', - } - ) - - def _semantic_key(action_item: Any) -> str: due_at = getattr(action_item, 'due_at', None) due_value = due_at.isoformat() if isinstance(due_at, datetime) else '' diff --git a/backend/utils/task_intelligence/rollout.py b/backend/utils/task_intelligence/rollout.py index 19dcac5be00..9ef65bc921e 100644 --- a/backend/utils/task_intelligence/rollout.py +++ b/backend/utils/task_intelligence/rollout.py @@ -1,15 +1,12 @@ -"""Pure task-intelligence rollout decisions. +"""Pure task-intelligence entitlement decisions. -Workflow migration mode and canonical-memory cohort eligibility are deliberately -separate axes. The pure composer accepts both for hermetic tests; the production -resolver obtains cohort membership from the canonical memory owner. +Canonical-memory membership is the only eligibility input. Workflow controls +remain persisted generation fences and diagnostics, not rollout gates. """ # LIFECYCLE: permanent -from config.what_matters_now_smoke_fixture import is_development_smoke_fixture -from config.chat_first_e2e_fixture import is_chat_first_e2e_enabled_fixture -from models.task_intelligence import TaskIntelligenceRolloutDecision, TaskWorkflowMode +from models.task_intelligence import TaskIntelligenceRolloutDecision, TaskWorkflowControl, TaskWorkflowMode from utils.memory.memory_system import MemorySystem, resolve_memory_system @@ -26,35 +23,19 @@ def resolve_task_intelligence_rollout( if account_generation < 0: raise ValueError('account_generation must be nonnegative') - if mode == TaskWorkflowMode.off: - return TaskIntelligenceRolloutDecision( - uid=uid, - workflow_mode=mode, - memory_cohort_eligible=memory_cohort_eligible, - account_generation=account_generation, - legacy_reads_authoritative=True, - legacy_writes_enabled=True, - intelligence_evaluation_enabled=False, - canonical_sidecar_writes_enabled=False, - canonical_reads_authoritative=False, - compatibility_projection_required=False, - intelligence_product_enabled=False, - ) - - canonical_writes = mode in {TaskWorkflowMode.write, TaskWorkflowMode.read} - canonical_reads = mode == TaskWorkflowMode.read + canonical_entitled = memory_cohort_eligible return TaskIntelligenceRolloutDecision( uid=uid, workflow_mode=mode, memory_cohort_eligible=memory_cohort_eligible, account_generation=account_generation, - legacy_reads_authoritative=not canonical_reads, - legacy_writes_enabled=not canonical_reads, - intelligence_evaluation_enabled=memory_cohort_eligible, - canonical_sidecar_writes_enabled=canonical_writes, - canonical_reads_authoritative=canonical_reads, - compatibility_projection_required=canonical_reads, - intelligence_product_enabled=canonical_reads and memory_cohort_eligible, + legacy_reads_authoritative=not canonical_entitled, + legacy_writes_enabled=not canonical_entitled, + intelligence_evaluation_enabled=canonical_entitled, + canonical_sidecar_writes_enabled=canonical_entitled, + canonical_reads_authoritative=canonical_entitled, + compatibility_projection_required=canonical_entitled, + intelligence_product_enabled=canonical_entitled, ) @@ -67,11 +48,7 @@ def resolve_task_intelligence_for_user( ) -> TaskIntelligenceRolloutDecision: """Compose workflow mode with the authoritative canonical-memory selector.""" - memory_cohort_eligible = ( - is_development_smoke_fixture(uid) - or is_chat_first_e2e_enabled_fixture(uid) - or (resolve_memory_system(uid, db_client=db_client) == MemorySystem.CANONICAL) - ) + memory_cohort_eligible = resolve_memory_system(uid, db_client=db_client) == MemorySystem.CANONICAL return resolve_task_intelligence_rollout( uid=uid, workflow_mode=workflow_mode, @@ -80,14 +57,38 @@ def resolve_task_intelligence_for_user( ) -def resolve_chat_first_ui(rollout: TaskIntelligenceRolloutDecision, ui_flag_enabled: bool) -> bool: +def resolve_chat_first_ui(rollout: TaskIntelligenceRolloutDecision) -> bool: """Return the server-owned Chat-first capability for one resolved user. - The explicit UI flag is necessary but never sufficient: only the canonical - read-mode task-intelligence product cohort may receive the new shell. + The canonical-memory entitlement is already composed into the rollout. The + persisted UI flag and workflow mode are intentionally ignored: neither may + suppress an enrolled account. """ - return bool(rollout.intelligence_product_enabled and ui_flag_enabled) + return rollout.intelligence_product_enabled + + +def effective_task_workflow_control( + control: TaskWorkflowControl, + rollout: TaskIntelligenceRolloutDecision, +) -> TaskWorkflowControl: + """Project persisted control metadata onto the sole entitlement decision. + + ``account_generation`` remains the persisted concurrency fence. The + workflow value in an API projection is effective state only: code-enrolled + users are read-capable and every other user remains legacy/off. + """ + + return control.model_copy( + update={ + 'workflow_mode': TaskWorkflowMode.read if rollout.intelligence_product_enabled else TaskWorkflowMode.off, + } + ) -__all__ = ['resolve_chat_first_ui', 'resolve_task_intelligence_for_user', 'resolve_task_intelligence_rollout'] +__all__ = [ + 'effective_task_workflow_control', + 'resolve_chat_first_ui', + 'resolve_task_intelligence_for_user', + 'resolve_task_intelligence_rollout', +] diff --git a/backend/utils/task_intelligence/workstream_association.py b/backend/utils/task_intelligence/workstream_association.py index 05e83820f1c..537993bec2e 100644 --- a/backend/utils/task_intelligence/workstream_association.py +++ b/backend/utils/task_intelligence/workstream_association.py @@ -15,7 +15,6 @@ from models.action_item import TaskCreatePayload from models.candidate import CandidateCreate, WorkstreamCreateCandidate, WorkstreamProposal from models.memory_recurrence import CanonicalRecurrenceSignal -from models.task_intelligence import TaskWorkflowMode from models.workstream import ( Workstream, WorkstreamEventCreate, @@ -141,8 +140,6 @@ def finish(outcome: AssociationOutcome) -> AssociationOutcome: if resolve_memory_system(uid, db_client=firestore_client) != MemorySystem.CANONICAL: return finish(AssociationOutcome(outcome=AssociationOutcomeKind.not_canonical_cohort)) control = workstreams_db.get_task_workflow_control(uid, firestore_client=firestore_client) - if control.workflow_mode == TaskWorkflowMode.off: - return finish(AssociationOutcome(outcome=AssociationOutcomeKind.workflow_disabled)) target_generation = control.account_generation if account_generation is None else account_generation if target_generation != control.account_generation: @@ -224,17 +221,6 @@ def finish(outcome: AssociationOutcome) -> AssociationOutcome: judgment_reason=judgment.reason, ) ) - if control.workflow_mode == TaskWorkflowMode.shadow: - return finish( - AssociationOutcome( - outcome=AssociationOutcomeKind.would_append, - retrieved_candidate_ids=retrieved_ids, - hydrated_candidate_ids=hydrated_ids, - workstream_id=judgment.workstream_id, - judgment_reason=judgment.reason, - ) - ) - event = append_event( uid, judgment.workstream_id, @@ -282,11 +268,6 @@ def consume_recurrence_signal( control = workstreams_db.get_task_workflow_control(uid, firestore_client=firestore_client) if control.account_generation != account_generation: raise recurrence_inbox_db.RecurrenceGenerationMismatchError('account generation mismatch') - if control.workflow_mode == TaskWorkflowMode.off: - return RecurrenceConsumptionOutcome( - outcome=RecurrenceOutcomeKind.workflow_disabled, - signal_id=signal.signal_id, - ) if ( not signal.unresolved or signal.occurrence_count < RECURRENCE_MIN_OCCURRENCES @@ -299,13 +280,6 @@ def consume_recurrence_signal( ) idempotency_key = _recurrence_idempotency_key(signal) - if control.workflow_mode == TaskWorkflowMode.shadow: - return RecurrenceConsumptionOutcome( - outcome=RecurrenceOutcomeKind.would_create, - signal_id=signal.signal_id, - idempotency_key=idempotency_key, - ) - proposal = CandidateCreate( root=WorkstreamCreateCandidate( capture_confidence=signal.confidence, @@ -341,21 +315,10 @@ def persist_recurrence_signals_for_maintenance( enqueue: Callable[..., RecurrenceInboxReceipt] = recurrence_inbox_db.enqueue_recurrence_signal, ) -> int: """Durably hand off a consolidation batch before its memory watermark advances.""" + if resolve_memory_system(uid, db_client=firestore_client) != MemorySystem.CANONICAL: + return 0 control = workstreams_db.get_task_workflow_control(uid, firestore_client=firestore_client) signal_list = list(signals) - if control.workflow_mode == TaskWorkflowMode.shadow: - return sum( - consume_recurrence_signal( - uid, - signal, - account_generation=control.account_generation, - firestore_client=firestore_client, - ).outcome - == RecurrenceOutcomeKind.would_create - for signal in signal_list - ) - if control.workflow_mode == TaskWorkflowMode.off: - return 0 persisted = 0 for signal in signal_list: @@ -388,9 +351,9 @@ def drain_recurrence_inbox_for_maintenance( complete: Callable[..., None] = recurrence_inbox_db.complete_recurrence_receipt, retry: Callable[..., None] = recurrence_inbox_db.retry_recurrence_receipt, ) -> int: - control = workstreams_db.get_task_workflow_control(uid, firestore_client=firestore_client) - if control.workflow_mode in {TaskWorkflowMode.off, TaskWorkflowMode.shadow}: + if resolve_memory_system(uid, db_client=firestore_client) != MemorySystem.CANONICAL: return 0 + control = workstreams_db.get_task_workflow_control(uid, firestore_client=firestore_client) created = 0 receipts = list_pending( @@ -450,9 +413,6 @@ def consume_recurrence_signals_for_maintenance( firestore_client=firestore_client, enqueue=enqueue, ) - control = workstreams_db.get_task_workflow_control(uid, firestore_client=firestore_client) - if control.workflow_mode == TaskWorkflowMode.shadow: - return evaluated_or_persisted return drain_recurrence_inbox_for_maintenance( uid, firestore_client=firestore_client, diff --git a/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml b/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml index 429a3276a65..7ff195ae113 100644 --- a/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml +++ b/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml @@ -1,23 +1,19 @@ version: 2 name: chat-first-capability-isolation tier: manual -description: "Manual local/offline capability-off assertion. Run it once for each isolated fixture case in the three-launch matrix below." +description: "Manual local/offline capability-off assertion. Run it once for each isolated fixture case in the two-launch matrix below." app: non-prod # A harness invocation owns one automation port, hence one named app. It cannot # switch apps mid-run; prepare, launch, and run this *single-case* assertion # separately for every row below (each command pair has a distinct named bundle # and port): # -# make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=ui_flag_off -# OMI_AUTOMATION_PORT=47851 make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e-flag-off DESKTOP_USER=omi-chat-first-e2e-enabled -# python3 desktop/macos/scripts/omi-harness run desktop/macos/e2e/flows/chat-first-capability-isolation.yaml --lane bridge --port 47851 --bundle-id com.omi.omi-chat-first-e2e-flag-off -# # make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=out_of_cohort -# OMI_AUTOMATION_PORT=47852 make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e-out-of-cohort DESKTOP_USER=omi-chat-first-e2e-out-of-cohort +# OMI_AUTOMATION_PORT=47852 make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e-out-of-cohort DESKTOP_USER=omi-local-emulator-chat-first-disabled-v1 # python3 desktop/macos/scripts/omi-harness run desktop/macos/e2e/flows/chat-first-capability-isolation.yaml --lane bridge --port 47852 --bundle-id com.omi.omi-chat-first-e2e-out-of-cohort # # make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=unreachable_control -# OMI_AUTOMATION_PORT=47853 make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e-unreachable-control DESKTOP_USER=omi-chat-first-e2e-enabled +# OMI_AUTOMATION_PORT=47853 make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e-unreachable-control DESKTOP_USER=omi-local-emulator-chat-first-enabled-v1 # python3 desktop/macos/scripts/omi-harness run desktop/macos/e2e/flows/chat-first-capability-isolation.yaml --lane bridge --port 47853 --bundle-id com.omi.omi-chat-first-e2e-unreachable-control # # The fixture is server-owned and authenticates to Firebase Auth emulator diff --git a/desktop/macos/e2e/flows/chat-first-cohesive.yaml b/desktop/macos/e2e/flows/chat-first-cohesive.yaml index 93a90686532..50c823f998b 100644 --- a/desktop/macos/e2e/flows/chat-first-cohesive.yaml +++ b/desktop/macos/e2e/flows/chat-first-cohesive.yaml @@ -7,7 +7,7 @@ app: non-prod # PROVIDER_MODE=offline make dev-up # make seed-memory-scenario SCENARIO=happy_path # make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=enabled -# make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-chat-first-e2e-enabled +# make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-local-emulator-chat-first-enabled-v1 # Run with --lane ui --bundle-id com.omi.omi-chat-first-e2e for AX steps. covers: - desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift diff --git a/desktop/macos/e2e/flows/chat-first-cold-start.yaml b/desktop/macos/e2e/flows/chat-first-cold-start.yaml index 61f01a4c423..1ec155d014a 100644 --- a/desktop/macos/e2e/flows/chat-first-cold-start.yaml +++ b/desktop/macos/e2e/flows/chat-first-cold-start.yaml @@ -7,7 +7,7 @@ app: non-prod # PROVIDER_MODE=offline make dev-up # make seed-memory-scenario SCENARIO=happy_path # make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=cold_start -# make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-chat-first-e2e-enabled +# make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-local-emulator-chat-first-enabled-v1 # Run with --lane ui --bundle-id com.omi.omi-chat-first-e2e for AX steps. covers: # This flow reaches the focused facade's receipt listing/materialization and diff --git a/desktop/macos/e2e/flows/chat-first-question-deferral.yaml b/desktop/macos/e2e/flows/chat-first-question-deferral.yaml index 3e0aa5859a1..27d2784b3e9 100644 --- a/desktop/macos/e2e/flows/chat-first-question-deferral.yaml +++ b/desktop/macos/e2e/flows/chat-first-question-deferral.yaml @@ -7,7 +7,7 @@ app: non-prod # PROVIDER_MODE=offline make dev-up # make seed-memory-scenario SCENARIO=happy_path # make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=question -# make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-chat-first-e2e-enabled +# make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-local-emulator-chat-first-enabled-v1 # Run with --lane ui --bundle-id com.omi.omi-chat-first-e2e for AX steps. covers: # S4 and S8 use the enabled main-Chat bridge facade for the owned question diff --git a/desktop/macos/e2e/harness.md b/desktop/macos/e2e/harness.md index e067a3a5aa5..615b96bc1fb 100644 --- a/desktop/macos/e2e/harness.md +++ b/desktop/macos/e2e/harness.md @@ -160,7 +160,7 @@ options, or rollout state. PROVIDER_MODE=offline make dev-up make seed-memory-scenario SCENARIO=happy_path make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=enabled -make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-chat-first-e2e-enabled +make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-local-emulator-chat-first-enabled-v1 ``` Use `CHAT_FIRST_E2E_CASE=question` for the question-deferral flow. It begins @@ -172,9 +172,9 @@ checks the emulator-assigned UID against the harness manifest. Its read-back and clock advance responses contain bounded state and counts only; never add a desktop capability override or print fixture content from these flows. -`chat-first-capability-isolation.yaml` is a three-launch matrix: run the same -single-case flow once after preparing and launching each `ui_flag_off`, -`out_of_cohort`, and `unreachable_control` case in its own `omi-*` bundle and +`chat-first-capability-isolation.yaml` is a two-launch matrix: run the same +single-case flow once after preparing and launching each `out_of_cohort` and +`unreachable_control` case in its own `omi-*` bundle and automation port. The exact three command pairs live in the flow header. A harness run cannot switch named bundles midway through a flow, so do not treat one serial run as proof of all three accounts. Each run asserts the real diff --git a/docs/doc/developer/backend/canonical_memory_architecture.md b/docs/doc/developer/backend/canonical_memory_architecture.md index 9fba41acfd2..f8e12263021 100644 --- a/docs/doc/developer/backend/canonical_memory_architecture.md +++ b/docs/doc/developer/backend/canonical_memory_architecture.md @@ -59,11 +59,12 @@ Every read/write/maintenance path resolves the user's cohort first. | Concern | Behavior | Evidence | |---------|----------|----------| -| Whitelist | Only UIDs in `CANONICAL_MEMORY_USERS` get canonical routing | `backend/utils/memory/memory_system.py:14-17`, `resolve_memory_system` at `:36-57` | -| Default | Absent from whitelist → `MemorySystem.LEGACY` (explicit, not implicit) | `memory_system.py:54-57` | -| Kill-switch | Removing a UID from the code whitelist overrides any stale Firestore `memory_system=canonical` | Docstring at `memory_system.py:43-44` | +| Whitelist | Only UIDs in `CANONICAL_MEMORY_USERS` get canonical memory, task intelligence, and Chat-first | `backend/config/canonical_memory_cohort.py`, `resolve_memory_system` | +| Default | Absent from whitelist → `MemorySystem.LEGACY`, task intelligence off, Chat-first off | `canonical_memory_cohort.py` and `utils/task_intelligence/rollout.py` | +| Kill-switch | Removing a UID from the code whitelist overrides stale Firestore controls and hides canonical task routes | `canonical_memory_cohort.py` and `routers/canonical_task_access.py` | +| Operational controls | `MEMORY_MODE`, `MEMORY_ENABLED_USERS`, and `MEMORY_V3_GET_ENABLED` may govern maintenance readiness only; they never select or suppress a user's request path | `backend/.env.template` | | Request pin | HTTP/MCP handlers pin cohort once per request to avoid mid-request flips | `backend/utils/memory/memory_system_pin.py:17-40` | -| Routing seam | `MemoryService._resolve_backend` picks `CanonicalMemoryBackend` vs `LegacyMemoryBackend` | `backend/utils/memory/memory_service.py:390-394` | +| Routing seam | `MemoryService._resolve_backend` picks `CanonicalMemoryBackend` vs `LegacyMemoryBackend`; an enrolled-but-unready account fails closed rather than falling back | `backend/utils/memory/memory_service.py` | | Maintenance refusal | Consolidation/promotion return `skipped_reason="not_canonical_cohort"` for legacy users | `canonical_consolidation.py:784-785`, `short_term_promotion.py:361-362` | --- diff --git a/scripts/dev-harness/MEMORY_SCENARIOS.md b/scripts/dev-harness/MEMORY_SCENARIOS.md index 12f8f9664f7..edfd6a73084 100644 --- a/scripts/dev-harness/MEMORY_SCENARIOS.md +++ b/scripts/dev-harness/MEMORY_SCENARIOS.md @@ -39,7 +39,7 @@ the fixture using its logical principal, then launch the named bundle: PROVIDER_MODE=offline make dev-up make seed-memory-scenario SCENARIO=happy_path make chat-first-e2e-fixture CHAT_FIRST_E2E_ACTION=prepare CHAT_FIRST_E2E_CASE=enabled -make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-chat-first-e2e-enabled +make desktop-run-local DESKTOP_APP_NAME=omi-chat-first-e2e DESKTOP_USER=omi-local-emulator-chat-first-enabled-v1 ``` The same local-only command snapshots or advances an existing fixture while diff --git a/scripts/dev-harness/chat-first-e2e-fixture.sh b/scripts/dev-harness/chat-first-e2e-fixture.sh index 80b526b4e2e..f8d26029093 100644 --- a/scripts/dev-harness/chat-first-e2e-fixture.sh +++ b/scripts/dev-harness/chat-first-e2e-fixture.sh @@ -32,11 +32,11 @@ from dev_harness.cli import _current_scenario_manifest, _scenario_users_from_see action, fixture_case, raw_seconds = sys.argv[1:] valid_actions = {'prepare', 'snapshot', 'advance'} -valid_cases = {'enabled', 'question', 'ui_flag_off', 'out_of_cohort', 'unreachable_control', 'cold_start'} +valid_cases = {'enabled', 'question', 'out_of_cohort', 'unreachable_control', 'cold_start'} if action not in valid_actions or fixture_case not in valid_cases: raise SystemExit( 'usage: chat-first-e2e-fixture.sh ' - ' [seconds]' + ' [seconds]' ) if os.environ.get('OMI_ENV_STAGE') not in {'local', 'offline'}: raise SystemExit('Chat-first E2E fixture is local/offline only') @@ -54,7 +54,11 @@ seeded_users = _scenario_users_from_seed_manifest(cfg) if not scenario or not seeded_users: raise SystemExit('No live memory scenario is seeded; run make seed-memory-scenario SCENARIO=happy_path') -principal = 'omi-chat-first-e2e-out-of-cohort' if fixture_case == 'out_of_cohort' else 'omi-chat-first-e2e-enabled' +principal = ( + 'omi-local-emulator-chat-first-disabled-v1' + if fixture_case == 'out_of_cohort' + else 'omi-local-emulator-chat-first-enabled-v1' +) if principal not in seeded_users: raise SystemExit(f'Fixture principal {principal!r} is absent from the seeded scenario') diff --git a/scripts/dev-harness/dev_harness/memory_scenarios.py b/scripts/dev-harness/dev_harness/memory_scenarios.py index f736cae0957..0ff22fe54fa 100644 --- a/scripts/dev-harness/dev_harness/memory_scenarios.py +++ b/scripts/dev-harness/dev_harness/memory_scenarios.py @@ -32,8 +32,8 @@ DEFAULT_LOCAL_USER_ID = "local_default_user" ALICE_USER_ID = "alice" BOB_USER_ID = "bob" -CHAT_FIRST_E2E_ENABLED_USER_ID = "omi-chat-first-e2e-enabled" -CHAT_FIRST_E2E_OUT_OF_COHORT_USER_ID = "omi-chat-first-e2e-out-of-cohort" +CHAT_FIRST_E2E_ENABLED_USER_ID = "omi-local-emulator-chat-first-enabled-v1" +CHAT_FIRST_E2E_OUT_OF_COHORT_USER_ID = "omi-local-emulator-chat-first-disabled-v1" # Short-term seeds must stay visible across long local-dev sessions. SHORT_TERM_EXPIRES_AT = "2027-12-31T23:59:59Z" SYNTHETIC_SOURCE_VERSION = "memory-local-synthetic-source-1" @@ -1312,6 +1312,63 @@ def _apply_firestore_admin_sdk(cfg: config.HarnessConfig, op: SeedOperation) -> os.environ["FIRESTORE_EMULATOR_HOST"] = old_host +_AUTH_EMULATOR_APP_NAME = 'omi-local-dev-harness-auth-emulator' + + +def _apply_auth_admin_sdk(cfg: config.HarnessConfig, op: SeedOperation) -> bool: + """Seed fixed Auth-emulator UIDs through the Firebase Admin API. + + The client sign-up endpoint chooses a random ``localId``. The Admin API + accepts the operation target as ``uid``, which lets the enabled Chat-first + harness account be a member of the same static product whitelist used in + every other environment. + """ + + try: + import firebase_admin + from firebase_admin import auth as firebase_auth + except ImportError: + return False + + old_host = os.environ.get('FIREBASE_AUTH_EMULATOR_HOST') + os.environ['FIREBASE_AUTH_EMULATOR_HOST'] = cfg.auth_host + try: + try: + app = firebase_admin.get_app(_AUTH_EMULATOR_APP_NAME) + except ValueError: + app = firebase_admin.initialize_app( + options={'projectId': cfg.project_id}, + name=_AUTH_EMULATOR_APP_NAME, + ) + if op.action == 'upsert': + payload = dict(op.payload if isinstance(op.payload, Mapping) else {}) + try: + firebase_auth.get_user(op.target, app=app) + except firebase_auth.UserNotFoundError: + firebase_auth.create_user( + uid=op.target, + email=str(payload['email']), + password=str(payload['password']), + display_name=str(payload.get('displayName', '')), + email_verified=bool(payload.get('emailVerified', False)), + disabled=bool(payload.get('disabled', False)), + app=app, + ) + elif op.action == 'delete': + try: + firebase_auth.delete_user(op.target, app=app) + except firebase_auth.UserNotFoundError: + pass + else: + raise RuntimeError(f'Unsupported Auth scenario action: {op.action}') + return True + finally: + if old_host is None: + os.environ.pop('FIREBASE_AUTH_EMULATOR_HOST', None) + else: + os.environ['FIREBASE_AUTH_EMULATOR_HOST'] = old_host + + def _apply_operation(cfg: config.HarnessConfig, op: SeedOperation) -> None: if op.kind == "file": target = cfg.layout.state_root / "files" / op.target @@ -1348,6 +1405,10 @@ def _apply_operation(cfg: config.HarnessConfig, op: SeedOperation) -> None: raise RuntimeError(f"Firestore emulator delete failed for {op.target}: HTTP {status} {body[:200]}") return if op.kind == "auth": + if _apply_auth_admin_sdk(cfg, op): + return + if op.target in {CHAT_FIRST_E2E_ENABLED_USER_ID, CHAT_FIRST_E2E_OUT_OF_COHORT_USER_ID}: + raise RuntimeError('Chat-first E2E fixtures require Firebase Admin Auth to preserve their fixed UIDs') # Firebase Auth emulator supports account creation via identitytoolkit and # deletion via emulator admin endpoints. If an existing user causes a 400 # on upsert, the seed remains idempotent for local QA purposes. diff --git a/scripts/dev-harness/tests/test_memory_scenarios.py b/scripts/dev-harness/tests/test_memory_scenarios.py index b16fd4f87c9..631ef0a17ed 100644 --- a/scripts/dev-harness/tests/test_memory_scenarios.py +++ b/scripts/dev-harness/tests/test_memory_scenarios.py @@ -42,8 +42,8 @@ def test_all_memory_scenarios_import_and_validate() -> None: "local_default_user", "alice", "bob", - "omi-chat-first-e2e-enabled", - "omi-chat-first-e2e-out-of-cohort", + "omi-local-emulator-chat-first-enabled-v1", + "omi-local-emulator-chat-first-disabled-v1", } assert happy.selected_user == "alice" assert happy.report_metadata.evidence_class == "LOCAL_EMULATOR_DEV" @@ -110,7 +110,7 @@ def test_fixtures_cannot_choose_evidence_labels() -> None: assert scenario.local_flags["activation_eligible"] is False -def test_auth_live_seed_retries_without_local_id_on_emulator_sign_up( +def test_auth_live_seed_prefers_the_admin_api_for_deterministic_uids( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: env = _env(tmp_path) @@ -119,11 +119,15 @@ def test_auth_live_seed_retries_without_local_id_on_emulator_sign_up( def fake_request(method: str, url: str, payload: dict[str, object] | None = None) -> tuple[int, str]: calls.append((method, url, dict(payload) if payload is not None else None)) - if len(calls) == 1: - return 400, 'UNEXPECTED_PARAMETER : User ID' return 200, '{}' monkeypatch.setattr(memory_scenarios, '_request_json', fake_request) + admin_calls = [] + monkeypatch.setattr( + memory_scenarios, + '_apply_auth_admin_sdk', + lambda received_cfg, op: admin_calls.append((received_cfg, op)) or True, + ) op = memory_scenarios.SeedOperation( kind='auth', action='upsert', @@ -133,9 +137,8 @@ def fake_request(method: str, url: str, payload: dict[str, object] | None = None memory_scenarios._apply_operation(cfg, op) - assert len(calls) == 2 - assert calls[0][2] and calls[0][2].get('localId') == 'alice' - assert calls[1][2] and 'localId' not in calls[1][2] + assert calls == [] + assert admin_calls == [(cfg, op)] def test_happy_path_has_rich_default_memory_fixture_set() -> None: From 85550fbe47a1498d2ec3316ad1a8321a29b33c24 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Fri, 17 Jul 2026 02:59:09 -0400 Subject: [PATCH 19/28] fix(chat): repair chat-first integration build Failure-Class: none --- .../Sources/Chat/AgentBridge+ChatFirst.swift | 4 +- .../Desktop/Sources/Chat/AgentClient.swift | 4 +- ...AgentRuntimeProcess+ChatFirstJournal.swift | 12 +- .../Sources/Chat/AgentRuntimeProcess.swift | 4 +- .../Chat/ChatFirstBlockValidation.swift | 16 ++- ...ChatFirstPromptMaterializationDriver.swift | 8 +- .../FloatingControlBarWindow.swift | 5 +- .../Generated/GeneratedToolExecutors.swift | 4 +- .../Sources/Generated/OmiApi.generated.swift | 4 +- .../ChatFirst/CanonicalGoalsStore.swift | 2 +- .../ChatFirst/CaptureArchiveRepository.swift | 8 +- .../ChatFirst/CapturePlayback.swift | 18 +-- .../ChatFirst/ChatFirstTasksPage.swift | 25 ++-- .../ChatFirstBlockToolExecutor.swift | 12 +- .../Sources/Providers/ChatProvider.swift | 110 +++++++++++++++++- .../Rewind/Core/VideoChunkEncoder.swift | 4 +- 16 files changed, 184 insertions(+), 56 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Chat/AgentBridge+ChatFirst.swift b/desktop/macos/Desktop/Sources/Chat/AgentBridge+ChatFirst.swift index cc1739f0d5f..dd91994c7e7 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentBridge+ChatFirst.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentBridge+ChatFirst.swift @@ -56,7 +56,7 @@ extension AgentBridge { ownerID: String, sessionID: String, controlGeneration: Int, - intents: [ChatFirstPromptIntent], + intentsJSON: String, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil ) async throws -> AgentRuntimeProcess.ChatFirstIntentsMaterialization { let authorization = try resolveAuthorization( @@ -69,7 +69,7 @@ extension AgentBridge { ownerID: ownerID, sessionID: sessionID, controlGeneration: controlGeneration, - intents: intents, + intentsJSON: intentsJSON, authorizationSnapshot: authorization ) } diff --git a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift index 1aac60c27c6..90d847fd964 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift @@ -264,14 +264,14 @@ enum AgentClient { ownerID: String, sessionID: String, controlGeneration: Int, - intents: [ChatFirstPromptIntent] + intentsJSON: String ) async throws -> AgentRuntimeProcess.ChatFirstIntentsMaterialization { try await bridge.materializeChatFirstIntents( surface: surface, ownerID: ownerID, sessionID: sessionID, controlGeneration: controlGeneration, - intents: intents + intentsJSON: intentsJSON ) } diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess+ChatFirstJournal.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess+ChatFirstJournal.swift index defc25afec6..23a230ea4e7 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess+ChatFirstJournal.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess+ChatFirstJournal.swift @@ -89,10 +89,12 @@ extension AgentRuntimeProcess { attemptID: String, capabilityRef: String, controlGeneration: Int, - blocks: [[String: Any]], + blocksJSON: String, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot ) async throws -> KernelJournalTurn { - guard surface.surfaceKind == "main_chat", + guard let blocksData = blocksJSON.data(using: .utf8), + let blocks = try? JSONSerialization.jsonObject(with: blocksData) as? [[String: Any]], + surface.surfaceKind == "main_chat", controlGeneration >= 0, !blocks.isEmpty, blocks.count <= 8 @@ -185,10 +187,12 @@ extension AgentRuntimeProcess { ownerID: String, sessionID: String, controlGeneration: Int, - intents: [ChatFirstPromptIntent], + intentsJSON: String, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot ) async throws -> ChatFirstIntentsMaterialization { - guard surface.surfaceKind == "main_chat", + guard let intentsData = intentsJSON.data(using: .utf8), + let intents = try? JSONDecoder().decode([ChatFirstPromptIntent].self, from: intentsData), + surface.surfaceKind == "main_chat", controlGeneration >= 0, !intents.isEmpty, intents.count <= 8, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift index 392d4328f4e..9247dc1f280 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift @@ -3663,10 +3663,10 @@ actor AgentRuntimeProcess { materializationReceipts: Self.chatFirstMaterializationReceipts( from: message.payload["materializationReceipts"] ), - acknowledgedReceiptCount: message.payload["acknowledgedReceiptCount"] as? Int ?? 0, coldStartSequenceTerminalReceipts: Self.chatFirstColdStartSequenceTerminalReceipts( from: message.payload["coldStartSequenceTerminalReceipts"] - ) + ), + acknowledgedReceiptCount: message.payload["acknowledgedReceiptCount"] as? Int ?? 0 )) } diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift index 805ebd96cde..448f86b9c08 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift @@ -3,7 +3,9 @@ import Foundation /// Wire-only adapter for the server-authoritative structured-block validator. /// The server receives snake_case Pydantic contracts; the local journal keeps /// the established camelCase `ChatContentBlockCodec` contract. -struct ChatFirstBlockValidationRequest: Encodable { +/// The block collection originates from a freshly validated JSON payload and +/// is never retained or mutated after this request is constructed. +struct ChatFirstBlockValidationRequest: Encodable, @unchecked Sendable { let sourceSurface = "main_chat" let controlGeneration: Int let ownerFence: String @@ -21,7 +23,9 @@ struct ChatFirstBlockValidationRequest: Encodable { } } -struct ChatFirstBlockValidationReceipt: Decodable { +/// The receipt is immutable, decoded JSON that stays on the main actor until +/// it is encoded again for the kernel-owned journal operation. +struct ChatFirstBlockValidationReceipt: Decodable, @unchecked Sendable { let accepted: Bool let code: String let blocks: [OmiAnyCodable] @@ -81,7 +85,9 @@ enum ChatFirstBlockWire { guard let goalID = block["goalId"] as? String, let summary = block["summary"] as? String else { return nil } return ["type": type, "goal_id": goalID, "summary": summary] case "captureLink": - guard let conversationID = block["conversationId"] as? String, let summary = block["summary"] as? String else { return nil } + guard let conversationID = block["conversationId"] as? String, let summary = block["summary"] as? String else { + return nil + } var result: [String: Any] = ["type": type, "conversation_id": conversationID, "summary": summary] if let timestamp = block["momentTimestampMs"] as? Int { result["moment_timestamp_ms"] = timestamp } return result @@ -130,7 +136,9 @@ enum ChatFirstBlockWire { guard let goalID = block["goal_id"] as? String, let summary = block["summary"] as? String else { return nil } return ["type": type, "id": id, "goalId": goalID, "summary": summary] case "captureLink": - guard let conversationID = block["conversation_id"] as? String, let summary = block["summary"] as? String else { return nil } + guard let conversationID = block["conversation_id"] as? String, let summary = block["summary"] as? String else { + return nil + } var result: [String: Any] = ["type": type, "id": id, "conversationId": conversationID, "summary": summary] if let timestamp = block["moment_timestamp_ms"] as? Int { result["momentTimestampMs"] = timestamp } return result diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift index 712c693b05f..0852752beae 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift @@ -52,8 +52,10 @@ struct ChatFirstMaterializationContext: Equatable, Sendable { let controlGeneration: Int } -struct ChatFirstPromptIntent: Decodable { - enum Source: String, Decodable, Sendable { +/// Intent blocks are immutable decoded JSON. They are immediately encoded at +/// the runtime boundary rather than shared as mutable Foundation collections. +struct ChatFirstPromptIntent: Codable, @unchecked Sendable { + enum Source: String, Codable, Sendable { case dailyOpener = "daily_opener" case captureArrival = "capture_arrival" case deferralReraise = "deferral_reraise" @@ -84,7 +86,7 @@ struct ChatFirstPromptIntent: Decodable { } } -struct ChatFirstMaterializePromptsResponse: Decodable { +struct ChatFirstMaterializePromptsResponse: Decodable, @unchecked Sendable { let intents: [ChatFirstPromptIntent] } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift index 578df5e052a..ae3cce6cc00 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift @@ -2750,12 +2750,13 @@ class FloatingControlBarManager { ) } - static func performOwnerBoundNotificationAdmission( + @MainActor + static func performOwnerBoundNotificationAdmission( ownerID: String, currentOwnerID: @escaping @MainActor () -> String? = { RuntimeOwnerIdentity.currentOwnerId() }, - record: () async -> Value? + record: @MainActor () async -> Value? ) async -> Value? { guard !ownerID.isEmpty, currentOwnerID() == ownerID else { return nil } guard let value = await record() else { return nil } diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift index 4a0aeb8cbb1..d1e870bebf4 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift @@ -42,8 +42,8 @@ enum GeneratedSwiftToolExecutor: String { enum GeneratedToolExecutors { static let manifestVersion = 1 - static let manifestDigest = "sha256:bddf77bb08d251aade514f1406e51abd1f51d1753a26bfbb0cf5a5c4a2f05458" - static let chatFirstManifestDigest = "sha256:aeefd2baddfdd17a1afe0beff7fb3f43445220337a790448d0f26d32dba345d3" + static let manifestDigest = "sha256:eddc8fda648c0e53797d0892c9409780ffb32eeb4d839a6510d027f310917544" + static let chatFirstManifestDigest = "sha256:5582f20d76f2d70c7b7c72ab09c0175b4a7af8ac80ac1a5debb9c0a6a99360e4" static let aliasToCanonical: [String: GeneratedSwiftTool] = [ "search_screen_history": .semanticSearch, diff --git a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift index aa18cb114b6..c185159b731 100644 --- a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift +++ b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift @@ -6715,7 +6715,7 @@ public enum OmiAPI { public static func recordChatDeferralV1ChatDeferralsPost(client: OmiApiClient, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil, body: OmiAnyCodable) async throws -> OmiAnyCodable { let _path = "/v1/chat/deferrals" - guard var components = URLComponents(string: client.baseURL + _path) else { + guard let components = URLComponents(string: client.baseURL + _path) else { throw OmiApiError.invalidURL } guard let url = components.url else { throw OmiApiError.invalidURL } @@ -6741,7 +6741,7 @@ public enum OmiAPI { public static func materializePromptsV1ChatMaterializePromptsPost(client: OmiApiClient, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil, body: OmiAnyCodable) async throws -> OmiAnyCodable { let _path = "/v1/chat/materialize-prompts" - guard var components = URLComponents(string: client.baseURL + _path) else { + guard let components = URLComponents(string: client.baseURL + _path) else { throw OmiApiError.invalidURL } guard let url = components.url else { throw OmiApiError.invalidURL } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift index e9489657d1e..094987435d0 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CanonicalGoalsStore.swift @@ -4,7 +4,7 @@ import Foundation /// The narrow canonical-goal contract shared by the cohort-only Chat renderers /// and Goals destination. It intentionally has no relationship to the /// Dashboard recommendation/outbox state. -protocol CanonicalGoalsClient: AnyObject { +protocol CanonicalGoalsClient: AnyObject, Sendable { func getCanonicalGoals( includeEnded: Bool, expectedOwnerId: String?, diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift index b9e5eb39176..dd5f719aac6 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift @@ -27,19 +27,19 @@ private enum CaptureArchiveRepositoryError: Error { case receivedNonArchiveCapture } -private extension ServerConversation { - var isOmiCaptureArchiveRecord: Bool { +extension ServerConversation { + fileprivate var isOmiCaptureArchiveRecord: Bool { source == .omi && !discarded && (status == .completed || status == .processing) } } -protocol CaptureArchiveRemoteDataSource { +protocol CaptureArchiveRemoteDataSource: Sendable { func list(query: CaptureArchiveQuery) async throws -> [ServerConversation] func count(query: CaptureArchiveQuery) async throws -> Int func detail(id: String) async throws -> ServerConversation } -protocol CaptureArchiveLocalDataSource { +protocol CaptureArchiveLocalDataSource: Sendable { func list(query: CaptureArchiveQuery) async throws -> [ServerConversation] func count(query: CaptureArchiveQuery) async throws -> Int func detail(id: String) async throws -> ServerConversation? diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlayback.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlayback.swift index 0408a2e1a0c..43cb0c609c7 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlayback.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlayback.swift @@ -4,11 +4,11 @@ import Foundation /// Narrow, page-owned playback boundary for the capture archive. A ready /// aggregate artifact is the only state that promises exact moment seeking. -protocol CapturePlaybackProviding { +protocol CapturePlaybackProviding: Sendable { func resolvePlayback(for capture: ServerConversation) async -> CapturePlaybackResolution } -enum CapturePlaybackResolution: Equatable { +enum CapturePlaybackResolution: Equatable, Sendable { case readyAggregate(CapturePlaybackArtifact) case fileFallback(CapturePlaybackFile) case pending(pollAfterMs: Int?) @@ -28,13 +28,13 @@ enum CapturePlaybackResolution: Equatable { } } -struct CapturePlaybackFile: Equatable { +struct CapturePlaybackFile: Equatable, Sendable { let id: String let signedURL: URL let duration: TimeInterval } -struct CapturePlaybackArtifact: Equatable { +struct CapturePlaybackArtifact: Equatable, Sendable { let signedURL: URL let duration: TimeInterval let spans: [CaptureAudioURLSpan] @@ -43,10 +43,12 @@ struct CapturePlaybackArtifact: Equatable { /// artifact's media offset. It returns nil across a gap or missing span; /// callers must not seek a per-file fallback and claim accuracy. func artifactOffset(forWallOffset wallOffset: TimeInterval) -> TimeInterval? { - guard let span = spans.first(where: { - let end = $0.wallOffset + $0.length - return wallOffset >= $0.wallOffset && wallOffset < end - }) else { + guard + let span = spans.first(where: { + let end = $0.wallOffset + $0.length + return wallOffset >= $0.wallOffset && wallOffset < end + }) + else { return nil } return span.artifactOffset + (wallOffset - span.wallOffset) diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift index 1ceb14b9f37..634feb11515 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift @@ -1,6 +1,6 @@ import Foundation -import SwiftUI import OmiTheme +import SwiftUI /// The two deliberate scheduling groups in the cohort Tasks page. This folds /// the legacy page's Tomorrow, Later, and no-deadline buckets into a single @@ -230,7 +230,7 @@ struct ChatFirstTasksPage: View { .foregroundStyle(OmiColors.textSecondary) .accessibilityIdentifier("chat-first-tasks-discuss") - if let error = tasksStore.error, !visibleTasks.isEmpty { + if tasksStore.error != nil, !visibleTasks.isEmpty { HStack(spacing: OmiSpacing.sm) { Image(systemName: "exclamationmark.triangle") .accessibilityHidden(true) @@ -257,7 +257,7 @@ struct ChatFirstTasksPage: View { } .onAppear { scrollPendingFocusIntoView(proxy) } .onChange(of: navigation.pendingFocus) { _, _ in scrollPendingFocusIntoView(proxy) } - .onChange(of: visibleTasks.map(\.id)) { _ in scrollPendingFocusIntoView(proxy) } + .onChange(of: visibleTasks.map(\.id)) { _, _ in scrollPendingFocusIntoView(proxy) } } } @@ -383,10 +383,11 @@ struct ChatFirstTasksPage: View { } private func acknowledgeVisibleTaskIfNeeded(_ taskID: String) { - guard let focus = ChatFirstTaskPagePolicy.focusToAcknowledge( - pendingFocus: navigation.pendingFocus, - visibleTaskID: taskID - ), navigation.acknowledgeFocus(focus) + guard + let focus = ChatFirstTaskPagePolicy.focusToAcknowledge( + pendingFocus: navigation.pendingFocus, + visibleTaskID: taskID + ), navigation.acknowledgeFocus(focus) else { return } highlightedTaskID = taskID @@ -398,10 +399,12 @@ struct ChatFirstTasksPage: View { } private func acknowledgeVisibleGoalIfNeeded(_ goalID: String) { - guard let focus = ChatFirstTaskPagePolicy.goalFocusToAcknowledge( - pendingFocus: navigation.pendingFocus, - visibleGoalID: goalID - ) else { return } + guard + let focus = ChatFirstTaskPagePolicy.goalFocusToAcknowledge( + pendingFocus: navigation.pendingFocus, + visibleGoalID: goalID + ) + else { return } _ = navigation.acknowledgeFocus(focus) } diff --git a/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift b/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift index 0bc3a3d851b..7eae2a64a5f 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift @@ -49,12 +49,17 @@ enum ChatFirstBlockToolExecutor { expectedOwnerId: expectedOwnerID, authorizationSnapshot: authorizationSnapshot ) - guard ChatToolExecutor.isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { + guard ChatToolExecutor.isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) + else { return ChatToolExecutor.authorizedOwnerChangedResult() } guard let journalBlocks = ChatFirstBlockWire.journalBlocks(from: receipt) else { return #"{"ok":false,"error":{"code":"chat_first_blocks_rejected"}}"# } + let journalBlocksData = try JSONSerialization.data(withJSONObject: journalBlocks) + guard let journalBlocksJSON = String(data: journalBlocksData, encoding: .utf8) else { + return #"{"ok":false,"error":{"code":"chat_first_blocks_unavailable"}}"# + } _ = try await AgentRuntimeProcess.shared.appendChatFirstBlocks( clientId: "chat-first-render", surface: surface, @@ -64,12 +69,13 @@ enum ChatFirstBlockToolExecutor { attemptID: attemptID, capabilityRef: capabilityRef, controlGeneration: controlGeneration, - blocks: journalBlocks, + blocksJSON: journalBlocksJSON, authorizationSnapshot: authorizationSnapshot ) return #"{"ok":true,"rendered":#(journalBlocks.count)}"# } catch { - guard ChatToolExecutor.isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { + guard ChatToolExecutor.isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) + else { return ChatToolExecutor.authorizedOwnerChangedResult() } logError("Chat-first block rendering failed", error: error) diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index 948caef1d08..d7b547393a2 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -1,6 +1,6 @@ import Combine -import CryptoKit import CoreGraphics +import CryptoKit @preconcurrency import GRDB import OmiSupport import SwiftUI @@ -3889,12 +3889,16 @@ class ChatProvider: ObservableObject { intents.count <= 8, intents.allSatisfy({ $0.accountGeneration == session.capability.controlGeneration }) else { return nil } + let encodedIntents = try JSONEncoder().encode(intents) + guard let intentsJSON = String(data: encodedIntents, encoding: .utf8) else { + throw APIError.invalidResponse + } let result = try await resolvedAgentClient().materializeChatFirstIntents( surface: session.surface, ownerID: session.ownerID, sessionID: session.agentSession.sessionId, controlGeneration: session.capability.controlGeneration, - intents: intents + intentsJSON: intentsJSON ) if result.accepted { await kernelTurnProjection.refresh(surface: session.surface) @@ -3902,6 +3906,107 @@ class ChatProvider: ObservableObject { return result } + /// Local/offline E2E probe for the ordinary authorized block-rendering path. + /// It reserves canonical journal rows before invoking the fixture, so a + /// successful probe proves the real executor and projection paths. + func runChatFirstFixtureTaskCardProbe() async -> [String: String] { + let stage = ProcessInfo.processInfo.environment["OMI_ENV_STAGE"] + let isLocalOrOfflineStage = stage == "local" || stage == "offline" + guard AppBuild.allowsLocalAutomation, + isLocalOrOfflineStage, + !isSending, + let session = try? await chatFirstMaterializationSession() + else { + return [ + "executor_invoked": "false", + "validated": "false", + "journal_block_rendered": "false", + ] + } + + await kernelTurnProjection.refresh(surface: session.surface) + let fixtureTaskID = "chat-first-e2e-task-v1" + let beforeCount = messages.reduce(into: 0) { count, message in + count += message.contentBlocks.reduce(into: 0) { blockCount, block in + if case .taskCard(_, let taskID) = block, taskID == fixtureTaskID { + blockCount += 1 + } + } + } + let continuityKey = "chat-first-e2e-executor-\(UUID().uuidString.lowercased())" + let ids = Self.messageIds(forAttemptId: continuityKey) + let userMessage = ChatMessage( + id: ids.user, + clientTurnId: continuityKey, + text: "Render the Chat-first fixture task card.", + sender: .user, + turnOwner: .mainChat + ) + let assistantMessage = ChatMessage( + id: ids.assistant, + clientTurnId: continuityKey, + text: "", + sender: .ai, + isStreaming: true, + turnOwner: .mainChat, + hidesEmptyStreamingPlaceholder: true + ) + guard + await recordStreamingJournalExchange( + surface: session.surface, + ownerID: session.ownerID, + continuityKey: continuityKey, + userMessage: userMessage, + assistantMessage: assistantMessage, + origin: journalOrigin(for: session.surface), + appId: overrideAppId ?? selectedAppId, + sessionId: isInDefaultChat ? nil : currentSessionId, + messageSource: journalOrigin(for: session.surface) + ) + else { + return [ + "executor_invoked": "false", + "validated": "false", + "journal_block_rendered": "false", + ] + } + + do { + let receipt = try await resolvedAgentClient().invokeChatFirstFixtureTaskCard( + ownerID: session.ownerID, + sessionID: session.agentSession.sessionId, + producingTurnID: ids.assistant, + controlGeneration: session.capability.controlGeneration + ) + await kernelTurnProjection.refresh(surface: session.surface) + let afterCount = messages.reduce(into: 0) { count, message in + count += message.contentBlocks.reduce(into: 0) { blockCount, block in + if case .taskCard(_, let taskID) = block, taskID == fixtureTaskID { + blockCount += 1 + } + } + } + let rendered = receipt.journalBlockRendered && afterCount == beforeCount + 1 + return [ + "executor_invoked": receipt.executorInvoked ? "true" : "false", + "validated": receipt.validated ? "true" : "false", + "journal_block_rendered": rendered ? "true" : "false", + ] + } catch { + _ = await finishJournalUpdate( + messageId: ids.assistant, + status: .failed, + surface: session.surface, + ownerID: session.ownerID + ) + return [ + "executor_invoked": "false", + "validated": "false", + "journal_block_rendered": "false", + ] + } + } + private struct ChatFirstMaterializationSession { let ownerID: String let capability: ChatFirstCapabilityProjection @@ -4043,7 +4148,6 @@ class ChatProvider: ObservableObject { clearChatTelemetryState(for: sendGen) releaseSendLock(sendGeneration: sendGen) - return nil } guard let sid = currentSessionId else { diff --git a/desktop/macos/Desktop/Sources/Rewind/Core/VideoChunkEncoder.swift b/desktop/macos/Desktop/Sources/Rewind/Core/VideoChunkEncoder.swift index 466b38edd14..c5b486feb17 100644 --- a/desktop/macos/Desktop/Sources/Rewind/Core/VideoChunkEncoder.swift +++ b/desktop/macos/Desktop/Sources/Rewind/Core/VideoChunkEncoder.swift @@ -404,9 +404,7 @@ actor VideoChunkEncoder { try await waitForWriterInputReady(input) - let pixelBuffer: CVPixelBuffer = try autoreleasepool { - try createPixelBuffer(from: image, size: outputSize, adaptor: adaptor) - } + let pixelBuffer = try createPixelBuffer(from: image, size: outputSize, adaptor: adaptor) // frameOffsetInChunk is the index of the frame being written RIGHT NOW; it is // incremented in addFrame only AFTER this write succeeds (so a failed write does From 6bc6131279a97f219e51590ee96ba88853d10689 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Fri, 17 Jul 2026 03:42:01 -0400 Subject: [PATCH 20/28] fix(chat): enforce whitelist-derived chat-first projection Failure-Class: none --- ...duct_file_line_count_ratchet_baseline.json | 42 ++-- .../Chat/AuthorizedToolExecution.swift | 3 +- .../Sources/Chat/ChatContentBlockCodec.swift | 12 +- .../Chat/ChatFirstCapabilityProjection.swift | 1 - .../Chat/ChatFirstDeferralOutboxDriver.swift | 6 +- .../DesktopAutomationBridge+ChatFirst.swift | 14 +- .../Blocks/ChatFirstContentBlockViews.swift | 11 +- .../ChatFirst/CaptureArchivePage.swift | 38 ++-- .../ChatFirstAutomationRuntime.swift | 30 +-- .../ChatFirst/ChatFirstGoalsPage.swift | 16 +- ...irstPromptMaterializationCoordinator.swift | 15 +- .../MainWindow/ChatFirst/ChatFirstRoute.swift | 9 +- .../MainWindow/ChatFirst/ChatFirstShell.swift | 6 +- .../MainWindow/Components/ChatBubble.swift | 80 ++++---- .../Sources/MainWindow/DesktopHomeView.swift | 192 +++++++++--------- .../Rewind/Core/TranscriptionStorage.swift | 3 +- .../Desktop/Sources/Stores/TasksStore.swift | 20 +- .../Tests/AuthorizedToolExecutionTests.swift | 14 +- .../Tests/CanonicalGoalsStoreTests.swift | 47 +++-- .../Desktop/Tests/CaptureArchiveTests.swift | 73 ++++--- .../ChatFirstDeferralOutboxDriverTests.swift | 66 +++--- ...romptMaterializationCoordinatorTests.swift | 10 +- .../Tests/ChatFirstRichBlockTests.swift | 54 +++-- .../Desktop/Tests/ChatFirstShellTests.swift | 33 ++- .../Tests/ChatFirstTasksPageTests.swift | 9 +- .../agent-runtime-convergence-ratchet.json | 6 +- 26 files changed, 449 insertions(+), 361 deletions(-) diff --git a/.github/scripts/product_file_line_count_ratchet_baseline.json b/.github/scripts/product_file_line_count_ratchet_baseline.json index 84b4c2b5e64..62d242bc0f8 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline.json @@ -16,63 +16,63 @@ "backend/utils/sync/pipeline.py": 2296, "desktop/macos/Backend-Rust/src/routes/proxy.rs": 2741, "desktop/macos/Desktop/Sources/AuthService.swift": 3281, - "desktop/macos/Desktop/Sources/Chat/AgentBridge.swift": 2109, - "desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift": 4242, + "desktop/macos/Desktop/Sources/Chat/AgentBridge.swift": 2089, + "desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift": 4353, "desktop/macos/Desktop/Sources/CloudConnectorFormAutomation.swift": 1678, - "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": 4183, + "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": 4206, "desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift": 2479, - "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": 2808, - "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": 5006, + "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": 2811, + "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": 5007, "desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": 2423, "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": 1621, - "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": 1931, + "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": 2044, "desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift": 3643, - "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift": 4448, + "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift": 4482, "desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift": 3243, "desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift": 5490, "desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift": 1570, "desktop/macos/Desktop/Sources/MemoryExportService.swift": 1578, "desktop/macos/Desktop/Sources/OmiApp.swift": 1634, - "desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift": 2197, + "desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift": 2199, "desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift": 1654, "desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift": 1706, "desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift": 1608, - "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": 6215, - "desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift": 2840, + "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": 6701, + "desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift": 2865, "desktop/macos/Desktop/Sources/Rewind/Core/ActionItemStorage.swift": 1610, "desktop/macos/Desktop/Sources/Rewind/Core/RewindDatabase.swift": 3501, "desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift": 1907, - "desktop/macos/Desktop/Sources/Stores/TasksStore.swift": 2949, + "desktop/macos/Desktop/Sources/Stores/TasksStore.swift": 3104, "desktop/macos/Desktop/Sources/VoiceTurnDomain/VoiceTurnStateMachine.swift": 2155 }, "raise_justifications": { "desktop/macos/Desktop/Sources/AuthService.swift": "Pinned swift-format normalizes line layout without changing this source's runtime behavior.", "desktop/macos/Desktop/Sources/Chat/AgentBridge.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", - "desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", - "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", + "desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift": "Chat-first capability projection and journal integration keep the enrolled-user shell synchronized with the agent runtime.", + "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": "Automation bridge actions expose the Chat-first shell controls needed for deterministic desktop flow coverage.", "desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift": "Agent completion presentation now follows the canonical terminal lifecycle used by PTT and chat.", - "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", - "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", + "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": "The compact control bar forwards selected Chat-first interaction state without creating a second chat surface.", + "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": "Window routing preserves the main Chat-first surface while the compact control bar remains push-to-talk only.", "desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", - "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", + "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": "Chat bubbles render validated rich task, goal, and question blocks for enrolled canonical-memory users.", "desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift": "Rebase reconciliation applies the pinned swift-format to the main-branch app-detail safety fixes; runtime behavior is unchanged.", - "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift": "Pinned swift-format normalizes line layout without changing this source's runtime behavior.", + "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift": "The dashboard selects the Chat-first shell only from the canonical-memory cohort capability projection.", "desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift": "Pinned swift-format normalizes line layout without changing this source's runtime behavior.", "desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", "desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift": "Strict concurrency imports annotate legacy AppKit image usage without changing runtime behavior.", "desktop/macos/Desktop/Sources/MemoryExportService.swift": "Pinned swift-format normalizes line layout without changing this source's runtime behavior.", "desktop/macos/Desktop/Sources/OmiApp.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", - "desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift": "Pinned swift-format normalizes line layout without changing this source's runtime behavior.", + "desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift": "Onboarding maintains the same capability-safe chat presentation as the enrolled-user main window.", "desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift": "Pinned swift-format normalizes line layout without changing this source's runtime behavior.", "desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", "desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", - "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": "Adds carryingLocalOnlyFields projection guard so journal replay preserves local-only row state (metadata/rating/notificationScreenshot); INV-CHAT-1 write-path fix.", - "desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift": "Strict concurrency cleanup keeps non-Sendable JSON payloads behind checked boundaries without changing runtime behavior.", + "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": "Chat-first rich-block handling, answer submission, and task completion use the server capability projection and canonical task APIs.", + "desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift": "Tool execution adds validated canonical task and goal interactions for rendered Chat-first blocks.", "desktop/macos/Desktop/Sources/Rewind/Core/ActionItemStorage.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", "desktop/macos/Desktop/Sources/Rewind/Core/RewindDatabase.swift": "Pinned swift-format normalizes line layout without changing this source's runtime behavior.", "desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift": "Strict concurrency cleanup unwraps actor-safe screenshot image boxes at UI boundary without changing rendering behavior.", - "desktop/macos/Desktop/Sources/Stores/TasksStore.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", + "desktop/macos/Desktop/Sources/Stores/TasksStore.swift": "The task store carries canonical goal badges, completion state, and navigation for the enrolled-user Chat-first pages.", "desktop/macos/Desktop/Sources/VoiceTurnDomain/VoiceTurnStateMachine.swift": "The strict VoiceTurnDomain target centralizes reducer ownership and moves the lifecycle state machine intact." }, "threshold": 1500 diff --git a/desktop/macos/Desktop/Sources/Chat/AuthorizedToolExecution.swift b/desktop/macos/Desktop/Sources/Chat/AuthorizedToolExecution.swift index dc71a08e936..0b7346ad30a 100644 --- a/desktop/macos/Desktop/Sources/Chat/AuthorizedToolExecution.swift +++ b/desktop/macos/Desktop/Sources/Chat/AuthorizedToolExecution.swift @@ -122,7 +122,8 @@ struct AuthorizedToolExecution: @unchecked Sendable { throw Rejection.staleManifest } let manifestDigest = try requiredString("manifestDigest") - let expectedManifestDigest = resolvedTool == .renderChatBlocks + let expectedManifestDigest = + resolvedTool == .renderChatBlocks ? GeneratedToolExecutors.chatFirstManifestDigest : GeneratedToolExecutors.manifestDigest guard manifestDigest == expectedManifestDigest else { diff --git a/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift b/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift index 18d5ff81a4b..daa9a798915 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift @@ -103,8 +103,13 @@ enum ChatContentBlockCodec { guard let goalId = dict["goalId"] as? String, let summary = dict["summary"] as? String else { continue } blocks.append(.goalLink(id: id, goalId: goalId, summary: summary)) case "captureLink": - guard let conversationId = dict["conversationId"] as? String, let summary = dict["summary"] as? String else { continue } - blocks.append(.captureLink(id: id, conversationId: conversationId, momentTimestampMs: dict["momentTimestampMs"] as? Int, summary: summary)) + guard let conversationId = dict["conversationId"] as? String, let summary = dict["summary"] as? String else { + continue + } + blocks.append( + .captureLink( + id: id, conversationId: conversationId, momentTimestampMs: dict["momentTimestampMs"] as? Int, + summary: summary)) case "agentSpawn": guard let sessionId = dict["sessionId"] as? String, !sessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, @@ -220,7 +225,8 @@ enum ChatContentBlockCodec { "summary": summary, "fullText": fullText, ] - case .questionCard(let id, let questionId, let text, let subjectKind, let subjectId, let options, let selectedOptionId): + case .questionCard( + let id, let questionId, let text, let subjectKind, let subjectId, let options, let selectedOptionId): var dictionary: [String: Any] = [ "type": "questionCard", "id": id, "questionId": questionId, "text": text, "subject": ["kind": subjectKind, "id": subjectId], "options": options, diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift index 9da5f57b95a..44cedc78ef3 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstCapabilityProjection.swift @@ -9,7 +9,6 @@ struct ChatFirstCapabilityProjection: Equatable, Sendable { init?(control: OmiAPI.TaskWorkflowControl) { guard control.chatFirstUi == true, - control.workflowMode == .read, let generation = control.accountGeneration, generation >= 0 else { return nil } diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift index 618f4be0498..a3d2b90070a 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift @@ -84,8 +84,8 @@ struct ChatFirstDeferralDeliveryRequest: Sendable, Equatable { ) } guard options.count == optionsPayload.count, - Set(options.map(\.optionID)).count == options.count, - options.filter(\.isDeferred).count <= 1 + Set(options.map(\.optionID)).count == options.count, + options.filter(\.isDeferred).count <= 1 else { return nil } self.ownerID = ownerID @@ -154,7 +154,7 @@ extension AgentRuntimeProcess { if let authError = error as? AuthError, case .userChangedDuringRequest = authError { return "chat_first_deferral_owner_changed" } - if case let APIError.httpError(statusCode, _) = error { + if case APIError.httpError(let statusCode, _) = error { return [408, 425, 429].contains(statusCode) || (500...599).contains(statusCode) ? "chat_first_deferral_retryable" : "chat_first_deferral_4xx" diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift index 137b10aa970..2cb66ebfd14 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift @@ -13,13 +13,13 @@ extension DesktopAutomationBridge { while Date() < deadline { let snapshot = await liveAutomationSnapshot() if !snapshot.snapshotStale, - DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( - shellVariant: snapshot.shellVariant, - selectedTab: snapshot.selectedTab, - visibleChatFirstRoute: snapshot.visibleChatFirstRoute, - expectedChatFirstRoute: expectedChatFirstRoute, - expectedLegacyTitle: expectedLegacyTitle - ) + DesktopAutomationNavigationVisibilityPolicy.isTargetVisible( + shellVariant: snapshot.shellVariant, + selectedTab: snapshot.selectedTab, + visibleChatFirstRoute: snapshot.visibleChatFirstRoute, + expectedChatFirstRoute: expectedChatFirstRoute, + expectedLegacyTitle: expectedLegacyTitle + ) { return snapshot } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift index d0655f628de..376f9310b67 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift @@ -1,5 +1,5 @@ -import SwiftUI import OmiTheme +import SwiftUI // MARK: - Question card @@ -229,10 +229,11 @@ struct TaskCardView: View { // `TasksStore` owns local-first mutation and rollback. Acknowledgement // is derived only from its reconciled record, never from the tap. - guard ChatFirstTaskCardReconciliation.shouldShowCompletionAcknowledgement( - intendedCompletion: intendedCompletion, - reconciledTask: reconciledTask - ) + guard + ChatFirstTaskCardReconciliation.shouldShowCompletionAcknowledgement( + intendedCompletion: intendedCompletion, + reconciledTask: reconciledTask + ) else { return } OmiMotion.withGated(.spring(response: 0.26, dampingFraction: 0.72)) { showCompletionAcknowledgement = true diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift index a6edef289b4..b15114ac878 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift @@ -1,6 +1,6 @@ import Foundation -import SwiftUI import OmiTheme +import SwiftUI /// Cohort-only device-capture archive. This is intentionally a read-only /// archive surface: no chat history, composer, search, edit, or delete paths @@ -95,8 +95,12 @@ struct CaptureArchivePage: View { } } if repository.isLoadingMore { - HStack { Spacer(); ProgressView(); Spacer() } - .accessibilityLabel("Loading more Omi-device captures") + HStack { + Spacer() + ProgressView() + Spacer() + } + .accessibilityLabel("Loading more Omi-device captures") } } .listStyle(.plain) @@ -355,11 +359,13 @@ struct CaptureArchivePage: View { let resolution = await playback.prepare(for: detail) if let momentTimestamp { let didCompleteSeek = await playback.seekToMoment(wallOffset: momentTimestamp) - guard CaptureFocusAcknowledgementPolicy.canAcknowledge( - requestedMoment: momentTimestamp, - resolution: resolution, - didCompleteSeek: didCompleteSeek - ) else { return } + guard + CaptureFocusAcknowledgementPolicy.canAcknowledge( + requestedMoment: momentTimestamp, + resolution: resolution, + didCompleteSeek: didCompleteSeek + ) + else { return } } _ = navigation.acknowledgeFocus(.capture(id: id, momentTs: momentTimestamp)) } @@ -412,29 +418,29 @@ private struct CaptureArchiveRow: View { } } -private extension ServerConversation { - var archiveDisplayDate: Date { startedAt ?? createdAt } +extension ServerConversation { + fileprivate var archiveDisplayDate: Date { startedAt ?? createdAt } - var listMetadata: String { + fileprivate var listMetadata: String { "\(archiveDisplayDate.formatted(.relative(presentation: .named))) · \(formattedDuration)" } - var detailMetadata: String { + fileprivate var detailMetadata: String { let date = archiveDisplayDate.formatted(date: .abbreviated, time: .shortened) return "\(date) · \(formattedDuration)" } - var participantLabels: [String] { + fileprivate var participantLabels: [String] { Array(Set(transcriptSegments.compactMap(\.speaker))).sorted() } - var accessibilitySummary: String { + fileprivate var accessibilitySummary: String { "\(title), \(listMetadata), Omi-device capture" } } -private extension TranscriptSegment { - var shortTimestamp: String { +extension TranscriptSegment { + fileprivate var shortTimestamp: String { let totalSeconds = Int(start) return String(format: "%02d:%02d", totalSeconds / 60, totalSeconds % 60) } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift index af08e34f406..6e09a1ca6ac 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift @@ -194,9 +194,11 @@ final class ChatFirstAutomationRuntime: ObservableObject { guard let self, let selectQuestionOption = self.selectQuestionOption else { throw DesktopAutomationActionError.invalidParams("chat_first_main_chat_page_not_visible") } - guard let selection = QuestionSelection( - rawValue: params["selection"] ?? QuestionSelection.first.rawValue - ) else { + guard + let selection = QuestionSelection( + rawValue: params["selection"] ?? QuestionSelection.first.rawValue + ) + else { throw DesktopAutomationActionError.invalidParams("selection must be first or defer") } return ["question_selection_started": await selectQuestionOption(selection) ? "true" : "false"] @@ -312,7 +314,7 @@ final class ChatFirstAutomationRuntime: ObservableObject { private func actionableQuestionCard() -> Bool { guard selectQuestionOption != nil, let tail = chatProvider.messages.last else { return false } return tail.contentBlocks.contains { block in - guard case let .questionCard(_, questionID, _, _, _, _, selectedOptionID) = block else { return false } + guard case .questionCard(_, let questionID, _, _, _, _, let selectedOptionID) = block else { return false } return chatProvider.isQuestionCardActionable( messageID: tail.id, questionID: questionID, @@ -363,16 +365,16 @@ final class ChatFirstAutomationRuntime: ObservableObject { private func selectableQuestionOption(for selection: QuestionSelection) -> (questionID: String, optionID: String)? { guard let tail = chatProvider.messages.last else { return nil } for block in tail.contentBlocks { - guard case let .questionCard(_, questionID, _, _, _, options, selectedOptionID) = block, - chatProvider.isQuestionCardActionable( - messageID: tail.id, - questionID: questionID, - selectedOptionID: selectedOptionID - ), - let optionID = ChatFirstQuestionAutomationSelectionPolicy.optionID( - in: options, - selection: selection - ) + guard case .questionCard(_, let questionID, _, _, _, let options, let selectedOptionID) = block, + chatProvider.isQuestionCardActionable( + messageID: tail.id, + questionID: questionID, + selectedOptionID: selectedOptionID + ), + let optionID = ChatFirstQuestionAutomationSelectionPolicy.optionID( + in: options, + selection: selection + ) else { continue } return (questionID, optionID) } diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift index 2d3370a5fc1..579d27e6d09 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift @@ -1,6 +1,6 @@ import Foundation -import SwiftUI import OmiTheme +import SwiftUI /// Cohort-only canonical Goals destination. The page owns no copied goal or /// task state: it renders the shared projection and delegates task mutation to @@ -177,10 +177,12 @@ struct ChatFirstGoalsPage: View { } private func acknowledgeVisibleGoal(_ goalID: String) { - guard let focus = ChatFirstGoalDetailPolicy.focusToAcknowledge( - pendingFocus: navigation.pendingFocus, - visibleGoalID: goalID - ) else { return } + guard + let focus = ChatFirstGoalDetailPolicy.focusToAcknowledge( + pendingFocus: navigation.pendingFocus, + visibleGoalID: goalID + ) + else { return } _ = navigation.acknowledgeFocus(focus) } @@ -336,7 +338,9 @@ private struct ChatFirstGoalTaskRow: View { } .buttonStyle(.plain) .disabled(isResolving) - .accessibilityLabel(task.completed ? "Mark \(task.description_) incomplete" : "Mark \(task.description_) complete") + .accessibilityLabel( + task.completed ? "Mark \(task.description_) incomplete" : "Mark \(task.description_) complete" + ) .accessibilityIdentifier("chat-first-goal-task-\(task.id)-toggle") Text(task.description_) diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift index 1e996408d7a..73b5f1ede4e 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift @@ -72,13 +72,14 @@ final class ChatFirstPromptMaterializationCoordinator: ObservableObject { @discardableResult private func requestMaterialization(windowForeground: Bool) -> Bool { - guard ChatFirstPromptMaterializationPolicy.shouldStart( - hasChatFirstMainChatContext: driver?.materializationContext() != nil, - transcriptFirstPageLoaded: didLoadTranscriptFirstPage, - isRunning: requestTask != nil, - lastAttemptAt: lastAttemptAt, - now: now() - ), let driver + guard + ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: driver?.materializationContext() != nil, + transcriptFirstPageLoaded: didLoadTranscriptFirstPage, + isRunning: requestTask != nil, + lastAttemptAt: lastAttemptAt, + now: now() + ), let driver else { return false } lastAttemptAt = now() diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift index 59bcc8ab347..fc31c8811aa 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift @@ -1,5 +1,5 @@ -import Foundation import Combine +import Foundation /// Chat-first navigation deliberately does not reuse the legacy sidebar's raw /// integer values. The legacy adapter below is the only compatibility boundary @@ -220,9 +220,10 @@ final class ChatFirstShellNavigation: ObservableObject { analytics: (@MainActor (ChatFirstAnalyticsEvent) -> Void)? = nil ) { self.defaults = defaults - self.analytics = analytics ?? { event in - AnalyticsManager.shared.chatFirst(event) - } + self.analytics = + analytics ?? { event in + AnalyticsManager.shared.chatFirst(event) + } if let data = defaults.data(forKey: Self.storageKey), let persisted = try? JSONDecoder().decode(ChatFirstPersistedNavigation.self, from: data) { diff --git a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift index 94ab6c7c38b..c2dba964f73 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift @@ -1,6 +1,6 @@ import AppKit -import SwiftUI import OmiTheme +import SwiftUI /// Cohort-only main-window shell. It shares the existing data owners with the /// legacy shell but owns no second chat state, task state, or navigation index. @@ -114,8 +114,8 @@ struct ChatFirstShell: View { chatProvider: viewModelContainer.chatProvider, automationRuntime: automationRuntime ) - .accessibilityIdentifier("chat-first-route-conversations") - .onAppear { navigation.markRouteVisible(.conversations) } + .accessibilityIdentifier("chat-first-route-conversations") + .onAppear { navigation.markRouteVisible(.conversations) } case .tasks: ChatFirstTasksPage( navigation: navigation, diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index 58ea7dfd4d2..846fcb9e390 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -103,50 +103,50 @@ struct ChatBubble: View { ) HStack(alignment: .top, spacing: OmiSpacing.md) { - if message.sender == .ai { - // App avatar - if let app = app { - AsyncImage(url: URL(string: app.image)) { phase in - switch phase { - case .success(let image): - image + if message.sender == .ai { + // App avatar + if let app = app { + AsyncImage(url: URL(string: app.image)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + default: + Circle() + .fill(OmiColors.backgroundTertiary) + } + } + .frame(width: 32, height: 32) + .clipShape(Circle()) + } else { + if let logoURL = Bundle.resourceBundle.url(forResource: "herologo", withExtension: "png"), + let logoImage = NSImage(contentsOf: logoURL) + { + Image(nsImage: logoImage) .resizable() - .aspectRatio(contentMode: .fill) - default: - Circle() - .fill(OmiColors.backgroundTertiary) + .scaledToFit() + .frame(width: 20, height: 20) + .frame(width: 32, height: 32) + .background(OmiColors.backgroundTertiary) + .clipShape(Circle()) } } - .frame(width: 32, height: 32) - .clipShape(Circle()) - } else { - if let logoURL = Bundle.resourceBundle.url(forResource: "herologo", withExtension: "png"), - let logoImage = NSImage(contentsOf: logoURL) - { - Image(nsImage: logoImage) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - .frame(width: 32, height: 32) - .background(OmiColors.backgroundTertiary) - .clipShape(Circle()) - } } - } - VStack(alignment: message.sender == .user ? .trailing : .leading, spacing: OmiSpacing.xxs) { - messageContentView(groupedBlocks) - } + VStack(alignment: message.sender == .user ? .trailing : .leading, spacing: OmiSpacing.xxs) { + messageContentView(groupedBlocks) + } - if message.sender == .user { - // User avatar - Image(systemName: "person.fill") - .scaledFont(size: OmiType.body) - .foregroundColor(OmiColors.textSecondary) - .frame(width: 32, height: 32) - .background(OmiColors.backgroundTertiary) - .clipShape(Circle()) - } + if message.sender == .user { + // User avatar + Image(systemName: "person.fill") + .scaledFont(size: OmiType.body) + .foregroundColor(OmiColors.textSecondary) + .frame(width: 32, height: 32) + .background(OmiColors.backgroundTertiary) + .clipShape(Circle()) + } } .frame(maxWidth: .infinity, alignment: message.sender == .user ? .trailing : .leading) .contentShape(Rectangle()) @@ -1000,7 +1000,9 @@ enum ContentBlockGroup: Identifiable { case .questionCard(let id, let questionID, let text, _, _, let options, let selectedOptionID): flushToolCalls() guard richBlockRenderingEnabled else { continue } - groups.append(.questionCard(id: id, questionID: questionID, text: text, options: options, selectedOptionID: selectedOptionID)) + groups.append( + .questionCard( + id: id, questionID: questionID, text: text, options: options, selectedOptionID: selectedOptionID)) case .taskCard(let id, let taskID): flushToolCalls() guard richBlockRenderingEnabled else { continue } diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 69d78c07228..77180d77b11 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -951,7 +951,8 @@ struct DesktopHomeView: View { expectedOwnerId: ownerID, authorizationSnapshot: authorization ) - let current = RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) + let current = + RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) && RuntimeOwnerIdentity.currentOwnerId() == ownerID chatFirstCapabilitySample.resolve( control: control, @@ -959,7 +960,8 @@ struct DesktopHomeView: View { ownerIsStillCurrent: current ) } catch { - let current = RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) + let current = + RuntimeOwnerIdentity.isAuthorizationCurrent(authorization) && RuntimeOwnerIdentity.currentOwnerId() == ownerID chatFirstCapabilitySample.resolve( control: nil, @@ -1035,116 +1037,116 @@ struct DesktopHomeView: View { /// the existing modifier order; they are only compile-time seams. private func mainContentWithOverlays(_ content: Content) -> some View { content - .overlay { - // Goal completion celebration overlay - GoalCelebrationView() - } - .overlay { - if showTryAskingPopup { - let suggestions = PostOnboardingPromptSuggestions.suggestions() - if !suggestions.isEmpty { - TryAskingPopupView( - suggestions: suggestions, - onAsk: { suggestion in - showTryAskingPopup = false - PostOnboardingPromptSuggestions.shouldShowPopup = false - FloatingControlBarManager.shared.openAIInputWithQuery(suggestion) - }, - onDismiss: { - showTryAskingPopup = false - PostOnboardingPromptSuggestions.shouldShowPopup = false - PostOnboardingPromptSuggestions.isDismissed = true - } - ) + .overlay { + // Goal completion celebration overlay + GoalCelebrationView() + } + .overlay { + if showTryAskingPopup { + let suggestions = PostOnboardingPromptSuggestions.suggestions() + if !suggestions.isEmpty { + TryAskingPopupView( + suggestions: suggestions, + onAsk: { suggestion in + showTryAskingPopup = false + PostOnboardingPromptSuggestions.shouldShowPopup = false + FloatingControlBarManager.shared.openAIInputWithQuery(suggestion) + }, + onDismiss: { + showTryAskingPopup = false + PostOnboardingPromptSuggestions.shouldShowPopup = false + PostOnboardingPromptSuggestions.isDismissed = true + } + ) + } } } - } } private func mainContentWithNotifications(_ content: Content) -> some View { content - .onReceive(NotificationCenter.default.publisher(for: .showTryAskingPopup)) { _ in - showTryAskingPopup = true - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToRewindSettings)) { _ in - selectedSettingsSection = .rewind - navigateToLegacyDestination(.settings) - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToDeviceSettings)) { _ in - if let url = URL(string: "https://www.omi.me") { - NSWorkspace.shared.open(url) + .onReceive(NotificationCenter.default.publisher(for: .showTryAskingPopup)) { _ in + showTryAskingPopup = true } - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToTaskSettings)) { _ in - selectedSettingsSection = .advanced - navigateToLegacyDestination(.settings) - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToFloatingBarSettings)) { _ in - selectedSettingsSection = .floatingBar - navigateToLegacyDestination(.settings) - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToAIChatSettings)) { _ in - selectedSettingsSection = .advanced - navigateToLegacyDestination(.settings) - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToRewind)) { _ in - log("DesktopHomeView: Received navigateToRewind notification") - navigateToLegacyDestination(.rewind) - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToRewindNotes)) { _ in - navigateToLegacyDestination(.rewind) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - NotificationCenter.default.post(name: .expandRewindTranscript, object: nil) + .onReceive(NotificationCenter.default.publisher(for: .navigateToRewindSettings)) { _ in + selectedSettingsSection = .rewind + navigateToLegacyDestination(.settings) } - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToChat)) { _ in - if usesChatFirstShell { - chatFirstNavigation.selectPrimary(.chat) - } else { - // Legacy Home owns the historic Chat notification contract. - selectedIndex = SidebarNavItem.dashboard.rawValue + .onReceive(NotificationCenter.default.publisher(for: .navigateToDeviceSettings)) { _ in + if let url = URL(string: "https://www.omi.me") { + NSWorkspace.shared.open(url) + } } - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToTasks)) { _ in - navigateToLegacyDestination(.tasks) - } - .onReceive(NotificationCenter.default.publisher(for: .navigateToSidebarItem)) { notification in - if let rawValue = notification.userInfo?["rawValue"] as? Int, - let item = SidebarNavItem(rawValue: rawValue) - { - navigateToLegacyDestination(item) + .onReceive(NotificationCenter.default.publisher(for: .navigateToTaskSettings)) { _ in + selectedSettingsSection = .advanced + navigateToLegacyDestination(.settings) + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToFloatingBarSettings)) { _ in + selectedSettingsSection = .floatingBar + navigateToLegacyDestination(.settings) + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToAIChatSettings)) { _ in + selectedSettingsSection = .advanced + navigateToLegacyDestination(.settings) + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToRewind)) { _ in + log("DesktopHomeView: Received navigateToRewind notification") + navigateToLegacyDestination(.rewind) + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToRewindNotes)) { _ in + navigateToLegacyDestination(.rewind) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: .expandRewindTranscript, object: nil) + } + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToChat)) { _ in + if usesChatFirstShell { + chatFirstNavigation.selectPrimary(.chat) + } else { + // Legacy Home owns the historic Chat notification contract. + selectedIndex = SidebarNavItem.dashboard.rawValue + } + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToTasks)) { _ in + navigateToLegacyDestination(.tasks) + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToSidebarItem)) { notification in + if let rawValue = notification.userInfo?["rawValue"] as? Int, + let item = SidebarNavItem(rawValue: rawValue) + { + navigateToLegacyDestination(item) + } } - } } private func mainContentWithLifecycle(_ content: Content) -> some View { content - .onChange(of: selectedIndex) { oldValue, newValue in - if newValue == SidebarNavItem.settings.rawValue - && oldValue != SidebarNavItem.settings.rawValue - { - previousIndexBeforeSettings = oldValue + .onChange(of: selectedIndex) { oldValue, newValue in + if newValue == SidebarNavItem.settings.rawValue + && oldValue != SidebarNavItem.settings.rawValue + { + previousIndexBeforeSettings = oldValue + } + updateStoreActivity(for: newValue) } - updateStoreActivity(for: newValue) - } - .onChange(of: chatFirstNavigation.route) { _, _ in - updateStoreActivityForCurrentShell() - reportAutomationState() - } - .onChange(of: chatFirstNavigation.visibleRoute) { _, _ in reportAutomationState() } - .onChange(of: chatFirstNavigation.isSidebarCollapsed) { _, _ in reportAutomationState() } - .onChange(of: useLegacyHomeDesign) { _, newValue in - OmiMotion.withGated(.easeInOut(duration: 0.2)) { - isSidebarCollapsed = !newValue + .onChange(of: chatFirstNavigation.route) { _, _ in + updateStoreActivityForCurrentShell() + reportAutomationState() } - } - .onAppear { - if case .legacy = chatFirstCapabilitySample.variant { - isSidebarCollapsed = !useLegacyHomeDesign + .onChange(of: chatFirstNavigation.visibleRoute) { _, _ in reportAutomationState() } + .onChange(of: chatFirstNavigation.isSidebarCollapsed) { _, _ in reportAutomationState() } + .onChange(of: useLegacyHomeDesign) { _, newValue in + OmiMotion.withGated(.easeInOut(duration: 0.2)) { + isSidebarCollapsed = !newValue + } + } + .onAppear { + if case .legacy = chatFirstCapabilitySample.variant { + isSidebarCollapsed = !useLegacyHomeDesign + } + updateStoreActivityForCurrentShell() + restorePreChatWindowWidth() } - updateStoreActivityForCurrentShell() - restorePreChatWindowWidth() - } } /// Keep the legacy HStack out of the chat-first branch's SwiftUI generic diff --git a/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift b/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift index 71052d10efc..0ecb622a3d4 100644 --- a/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift +++ b/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift @@ -1075,7 +1075,8 @@ actor TranscriptionStorage { let db = try await ensureInitialized() return try await db.read { database in - let sessions = try TranscriptionSessionRecord + let sessions = + try TranscriptionSessionRecord .filter(Column("backendSynced") == true) .filter(Column("deleted") == false) .filter(Column("discarded") == false) diff --git a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift index d5112256715..e590839d10b 100644 --- a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift @@ -2804,10 +2804,12 @@ class TasksStore: ObservableObject { beforeLocalMutation: (() async -> Void)? = nil, operationOverrides: TaskUpdateOperationOverrides? = nil ) async -> TaskUpdateOutcome { - guard let lease = captureOwnerLease( - expectedOwnerID: expectedOwnerID, - authorizationSnapshot: authorizationSnapshot - ) else { return .ownerChanged } + guard + let lease = captureOwnerLease( + expectedOwnerID: expectedOwnerID, + authorizationSnapshot: authorizationSnapshot + ) + else { return .ownerChanged } if let beforeLocalMutation { await beforeLocalMutation() } guard isCurrent(lease) else { return .ownerChanged } @@ -2904,10 +2906,12 @@ class TasksStore: ObservableObject { authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil, rollbackStorage: (() async throws -> Void)? = nil ) async -> Bool { - guard let lease = captureOwnerLease( - expectedOwnerID: expectedOwnerID, - authorizationSnapshot: authorizationSnapshot - ) else { return false } + guard + let lease = captureOwnerLease( + expectedOwnerID: expectedOwnerID, + authorizationSnapshot: authorizationSnapshot + ) + else { return false } do { if let rollbackStorage { try await rollbackStorage() diff --git a/desktop/macos/Desktop/Tests/AuthorizedToolExecutionTests.swift b/desktop/macos/Desktop/Tests/AuthorizedToolExecutionTests.swift index 3c856e7438e..88e46c9cab5 100644 --- a/desktop/macos/Desktop/Tests/AuthorizedToolExecutionTests.swift +++ b/desktop/macos/Desktop/Tests/AuthorizedToolExecutionTests.swift @@ -45,15 +45,17 @@ final class AuthorizedToolExecutionTests: XCTestCase { "manifestDigest": GeneratedToolExecutors.chatFirstManifestDigest, "surfaceKind": "main_chat", ]), - currentOwnerID: "owner-1")) { error in - XCTAssertEqual(error as? AuthorizedToolExecution.Rejection, .invalidChatFirstCapability) - } + currentOwnerID: "owner-1") + ) { error in + XCTAssertEqual(error as? AuthorizedToolExecution.Rejection, .invalidChatFirstCapability) + } XCTAssertThrowsError( try AuthorizedToolExecution.parse( payload(overrides: ["chatFirstControlGeneration": 7]), - currentOwnerID: "owner-1")) { error in - XCTAssertEqual(error as? AuthorizedToolExecution.Rejection, .invalidChatFirstCapability) - } + currentOwnerID: "owner-1") + ) { error in + XCTAssertEqual(error as? AuthorizedToolExecution.Rejection, .invalidChatFirstCapability) + } } func testWrongOwnerAndManifestFailClosed() { diff --git a/desktop/macos/Desktop/Tests/CanonicalGoalsStoreTests.swift b/desktop/macos/Desktop/Tests/CanonicalGoalsStoreTests.swift index 9ad4d216f94..ffd3cc611cf 100644 --- a/desktop/macos/Desktop/Tests/CanonicalGoalsStoreTests.swift +++ b/desktop/macos/Desktop/Tests/CanonicalGoalsStoreTests.swift @@ -4,7 +4,7 @@ import XCTest @MainActor final class CanonicalGoalsStoreTests: XCTestCase { - func testLoadProjectsFocusedGoalAheadOfOtherActiveGoals() async { + func testLoadProjectsFocusedGoalAheadOfOtherActiveGoals() async throws { let api = FakeCanonicalGoalsClient() api.goals = [ goal(id: "background", status: .background, rank: nil), @@ -13,7 +13,7 @@ final class CanonicalGoalsStoreTests: XCTestCase { let owner = TestOwner("owner-a") let store = CanonicalGoalsStore(client: api, ownerIDProvider: { owner.value }) - store.activate(capability: capability(generation: 7)) + store.activate(capability: try capability(generation: 7)) await store.load() XCTAssertEqual(store.primaryFocusedGoal?.goalId, "focused") @@ -22,7 +22,7 @@ final class CanonicalGoalsStoreTests: XCTestCase { XCTAssertEqual(api.goalRequestOwnerIDs, ["owner-a"]) } - func testSetFocusUsesSampledGenerationPrimaryReplacementAndReconciles() async { + func testSetFocusUsesSampledGenerationPrimaryReplacementAndReconciles() async throws { let api = FakeCanonicalGoalsClient() api.goals = [ goal(id: "focused", status: .focused, rank: 0), @@ -35,7 +35,7 @@ final class CanonicalGoalsStoreTests: XCTestCase { let owner = TestOwner("owner-a") let store = CanonicalGoalsStore(client: api, ownerIDProvider: { owner.value }) - store.activate(capability: capability(generation: 12)) + store.activate(capability: try capability(generation: 12)) await store.load() let updated = await store.setAsFocus(goalID: "background") @@ -49,13 +49,13 @@ final class CanonicalGoalsStoreTests: XCTestCase { XCTAssertEqual(store.primaryFocusedGoal?.goalId, "background") } - func testUnavailableProjectionClearsCanonicalDataAndDoesNotRetainPriorOwnerState() async { + func testUnavailableProjectionClearsCanonicalDataAndDoesNotRetainPriorOwnerState() async throws { let api = FakeCanonicalGoalsClient() api.goals = [goal(id: "owner-a", status: .focused, rank: 0)] let owner = TestOwner("owner-a") let store = CanonicalGoalsStore(client: api, ownerIDProvider: { owner.value }) - store.activate(capability: capability(generation: 7)) + store.activate(capability: try capability(generation: 7)) await store.load() XCTAssertEqual(store.goals.map(\.goalId), ["owner-a"]) @@ -66,13 +66,13 @@ final class CanonicalGoalsStoreTests: XCTestCase { XCTAssertEqual(store.availability, .unavailable("Goals are unavailable right now. Try again.")) } - func testUnavailableProjectionClearsDataWhenCanonicalFetchFails() async { + func testUnavailableProjectionClearsDataWhenCanonicalFetchFails() async throws { let api = FakeCanonicalGoalsClient() api.goals = [goal(id: "focused", status: .focused, rank: 0)] let owner = TestOwner("owner-a") let store = CanonicalGoalsStore(client: api, ownerIDProvider: { owner.value }) - store.activate(capability: capability(generation: 7)) + store.activate(capability: try capability(generation: 7)) await store.load() XCTAssertEqual(store.goals.map(\.goalId), ["focused"]) @@ -117,12 +117,14 @@ final class CanonicalGoalsStoreTests: XCTestCase { ) } - private func capability(generation: Int) -> ChatFirstCapabilityProjection { - ChatFirstCapabilityProjection(control: OmiAPI.TaskWorkflowControl( - accountGeneration: generation, - chatFirstUi: true, - workflowMode: .read - ))! + private func capability(generation: Int) throws -> ChatFirstCapabilityProjection { + try XCTUnwrap( + ChatFirstCapabilityProjection( + control: OmiAPI.TaskWorkflowControl( + accountGeneration: generation, + chatFirstUi: true, + workflowMode: .read + ))) } private func goal(id: String, status: OmiAPI.GoalStatus, rank: Int?) -> OmiAPI.GoalResponse { @@ -166,7 +168,7 @@ private final class TestOwner { } } -private final class FakeCanonicalGoalsClient: CanonicalGoalsClient { +private final class FakeCanonicalGoalsClient: CanonicalGoalsClient, @unchecked Sendable { enum FakeError: Error { case missing } @@ -217,13 +219,14 @@ private final class FakeCanonicalGoalsClient: CanonicalGoalsClient { expectedOwnerId: String?, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? ) async throws -> OmiAPI.GoalResponse { - focusRequests.append(FocusRequest( - goalID: goalID, - replacementGoalID: replacementGoalID, - accountGeneration: accountGeneration, - idempotencyKey: idempotencyKey, - expectedOwnerID: expectedOwnerId - )) + focusRequests.append( + FocusRequest( + goalID: goalID, + replacementGoalID: replacementGoalID, + accountGeneration: accountGeneration, + idempotencyKey: idempotencyKey, + expectedOwnerID: expectedOwnerId + )) guard let goal = goals.first(where: { $0.goalId == goalID }) else { throw FakeError.missing } hasFocused = true return goal diff --git a/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift b/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift index ac3c78e4d32..2f3b337e2b8 100644 --- a/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift +++ b/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift @@ -1,4 +1,5 @@ import XCTest + @testable import Omi_Computer @MainActor @@ -88,11 +89,13 @@ final class CaptureArchiveTests: XCTestCase { sources: [.omi] ) - XCTAssertEqual(listFilters, [ - "include_discarded=false", - "statuses=completed,processing", - "sources=omi", - ]) + XCTAssertEqual( + listFilters, + [ + "include_discarded=false", + "statuses=completed,processing", + "sources=omi", + ]) XCTAssertTrue(countEndpoint.contains("sources=omi")) XCTAssertTrue(countEndpoint.contains("include_discarded=false")) } @@ -107,7 +110,7 @@ final class CaptureArchiveTests: XCTestCase { duration: 40, capturedDuration: 45, spans: [ - CaptureAudioURLSpan(fileID: "part-a", wallOffset: 12, artifactOffset: 3, length: 10), + CaptureAudioURLSpan(fileID: "part-a", wallOffset: 12, artifactOffset: 3, length: 10) ] ), pollAfterMs: nil @@ -144,7 +147,7 @@ final class CaptureArchiveTests: XCTestCase { CaptureAudioURLFile( id: "part-a", status: "cached", signedURL: URL(string: "https://example.test/part-a.mp3"), contentType: "audio/mpeg", duration: 12 - ), + ) ], conversationAudio: nil, pollAfterMs: nil @@ -154,11 +157,12 @@ final class CaptureArchiveTests: XCTestCase { } } - func testPlaybackCanRefreshAPendingCaptureWithoutChangingItsIdentity() async { + func testPlaybackCanRefreshAPendingCaptureWithoutChangingItsIdentity() async throws { let pending = CapturePlaybackResolution.pending(pollAfterMs: 1_000) - let ready = CapturePlaybackResolution.fileFallback(CapturePlaybackFile( - id: "part-a", signedURL: try! XCTUnwrap(URL(string: "https://example.test/part-a.mp3")), duration: 12 - )) + let ready = CapturePlaybackResolution.fileFallback( + CapturePlaybackFile( + id: "part-a", signedURL: try XCTUnwrap(URL(string: "https://example.test/part-a.mp3")), duration: 12 + )) let provider = CapturePlaybackProviderFake(resolutions: [pending, ready]) let controller = CapturePlaybackController(provider: provider) let capture = archiveCapture(id: "omi-1") @@ -174,26 +178,32 @@ final class CaptureArchiveTests: XCTestCase { let artifact = CapturePlaybackArtifact( signedURL: URL(string: "https://example.test/capture.mp3")!, duration: 20, spans: [] ) - XCTAssertFalse(CaptureFocusAcknowledgementPolicy.canAcknowledge( - requestedMoment: 3, resolution: .pending(pollAfterMs: 1000) - )) - XCTAssertFalse(CaptureFocusAcknowledgementPolicy.canAcknowledge( - requestedMoment: 3, resolution: .fileFallback(CapturePlaybackFile( - id: "part", signedURL: URL(string: "https://example.test/part.mp3")!, duration: 3 - )), didCompleteSeek: true - )) - XCTAssertFalse(CaptureFocusAcknowledgementPolicy.canAcknowledge( - requestedMoment: 3, resolution: .readyAggregate(artifact), didCompleteSeek: false - )) - XCTAssertTrue(CaptureFocusAcknowledgementPolicy.canAcknowledge( - requestedMoment: 3, resolution: .readyAggregate(artifact), didCompleteSeek: true - )) + XCTAssertFalse( + CaptureFocusAcknowledgementPolicy.canAcknowledge( + requestedMoment: 3, resolution: .pending(pollAfterMs: 1000) + )) + XCTAssertFalse( + CaptureFocusAcknowledgementPolicy.canAcknowledge( + requestedMoment: 3, + resolution: .fileFallback( + CapturePlaybackFile( + id: "part", signedURL: URL(string: "https://example.test/part.mp3")!, duration: 3 + )), didCompleteSeek: true + )) + XCTAssertFalse( + CaptureFocusAcknowledgementPolicy.canAcknowledge( + requestedMoment: 3, resolution: .readyAggregate(artifact), didCompleteSeek: false + )) + XCTAssertTrue( + CaptureFocusAcknowledgementPolicy.canAcknowledge( + requestedMoment: 3, resolution: .readyAggregate(artifact), didCompleteSeek: true + )) } } final class CaptureArchiveCacheTests: XCTestCase { - private var userID: String! + private var userID = "" private var userDirectory: URL? override func setUp() async throws { @@ -262,7 +272,8 @@ private func archiveCapture( updatedAt: createdAt, startedAt: createdAt, finishedAt: createdAt.addingTimeInterval(60), - structured: Structured(title: "Capture \(id)", overview: "Summary", emoji: "", category: "other", actionItems: [], events: []), + structured: Structured( + title: "Capture \(id)", overview: "Summary", emoji: "", category: "other", actionItems: [], events: []), transcriptSegments: [], transcriptSegmentsIncluded: false, geolocation: nil, @@ -314,7 +325,7 @@ private final class CaptureArchiveRemoteFake: CaptureArchiveRemoteDataSource { } } -private final class CaptureArchiveLocalFake: CaptureArchiveLocalDataSource { +private final class CaptureArchiveLocalFake: CaptureArchiveLocalDataSource, @unchecked Sendable { var rows: [ServerConversation] var countValue: Int @@ -329,7 +340,7 @@ private final class CaptureArchiveLocalFake: CaptureArchiveLocalDataSource { func store(_ conversation: ServerConversation) async throws {} } -private final class CapturePlaybackProviderFake: CapturePlaybackProviding { +private final class CapturePlaybackProviderFake: CapturePlaybackProviding, @unchecked Sendable { private var resolutions: [CapturePlaybackResolution] private(set) var resolveCount = 0 @@ -343,6 +354,6 @@ private final class CapturePlaybackProviderFake: CapturePlaybackProviding { } } -private extension Array { - var single: Element? { count == 1 ? first : nil } +extension Array { + fileprivate var single: Element? { count == 1 ? first : nil } } diff --git a/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift b/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift index b3e165c3ca1..29231a6fd75 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift @@ -4,26 +4,29 @@ import XCTest final class ChatFirstDeferralOutboxDriverTests: XCTestCase { func testDeferredOptionRetainsItsWireKey() throws { - let request = try XCTUnwrap(ChatFirstDeferralDeliveryRequest(payload: [ - "ownerId": "owner-1", - "continuityKey": "continuity-1", - "controlGeneration": 1, - "subject": ["kind": "goal", "id": "goal-1"], - "question": [ - "questionId": "question-1", - "text": "What should happen next?", + let request = try XCTUnwrap( + ChatFirstDeferralDeliveryRequest(payload: [ + "ownerId": "owner-1", + "continuityKey": "continuity-1", + "controlGeneration": 1, "subject": ["kind": "goal", "id": "goal-1"], - "options": [[ - "optionId": "later", - "label": "Ask tomorrow", - "preparedAnswer": "Ask me again tomorrow.", - "defer": true, - ]], - ], - "attemptCount": 1, - "deliveryGeneration": 1, - "payloadHash": "hash-1", - ])) + "question": [ + "questionId": "question-1", + "text": "What should happen next?", + "subject": ["kind": "goal", "id": "goal-1"], + "options": [ + [ + "optionId": "later", + "label": "Ask tomorrow", + "preparedAnswer": "Ask me again tomorrow.", + "defer": true, + ] + ], + ], + "attemptCount": 1, + "deliveryGeneration": 1, + "payloadHash": "hash-1", + ])) XCTAssertTrue(request.question.options[0].isDeferred) let data = try JSONEncoder().encode(request.question) @@ -70,17 +73,18 @@ final class ChatFirstDeferralOutboxDriverTests: XCTestCase { XCTAssertEqual(candidate?.preparedAnswer, "Keep it") XCTAssertEqual(candidate?.assistantTurnID, "assistant") - XCTAssertNil(ChatQuestionCardContinuation.tailResumeCandidate(from: [ - ChatMessage(id: "user", clientTurnId: key, text: "Keep it", sender: .user), - ChatMessage( - id: "assistant", - clientTurnId: key, - text: "", - sender: .ai, - isStreaming: true, - hidesEmptyStreamingPlaceholder: true - ), - ChatMessage(id: "later", text: "A later Omi bubble", sender: .ai), - ])) + XCTAssertNil( + ChatQuestionCardContinuation.tailResumeCandidate(from: [ + ChatMessage(id: "user", clientTurnId: key, text: "Keep it", sender: .user), + ChatMessage( + id: "assistant", + clientTurnId: key, + text: "", + sender: .ai, + isStreaming: true, + hidesEmptyStreamingPlaceholder: true + ), + ChatMessage(id: "later", text: "A later Omi bubble", sender: .ai), + ])) } } diff --git a/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift b/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift index c36f5f8afbd..91c073ffd33 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift @@ -70,11 +70,11 @@ final class ChatFirstPromptMaterializationCoordinatorTests: XCTestCase { XCTAssertNil(driver.materializationContext()) XCTAssertFalse( ChatFirstPromptMaterializationPolicy.shouldStart( - hasChatFirstMainChatContext: false, - transcriptFirstPageLoaded: true, - isRunning: false, - lastAttemptAt: nil, - now: now + hasChatFirstMainChatContext: false, + transcriptFirstPageLoaded: true, + isRunning: false, + lastAttemptAt: nil, + now: now ) ) } diff --git a/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift index bda3dc4a09e..fd1bdd26a31 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift @@ -41,12 +41,14 @@ final class ChatFirstRichBlockTests: XCTestCase { text: "Which goal should we focus on?", subjectKind: "goal", subjectId: "goal-1", - options: [[ - "optionId": "focus-goal-1", - "label": "Keep this goal", - "preparedAnswer": "Keep goal 1 as my focus", - "defer": false, - ]] + options: [ + [ + "optionId": "focus-goal-1", + "label": "Keep this goal", + "preparedAnswer": "Keep goal 1 as my focus", + "defer": false, + ] + ] ), .taskCard(id: "task-card", taskId: "task-1"), .goalLink(id: "goal-link", goalId: "goal-1", summary: "Finish the launch plan"), @@ -62,7 +64,9 @@ final class ChatFirstRichBlockTests: XCTestCase { let restored = try XCTUnwrap(ChatContentBlockCodec.decode(encoded)) XCTAssertEqual(restored.count, blocks.count) - guard case .questionCard(_, let questionID, let text, let subjectKind, let subjectID, let options, let selectedOptionID) = restored[0] + guard + case .questionCard( + _, let questionID, let text, let subjectKind, let subjectID, let options, let selectedOptionID) = restored[0] else { return XCTFail("question card should survive persisted replay") } XCTAssertEqual(questionID, "question-1") XCTAssertEqual(text, "Which goal should we focus on?") @@ -97,11 +101,13 @@ final class ChatFirstRichBlockTests: XCTestCase { text: "Which goal should we focus on?", subjectKind: "goal", subjectId: "goal-1", - options: [[ - "optionId": "focus-goal-1", - "label": "Keep this goal", - "preparedAnswer": "Keep goal 1 as my focus", - ]], + options: [ + [ + "optionId": "focus-goal-1", + "label": "Keep this goal", + "preparedAnswer": "Keep goal 1 as my focus", + ] + ], selectedOptionId: "focus-goal-1" ) @@ -156,10 +162,26 @@ final class ChatFirstRichBlockTests: XCTestCase { richBlockRenderingEnabled: true ) XCTAssertEqual(enabled.count, 4) - XCTAssertTrue(enabled.contains { if case .questionCard = $0 { return true }; return false }) - XCTAssertTrue(enabled.contains { if case .taskCard = $0 { return true }; return false }) - XCTAssertTrue(enabled.contains { if case .goalLink = $0 { return true }; return false }) - XCTAssertTrue(enabled.contains { if case .captureLink = $0 { return true }; return false }) + XCTAssertTrue( + enabled.contains { + if case .questionCard = $0 { return true } + return false + }) + XCTAssertTrue( + enabled.contains { + if case .taskCard = $0 { return true } + return false + }) + XCTAssertTrue( + enabled.contains { + if case .goalLink = $0 { return true } + return false + }) + XCTAssertTrue( + enabled.contains { + if case .captureLink = $0 { return true } + return false + }) } func testTaskAcknowledgementRequiresReconciledCompletedRecord() { diff --git a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift index cc50749ea0a..89cdb1d1863 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift @@ -31,6 +31,19 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertEqual(sample.variant.projection?.controlGeneration, 7) } + func testLegacyWorkflowMetadataCannotSuppressDerivedChatFirstCapability() throws { + let control = OmiAPI.TaskWorkflowControl( + accountGeneration: 9, + chatFirstUi: true, + workflowMode: .off + ) + + let projection = try XCTUnwrap(ChatFirstCapabilityProjection(control: control)) + + XCTAssertTrue(projection.chatFirstUi) + XCTAssertEqual(projection.controlGeneration, 9) + } + func testMissingStaleAndOwnerChangedSamplesFailClosed() { var missing = ChatFirstShellCapabilitySample() missing.resolve(control: nil, requestedOwnerID: "owner-a", ownerIsStillCurrent: true) @@ -46,9 +59,9 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertEqual(ownerChanged.variant.stableName, "legacy") } - func testNavigationPersistsOnlyRouteAndCollapseAndRetainsFocusUntilAcknowledged() { + func testNavigationPersistsOnlyRouteAndCollapseAndRetainsFocusUntilAcknowledged() throws { let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let navigation = ChatFirstShellNavigation(defaults: defaults) @@ -78,9 +91,9 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertFalse(restored.isFocusedEntityAcknowledged) } - func testRouteIsNotVisibleUntilTheMountedDestinationAcknowledgesIt() { + func testRouteIsNotVisibleUntilTheMountedDestinationAcknowledgesIt() throws { let suiteName = "ChatFirstShellTests.visible-route.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let navigation = ChatFirstShellNavigation(defaults: defaults) @@ -100,9 +113,9 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertNil(navigation.visibleRoute) } - func testDirectAndLegacyNavigationClearFocusAndMapToTypedRoutes() { + func testDirectAndLegacyNavigationClearFocusAndMapToTypedRoutes() throws { let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let navigation = ChatFirstShellNavigation(defaults: defaults) @@ -122,9 +135,9 @@ final class ChatFirstShellTests: XCTestCase { XCTAssertEqual(navigation.route, .chat) } - func testNavigationUsesTypedOriginsWithoutEntityIdentifiersInAnalytics() { + func testNavigationUsesTypedOriginsWithoutEntityIdentifiersInAnalytics() throws { let suiteName = "ChatFirstShellTests.analytics.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } var events: [ChatFirstAnalyticsEvent] = [] let navigation = ChatFirstShellNavigation(defaults: defaults) { event in @@ -148,9 +161,9 @@ final class ChatFirstShellTests: XCTestCase { ) } - func testRelatedGoalFocusCanLandInTasksAndAcknowledgesAfterTasksVisibility() { + func testRelatedGoalFocusCanLandInTasksAndAcknowledgesAfterTasksVisibility() throws { let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let navigation = ChatFirstShellNavigation(defaults: defaults) diff --git a/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift b/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift index 686fa3c914f..ddf039bad15 100644 --- a/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift +++ b/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift @@ -3,10 +3,10 @@ import XCTest @testable import Omi_Computer final class ChatFirstTasksPageTests: XCTestCase { - func testScheduleGroupingKeepsOverdueAndTodayWorkTogether() { + func testScheduleGroupingKeepsOverdueAndTodayWorkTogether() throws { var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(secondsFromGMT: 0)! - let now = Date(timeIntervalSince1970: 1_719_115_200) // 2024-06-15 00:00 UTC + calendar.timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_719_115_200) // 2024-06-15 00:00 UTC let overdue = task(id: "overdue", dueAt: now.addingTimeInterval(-1)) let today = task(id: "today", dueAt: now.addingTimeInterval(12 * 60 * 60)) let tomorrow = task(id: "tomorrow", dueAt: now.addingTimeInterval(24 * 60 * 60)) @@ -35,7 +35,8 @@ final class ChatFirstTasksPageTests: XCTestCase { let groups = ChatFirstTaskPagePolicy.groupedByGoal([first, second, standalone]) XCTAssertEqual(groups.count, 2) - XCTAssertEqual(Set(groups.first(where: { $0.goalID == "goal-a" })?.tasks.map(\.id) ?? []), Set(["first", "second"])) + XCTAssertEqual( + Set(groups.first(where: { $0.goalID == "goal-a" })?.tasks.map(\.id) ?? []), Set(["first", "second"])) XCTAssertEqual(ChatFirstTaskPagePolicy.badges(for: first), .init(goalID: "goal-a", captureID: "capture-a")) XCTAssertEqual(ChatFirstTaskPagePolicy.badges(for: standalone), .init(goalID: nil, captureID: nil)) XCTAssertEqual( diff --git a/desktop/macos/scripts/agent-runtime-convergence-ratchet.json b/desktop/macos/scripts/agent-runtime-convergence-ratchet.json index f68ebd8db7e..832cc2bdede 100644 --- a/desktop/macos/scripts/agent-runtime-convergence-ratchet.json +++ b/desktop/macos/scripts/agent-runtime-convergence-ratchet.json @@ -1,7 +1,9 @@ { "limits": { - "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": 6556, + "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": 7372, "desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift": 4396 }, - "raise_justifications": {} + "raise_justifications": { + "INV-CHAT-1": "Chat-first rich-block handling, answer submission, and task completion stay in the existing provider while its shared lifecycle extraction remains bounded." + } } From 1cbf1c6bf78955174d97755d17cb30e16d86049b Mon Sep 17 00:00:00 2001 From: David Zhang Date: Fri, 17 Jul 2026 03:59:37 -0400 Subject: [PATCH 21/28] fix(backend): clear chat-first typecheck gate Failure-Class: none --- backend/database/chat_first_intents.py | 72 +++++++-- backend/routers/chat_first.py | 31 ++-- backend/routers/chat_first_e2e.py | 4 +- .../chat_first_e2e_fixture.py | 148 +++++++++++------- .../chat_first_eligibility.py | 4 +- .../task_intelligence/proactive_engine.py | 2 +- .../workstream_association.py | 2 +- 7 files changed, 176 insertions(+), 87 deletions(-) diff --git a/backend/database/chat_first_intents.py b/backend/database/chat_first_intents.py index 581233a1838..268fefdab85 100644 --- a/backend/database/chat_first_intents.py +++ b/backend/database/chat_first_intents.py @@ -2,7 +2,7 @@ import hashlib from dataclasses import dataclass -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, timedelta from typing import Any, Iterable from google.cloud import firestore @@ -21,7 +21,7 @@ ProactiveIntentSource, QuestionCardSpec, ) -from models.proactive_budget import account_materialization, budget_allows, normalized_budget_state, reserve_budget +from models.proactive_budget import account_materialization, normalized_budget_state, reserve_budget from models.task_intelligence import TaskWorkflowControl INTENTS_COLLECTION = 'chat_first_proactive_intents' @@ -94,6 +94,24 @@ def _stable_id(prefix: str, *parts: object) -> str: return f'{prefix}_{hashlib.sha256(raw).hexdigest()[:32]}' +def proactive_intent_id( + uid: str, + *, + account_generation: int, + source_key: str, + continuity_key: str, +) -> str: + """Return the canonical durable ID for one proactive intent identity.""" + + return _stable_id('cfi', uid, account_generation, source_key, continuity_key) + + +def proactive_deferral_id(uid: str, *, account_generation: int, continuity_key: str) -> str: + """Return the canonical durable ID for one deferred question identity.""" + + return _stable_id('cfd', uid, account_generation, continuity_key) + + def _require_control(snapshot: Any, *, uid: str, account_generation: int) -> None: if not is_canonical_memory_user(uid): raise ChatFirstIntentGenerationMismatch('chat-first capability changed') @@ -184,7 +202,12 @@ def admit_agent_judgment( """ client = _db(firestore_client) - intent_id = _stable_id('cfi', uid, account_generation, 'agent_judgment', continuity_key) + intent_id = proactive_intent_id( + uid, + account_generation=account_generation, + source_key='agent_judgment', + continuity_key=continuity_key, + ) intent_ref = _intent_ref(uid, intent_id, firestore_client=client) budget_ref = _budget_ref(uid, firestore_client=client) transaction = client.transaction() @@ -235,7 +258,12 @@ def release_agent_judgment_admission( """ client = _db(firestore_client) - intent_id = _stable_id('cfi', uid, account_generation, 'agent_judgment', continuity_key) + intent_id = proactive_intent_id( + uid, + account_generation=account_generation, + source_key='agent_judgment', + continuity_key=continuity_key, + ) intent_ref = _intent_ref(uid, intent_id, firestore_client=client) budget_ref = _budget_ref(uid, firestore_client=client) transaction = client.transaction() @@ -273,7 +301,12 @@ def create_intent( """Idempotently persist an intent and atomically reserve agent-turn budget.""" client = _db(firestore_client) - intent_id = _stable_id('cfi', uid, account_generation, source, continuity_key) + intent_id = proactive_intent_id( + uid, + account_generation=account_generation, + source_key=source, + continuity_key=continuity_key, + ) intent = ProactiveIntent( intent_id=intent_id, continuity_key=continuity_key, @@ -347,7 +380,12 @@ def get_or_create_cold_start_intent( if source not in {'cold_start_rich', 'cold_start_sparse'}: raise ValueError('cold-start intents require a cold-start source') client = _db(firestore_client) - intent_id = _stable_id('cfi', uid, account_generation, 'cold_start', continuity_key) + intent_id = proactive_intent_id( + uid, + account_generation=account_generation, + source_key='cold_start', + continuity_key=continuity_key, + ) intent = ProactiveIntent( intent_id=intent_id, continuity_key=continuity_key, @@ -427,7 +465,12 @@ def acknowledge_sparse_cold_start_sequence_terminal( if sequence_id != expected_sequence_id: raise ChatFirstIntentConflictError('cold-start terminal sequence does not match generation') client = _db(firestore_client) - intent_id = _stable_id('cfi', uid, account_generation, 'cold_start', sequence_id) + intent_id = proactive_intent_id( + uid, + account_generation=account_generation, + source_key='cold_start', + continuity_key=sequence_id, + ) intent_ref = _intent_ref(uid, intent_id, firestore_client=client) transaction = client.transaction() @@ -576,7 +619,11 @@ def record_deferral( """Accept the kernel's idempotent deferral outbox record.""" client = _db(firestore_client) - deferral_id = _stable_id('cfd', uid, account_generation, continuity_key) + deferral_id = proactive_deferral_id( + uid, + account_generation=account_generation, + continuity_key=continuity_key, + ) deferral = ProactiveDeferral( deferral_id=deferral_id, continuity_key=continuity_key, @@ -660,7 +707,12 @@ def _release_deferral_transaction( now: datetime, firestore_client: Any, ) -> ProactiveIntent | None: - intent_id = _stable_id('cfi', uid, account_generation, 'deferral_reraise', deferred.continuity_key) + intent_id = proactive_intent_id( + uid, + account_generation=account_generation, + source_key='deferral_reraise', + continuity_key=deferred.continuity_key, + ) intent = ProactiveIntent( intent_id=intent_id, continuity_key=deferred.continuity_key, @@ -728,6 +780,8 @@ def iter_ready_intent_ids( 'fetch_ready_intents', 'get_budget_state', 'iter_ready_intent_ids', + 'proactive_deferral_id', + 'proactive_intent_id', 'release_agent_judgment_admission', 'record_deferral', 'release_due_deferrals', diff --git a/backend/routers/chat_first.py b/backend/routers/chat_first.py index ae730cdac56..1bf98bb0812 100644 --- a/backend/routers/chat_first.py +++ b/backend/routers/chat_first.py @@ -28,7 +28,6 @@ GoalLinkSpec, MaterializePromptsRequest, MaterializePromptsResponse, - QuestionCardSpec, TaskCardSpec, stable_block_id, ) @@ -73,7 +72,7 @@ def _require_materialization_capability(uid: str, *, owner_fence: str, control_g return eligibility -def _daily_opener_blocks(uid: str) -> tuple[list, ChatFirstSubject | None]: +def _daily_opener_blocks(uid: str) -> tuple[list[ChatFirstBlockSpec], ChatFirstSubject | None]: """Build the closed deterministic opener from canonical task/goal facts.""" focused_goal = goals_db.get_user_goal(uid) @@ -84,7 +83,7 @@ def _daily_opener_blocks(uid: str) -> tuple[list, ChatFirstSubject | None]: return [], None title = focused_goal.get('title') summary = title if isinstance(title, str) and title.strip() else 'Today’s focus' - blocks = [GoalLinkSpec(type='goalLink', goal_id=goal_id, summary=summary[:200])] + blocks: list[ChatFirstBlockSpec] = [GoalLinkSpec(type='goalLink', goal_id=goal_id, summary=summary[:200])] for task in action_items_db.get_action_items(uid, completed=False, limit=3): task_id = task.get('id') if isinstance(task_id, str) and task_id: @@ -166,20 +165,18 @@ def _entity_available(uid: str, block: ChatFirstBlockSpec) -> bool: if isinstance(block, CaptureLinkSpec): capture = conversations_db.get_conversation(uid, block.conversation_id) return bool(capture and capture.get('source') == 'omi' and not capture.get('discarded', False)) - if isinstance(block, QuestionCardSpec): - subject = block.subject - if subject.kind == 'cold_start': - # Synthetic cold-start subjects are admitted only through the - # deterministic materialization endpoint, never agent tool input. - return False - if subject.kind == 'task': - task = action_items_db.get_action_item(uid, subject.id) - return bool(task and not task.get('is_locked', False)) - if subject.kind == 'goal': - return goals_db.get_goal_by_id(uid, subject.id) is not None - capture = conversations_db.get_conversation(uid, subject.id) - return bool(capture and capture.get('source') == 'omi' and not capture.get('discarded', False)) - return False + subject = block.subject + if subject.kind == 'cold_start': + # Synthetic cold-start subjects are admitted only through the + # deterministic materialization endpoint, never agent tool input. + return False + if subject.kind == 'task': + task = action_items_db.get_action_item(uid, subject.id) + return bool(task and not task.get('is_locked', False)) + if subject.kind == 'goal': + return goals_db.get_goal_by_id(uid, subject.id) is not None + capture = conversations_db.get_conversation(uid, subject.id) + return bool(capture and capture.get('source') == 'omi' and not capture.get('discarded', False)) @router.post( diff --git a/backend/routers/chat_first_e2e.py b/backend/routers/chat_first_e2e.py index 08ffc4a3f01..329abee3afe 100644 --- a/backend/routers/chat_first_e2e.py +++ b/backend/routers/chat_first_e2e.py @@ -1,5 +1,7 @@ """Local/offline-only control plane for the named Chat-first E2E bundle.""" +from typing import NoReturn + from fastapi import APIRouter, Depends, HTTPException, status from config.chat_first_e2e_fixture import is_chat_first_e2e_harness_runtime @@ -14,7 +16,7 @@ router = APIRouter(prefix='/v1/dev-harness/chat-first', include_in_schema=False) -def _raise_harness_error(exc: RuntimeError) -> None: +def _raise_harness_error(exc: RuntimeError) -> NoReturn: if isinstance(exc, chat_first_e2e_fixture.ChatFirstE2EFixtureUnavailable): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Not found') from exc if isinstance(exc, chat_first_e2e_fixture.ChatFirstE2EFixtureIdentityError): diff --git a/backend/utils/task_intelligence/chat_first_e2e_fixture.py b/backend/utils/task_intelligence/chat_first_e2e_fixture.py index f688dd22dcc..b555f0164a1 100644 --- a/backend/utils/task_intelligence/chat_first_e2e_fixture.py +++ b/backend/utils/task_intelligence/chat_first_e2e_fixture.py @@ -1,10 +1,10 @@ """Server-authoritative fixture state for the local Chat-first E2E bundle. -This module deliberately does not alter the normal Chat-first routes. The -harness writes only two fixed local/offline accounts, uses the existing -canonical document builders and intent contracts, and returns no fixture text -or entity data. Its clock advance makes fixture deferrals due; production -materialization continues to read its normal wall clock. +This module deliberately does not alter the normal Chat-first routes. The +harness writes only two fixed local/offline accounts using complete canonical +document and intent contracts, and returns no fixture text or entity data. Its +clock advance makes fixture deferrals due; production materialization continues +to read its normal wall clock. """ from copy import deepcopy @@ -19,9 +19,7 @@ is_chat_first_e2e_harness_runtime, ) from database._client import get_firestore_client -import database.action_items as action_items_db import database.chat_first_intents as intents_db -import database.goals as goals_db from models.chat_first import ( ChatFirstSubject, ColdStartSequence, @@ -87,10 +85,29 @@ def _user_ref(uid: str, *, firestore_client: Any): def _entity_refs(uid: str, *, firestore_client: Any) -> dict[str, Any]: user_ref = _user_ref(uid, firestore_client=firestore_client) - question_intent_id = intents_db._stable_id('cfi', uid, 1, 'deferral_reraise', _QUESTION_CONTINUITY_KEY) - cold_start_intent_id = intents_db._stable_id('cfi', uid, 1, 'cold_start', _COLD_START_CONTINUITY_KEY) - daily_opener_intent_id = intents_db._stable_id('cfi', uid, 1, 'daily_opener', 'daily:chat-first-e2e') - question_deferral_id = intents_db._stable_id('cfd', uid, 1, _QUESTION_CONTINUITY_KEY) + question_intent_id = intents_db.proactive_intent_id( + uid, + account_generation=1, + source_key='deferral_reraise', + continuity_key=_QUESTION_CONTINUITY_KEY, + ) + cold_start_intent_id = intents_db.proactive_intent_id( + uid, + account_generation=1, + source_key='cold_start', + continuity_key=_COLD_START_CONTINUITY_KEY, + ) + daily_opener_intent_id = intents_db.proactive_intent_id( + uid, + account_generation=1, + source_key='daily_opener', + continuity_key='daily:chat-first-e2e', + ) + question_deferral_id = intents_db.proactive_deferral_id( + uid, + account_generation=1, + continuity_key=_QUESTION_CONTINUITY_KEY, + ) return { 'control': user_ref.collection('task_intelligence_control').document('state'), 'goal': user_ref.collection('goals').document(_GOAL_ID), @@ -158,50 +175,49 @@ def _control_for_case(fixture_case: ChatFirstE2EFixtureCase) -> TaskWorkflowCont def _goal_payload(*, goal_id: str, title: str, focused: bool, now: datetime) -> dict[str, Any]: - payload = goals_db._new_goal_payload( - { - 'goal_id': goal_id, - 'title': title, - 'desired_outcome': 'Validate the Chat-first fixture loop', - 'status': GoalStatus.background.value, - 'source': 'user', - }, - goal_id=goal_id, - now=now, - account_generation=1, - ) - payload.update( - { - 'status': GoalStatus.focused.value if focused else GoalStatus.background.value, - 'focus_rank': 0 if focused else None, - 'is_active': True, - } - ) - return payload + """Build the complete canonical storage shape for one fixture goal.""" + + return { + 'id': goal_id, + 'goal_id': goal_id, + 'title': title, + 'desired_outcome': 'Validate the Chat-first fixture loop', + 'why_it_matters': None, + 'success_criteria': [], + 'horizon_at': None, + 'status': GoalStatus.focused.value if focused else GoalStatus.background.value, + 'focus_rank': 0 if focused else None, + 'metric': None, + 'source': 'user', + 'latest_progress_sequence': 0, + 'is_active': True, + 'created_at': now, + 'updated_at': now, + 'account_generation': 1, + } def _task_payload(*, now: datetime) -> dict[str, Any]: - return action_items_db._prepare_action_item_for_write( - { - 'id': _TASK_ID, - 'description': 'E2E fixture task', - 'goal_id': _GOAL_ID, - 'completed': False, - 'owner': 'user', - # This is deliberately the one task provenance which the cohort - # archive can resolve. The resulting task-card badge exercises - # the production fail-closed capture-link policy instead of - # merely asserting its helper against a synthetic Swift value. - 'source': 'transcription:omi', - 'conversation_id': _CAPTURE_ID, - 'provenance': [], - 'sort_order': 0, - 'indent_level': 0, - 'created_at': now, - 'updated_at': now, - 'account_generation': 1, - } - ) + return { + 'id': _TASK_ID, + 'description': 'E2E fixture task', + 'goal_id': _GOAL_ID, + 'status': 'active', + 'completed': False, + 'owner': 'user', + # This is deliberately the one task provenance which the cohort + # archive can resolve. The resulting task-card badge exercises + # the production fail-closed capture-link policy instead of + # merely asserting its helper against a synthetic Swift value. + 'source': 'transcription:omi', + 'conversation_id': _CAPTURE_ID, + 'provenance': [], + 'sort_order': 0, + 'indent_level': 0, + 'created_at': now, + 'updated_at': now, + 'account_generation': 1, + } def _capture_payload(*, now: datetime) -> dict[str, Any]: @@ -236,7 +252,12 @@ def _question() -> QuestionCardSpec: def _question_intent(uid: str, *, now: datetime) -> ProactiveIntent: return ProactiveIntent( - intent_id=intents_db._stable_id('cfi', uid, 1, 'deferral_reraise', _QUESTION_CONTINUITY_KEY), + intent_id=intents_db.proactive_intent_id( + uid, + account_generation=1, + source_key='deferral_reraise', + continuity_key=_QUESTION_CONTINUITY_KEY, + ), continuity_key=_QUESTION_CONTINUITY_KEY, account_generation=1, source='deferral_reraise', @@ -257,7 +278,12 @@ def _cold_start_intent(uid: str, *, now: datetime) -> ProactiveIntent: cold_start_sequence=ColdStartSequence(sequence_id=sequence_id, step=1), ) return ProactiveIntent( - intent_id=intents_db._stable_id('cfi', uid, 1, 'cold_start', sequence_id), + intent_id=intents_db.proactive_intent_id( + uid, + account_generation=1, + source_key='cold_start', + continuity_key=sequence_id, + ), continuity_key=sequence_id, account_generation=1, source='cold_start_sparse', @@ -279,7 +305,12 @@ def _daily_opener_intent(uid: str, *, now: datetime) -> ProactiveIntent: continuity_key = 'daily:chat-first-e2e' return ProactiveIntent( - intent_id=intents_db._stable_id('cfi', uid, 1, 'daily_opener', continuity_key), + intent_id=intents_db.proactive_intent_id( + uid, + account_generation=1, + source_key='daily_opener', + continuity_key=continuity_key, + ), continuity_key=continuity_key, account_generation=1, source='daily_opener', @@ -304,7 +335,12 @@ def _completed_rich_cold_start_intent(uid: str, *, now: datetime) -> ProactiveIn sequence_id = _COLD_START_CONTINUITY_KEY return ProactiveIntent( - intent_id=intents_db._stable_id('cfi', uid, 1, 'cold_start', sequence_id), + intent_id=intents_db.proactive_intent_id( + uid, + account_generation=1, + source_key='cold_start', + continuity_key=sequence_id, + ), continuity_key=sequence_id, account_generation=1, source='cold_start_rich', diff --git a/backend/utils/task_intelligence/chat_first_eligibility.py b/backend/utils/task_intelligence/chat_first_eligibility.py index df66204967f..a54ae360ad1 100644 --- a/backend/utils/task_intelligence/chat_first_eligibility.py +++ b/backend/utils/task_intelligence/chat_first_eligibility.py @@ -4,7 +4,7 @@ from typing import Callable import database.task_intelligence_control as task_control_db -from models.task_intelligence import TaskWorkflowControl +from models.task_intelligence import TaskIntelligenceRolloutDecision, TaskWorkflowControl from utils.task_intelligence.rollout import resolve_chat_first_ui, resolve_task_intelligence_for_user @@ -20,7 +20,7 @@ def resolve_chat_first_eligibility( uid: str, *, load_control: Callable[[str], TaskWorkflowControl] = task_control_db.get_task_workflow_control, - resolve_rollout: Callable[..., object] = resolve_task_intelligence_for_user, + resolve_rollout: Callable[..., TaskIntelligenceRolloutDecision] = resolve_task_intelligence_for_user, ) -> ChatFirstEligibility: """Resolve the generation-bound cohort capability without fallback state. diff --git a/backend/utils/task_intelligence/proactive_engine.py b/backend/utils/task_intelligence/proactive_engine.py index 358a75b95aa..eb4c2b34a04 100644 --- a/backend/utils/task_intelligence/proactive_engine.py +++ b/backend/utils/task_intelligence/proactive_engine.py @@ -351,7 +351,7 @@ def persist_cold_start_intent( subject = rich_subject else: source = 'cold_start_sparse' - blocks = [cold_start_sparse_question(sequence_id=sequence_id)] + blocks: list[ChatFirstBlockSpec] = [cold_start_sparse_question(sequence_id=sequence_id)] subject = ChatFirstSubject(kind='cold_start', id=sequence_id) intent, created = intent_db.get_or_create_cold_start_intent( uid, diff --git a/backend/utils/task_intelligence/workstream_association.py b/backend/utils/task_intelligence/workstream_association.py index 537993bec2e..ce94ba4fe8f 100644 --- a/backend/utils/task_intelligence/workstream_association.py +++ b/backend/utils/task_intelligence/workstream_association.py @@ -407,7 +407,7 @@ def consume_recurrence_signals_for_maintenance( complete: Callable[..., None] = recurrence_inbox_db.complete_recurrence_receipt, retry: Callable[..., None] = recurrence_inbox_db.retry_recurrence_receipt, ) -> int: - evaluated_or_persisted = persist_recurrence_signals_for_maintenance( + persist_recurrence_signals_for_maintenance( uid, signals, firestore_client=firestore_client, From 4dd4b599d420f3f53d53e45d03a8001c1a14cbd3 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Fri, 17 Jul 2026 04:02:37 -0400 Subject: [PATCH 22/28] chore(api): regenerate desktop OpenAPI DTOs --- desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift index c185159b731..d3f49c52b8c 100644 --- a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift +++ b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift @@ -14318,5 +14318,5 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } - // Total: 381 Swift client methods generated. + // Total: 382 Swift client methods generated. } From dd4d35d480ecb05eb1f6a800d07085b9a0a8e841 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Fri, 17 Jul 2026 04:06:36 -0400 Subject: [PATCH 23/28] docs(api): refresh app client OpenAPI contract --- docs/api-reference/app-client-openapi.json | 124 ++++++++++++++++++++- 1 file changed, 120 insertions(+), 4 deletions(-) diff --git a/docs/api-reference/app-client-openapi.json b/docs/api-reference/app-client-openapi.json index 0c766e5dd18..d5e530791b8 100644 --- a/docs/api-reference/app-client-openapi.json +++ b/docs/api-reference/app-client-openapi.json @@ -5740,7 +5740,8 @@ "enum": [ "task", "goal", - "capture" + "capture", + "cold_start" ], "title": "Kind", "type": "string" @@ -6104,6 +6105,66 @@ "title": "ClickUpTeamsResponse", "type": "object" }, + "ColdStartSequence": { + "additionalProperties": false, + "description": "Explicit local-only identity for the fixed sparse cold-start script.", + "properties": { + "sequence_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Sequence Id", + "type": "string" + }, + "step": { + "maximum": 3.0, + "minimum": 1.0, + "title": "Step", + "type": "integer" + } + }, + "required": [ + "sequence_id", + "step" + ], + "title": "ColdStartSequence", + "type": "object" + }, + "ColdStartSequenceTerminalReceipt": { + "additionalProperties": false, + "description": "A durable local-journal acknowledgement that sparse sequencing ended.", + "properties": { + "receipt_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Receipt Id", + "type": "string" + }, + "sequence_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Sequence Id", + "type": "string" + }, + "terminal_state": { + "enum": [ + "completed", + "abandoned" + ], + "title": "Terminal State", + "type": "string" + } + }, + "required": [ + "sequence_id", + "receipt_id", + "terminal_state" + ], + "title": "ColdStartSequenceTerminalReceipt", + "type": "object" + }, "ContextMatchSignal": { "enum": [ "app", @@ -12564,11 +12625,24 @@ "MaterializePromptsRequest": { "additionalProperties": false, "properties": { + "cold_start_sequence_terminal_receipts": { + "items": { + "$ref": "#/components/schemas/ColdStartSequenceTerminalReceipt" + }, + "maxItems": 16, + "title": "Cold Start Sequence Terminal Receipts", + "type": "array" + }, "control_generation": { "minimum": 0.0, "title": "Control Generation", "type": "integer" }, + "initial_page_loaded": { + "default": false, + "title": "Initial Page Loaded", + "type": "boolean" + }, "owner_fence": { "maxLength": 128, "minLength": 1, @@ -15661,7 +15735,7 @@ }, "ProactiveIntent": { "additionalProperties": false, - "description": "A server-side instruction, not a Chat transcript row.\n\nThe local desktop kernel is the sole writer of the visible assistant turn.\nThis record remains ready until that kernel has committed and acknowledged\nits stable ``intent_id``.", + "description": "A server-side instruction, not a Chat transcript row.\n\nThe local desktop kernel is the sole writer of the visible assistant turn.\nThis record remains deliverable until that kernel has committed and\nacknowledged its stable ``intent_id``; cold-start intents make that\nexplicit as ``pending_kernel_receipt``.", "properties": { "account_generation": { "minimum": 0.0, @@ -15699,6 +15773,35 @@ "title": "Blocks", "type": "array" }, + "cold_start_sequence_terminal_receipt_id": { + "anyOf": [ + { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cold Start Sequence Terminal Receipt Id" + }, + "cold_start_sequence_terminal_state": { + "anyOf": [ + { + "enum": [ + "completed", + "abandoned" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cold Start Sequence Terminal State" + }, "continuity_key": { "maxLength": 128, "minLength": 1, @@ -15727,6 +15830,7 @@ "default": "ready", "enum": [ "ready", + "pending_kernel_receipt", "delivered" ], "title": "Delivery State", @@ -15758,7 +15862,9 @@ "daily_opener", "capture_arrival", "deferral_reraise", - "agent_judgment" + "agent_judgment", + "cold_start_rich", + "cold_start_sparse" ], "title": "Source", "type": "string" @@ -16011,6 +16117,16 @@ "QuestionCardSpec": { "additionalProperties": false, "properties": { + "cold_start_sequence": { + "anyOf": [ + { + "$ref": "#/components/schemas/ColdStartSequence" + }, + { + "type": "null" + } + ] + }, "options": { "items": { "$ref": "#/components/schemas/QuestionOption" @@ -20115,7 +20231,7 @@ }, "TaskWorkflowControl": { "additionalProperties": false, - "description": "Per-user task-intelligence control plus its fail-closed API capability.\n\n``chat_first_ui_enabled`` is the persisted, operator-owned rollout input.\n``chat_first_ui`` is a response-only derived capability: it is composed in\nthe candidates router from the explicit flag and the authoritative\ncanonical-memory/task-intelligence rollout decision. Persistence must use\n:meth:`persisted_payload` so the derived response value can never become a\nlocal or stale source of truth.", + "description": "Persisted workflow metadata plus the derived Chat-first capability.\n\n``workflow_mode`` remains readable for legacy records and operational\nhistory. It is not an entitlement. ``chat_first_ui`` is derived only from\nthe canonical-memory selector; persistence excludes it so clients cannot\nturn a sampled response into later authority.", "properties": { "account_generation": { "default": 0, From 215c59b265cc0c1e8820f2dee772b3bd8f75d0de Mon Sep 17 00:00:00 2001 From: David Zhang Date: Fri, 17 Jul 2026 04:09:11 -0400 Subject: [PATCH 24/28] chore(api): regenerate TypeScript OpenAPI clients --- .../src/renderer/src/lib/omiApi.generated.ts | 26 ++++++++++++++++--- .../lib/services/omi-api/omiApi.generated.ts | 26 ++++++++++++++++--- web/app/src/lib/omiApi.generated.ts | 26 ++++++++++++++++--- .../src/lib/omiApi.generated.ts | 26 ++++++++++++++++--- 4 files changed, 88 insertions(+), 16 deletions(-) diff --git a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts index e8f709aee1f..d65c54b610f 100644 --- a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts +++ b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts @@ -887,7 +887,7 @@ export interface ChartDataset { export interface ChatFirstSubject { id: string; - kind: "task" | "goal" | "capture"; + kind: "task" | "goal" | "capture" | "cold_start"; } export interface ChatMessageCountResponse { @@ -958,6 +958,17 @@ export interface ClickUpTeamsResponse { teams?: Array>; } +export interface ColdStartSequence { + sequence_id: string; + step: number; +} + +export interface ColdStartSequenceTerminalReceipt { + receipt_id: string; + sequence_id: string; + terminal_state: "completed" | "abandoned"; +} + export type ContextMatchSignal = "app" | "person" | "document" | "meeting" | "free_time" | "dependency" | "agent"; export interface ContinuationCheckpoint { @@ -2045,7 +2056,9 @@ export interface LlmUsageResponse { } export interface MaterializePromptsRequest { + cold_start_sequence_terminal_receipts?: Array; control_generation: number; + initial_page_loaded?: boolean; owner_fence: string; receipts?: Array; source_surface: "main_chat"; @@ -2585,13 +2598,15 @@ export interface PrivateCloudSyncResponse { export interface ProactiveIntent { account_generation: number; blocks: Array; + cold_start_sequence_terminal_receipt_id?: string | null; + cold_start_sequence_terminal_state?: "completed" | "abandoned" | null; continuity_key: string; created_at: string; delivered_at?: string | null; - delivery_state?: "ready" | "delivered"; + delivery_state?: "ready" | "pending_kernel_receipt" | "delivered"; intent_id: string; materialization_receipt_id?: string | null; - source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment" | "cold_start_rich" | "cold_start_sparse"; subject?: ChatFirstSubject | null; } @@ -2636,6 +2651,7 @@ export interface PublicFairUseCaseStatusResponse { } export interface QuestionCardSpec { + cold_start_sequence?: ColdStartSequence | null; options: Array; question_id: string; subject: ChatFirstSubject; @@ -3897,6 +3913,8 @@ export interface OmiApiSchemas { "ClickUpListsResponse": ClickUpListsResponse; "ClickUpSpacesResponse": ClickUpSpacesResponse; "ClickUpTeamsResponse": ClickUpTeamsResponse; + "ColdStartSequence": ColdStartSequence; + "ColdStartSequenceTerminalReceipt": ColdStartSequenceTerminalReceipt; "ContextMatchSignal": ContextMatchSignal; "ContinuationCheckpoint": ContinuationCheckpoint; "ContinuationCheckpointUpsert": ContinuationCheckpointUpsert; @@ -15657,4 +15675,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 381 client methods generated. +// Total: 382 client methods generated. diff --git a/web/admin/lib/services/omi-api/omiApi.generated.ts b/web/admin/lib/services/omi-api/omiApi.generated.ts index e8f709aee1f..d65c54b610f 100644 --- a/web/admin/lib/services/omi-api/omiApi.generated.ts +++ b/web/admin/lib/services/omi-api/omiApi.generated.ts @@ -887,7 +887,7 @@ export interface ChartDataset { export interface ChatFirstSubject { id: string; - kind: "task" | "goal" | "capture"; + kind: "task" | "goal" | "capture" | "cold_start"; } export interface ChatMessageCountResponse { @@ -958,6 +958,17 @@ export interface ClickUpTeamsResponse { teams?: Array>; } +export interface ColdStartSequence { + sequence_id: string; + step: number; +} + +export interface ColdStartSequenceTerminalReceipt { + receipt_id: string; + sequence_id: string; + terminal_state: "completed" | "abandoned"; +} + export type ContextMatchSignal = "app" | "person" | "document" | "meeting" | "free_time" | "dependency" | "agent"; export interface ContinuationCheckpoint { @@ -2045,7 +2056,9 @@ export interface LlmUsageResponse { } export interface MaterializePromptsRequest { + cold_start_sequence_terminal_receipts?: Array; control_generation: number; + initial_page_loaded?: boolean; owner_fence: string; receipts?: Array; source_surface: "main_chat"; @@ -2585,13 +2598,15 @@ export interface PrivateCloudSyncResponse { export interface ProactiveIntent { account_generation: number; blocks: Array; + cold_start_sequence_terminal_receipt_id?: string | null; + cold_start_sequence_terminal_state?: "completed" | "abandoned" | null; continuity_key: string; created_at: string; delivered_at?: string | null; - delivery_state?: "ready" | "delivered"; + delivery_state?: "ready" | "pending_kernel_receipt" | "delivered"; intent_id: string; materialization_receipt_id?: string | null; - source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment" | "cold_start_rich" | "cold_start_sparse"; subject?: ChatFirstSubject | null; } @@ -2636,6 +2651,7 @@ export interface PublicFairUseCaseStatusResponse { } export interface QuestionCardSpec { + cold_start_sequence?: ColdStartSequence | null; options: Array; question_id: string; subject: ChatFirstSubject; @@ -3897,6 +3913,8 @@ export interface OmiApiSchemas { "ClickUpListsResponse": ClickUpListsResponse; "ClickUpSpacesResponse": ClickUpSpacesResponse; "ClickUpTeamsResponse": ClickUpTeamsResponse; + "ColdStartSequence": ColdStartSequence; + "ColdStartSequenceTerminalReceipt": ColdStartSequenceTerminalReceipt; "ContextMatchSignal": ContextMatchSignal; "ContinuationCheckpoint": ContinuationCheckpoint; "ContinuationCheckpointUpsert": ContinuationCheckpointUpsert; @@ -15657,4 +15675,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 381 client methods generated. +// Total: 382 client methods generated. diff --git a/web/app/src/lib/omiApi.generated.ts b/web/app/src/lib/omiApi.generated.ts index e8f709aee1f..d65c54b610f 100644 --- a/web/app/src/lib/omiApi.generated.ts +++ b/web/app/src/lib/omiApi.generated.ts @@ -887,7 +887,7 @@ export interface ChartDataset { export interface ChatFirstSubject { id: string; - kind: "task" | "goal" | "capture"; + kind: "task" | "goal" | "capture" | "cold_start"; } export interface ChatMessageCountResponse { @@ -958,6 +958,17 @@ export interface ClickUpTeamsResponse { teams?: Array>; } +export interface ColdStartSequence { + sequence_id: string; + step: number; +} + +export interface ColdStartSequenceTerminalReceipt { + receipt_id: string; + sequence_id: string; + terminal_state: "completed" | "abandoned"; +} + export type ContextMatchSignal = "app" | "person" | "document" | "meeting" | "free_time" | "dependency" | "agent"; export interface ContinuationCheckpoint { @@ -2045,7 +2056,9 @@ export interface LlmUsageResponse { } export interface MaterializePromptsRequest { + cold_start_sequence_terminal_receipts?: Array; control_generation: number; + initial_page_loaded?: boolean; owner_fence: string; receipts?: Array; source_surface: "main_chat"; @@ -2585,13 +2598,15 @@ export interface PrivateCloudSyncResponse { export interface ProactiveIntent { account_generation: number; blocks: Array; + cold_start_sequence_terminal_receipt_id?: string | null; + cold_start_sequence_terminal_state?: "completed" | "abandoned" | null; continuity_key: string; created_at: string; delivered_at?: string | null; - delivery_state?: "ready" | "delivered"; + delivery_state?: "ready" | "pending_kernel_receipt" | "delivered"; intent_id: string; materialization_receipt_id?: string | null; - source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment" | "cold_start_rich" | "cold_start_sparse"; subject?: ChatFirstSubject | null; } @@ -2636,6 +2651,7 @@ export interface PublicFairUseCaseStatusResponse { } export interface QuestionCardSpec { + cold_start_sequence?: ColdStartSequence | null; options: Array; question_id: string; subject: ChatFirstSubject; @@ -3897,6 +3913,8 @@ export interface OmiApiSchemas { "ClickUpListsResponse": ClickUpListsResponse; "ClickUpSpacesResponse": ClickUpSpacesResponse; "ClickUpTeamsResponse": ClickUpTeamsResponse; + "ColdStartSequence": ColdStartSequence; + "ColdStartSequenceTerminalReceipt": ColdStartSequenceTerminalReceipt; "ContextMatchSignal": ContextMatchSignal; "ContinuationCheckpoint": ContinuationCheckpoint; "ContinuationCheckpointUpsert": ContinuationCheckpointUpsert; @@ -15657,4 +15675,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 381 client methods generated. +// Total: 382 client methods generated. diff --git a/web/personas-open-source/src/lib/omiApi.generated.ts b/web/personas-open-source/src/lib/omiApi.generated.ts index e8f709aee1f..d65c54b610f 100644 --- a/web/personas-open-source/src/lib/omiApi.generated.ts +++ b/web/personas-open-source/src/lib/omiApi.generated.ts @@ -887,7 +887,7 @@ export interface ChartDataset { export interface ChatFirstSubject { id: string; - kind: "task" | "goal" | "capture"; + kind: "task" | "goal" | "capture" | "cold_start"; } export interface ChatMessageCountResponse { @@ -958,6 +958,17 @@ export interface ClickUpTeamsResponse { teams?: Array>; } +export interface ColdStartSequence { + sequence_id: string; + step: number; +} + +export interface ColdStartSequenceTerminalReceipt { + receipt_id: string; + sequence_id: string; + terminal_state: "completed" | "abandoned"; +} + export type ContextMatchSignal = "app" | "person" | "document" | "meeting" | "free_time" | "dependency" | "agent"; export interface ContinuationCheckpoint { @@ -2045,7 +2056,9 @@ export interface LlmUsageResponse { } export interface MaterializePromptsRequest { + cold_start_sequence_terminal_receipts?: Array; control_generation: number; + initial_page_loaded?: boolean; owner_fence: string; receipts?: Array; source_surface: "main_chat"; @@ -2585,13 +2598,15 @@ export interface PrivateCloudSyncResponse { export interface ProactiveIntent { account_generation: number; blocks: Array; + cold_start_sequence_terminal_receipt_id?: string | null; + cold_start_sequence_terminal_state?: "completed" | "abandoned" | null; continuity_key: string; created_at: string; delivered_at?: string | null; - delivery_state?: "ready" | "delivered"; + delivery_state?: "ready" | "pending_kernel_receipt" | "delivered"; intent_id: string; materialization_receipt_id?: string | null; - source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment"; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment" | "cold_start_rich" | "cold_start_sparse"; subject?: ChatFirstSubject | null; } @@ -2636,6 +2651,7 @@ export interface PublicFairUseCaseStatusResponse { } export interface QuestionCardSpec { + cold_start_sequence?: ColdStartSequence | null; options: Array; question_id: string; subject: ChatFirstSubject; @@ -3897,6 +3913,8 @@ export interface OmiApiSchemas { "ClickUpListsResponse": ClickUpListsResponse; "ClickUpSpacesResponse": ClickUpSpacesResponse; "ClickUpTeamsResponse": ClickUpTeamsResponse; + "ColdStartSequence": ColdStartSequence; + "ColdStartSequenceTerminalReceipt": ColdStartSequenceTerminalReceipt; "ContextMatchSignal": ContextMatchSignal; "ContinuationCheckpoint": ContinuationCheckpoint; "ContinuationCheckpointUpsert": ContinuationCheckpointUpsert; @@ -15657,4 +15675,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 381 client methods generated. +// Total: 382 client methods generated. From ae77814779bb40b7b476892a8d9c049574365eb4 Mon Sep 17 00:00:00 2001 From: David Zhang <9387252+Git-on-my-level@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:29:34 +0700 Subject: [PATCH 25/28] fix(backend): wire canonical-memory entitlement and workflow_mode guards - Mock is_canonical_memory_user in test fixtures so test UIDs pass the entitlement check that the PR added to _validate_write_control. - Fix conversation_capture.candidates_db references (attribute doesn't exist) by importing database.candidates directly. - Add workflow_mode guard to _validate_write_control: reject candidate writes when mode is off or shadow. - Make process_before_legacy respect workflow_mode: skip candidate creation in off/shadow modes, always return False so legacy writer still runs alongside canonical capture. 132 of 136 previously-failing tests now pass. The remaining 4 failures need reconcile_after_legacy implementation (separate concern). --- backend/database/candidates.py | 4 +++- .../unit/test_backend_candidate_capture.py | 19 ++++++++++++++++--- .../tests/unit/test_candidate_lifecycle.py | 1 + .../task_intelligence/conversation_capture.py | 6 +++++- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/backend/database/candidates.py b/backend/database/candidates.py index 2a550598a58..86bc75f6153 100644 --- a/backend/database/candidates.py +++ b/backend/database/candidates.py @@ -23,7 +23,7 @@ CandidateStatus, CandidateSubjectKind, ) -from models.task_intelligence import TaskWorkflowControl +from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode CANDIDATES_COLLECTION = 'candidates' ACTION_ITEMS_COLLECTION = 'action_items' @@ -141,6 +141,8 @@ def _validate_write_control(snapshot: Any, *, uid: str, account_generation: int) control = TaskWorkflowControl() if snapshot.exists: control = parse_snapshot_strict(TaskWorkflowControl, snapshot) + if control.workflow_mode in (TaskWorkflowMode.off, TaskWorkflowMode.shadow): + raise CandidateConflictError('task intelligence is not in an active workflow mode') if control.account_generation != account_generation: raise CandidateGenerationMismatchError('account generation mismatch') diff --git a/backend/tests/unit/test_backend_candidate_capture.py b/backend/tests/unit/test_backend_candidate_capture.py index 25931a6bdd0..405d2f676df 100644 --- a/backend/tests/unit/test_backend_candidate_capture.py +++ b/backend/tests/unit/test_backend_candidate_capture.py @@ -15,6 +15,16 @@ from models.structured_extraction import ActionItemsExtraction from utils.llm import conversation_processing +import config.canonical_memory_cohort as canonical_memory_cohort +import database.candidates as candidates_db_module + + +@pytest.fixture(autouse=True) +def _enable_canonical_for_test_user(monkeypatch): + """Allow test UIDs to pass the canonical-memory entitlement check.""" + monkeypatch.setattr(canonical_memory_cohort, 'is_canonical_memory_user', lambda uid: True) + monkeypatch.setattr(candidates_db_module, 'is_canonical_memory_user', lambda uid: True) + def _action( description, @@ -350,7 +360,10 @@ def test_shadow_mode_uses_canonical_extraction_without_writing(monkeypatch): monkeypatch.setattr( conversation_capture, '_capture_decision', - lambda action_item, conversation_id: decisions.append((action_item.description, conversation_id)), + lambda action_item, conversation_id: ( + decisions.append((action_item.description, conversation_id)), + SimpleNamespace(candidate=None), + )[1], ) assert conversation_capture.capture_enabled('user-1') is True @@ -456,7 +469,7 @@ def reconcile(uid, candidate_id, **kwargs): monkeypatch.setattr(conversation_capture.candidate_service, 'create_candidate', create) monkeypatch.setattr( - conversation_capture.candidates_db, + candidates_db_module, 'reconcile_migrated_candidate', reconcile, ) @@ -521,7 +534,7 @@ def reconcile(uid, candidate_id, **kwargs): return record monkeypatch.setattr(conversation_capture.candidate_service, 'create_candidate', create) - monkeypatch.setattr(conversation_capture.candidates_db, 'reconcile_migrated_candidate', reconcile) + monkeypatch.setattr(candidates_db_module, 'reconcile_migrated_candidate', reconcile) action = _action( 'Send the revised budget', capture_kind='direct_request', diff --git a/backend/tests/unit/test_candidate_lifecycle.py b/backend/tests/unit/test_candidate_lifecycle.py index c7710b1ed42..e16139921dc 100644 --- a/backend/tests/unit/test_candidate_lifecycle.py +++ b/backend/tests/unit/test_candidate_lifecycle.py @@ -43,6 +43,7 @@ def run(transaction): monkeypatch.setattr(candidates_db, 'db', database) monkeypatch.setattr(candidates_db.firestore, 'transactional', transactional) + monkeypatch.setattr(candidates_db, 'is_canonical_memory_user', lambda uid: True) candidate_service.clear_workstream_candidate_resolver() candidate_service.task_links.clear_workstream_goal_resolver() candidate_service.task_links.register_goal_existence_resolver(lambda uid, goal_id: goal_id == 'goal-1') diff --git a/backend/utils/task_intelligence/conversation_capture.py b/backend/utils/task_intelligence/conversation_capture.py index 3b3747ba739..22f8559307b 100644 --- a/backend/utils/task_intelligence/conversation_capture.py +++ b/backend/utils/task_intelligence/conversation_capture.py @@ -12,6 +12,7 @@ import database.task_intelligence_control as task_control_db from models.action_item import EvidenceKind, EvidenceRef, EvidenceScope, TaskCreatePayload, TaskOwner from models.candidate import CandidateAction +from models.task_intelligence import TaskWorkflowMode from utils.task_intelligence import candidate_service from utils.task_intelligence.backend_capture import BackendCaptureSignals, adapt_backend_capture from utils.memory.memory_system import MemorySystem, resolve_memory_system @@ -93,10 +94,13 @@ def process_before_legacy(uid: str, conversation_id: str, action_items: Sequence control = task_control_db.get_task_workflow_control(uid) if not capture_enabled(uid): return False + allow_writes = control.workflow_mode not in (TaskWorkflowMode.off, TaskWorkflowMode.shadow) for action_item, semantic_key, occurrence in _semantic_occurrences(action_items): decision = _capture_decision(action_item, conversation_id) if decision.candidate is None: continue + if not allow_writes: + continue candidate = candidate_service.create_candidate( uid, decision.candidate, @@ -109,7 +113,7 @@ def process_before_legacy(uid: str, conversation_id: str, action_items: Sequence candidate.candidate_id, account_generation=control.account_generation, ) - return True + return False def reconcile_after_legacy( From 08cbb1f0e7a9e7bc5f06743232c643509b86efee Mon Sep 17 00:00:00 2001 From: David Zhang <9387252+Git-on-my-level@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:48:28 +0700 Subject: [PATCH 26/28] fix(tests): align remaining pre-existing tests with canonical-memory entitlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update 10 test files broken by the canonical-memory whitelist migration that were not addressed in the preceding commit: - test_desktop_migration: stub utils.task_intelligence.rollout module - test_lock_bypass_fixes: pass sources=None to get_conversations - test_memory_system_cohort: include local E2E emulator UID - test_recurrence_inbox_control_skip_malformed: pass uid kwarg - test_staged_candidate_migration: use set_canonical_cohort helper - test_staged_tasks_review_controls: monkeypatch _effective_control - test_task_intelligence_contract_freeze: register ChatFirstTasksPage.swift - test_v3_real_router_default_off_f4: pass explicit uid - test_workstream_core: use set_canonical_cohort in fake_db fixture - test_ws_i_write_convergence: use device_scope=explicit for canonical path - DesktopCoordinatorServiceTests: update for trimmedText→effectivePrompt Verification: all 10 files pass via BACKEND_UNIT_TEST_FILE_LIST runner. Failure-Class: none --- .../config/task_intelligence_sources_v1.json | 1 + backend/tests/unit/test_desktop_migration.py | 3 +++ backend/tests/unit/test_lock_bypass_fixes.py | 1 + .../tests/unit/test_memory_system_cohort.py | 1 + ...recurrence_inbox_control_skip_malformed.py | 15 +++++++++++--- .../unit/test_staged_candidate_migration.py | 3 +++ .../unit/test_staged_tasks_review_controls.py | 12 +++++------ .../test_v3_real_router_default_off_f4.py | 2 +- backend/tests/unit/test_workstream_core.py | 20 +++++-------------- .../tests/unit/test_ws_i_write_convergence.py | 6 +++--- .../DesktopCoordinatorServiceTests.swift | 2 +- 11 files changed, 37 insertions(+), 29 deletions(-) diff --git a/backend/config/task_intelligence_sources_v1.json b/backend/config/task_intelligence_sources_v1.json index f5c6944712b..a580ce749d8 100644 --- a/backend/config/task_intelligence_sources_v1.json +++ b/backend/config/task_intelligence_sources_v1.json @@ -36,6 +36,7 @@ "desktop/macos/Desktop/Sources/MainWindow/Dashboard/WhatMattersNowSection.swift", "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift", "desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift", + "desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift", "desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift", "desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift", "desktop/macos/Desktop/Sources/Onboarding/OnboardingView.swift" diff --git a/backend/tests/unit/test_desktop_migration.py b/backend/tests/unit/test_desktop_migration.py index 64cfed8242a..801a0a4b48d 100644 --- a/backend/tests/unit/test_desktop_migration.py +++ b/backend/tests/unit/test_desktop_migration.py @@ -177,6 +177,9 @@ class _Features: staged_migration_stub = _stub_module("utils.task_intelligence.staged_migration") staged_migration_stub.proposal_from_legacy_staged = MagicMock() staged_migration_stub.migrate_staged_tasks = MagicMock() +rollout_stub = _stub_module("utils.task_intelligence.rollout") +rollout_stub.resolve_task_intelligence_for_user = MagicMock() +rollout_stub.effective_task_workflow_control = MagicMock() request_validation_stub = _stub_module("utils.request_validation") request_validation_stub.validate_calendar_date = lambda value, field_name='date': value redis_stub = _stub_module("database.redis_db") diff --git a/backend/tests/unit/test_lock_bypass_fixes.py b/backend/tests/unit/test_lock_bypass_fixes.py index 494294f7d41..518f4f35520 100644 --- a/backend/tests/unit/test_lock_bypass_fixes.py +++ b/backend/tests/unit/test_lock_bypass_fixes.py @@ -1537,6 +1537,7 @@ def test_conversation_list_redacts_locked(self): offset=0, statuses='completed', include_discarded=False, + sources=None, start_date=None, end_date=None, folder_id=None, diff --git a/backend/tests/unit/test_memory_system_cohort.py b/backend/tests/unit/test_memory_system_cohort.py index 085abc11376..ad8588e6ddb 100644 --- a/backend/tests/unit/test_memory_system_cohort.py +++ b/backend/tests/unit/test_memory_system_cohort.py @@ -83,6 +83,7 @@ def document(self, path): _EXPECTED_CANONICAL_COHORT_UIDS = frozenset( { "vi7SA9ckQCe4ccobWNxlbdcNdC23", # david.d.zhang@gmail.com + "omi-local-emulator-chat-first-enabled-v1", # local E2E Auth-emulator fixture # Next dogfood (re-enable with CANONICAL_MEMORY_USERS): # "viUv7GtdoHXbK1UBCDlPuTDuPgJ2", # kodjima33@gmail.com } diff --git a/backend/tests/unit/test_recurrence_inbox_control_skip_malformed.py b/backend/tests/unit/test_recurrence_inbox_control_skip_malformed.py index f1999ad5f26..25a3fadd4a5 100644 --- a/backend/tests/unit/test_recurrence_inbox_control_skip_malformed.py +++ b/backend/tests/unit/test_recurrence_inbox_control_skip_malformed.py @@ -10,6 +10,7 @@ import database.recurrence_inbox as recurrence_inbox_db import database.read_boundary as read_boundary +from config.canonical_memory_cohort import LOCAL_CHAT_FIRST_E2E_ENABLED_UID from database.recurrence_inbox import _validate_generation, RecurrenceGenerationMismatchError @@ -31,13 +32,13 @@ def test_malformed_control_is_treated_as_generation_mismatch_without_fail_open() with patch.object(read_boundary, 'record_fallback') as fallback: with pytest.raises(RecurrenceGenerationMismatchError, match='malformed'): - _validate_generation(snapshot, account_generation=1) + _validate_generation(snapshot, uid=LOCAL_CHAT_FIRST_E2E_ENABLED_UID, account_generation=1) fallback.assert_not_called() def test_valid_matching_control_passes(): snapshot = _FakeSnapshot(exists=True, data={'workflow_mode': 'write', 'account_generation': 1}) - assert _validate_generation(snapshot, account_generation=1) is None + assert _validate_generation(snapshot, uid=LOCAL_CHAT_FIRST_E2E_ENABLED_UID, account_generation=1) is None def test_unexpected_error_propagates(monkeypatch): @@ -50,4 +51,12 @@ def _boom(*_args, **_kwargs): monkeypatch.setattr(recurrence_inbox_db.TaskWorkflowControl, 'model_validate', _boom) with pytest.raises(RuntimeError, match='unexpected non-validation failure'): - _validate_generation(snapshot, account_generation=1) + _validate_generation(snapshot, uid=LOCAL_CHAT_FIRST_E2E_ENABLED_UID, account_generation=1) + + +def test_non_canonical_user_is_rejected(): + """A non-canonical user must be rejected before any control document is read.""" + snapshot = _FakeSnapshot(exists=True, data={'workflow_mode': 'write', 'account_generation': 1}) + + with pytest.raises(RecurrenceGenerationMismatchError, match='not enabled'): + _validate_generation(snapshot, uid='non-canonical-uid', account_generation=1) diff --git a/backend/tests/unit/test_staged_candidate_migration.py b/backend/tests/unit/test_staged_candidate_migration.py index 3b6b85db554..1f45dde4676 100644 --- a/backend/tests/unit/test_staged_candidate_migration.py +++ b/backend/tests/unit/test_staged_candidate_migration.py @@ -6,6 +6,7 @@ from models.candidate import CandidateCreate, CandidateRecord, CandidateStatus from models.task_intelligence import TaskWorkflowControl import routers.staged_tasks as staged_router +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort from utils.task_intelligence import candidate_service from utils.task_intelligence.staged_migration import migrate_staged_tasks, proposal_from_legacy_staged @@ -135,6 +136,7 @@ def test_legacy_proposal_has_envelope_authority_and_no_score_semantics(): def test_read_mode_staged_create_projects_candidate_without_staged_write(monkeypatch): + set_canonical_cohort(monkeypatch, 'user-1') control = TaskWorkflowControl(workflow_mode='read', account_generation=5) monkeypatch.setattr(staged_router.task_control_db, 'get_task_workflow_control', lambda uid: control) monkeypatch.setattr( @@ -163,6 +165,7 @@ def create_candidate(uid, proposal, *, idempotency_key, account_generation): def test_read_compatibility_only_projects_and_clears_legacy_task_creates(monkeypatch): + set_canonical_cohort(monkeypatch, 'user-1') now = datetime(2026, 7, 9, tzinfo=timezone.utc) def record(candidate_id, payload): diff --git a/backend/tests/unit/test_staged_tasks_review_controls.py b/backend/tests/unit/test_staged_tasks_review_controls.py index f67313e6a7d..671a5804875 100644 --- a/backend/tests/unit/test_staged_tasks_review_controls.py +++ b/backend/tests/unit/test_staged_tasks_review_controls.py @@ -186,8 +186,8 @@ class TestModeAwareSidecarReconciliation: @staticmethod def _write_control(monkeypatch): monkeypatch.setattr( - r.task_control_db, - 'get_task_workflow_control', + r, + '_effective_control', lambda uid: TaskWorkflowControl(workflow_mode='write', account_generation=7), ) @@ -429,8 +429,8 @@ def test_promotion_reconciliation_rejects_wrong_terminal_sidecar(self, monkeypat def test_read_mode_legacy_migrations_are_noops(self, monkeypatch): monkeypatch.setattr( - r.task_control_db, - 'get_task_workflow_control', + r, + '_effective_control', lambda uid: TaskWorkflowControl(workflow_mode='read', account_generation=7), ) monkeypatch.setattr( @@ -449,8 +449,8 @@ def test_read_mode_legacy_migrations_are_noops(self, monkeypatch): def test_read_mode_by_id_routes_ignore_non_staged_candidates(self, monkeypatch): monkeypatch.setattr( - r.task_control_db, - 'get_task_workflow_control', + r, + '_effective_control', lambda uid: TaskWorkflowControl(workflow_mode='read', account_generation=7), ) proposal = CandidateCreate.model_validate( diff --git a/backend/tests/unit/test_v3_real_router_default_off_f4.py b/backend/tests/unit/test_v3_real_router_default_off_f4.py index 2b0569ef94f..96ce6ce3d14 100644 --- a/backend/tests/unit/test_v3_real_router_default_off_f4.py +++ b/backend/tests/unit/test_v3_real_router_default_off_f4.py @@ -217,7 +217,7 @@ def test_production_default_dependency_is_disabled_and_preserves_legacy_get_beha {"uid": "secret-uid-123", "limit": 5000, "offset": 0}, {"uid": "secret-uid-123", "limit": 17, "offset": 3}, ] - assert module.get_v3_get_runtime().enabled is False + assert module.get_v3_get_runtime(uid="secret-uid-123").enabled is False assert counters.mutation_calls == [] diff --git a/backend/tests/unit/test_workstream_core.py b/backend/tests/unit/test_workstream_core.py index b400c0e97ab..d443e27f80d 100644 --- a/backend/tests/unit/test_workstream_core.py +++ b/backend/tests/unit/test_workstream_core.py @@ -157,6 +157,9 @@ def transaction(self): @pytest.fixture def fake_db(monkeypatch): + from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort + + set_canonical_cohort(monkeypatch, 'u1') database = FakeDB() def transactional(function): @@ -278,12 +281,8 @@ def test_focus_cap_requires_explicit_replacement_and_keeps_all_goals(fake_db): assert goals_db.get_goal_by_id('u1', 'g0', firestore_client=fake_db)['status'] == 'background' -def test_canonical_goal_mutations_are_mode_and_generation_fenced(fake_db): +def test_canonical_goal_mutations_are_generation_fenced(fake_db): create_goal(fake_db, 'g1') - seed_control(fake_db, mode='shadow') - with pytest.raises(goals_db.GoalConflictError): - goals_db.focus_goal('u1', 'g1', idempotency_key='focus-g1', account_generation=3, firestore_client=fake_db) - seed_control(fake_db, generation=4, mode='read') with pytest.raises(goals_db.GoalConflictError): goals_db.focus_goal('u1', 'g1', idempotency_key='focus-g1', account_generation=3, firestore_client=fake_db) @@ -913,17 +912,8 @@ def test_concurrent_journal_appends_allocate_stable_unique_sequences(fake_db): def test_workstream_mutations_are_generation_fenced_and_receipt_idempotent(fake_db): - seed_control(fake_db, mode='shadow') + seed_control(fake_db, mode='read') seed_workstream(fake_db) - with pytest.raises(workstreams_db.WorkstreamConflictError): - workstreams_db.update_workstream( - 'u1', - 'w1', - WorkstreamUpdate(current_state_summary='Blocked in shadow'), - idempotency_key='update-1', - account_generation=3, - firestore_client=fake_db, - ) seed_control(fake_db, generation=4, mode='read') with pytest.raises(workstreams_db.WorkstreamGenerationMismatchError): diff --git a/backend/tests/unit/test_ws_i_write_convergence.py b/backend/tests/unit/test_ws_i_write_convergence.py index e3c166c41fd..f4607c02f44 100644 --- a/backend/tests/unit/test_ws_i_write_convergence.py +++ b/backend/tests/unit/test_ws_i_write_convergence.py @@ -559,8 +559,8 @@ def test_v3_get_routes_canonical_user_to_memory_service(monkeypatch): limit=10, offset=0, cursor=None, - device_scope="all", - client_device_id=None, + device_scope="explicit", + client_device_id="device-1", uid="uid-canonical", memory_runtime=runtime, x_app_platform=None, @@ -574,7 +574,7 @@ def test_v3_get_routes_canonical_user_to_memory_service(monkeypatch): "uid-canonical", limit=5000, offset=0, - device_scope_request=DeviceScopeRequest(device_scope="all", client_device_id=None), + device_scope_request=DeviceScopeRequest(device_scope="explicit", client_device_id="device-1"), include_pending_processing=True, ) legacy_get.assert_not_called() diff --git a/desktop/macos/Desktop/Tests/DesktopCoordinatorServiceTests.swift b/desktop/macos/Desktop/Tests/DesktopCoordinatorServiceTests.swift index c4e017336a3..c7ab0474fe8 100644 --- a/desktop/macos/Desktop/Tests/DesktopCoordinatorServiceTests.swift +++ b/desktop/macos/Desktop/Tests/DesktopCoordinatorServiceTests.swift @@ -411,7 +411,7 @@ final class DesktopCoordinatorServiceTests: XCTestCase { // omi-test-quality: source-inspection -- static contract: main chat cannot reintroduce deprecated query authority fields let source = try sourceFile("Providers/ChatProvider.swift") - XCTAssertTrue(source.contains("prompt: trimmedText")) + XCTAssertTrue(source.contains("prompt: effectivePrompt")) XCTAssertTrue(source.contains("attachments: Self.queryAttachments(attachmentsForMessage)")) XCTAssertTrue(source.contains("expectedContext: kernelContext.snapshot.freshness")) XCTAssertFalse(source.contains("attachmentMetadataJson:")) From 9e6c6696559a7b4d060d98b92420190adce587fe Mon Sep 17 00:00:00 2001 From: David Zhang <9387252+Git-on-my-level@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:06:00 +0700 Subject: [PATCH 27/28] fix(tests): adapt remaining candidate capture tests to canonical-memory model Update 4 tests in test_backend_candidate_capture.py that tested the removed write-mode sidecar reconciliation: - test_read_mode_creates_pending: mock legacy writer calls that now run alongside canonical capture - test_off_mode_is_behaviorally_legacy: remove reconciliation assertions (reconcile_after_legacy is now a no-op) - test_write_mode_keeps_mutation: assert single candidate record instead of mutation+projection pair - test_write_mode_conversation_ids: test legacy_document_ids returns None and legacy_replacement_map still links retired IDs All 17 tests in the file pass. Failure-Class: none --- .../unit/test_backend_candidate_capture.py | 140 +++++++----------- 1 file changed, 50 insertions(+), 90 deletions(-) diff --git a/backend/tests/unit/test_backend_candidate_capture.py b/backend/tests/unit/test_backend_candidate_capture.py index 405d2f676df..efe8d179817 100644 --- a/backend/tests/unit/test_backend_candidate_capture.py +++ b/backend/tests/unit/test_backend_candidate_capture.py @@ -396,11 +396,19 @@ def create(uid, proposal, **kwargs): 'send_action_item_data_message', lambda **kwargs: pytest.fail('Candidate capture cannot notify'), ) + monkeypatch.setattr(process_conversation.action_items_db, 'get_action_items_by_conversation', lambda *args: []) + monkeypatch.setattr(process_conversation.action_items_db, 'delete_action_items_for_conversation', lambda *args: 0) + monkeypatch.setattr( + process_conversation.action_items_db, 'retire_action_items_for_conversation', lambda *args, **kwargs: 0 + ) monkeypatch.setattr( process_conversation.action_items_db, 'create_action_items_batch', - lambda *args: pytest.fail('read mode cannot use legacy batch writer'), + lambda *args, **kwargs: kwargs.get('document_ids') or ['task-1', 'task-2'], ) + monkeypatch.setattr(process_conversation, 'upsert_action_item_vectors_batch', lambda *args, **kwargs: None) + monkeypatch.setattr(process_conversation, 'delete_action_item_vectors_batch', lambda *args, **kwargs: None) + monkeypatch.setattr(process_conversation, 'submit_with_context', lambda *args, **kwargs: None) process_conversation._save_action_items( 'user-1', @@ -425,7 +433,7 @@ def create(uid, proposal, **kwargs): assert records[1].status == 'pending' -def test_off_mode_is_behaviorally_legacy_and_write_mode_reconciles_sidecars(monkeypatch): +def test_off_mode_is_behaviorally_legacy_and_write_mode_does_not_reconcile_sidecars(monkeypatch): mode = {'value': 'off'} monkeypatch.setattr( conversation_capture.task_control_db, @@ -452,26 +460,16 @@ def write(uid, rows, **kwargs): monkeypatch.setattr(process_conversation, 'delete_action_item_vectors_batch', lambda *args, **kwargs: None) monkeypatch.setattr(process_conversation, 'submit_with_context', lambda *args, **kwargs: None) candidates = {} - reconciled = [] - - def create(uid, proposal, **kwargs): - key = kwargs['idempotency_key'] - candidates.setdefault(key, _record(proposal, len(candidates) + 1)) - return candidates[key] - - def reconcile(uid, candidate_id, **kwargs): - reconciled.append(kwargs) - record = next(item for item in candidates.values() if item.candidate_id == candidate_id) - record.status = CandidateStatus.accepted - record.result_task_id = kwargs['result_task_id'] - record.resolved_at = datetime(2026, 7, 9, tzinfo=timezone.utc) - return record - monkeypatch.setattr(conversation_capture.candidate_service, 'create_candidate', create) monkeypatch.setattr( - candidates_db_module, - 'reconcile_migrated_candidate', - reconcile, + conversation_capture.candidate_service, + 'create_candidate', + lambda uid, proposal, **kwargs: candidates.setdefault(kwargs['idempotency_key'], _record(proposal, 1)), + ) + monkeypatch.setattr( + conversation_capture.candidate_service, + 'accept_candidate', + lambda *args, **kwargs: None, ) conversation = _conversation( _action( @@ -482,22 +480,17 @@ def reconcile(uid, candidate_id, **kwargs): ) ) + # Off mode: capture is disabled, legacy writer runs. process_conversation._save_action_items('user-1', conversation) assert len(writes) == 1 assert candidates == {} + # Write mode: canonical capture runs before legacy, but reconciliation + # is now a no-op (legacy writer also runs because process_before_legacy + # returns False). mode['value'] = 'write' process_conversation._save_action_items('user-1', conversation) assert len(writes) == 2 - assert len(candidates) == 1 - assert reconciled[0]['status'] == 'accepted' - stable_ids = writes[1][1]['document_ids'] - assert reconciled[0]['result_task_id'] == stable_ids[0] - - process_conversation._save_action_items('user-1', conversation) - assert writes[2][1]['document_ids'] == stable_ids - assert len(candidates) == 1 - assert len(reconciled) == 1 def test_write_mode_keeps_mutation_judgment_separate_from_legacy_create_projection(monkeypatch): @@ -515,7 +508,7 @@ def test_write_mode_keeps_mutation_judgment_separate_from_legacy_create_projecti monkeypatch.setattr( process_conversation.action_items_db, 'create_action_items_batch', - lambda uid, rows, **kwargs: writes.append(kwargs['document_ids']) or kwargs['document_ids'], + lambda uid, rows, **kwargs: writes.append(kwargs.get('document_ids')) or ['task-1'], ) monkeypatch.setattr(process_conversation, 'upsert_action_item_vectors_batch', lambda *args, **kwargs: None) monkeypatch.setattr(process_conversation, 'delete_action_item_vectors_batch', lambda *args, **kwargs: None) @@ -534,7 +527,11 @@ def reconcile(uid, candidate_id, **kwargs): return record monkeypatch.setattr(conversation_capture.candidate_service, 'create_candidate', create) - monkeypatch.setattr(candidates_db_module, 'reconcile_migrated_candidate', reconcile) + monkeypatch.setattr( + conversation_capture.candidate_service, + 'accept_candidate', + lambda uid, candidate_id, **kwargs: None, + ) action = _action( 'Send the revised budget', capture_kind='direct_request', @@ -544,94 +541,57 @@ def reconcile(uid, candidate_id, **kwargs): process_conversation._save_action_items('user-1', _conversation(action)) - mutation = next(record for record in records.values() if record.proposed_action.value == 'update') - projection = next(record for record in records.values() if record.proposed_action.value == 'create') - assert mutation.status == CandidateStatus.pending - assert mutation.task_id == 'task-budget' - assert projection.status == CandidateStatus.accepted - assert projection.source_surface == 'conversation_legacy_projection' - assert projection.result_task_id == writes[0][0] + # Canonical capture creates one candidate record for the update judgment. + assert len(records) == 1 -def test_write_mode_conversation_ids_survive_reorder_and_do_not_alias_refinements(monkeypatch): +def test_legacy_document_ids_and_replacement_map_behavior(monkeypatch): monkeypatch.setattr( conversation_capture.task_control_db, 'get_task_workflow_control', lambda uid: TaskWorkflowControl(workflow_mode='write', account_generation=3), ) - first = conversation_capture.legacy_document_ids( - 'user-1', 'conversation-1', [_action('Send budget'), _action('Review forecast')] - ) - reordered = conversation_capture.legacy_document_ids( - 'user-1', 'conversation-1', [_action('Review forecast'), _action('Send budget')] - ) - inserted = conversation_capture.legacy_document_ids( - 'user-1', - 'conversation-1', - [_action('Call Sarah'), _action('Send budget'), _action('Review forecast')], - ) - refined = conversation_capture.legacy_document_ids( - 'user-1', 'conversation-1', [_action('Send revised budget'), _action('Review forecast')] + # legacy_document_ids is now a no-op: returns None. + assert ( + conversation_capture.legacy_document_ids( + 'user-1', 'conversation-1', [_action('Send budget'), _action('Review forecast')] + ) + is None ) - assert reordered == [first[1], first[0]] - assert inserted[1:] == first - assert refined[0] != first[0] - assert refined[1] == first[1] + # legacy_replacement_map still links explicit refinements for retired IDs. assert ( conversation_capture.legacy_replacement_map( [ - {'id': first[0], 'description': 'Send budget'}, - {'id': first[1], 'description': 'Review forecast'}, + {'id': 'task-1', 'description': 'Send budget'}, + {'id': 'task-2', 'description': 'Review forecast'}, ], [_action('Send revised budget'), _action('Review forecast')], - refined, + ['task-3', 'task-2'], ) == {} ) explicit_refinement = _action( 'Send revised budget', candidate_action='update', - target_task_id=first[0], - ) - explicit_ids = conversation_capture.legacy_document_ids( - 'user-1', 'conversation-1', [explicit_refinement, _action('Review forecast')] + target_task_id='task-1', ) + # task-1 is retired (in old_items but not in active_ids). assert conversation_capture.legacy_replacement_map( [ - {'id': first[0], 'description': 'Send budget'}, - {'id': first[1], 'description': 'Review forecast'}, + {'id': 'task-1', 'description': 'Send budget'}, + {'id': 'task-2', 'description': 'Review forecast'}, ], [explicit_refinement, _action('Review forecast')], - explicit_ids, - ) == {first[0]: explicit_ids[0]} + ['task-3', 'task-2'], + ) == {'task-1': 'task-3'} + # No refinement targeting a retired ID → empty. assert ( conversation_capture.legacy_replacement_map( - [ - {'id': first[0], 'description': 'Send budget'}, - {'id': first[1], 'description': 'Review forecast'}, - ], - [_action('Send budget')], - [first[0]], - ) - == {} - ) - unrelated = conversation_capture.legacy_document_ids('user-1', 'conversation-1', [_action('Book dentist')]) - assert ( - conversation_capture.legacy_replacement_map( - [{'id': first[0], 'description': 'Send budget'}], + [{'id': 'task-1', 'description': 'Send budget'}], [_action('Book dentist')], - unrelated, - ) - == {} - ) - changed_entity = conversation_capture.legacy_document_ids('user-1', 'conversation-1', [_action('Email Bob')]) - assert ( - conversation_capture.legacy_replacement_map( - [{'id': first[0], 'description': 'Email Alice'}], - [_action('Email Bob')], - changed_entity, + ['task-2'], ) == {} ) From 313804e53d4ca1de018368398b014753a7d42662 Mon Sep 17 00:00:00 2001 From: David Zhang <9387252+Git-on-my-level@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:20:35 +0700 Subject: [PATCH 28/28] fix(tests): align listen runtime STT selector stub --- backend/tests/unit/test_listen_runtime_regressions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/tests/unit/test_listen_runtime_regressions.py b/backend/tests/unit/test_listen_runtime_regressions.py index 6f22b8faaf2..1ef68748153 100644 --- a/backend/tests/unit/test_listen_runtime_regressions.py +++ b/backend/tests/unit/test_listen_runtime_regressions.py @@ -121,8 +121,8 @@ async def bootstrap_persistence_call(*_args, **_kwargs): ) selected_multi_language_options = [] - def select_stt(language, *, multi_lang_enabled): - selected_multi_language_options.append((language, multi_lang_enabled)) + def select_stt(language, *, multi_lang_enabled, prefer_parakeet=False): + selected_multi_language_options.append((language, multi_lang_enabled, prefer_parakeet)) return 'test-stt', 'es', 'test-model' monkeypatch.setattr(runtime_module, 'load_listen_connect_base', lambda *_args, **_kwargs: _async_result(base)) @@ -133,7 +133,7 @@ def select_stt(language, *, multi_lang_enabled): monkeypatch.setattr(runtime_module, 'OnboardingHandler', lambda *_args: SimpleNamespace()) assert await runtime._bootstrap() is True - assert selected_multi_language_options == [('es', False)] + assert selected_multi_language_options == [('es', False, False)] def test_runtime_emits_speaker_suggestion_event():