diff --git a/.github/scripts/preflight_runner.py b/.github/scripts/preflight_runner.py index f1247c9155a..f9354043491 100755 --- a/.github/scripts/preflight_runner.py +++ b/.github/scripts/preflight_runner.py @@ -157,7 +157,9 @@ def run_owned( started = time.monotonic() started_wall = time.time() phase = "starting" + last_phase = phase child: subprocess.Popen[str] | None = None + received_signal: int | None = None def write_status() -> None: atomic_json( @@ -166,6 +168,8 @@ def write_status() -> None: "pid": os.getpid(), "fingerprint": wanted_fingerprint, "phase": phase, + "last_phase": last_phase, + "received_signal": received_signal, "elapsed_seconds": round(time.monotonic() - started, 1), "log": str(log_path), "started_at_epoch": started_wall, @@ -173,6 +177,15 @@ def write_status() -> None: ) def forward_signal(signum: int, _frame: object) -> None: + nonlocal received_signal + received_signal = signum + print( + f"FAIL: preflight runner received signal {signal.Signals(signum).name} " + f"during phase={phase}; forwarding it to the child.", + file=sys.stderr, + flush=True, + ) + write_status() if child is not None and child.poll() is None: try: os.killpg(child.pid, signum) @@ -210,10 +223,27 @@ def forward_signal(signum: int, _frame: object) -> None: log.flush() if line.startswith("==> "): phase = line[4:].strip() + last_phase = phase write_status() exit_code = child.wait() phase = "passed" if exit_code == 0 else "failed" write_status() + if exit_code != 0: + if exit_code < 0: + child_failure = f"child terminated by {signal.Signals(-exit_code).name}" + else: + child_failure = f"child exited with status {exit_code}" + signal_note = ( + f"; runner received {signal.Signals(received_signal).name}" + if received_signal is not None + else "" + ) + print( + f"FAIL: preflight {child_failure} during phase={last_phase}{signal_note}; " + f"inspect {log_path}", + file=sys.stderr, + flush=True, + ) atomic_json( result_path, { @@ -222,6 +252,8 @@ def forward_signal(signum: int, _frame: object) -> None: "elapsed_seconds": round(time.monotonic() - started, 1), "finished_at_epoch": time.time(), "log": str(log_path), + "last_phase": last_phase, + "received_signal": received_signal, }, ) return exit_code diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/backend-database.json b/.github/scripts/product_file_line_count_ratchet_baseline/backend-database.json index cdf493da682..0f0a9a12cab 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/backend-database.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/backend-database.json @@ -1,10 +1,10 @@ { "files": { - "backend/database/conversations.py": 1580, + "backend/database/conversations.py": 1601, "backend/database/users.py": 1943 }, "raise_justifications": { - "backend/database/conversations.py": "Public shared-chat uses the existing conversation storage format for bounded, safe transcript decoding; extraction would duplicate crypto/storage invariants." + "backend/database/conversations.py": "Chat-first capture archive filtering belongs beside the shared conversation query and cursor invariants; extraction would duplicate its ordered query contract." }, "threshold": 1500 } diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-chat.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-chat.json index 5101c2f81e1..535329f8688 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-chat.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-chat.json @@ -1,11 +1,11 @@ { "files": { - "desktop/macos/Desktop/Sources/Chat/AgentBridge.swift": 2209, - "desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift": 4314 + "desktop/macos/Desktop/Sources/Chat/AgentBridge.swift": 2189, + "desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift": 4338 }, "raise_justifications": { - "desktop/macos/Desktop/Sources/Chat/AgentBridge.swift": "per-turn reasoningEffort threaded through both query variants", - "desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift": "reasoningEffort added to the query wire message" + "desktop/macos/Desktop/Sources/Chat/AgentBridge.swift": "Named non-production fault bundle supplies an inert hermetic model token so fault injection reaches the configured remote 5xx backend without Firebase auth.", + "desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift": "Chat-first evidence attachment stays with the owner-bound runtime bridge so run, attempt, and capability fences remain atomic." }, "threshold": 1500 } diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json index 42034731498..44ce5a8f4a2 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json @@ -1,8 +1,8 @@ { "files": { "desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift": 2375, - "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": 2853, - "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": 4842, + "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": 2856, + "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": 4843, "desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": 2423, "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": 1621 }, diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-mainwindow.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-mainwindow.json index d4ab37d85cc..c63e797abba 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-mainwindow.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-mainwindow.json @@ -1,16 +1,16 @@ { "files": { - "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": 1926, + "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": 2055, "desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift": 3643, - "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift": 4543, + "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift": 4577, "desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift": 3298, "desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift": 5518, "desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift": 1570 }, "raise_justifications": { - "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": "Keeps all chat tool groups compact and collapsed while streamed steps increment, with an explicit expansion policy, fixed collapsed height, and regression coverage.", + "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": "Chat-first blocks and persisted local evidence render at the existing chat-message resource boundary instead of creating a parallel transcript renderer.", "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": "Home redesign: chat-as-home surface plus the 2nd-brain knows-list hub replace the wordmark/ribbon/WMN-card layout; a component split is tracked follow-up.", + "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift": "Chat-first entry state remains colocated with the dashboard's canonical route selection and lifecycle synchronization.", "desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift": "Bridge memory search now awaits the active initial or pagination projection before refreshing, preventing an immediate post-navigation marker search from using stale lifecycle capability state.", "desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift": "The Load-more-tasks action reads displayTasks.last at execution time via loadMoreTapped() instead of force-unwrapping (an emptied list no longer crashes); merged with main binding first-load/loading/error/retry state to the selected To Do or Done view.", "desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift": "Strict concurrency imports annotate legacy AppKit image usage without changing runtime behavior." diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-onboarding.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-onboarding.json index 206d1924fb7..f47be3425ff 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-onboarding.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-onboarding.json @@ -1,10 +1,10 @@ { "files": { - "desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift": 2197, + "desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift": 2199, "desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift": 1714 }, "raise_justifications": { - "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": "Chat-first capability wiring remains with onboarding's shared chat surface so rollout state cannot diverge across entry points.", "desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift": "Onboarding adopts the async-arriving auth name, cancels the local file import task in deinit, and carries the sharpened goal/task prompts on top of the terminal-scan-state helper." }, "threshold": 1500 diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-proactiveassistants.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-proactiveassistants.json index 3b33b23d57b..a1ca1e2ede5 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-proactiveassistants.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-proactiveassistants.json @@ -1,7 +1,7 @@ { "files": { "desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift": 1742, - "desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift": 1641 + "desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift": 1614 }, "raise_justifications": { "desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift": "Task candidate context now carries only backend action-item IDs, while local SQLite/staged evidence remains id-less so the model cannot target it for a backend mutation.", diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-providers.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-providers.json index 6adec2dd416..df11ccc677a 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-providers.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-providers.json @@ -1,11 +1,11 @@ { "files": { - "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": 6214, - "desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift": 2861 + "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": 6723, + "desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift": 3080 }, "raise_justifications": { - "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": "ChatTurnOwner reasoning-effort lane derivation", - "desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift": "Already-granted permissions no longer reopen System Settings: screen-recording and full-disk-access requests gate their Settings/drag-card opens behind the granted result." + "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": "Chat-first uses the existing provider-owned generation, journal, and owner-fence lifecycle; extracting its canonical context cleanup would split that boundary.", + "desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift": "Canonical goal reads and owner-scoped Rewind evidence remain beside the existing authorized tool dispatch and sharing-policy boundary." }, "threshold": 1500 } diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json index 582b87b2f6f..eea9d1b86cf 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json @@ -2,15 +2,15 @@ "files": { "desktop/macos/Desktop/Sources/AuthService.swift": 3294, "desktop/macos/Desktop/Sources/CloudConnectorFormAutomation.swift": 1678, - "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": 4239, + "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": 4261, "desktop/macos/Desktop/Sources/MemoryExportService.swift": 1578, "desktop/macos/Desktop/Sources/OmiApp.swift": 1645 }, "raise_justifications": { "desktop/macos/Desktop/Sources/AuthService.swift": "Apple first-auth name capture signals observers (authNameDidUpdate) and persists the name to Firebase so it survives reinstalls.", - "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": "Reach-error card gains a debug bridge action so the actionable failure surface is verifiable in-process.", + "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": "Chat-first runtime automation remains in the owning desktop bridge so named-bundle UI verification keeps its lifecycle boundary.", "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": "shared openMainAppWindow helper for menu bar + Open Omi shortcut" + "desktop/macos/Desktop/Sources/OmiApp.swift": "Side-by-side Omi Beta: legacy Omi Computer.app cleanup gains a stable-identity guard so the beta app can never terminate or delete a stable install." }, "threshold": 1500 } diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-stores.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-stores.json index 08382a51fc7..506bdb0383f 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-stores.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-stores.json @@ -1,9 +1,9 @@ { "files": { - "desktop/macos/Desktop/Sources/Stores/TasksStore.swift": 3117 + "desktop/macos/Desktop/Sources/Stores/TasksStore.swift": 3273 }, "raise_justifications": { - "desktop/macos/Desktop/Sources/Stores/TasksStore.swift": "Unsynced local_ task mutations skip backend calls that can only 404; auto-refresh clamps only the API page to the 500 cap while the local reload keeps every loaded row (no >500 truncation); undo-restore of a local-only task re-inserts one unsynced local row instead of fabricating a synced backend id + duplicate; retry-sync carries completed state; and undo-restore re-creates with the full field set." + "desktop/macos/Desktop/Sources/Stores/TasksStore.swift": "Unsynced local task mutations and Chat-first task-card rollback remain with the authoritative task persistence and owner-fence transitions." }, "threshold": 1500 } diff --git a/.github/scripts/test_pr_preflight.py b/.github/scripts/test_pr_preflight.py index f83c100e886..1ff3f197a51 100755 --- a/.github/scripts/test_pr_preflight.py +++ b/.github/scripts/test_pr_preflight.py @@ -477,6 +477,24 @@ def test_different_input_is_rejected_while_active(self) -> None: first.stdout.close() self.assertEqual(first.wait(), 0) + def test_failure_reports_exit_status_and_last_phase(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + temp = Path(tmp) + command = [sys.executable, "-c", "print('==> deliberate-failure', flush=True); raise SystemExit(17)"] + process = self.run_runner(temp, command) + assert process.stdin is not None + process.stdin.close() + output = process.stdout.read() if process.stdout else "" + self.assertEqual(process.wait(), 17, output) + if process.stdout: + process.stdout.close() + self.assertIn("child exited with status 17", output) + self.assertIn("phase=deliberate-failure", output) + result = json.loads((temp / "test" / "result.json").read_text()) + self.assertEqual(result["exit_code"], 17) + self.assertEqual(result["last_phase"], "deliberate-failure") + self.assertIsNone(result["received_signal"]) + if __name__ == "__main__": unittest.main() diff --git a/Makefile b/Makefile index 360e39823ed..3d74f3f3bd8 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,11 @@ PYTHON ?= $(shell bash -c 'source "$$(git rev-parse --show-toplevel)/scripts/dev export PYTHON 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." @@ -79,5 +82,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/.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 a47e48a43b9..317ea013fe9 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -165,7 +165,7 @@ Serving STT provider/surface policy and canonical model order are owned exclusiv - **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..7e6b6c856ef --- /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: object) -> bool: + """Return whether ``uid`` belongs to the sole canonical entitlement cohort.""" + + return bool(uid) and isinstance(uid, str) 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 new file mode 100644 index 00000000000..4995e9293a2 --- /dev/null +++ b/backend/config/chat_first_e2e_fixture.py @@ -0,0 +1,78 @@ +"""Firebase Auth emulator identities and runtime guard for Chat-first E2E. + +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 + +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' + + +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_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_fixture_uid', + 'is_chat_first_e2e_harness_runtime', +] diff --git a/backend/config/task_intelligence_sources_v1.json b/backend/config/task_intelligence_sources_v1.json index f5c6944712b..3ce38ab3d73 100644 --- a/backend/config/task_intelligence_sources_v1.json +++ b/backend/config/task_intelligence_sources_v1.json @@ -33,6 +33,7 @@ "owner_paths": [ "desktop/macos/Desktop/Sources/Stores/TasksStore.swift", "desktop/macos/Desktop/Sources/APIClient.swift", + "desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift", "desktop/macos/Desktop/Sources/MainWindow/Dashboard/WhatMattersNowSection.swift", "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift", "desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift", @@ -41,7 +42,10 @@ "desktop/macos/Desktop/Sources/Onboarding/OnboardingView.swift" ], "test_adapter": "direct_command_contract", - "writer_anchors": [] + "writer_anchors": [ + {"path": "desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift", "symbol": "client.createTask", "discover": true}, + {"path": "desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift", "symbol": "client.updateTask", "discover": true} + ] }, { "id": "chat_voice_tools", 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 new file mode 100644 index 00000000000..268fefdab85 --- /dev/null +++ b/backend/database/chat_first_intents.py @@ -0,0 +1,788 @@ +"""Durable Chat-first proactive intent state, separate from the chat journal.""" + +import hashlib +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from typing import Any, Iterable + +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 ( + ChatFirstBlockSpec, + ChatFirstSubject, + ColdStartSequenceTerminalState, + DeferralReceipt, + ProactiveBudgetState, + ProactiveDeferral, + ProactiveIntent, + ProactiveIntentSource, + QuestionCardSpec, +) +from models.proactive_budget import account_materialization, normalized_budget_state, reserve_budget +from models.task_intelligence import TaskWorkflowControl + +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 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') + 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.account_generation != account_generation: + 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) + 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.""" + + _require_control( + _control_ref(uid, firestore_client=firestore_client).get(), + uid=uid, + account_generation=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 = 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() + + @firestore.transactional + def apply(write_transaction: Any) -> AgentJudgmentAdmission: + control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) + _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) + 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 = 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() + + @firestore.transactional + def apply(write_transaction: Any) -> None: + control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) + _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: + 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 = 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, + 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, uid=uid, account_generation=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 = _intent_from_snapshot(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 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 = 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, + 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, uid=uid, account_generation=account_generation) + existing_snapshot = intent_ref.get(transaction=write_transaction) + if existing_snapshot.exists: + existing = _intent_from_snapshot(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(): + intent = _intent_from_snapshot(snapshot) + if intent.account_generation != account_generation: + continue + if intent.source not in {'cold_start_rich', 'cold_start_sparse'}: + continue + 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 = 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() + + @firestore.transactional + def apply(write_transaction: Any) -> ProactiveIntent: + control_snapshot = _control_ref(uid, firestore_client=client).get(transaction=write_transaction) + _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') + intent = _intent_from_snapshot(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(): + intent = _intent_from_snapshot(snapshot) + if intent.account_generation != account_generation or intent.source != 'cold_start_sparse': + continue + if intent.cold_start_sequence_terminal_receipt_id is None: + return True + return False + + +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(): + intent = _intent_from_snapshot(snapshot) + if intent.account_generation != account_generation or intent.delivery_state not in { + 'ready', + 'pending_kernel_receipt', + }: + continue + ready.append(intent) + 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, 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') + 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') + 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 not in {'ready', 'pending_kernel_receipt'}: + 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 = proactive_deferral_id( + uid, + account_generation=account_generation, + continuity_key=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, uid=uid, account_generation=account_generation) + existing_snapshot = ref.get(transaction=write_transaction) + if existing_snapshot.exists: + existing = _deferral_from_snapshot(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(): + deferred = _deferral_from_snapshot(snapshot) + if deferred.account_generation != account_generation or deferred.state != 'pending': + continue + 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 = 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, + 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, 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: + return None + current = _deferral_from_snapshot(deferral_snapshot) + if current.account_generation != account_generation or current.state != 'pending': + return None + if intent_snapshot.exists: + 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}) + 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', + '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', + 'proactive_deferral_id', + 'proactive_intent_id', + 'release_agent_judgment_admission', + 'record_deferral', + 'release_due_deferrals', +] diff --git a/backend/database/conversations.py b/backend/database/conversations.py index e5662a24e3d..5bb69f2bca0 100644 --- a/backend/database/conversations.py +++ b/backend/database/conversations.py @@ -609,9 +609,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: @@ -633,6 +643,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, @@ -646,8 +657,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/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_intelligence_control.py b/backend/database/task_intelligence_control.py index c9acdcbf24e..f5648e21145 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,17 @@ 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: + 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/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/main.py b/backend/main.py index 1602149cb42..4b73615272d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,7 +3,8 @@ import logging import os -from utils.env_loader import load_backend_env +from utils.env_loader import firebase_admin_options, 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 @@ -41,6 +42,8 @@ auth, action_items, candidates, + chat_first, + chat_first_e2e, task_integrations, integrations, x_connector, @@ -97,6 +100,7 @@ validate_stripe_price_ids() _auth_emulator_host = os.environ.get("FIREBASE_AUTH_EMULATOR_HOST", "").strip() +_firebase_admin_options = firebase_admin_options() if _auth_emulator_host: for _adc_key in ("GOOGLE_APPLICATION_CREDENTIALS", "SERVICE_ACCOUNT_JSON"): os.environ.pop(_adc_key, None) @@ -107,9 +111,9 @@ elif os.environ.get("SERVICE_ACCOUNT_JSON"): service_account_info = json.loads(os.environ["SERVICE_ACCOUNT_JSON"]) credentials = firebase_admin.credentials.Certificate(service_account_info) - firebase_admin.initialize_app(credentials) # type: ignore[reportUnknownMemberType] # firebase_admin untyped + firebase_admin.initialize_app(credentials, options=_firebase_admin_options) # type: ignore[reportUnknownMemberType] # firebase_admin untyped else: - firebase_admin.initialize_app() # type: ignore[reportUnknownMemberType] # firebase_admin untyped + firebase_admin.initialize_app(options=_firebase_admin_options) # type: ignore[reportUnknownMemberType] # firebase_admin untyped app = FastAPI() @@ -120,6 +124,11 @@ app.include_router(public_shared_conversation_chat.router) 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.py b/backend/models/chat_first.py new file mode 100644 index 00000000000..fbd120d59aa --- /dev/null +++ b/backend/models/chat_first.py @@ -0,0 +1,293 @@ +"""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 + +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', 'cold_start'] + 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 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): + 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') + 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 + + +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) + + +class MemoryLinkSpec(_StrictModel): + type: Literal['memoryLink'] + memory_id: StableId + summary: str = Field(min_length=1, max_length=200) + + +ChatFirstBlockSpec = Annotated[ + Union[QuestionCardSpec, TaskCardSpec, GoalLinkSpec, CaptureLinkSpec, MemoryLinkSpec], + 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) + + +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 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 + 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 + # 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: + 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 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 + + +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') + if self.subject.kind == 'cold_start': + raise ValueError('cold-start question cards cannot be deferred') + 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.""" + + 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', + 'ColdStartSequenceTerminalReceipt', + 'ColdStartSequenceTerminalState', + 'ColdStartSequence', + 'DeferralCreateRequest', + 'DeferralReceipt', + 'GoalLinkSpec', + 'MaterializePromptsRequest', + 'MaterializePromptsResponse', + 'MemoryLinkSpec', + 'ProactiveBudgetReservation', + 'ProactiveBudgetState', + 'ProactiveDeferral', + 'ProactiveIntent', + 'ProactiveMaterializationReceipt', + 'QuestionCardSpec', + 'QuestionOption', + 'TaskCardSpec', + 'stable_block_id', +] diff --git a/backend/models/chat_first_e2e.py b/backend/models/chat_first_e2e.py new file mode 100644 index 00000000000..f2b64f42bbe --- /dev/null +++ b/backend/models/chat_first_e2e.py @@ -0,0 +1,59 @@ +"""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' + 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/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/models/task_intelligence.py b/backend/models/task_intelligence.py index 28ed7c83036..64e5ae6345d 100644 --- a/backend/models/task_intelligence.py +++ b/backend/models/task_intelligence.py @@ -92,12 +92,37 @@ class TaskIntelligenceRolloutDecision(BaseModel): class TaskWorkflowControl(BaseModel): - """Per-user universal-contract migration control; defaults preserve legacy behavior.""" + """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: 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, + } class TaskIntelligenceAttributionEvent(BaseModel): diff --git a/backend/route_policy_manifest.yaml b/backend/route_policy_manifest.yaml index c86e168c8a7..c89b9dfc478 100644 --- a/backend/route_policy_manifest.yaml +++ b/backend/route_policy_manifest.yaml @@ -342,6 +342,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} @@ -756,6 +825,29 @@ routes: deprecation: state: active owner: backend + - route_type: http + method: GET + path: /v1/goals/canonical/list + policy: + review_status: reviewed + auth: + mechanisms: + - firebase_id_token + placement: dependency + scopes: [] + byok: not_applicable + 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/goals/canonical diff --git a/backend/routers/candidates.py b/backend/routers/candidates.py index fc2418f4dff..ccccd4322e2 100644 --- a/backend/routers/candidates.py +++ b/backend/routers/candidates.py @@ -26,7 +26,12 @@ 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 ( + 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 @@ -41,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: @@ -183,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, @@ -210,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, ) @@ -227,12 +236,57 @@ 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']) def get_candidate_workflow_control(uid: str = Depends(auth.get_current_user_uid)) -> TaskWorkflowControl: - return task_control_db.get_task_workflow_control(uid) + # 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: + # 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) + except Exception: + # Cohort resolution is intentionally fail-closed: a backend outage or + # stale selector keeps this user in the existing shell. + return control.model_copy( + update={ + 'workflow_mode': TaskWorkflowMode.off, + 'chat_first_ui': False, + } + ) + + # 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']) @@ -253,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/chat_first.py b/backend/routers/chat_first.py new file mode 100644 index 00000000000..62aa7ccb094 --- /dev/null +++ b/backend/routers/chat_first.py @@ -0,0 +1,351 @@ +"""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 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._client as db_client_module +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 +from models.chat_first import ( + CaptureLinkSpec, + ChatFirstBlockSpec, + ChatFirstBlockValidationReceipt, + ChatFirstBlockValidationRequest, + ChatFirstSubject, + DeferralCreateRequest, + DeferralReceipt, + GoalLinkSpec, + MaterializePromptsRequest, + MaterializePromptsResponse, + MemoryLinkSpec, + TaskCardSpec, + stable_block_id, +) +from utils.metrics import CHAT_FIRST_PROACTIVE_TOTAL +from utils.memory.memory_service import fetch_memory_dict +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 ( + 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() +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[ChatFirstBlockSpec], 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: 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: + 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: + 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 + 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 _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) + 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, MemoryLinkSpec): + try: + return bool(fetch_memory_dict(uid, block.memory_id, db_client=getattr(db_client_module, 'db', None))) + except HTTPException: + 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( + '/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') + + eligibility = _eligibility(uid) + if not eligibility.enabled: + return ChatFirstBlockValidationReceipt(accepted=False, code='capability_unavailable') + 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=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') + + 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) + ], + ) + + +@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, + ) + # 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: + 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() + + # 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, + account_generation=request.control_generation, + now=now, + ) + for _intent in released: + CHAT_FIRST_PROACTIVE_TOTAL.labels(event='deferral_released', source='deferral_reraise').inc() + _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, + ) + 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/routers/chat_first_e2e.py b/backend/routers/chat_first_e2e.py new file mode 100644 index 00000000000..329abee3afe --- /dev/null +++ b/backend/routers/chat_first_e2e.py @@ -0,0 +1,68 @@ +"""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 +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) -> 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): + 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/routers/conversations.py b/backend/routers/conversations.py index fdf9e885ba7..d49a11d87ab 100644 --- a/backend/routers/conversations.py +++ b/backend/routers/conversations.py @@ -362,6 +362,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"), @@ -370,13 +374,23 @@ 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', + ) status_filter = statuses.split(",") if len(statuses) > 0 else [] _reject_oversized_filter(status_filter, "statuses") + _reject_oversized_filter(source_list, "sources") conversations = conversations_db.get_conversations_without_photos( uid, @@ -384,6 +398,7 @@ def get_conversations( offset, include_discarded=include_discarded, statuses=status_filter, + sources=source_list, start_date=start_date, end_date=end_date, folder_id=folder_id, @@ -402,7 +417,10 @@ 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): @@ -411,9 +429,8 @@ def get_conversations_count( source_list = [s.strip() for s in sources.split(',') if s.strip()] if sources else [] _reject_oversized_filter(status_list, "statuses") _reject_oversized_filter(source_list, "sources") - 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, @@ -440,9 +457,21 @@ def get_conversations_count( "may include an empty transcript_segments array even though transcript data exists." ), ) -def get_conversation_by_id(conversation_id: str, uid: str = Depends(auth.get_current_user_uid)): +def get_conversation_by_id( + conversation_id: str, + source: Optional[str] = Query(None, description="Optional provenance constraint for a detail read"), + include_discarded: bool = Query(True), + uid: str = Depends(auth.get_current_user_uid), +): logger.info(f'get_conversation_by_id {uid} {conversation_id}') conversation = _get_valid_conversation_by_id(uid, conversation_id) + if source is not None: + if source != 'omi': + raise HTTPException( + status_code=400, detail="Only source=omi is supported for provenance-constrained detail reads" + ) + if conversation.get('source') != 'omi' or (not include_discarded and conversation.get('discarded', False)): + raise HTTPException(status_code=404, detail="Conversation not found") # Lazy processing: a desktop conversation stored raw (deferred) for a freemium/Neo user is # enriched on first open. Other conversations are returned unchanged. if conversation.get('deferred'): diff --git a/backend/routers/goals.py b/backend/routers/goals.py index a0c110d655f..f7f1fb047c5 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)] @@ -57,6 +58,16 @@ def get_all_goals( return [normalize_goal_response(goal) for goal in goals] +@router.get('/v1/goals/canonical/list', tags=['goals'], response_model=List[GoalResponse]) +def get_canonical_goals( + include_ended: bool = Query(False), + uid: str = Depends(require_canonical_task_user), +) -> List[dict]: + """List goals for enrolled canonical-memory task-system clients only.""" + goals = goals_db.get_all_goals(uid, include_inactive=include_ended) + return [normalize_goal_response(goal) for goal in goals] + + @router.post('/v1/goals', tags=['goals'], response_model=GoalResponse) def create_goal(goal: GoalCreate, uid: str = Depends(auth.get_current_user_uid)) -> dict: """Create a durable goal without changing any other goal's focus or lifecycle.""" @@ -76,7 +87,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 +118,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 +140,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 +162,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 +180,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 +193,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 +212,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 d388d8c7e65..56e7d1ce953 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,7 +200,10 @@ 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, +) -> ActivationPlan: require_dogfood_uid(uid) target = TaskWorkflowControl( workflow_mode=TaskWorkflowMode.read, @@ -206,8 +212,8 @@ def build_activation_plan(uid: str, current: TaskWorkflowControl) -> ActivationP 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, ) @@ -248,13 +254,13 @@ def activate(transaction) -> bool: ) 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)}, @@ -333,19 +339,22 @@ 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.', + '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_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 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.') parser.add_argument( @@ -443,7 +452,7 @@ def main() -> int: workflow_mode=TaskWorkflowMode.read, account_generation=current.account_generation, ): - 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/scripts/export_openapi.py b/backend/scripts/export_openapi.py index 240b002d673..95c5eb6809d 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/scripts/generate_swift_openapi_types.py b/backend/scripts/generate_swift_openapi_types.py index bd0df2a874c..49047a44ad3 100644 --- a/backend/scripts/generate_swift_openapi_types.py +++ b/backend/scripts/generate_swift_openapi_types.py @@ -348,7 +348,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/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..009845c9970 100644 --- a/backend/tests/unit/canonical_cohort_test_helpers.py +++ b/backend/tests/unit/canonical_cohort_test_helpers.py @@ -7,18 +7,25 @@ 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)) + + def _is_canonical_memory_user(uid: object) -> bool: + return bool(uid) and isinstance(uid, str) and uid in frozenset(uids) + + monkeypatch.setattr(cohort_config, "is_canonical_memory_user", _is_canonical_memory_user) for module_name, module in list(sys.modules.items()): - if module_name.startswith("utils.memory.") and hasattr(module, "resolve_memory_system"): + if hasattr(module, "is_canonical_memory_user"): + monkeypatch.setattr(module, "is_canonical_memory_user", _is_canonical_memory_user) + if (module_name.startswith("utils.memory.") or module_name.startswith("utils.task_intelligence.")) 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 0f44734725e..5f549eb8aa6 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,8 +90,7 @@ 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) @@ -182,4 +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' not in patch_request.data assert b'short-lived-token' not in patch_request.data diff --git a/backend/tests/unit/test_app_client_swift_generator.py b/backend/tests/unit/test_app_client_swift_generator.py index 4a7ddaf26de..4a8154c8cc0 100644 --- a/backend/tests/unit/test_app_client_swift_generator.py +++ b/backend/tests/unit/test_app_client_swift_generator.py @@ -103,6 +103,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_backend_candidate_capture.py b/backend/tests/unit/test_backend_candidate_capture.py index 25931a6bdd0..a264a0bad5c 100644 --- a/backend/tests/unit/test_backend_candidate_capture.py +++ b/backend/tests/unit/test_backend_candidate_capture.py @@ -14,6 +14,18 @@ from models.action_item import EvidenceRef, TaskCreatePayload from models.structured_extraction import ActionItemsExtraction from utils.llm import conversation_processing +from utils.memory.memory_system import MemorySystem +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort + + +def _enable_canonical(monkeypatch): + """Patch conversation_capture so user-1 resolves to the canonical cohort.""" + set_canonical_cohort(monkeypatch, 'user-1') + monkeypatch.setattr( + conversation_capture, + 'resolve_memory_system', + lambda uid, db_client=None: MemorySystem.CANONICAL, + ) def _action( @@ -341,24 +353,37 @@ def from_messages(messages): def test_shadow_mode_uses_canonical_extraction_without_writing(monkeypatch): + _enable_canonical(monkeypatch) monkeypatch.setattr( conversation_capture.task_control_db, 'get_task_workflow_control', lambda uid: TaskWorkflowControl(workflow_mode='shadow', account_generation=3), ) decisions = [] + + class _NoCandidateDecision: + candidate = None + 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)) + or _NoCandidateDecision(), + ) + monkeypatch.setattr( + conversation_capture.candidate_service, + 'create_candidate', + lambda *a, **kw: pytest.fail('should not create candidate when decision.candidate is None'), ) assert conversation_capture.capture_enabled('user-1') is True - assert conversation_capture.process_before_legacy('user-1', 'conversation-1', [_action('Send budget')]) is False + # Canonical users route through process_before_legacy (returns True). + assert conversation_capture.process_before_legacy('user-1', 'conversation-1', [_action('Send budget')]) is True assert decisions == [('Send budget', 'conversation-1')] def test_read_mode_creates_pending_and_silently_accepts_commitment_without_notifications(monkeypatch): + _enable_canonical(monkeypatch) monkeypatch.setattr( conversation_capture.task_control_db, 'get_task_workflow_control', @@ -412,12 +437,13 @@ def create(uid, proposal, **kwargs): assert records[1].status == 'pending' -def test_off_mode_is_behaviorally_legacy_and_write_mode_reconciles_sidecars(monkeypatch): - mode = {'value': 'off'} +def test_off_mode_is_behaviorally_legacy_and_canonical_route_bypasses_legacy_writer(monkeypatch): + # Legacy (non-canonical) users keep the legacy writer untouched. + set_canonical_cohort(monkeypatch) # empty cohort = everyone is legacy monkeypatch.setattr( conversation_capture.task_control_db, 'get_task_workflow_control', - lambda uid: TaskWorkflowControl(workflow_mode=mode['value'], account_generation=3), + lambda uid: TaskWorkflowControl(workflow_mode='off', account_generation=3), ) 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) @@ -438,28 +464,7 @@ def write(uid, rows, **kwargs): 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) - 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( - conversation_capture.candidates_db, - 'reconcile_migrated_candidate', - reconcile, - ) conversation = _conversation( _action( 'Send the budget', @@ -469,59 +474,56 @@ def reconcile(uid, candidate_id, **kwargs): ) ) + # Legacy user: capture_enabled is False, so legacy writer runs. + assert conversation_capture.capture_enabled('user-1') is False process_conversation._save_action_items('user-1', conversation) assert len(writes) == 1 - assert candidates == {} - 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] + # Canonical user: capture_enabled is True, process_before_legacy returns True. + _enable_canonical(monkeypatch) + candidates = {} - process_conversation._save_action_items('user-1', conversation) - assert writes[2][1]['document_ids'] == stable_ids + def create(uid, proposal, **kwargs): + key = kwargs['idempotency_key'] + candidates.setdefault(key, _record(proposal, len(candidates) + 1)) + return candidates[key] + + monkeypatch.setattr(conversation_capture.candidate_service, 'create_candidate', create) + monkeypatch.setattr( + conversation_capture.candidate_service, + 'accept_candidate', + lambda uid, candidate_id, **kwargs: None, + ) + assert conversation_capture.capture_enabled('user-1') is True + result = conversation_capture.process_before_legacy( + 'user-1', 'conversation-1', conversation.structured.action_items + ) + assert result is True assert len(candidates) == 1 - assert len(reconciled) == 1 + # reconcile_after_legacy is a no-op for canonical users. + assert conversation_capture.reconcile_after_legacy('user-1', 'conversation-1', [], []) is None -def test_write_mode_keeps_mutation_judgment_separate_from_legacy_create_projection(monkeypatch): +def test_canonical_route_produces_single_pending_candidate(monkeypatch): + """Canonical users get one candidate per action item; no legacy projection.""" + _enable_canonical(monkeypatch) monkeypatch.setattr( conversation_capture.task_control_db, 'get_task_workflow_control', - lambda uid: TaskWorkflowControl(workflow_mode='write', account_generation=3), - ) - 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 - ) - writes = [] - 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: TaskWorkflowControl(workflow_mode='read', account_generation=3), ) - 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) records = {} def create(uid, proposal, **kwargs): records.setdefault(kwargs['idempotency_key'], _record(proposal, len(records) + 1)) return records[kwargs['idempotency_key']] - def reconcile(uid, candidate_id, **kwargs): - record = next(item for item in records.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(conversation_capture.candidates_db, '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', @@ -529,102 +531,75 @@ def reconcile(uid, candidate_id, **kwargs): target_task_id='task-budget', ) - 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] + result = conversation_capture.process_before_legacy('user-1', 'conversation-1', [action]) + assert result is True + # One candidate for the mutation, no separate legacy projection. + assert len(records) == 1 -def test_write_mode_conversation_ids_survive_reorder_and_do_not_alias_refinements(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')] - ) - - assert reordered == [first[1], first[0]] - assert inserted[1:] == first - assert refined[0] != first[0] - assert refined[1] == first[1] +def test_legacy_document_ids_returns_none_for_all_users(monkeypatch): + """legacy_document_ids is retired; canonical users bypass the legacy writer.""" + _enable_canonical(monkeypatch) assert ( - conversation_capture.legacy_replacement_map( - [ - {'id': first[0], 'description': 'Send budget'}, - {'id': first[1], 'description': 'Review forecast'}, - ], - [_action('Send revised budget'), _action('Review forecast')], - refined, + conversation_capture.legacy_document_ids( + 'user-1', 'conversation-1', [_action('Send budget'), _action('Review forecast')] ) - == {} + is None ) + + +def test_legacy_replacement_map_links_only_explicit_refinement_targets(): + """legacy_replacement_map still links extraction-provided update targets.""" + first_id = 'task-budget' + second_id = 'task-forecast' 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=first_id, ) + # Explicit refinement of a retired task links to the new ID. assert conversation_capture.legacy_replacement_map( [ - {'id': first[0], 'description': 'Send budget'}, - {'id': first[1], 'description': 'Review forecast'}, + {'id': first_id, 'description': 'Send budget'}, + {'id': second_id, 'description': 'Review forecast'}, ], [explicit_refinement, _action('Review forecast')], - explicit_ids, - ) == {first[0]: explicit_ids[0]} + ['task-new-budget', second_id], + ) == {first_id: 'task-new-budget'} + # No update action → no replacement. assert ( conversation_capture.legacy_replacement_map( [ - {'id': first[0], 'description': 'Send budget'}, - {'id': first[1], 'description': 'Review forecast'}, + {'id': first_id, 'description': 'Send budget'}, + {'id': second_id, 'description': 'Review forecast'}, ], [_action('Send budget')], - [first[0]], + [first_id], ) == {} ) - unrelated = conversation_capture.legacy_document_ids('user-1', 'conversation-1', [_action('Book dentist')]) + # Unrelated item → no replacement. assert ( conversation_capture.legacy_replacement_map( - [{'id': first[0], 'description': 'Send budget'}], + [{'id': first_id, 'description': 'Send budget'}], [_action('Book dentist')], - unrelated, + ['task-dentist'], ) == {} ) - changed_entity = conversation_capture.legacy_document_ids('user-1', 'conversation-1', [_action('Email Bob')]) + # Different entity with same action → no replacement. assert ( conversation_capture.legacy_replacement_map( - [{'id': first[0], 'description': 'Email Alice'}], + [{'id': first_id, 'description': 'Email Alice'}], [_action('Email Bob')], - changed_entity, + ['task-bob'], ) == {} ) def test_repeated_descriptions_use_semantic_occurrences_without_order_dependent_candidate_keys(monkeypatch): + _enable_canonical(monkeypatch) monkeypatch.setattr( conversation_capture.task_control_db, 'get_task_workflow_control', diff --git a/backend/tests/unit/test_candidate_lifecycle.py b/backend/tests/unit/test_candidate_lifecycle.py index c7710b1ed42..227c22f1aa1 100644 --- a/backend/tests/unit/test_candidate_lifecycle.py +++ b/backend/tests/unit/test_candidate_lifecycle.py @@ -43,6 +43,8 @@ def run(transaction): monkeypatch.setattr(candidates_db, 'db', database) monkeypatch.setattr(candidates_db.firestore, 'transactional', transactional) + # candidates.py gates writes on is_canonical_memory_user, not workflow_mode. + monkeypatch.setattr(candidates_db, 'is_canonical_memory_user', lambda uid: uid == 'user-1') 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') @@ -825,14 +827,30 @@ def test_expired_legacy_promotion_claim_gets_new_owner(fake_db): ) -@pytest.mark.parametrize('mode', ['off', 'shadow']) -def test_authoritative_control_blocks_candidate_writes_in_nonvisible_modes(fake_db, mode): - fake_db.rows[('users', 'user-1', 'task_intelligence_control', 'state')]['workflow_mode'] = mode +@pytest.mark.parametrize('cohort', ['in', 'out']) +def test_authoritative_control_blocks_candidate_writes_in_nonvisible_modes(fake_db, cohort): + if cohort == 'out': + # candidates.py gates writes on canonical cohort membership. + fake_db.rows[('users', 'user-1', 'task_intelligence_control', 'state')]['workflow_mode'] = 'off' + # Override the fixture's cohort patch to exclude user-1. + import database.candidates as _candidates_db - with pytest.raises(candidates_db.CandidateConflictError): - create_record(fake_db) + original_check = _candidates_db.is_canonical_memory_user + import database.candidates as candidates_module + + candidates_module.is_canonical_memory_user = lambda uid: False + + try: + with pytest.raises(candidates_db.CandidateConflictError): + create_record(fake_db) + finally: + candidates_module.is_canonical_memory_user = original_check - assert not [path for path in fake_db.rows if 'candidates' in path] + assert not [path for path in fake_db.rows if 'candidates' in path] + else: + # Canonical users are never blocked by the retired workflow_mode field. + record = create_record(fake_db) + assert record.candidate_id is not None def test_generation_rollover_fences_acceptance_inside_transaction(fake_db): diff --git a/backend/tests/unit/test_candidates_router.py b/backend/tests/unit/test_candidates_router.py index 1635b298530..152f8a77397 100644 --- a/backend/tests/unit/test_candidates_router.py +++ b/backend/tests/unit/test_candidates_router.py @@ -3,12 +3,19 @@ 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 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): @@ -35,10 +42,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 +75,169 @@ 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 _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) + + +@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') + + assert response.status_code == 200 + assert response.json() == { + 'workflow_mode': 'read', + 'account_generation': 8, + 'chat_first_ui': True, + } -def test_candidate_workflow_control_exposes_current_mode_and_generation(monkeypatch): +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, + '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 response.json()['workflow_mode'] == 'off' + 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 candidates_router.get_candidate_workflow_control(uid='user-1') == control + assert response.status_code == 200 + assert response.json() == { + 'workflow_mode': 'off', + 'account_generation': 0, + 'chat_first_ui': False, + } + + +def test_noncanonical_user_cannot_read_canonical_candidates(monkeypatch): + set_canonical_cohort(monkeypatch) + monkeypatch.setattr( + 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 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( + 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(): @@ -148,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', @@ -631,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 new file mode 100644 index 00000000000..b221ac0f101 --- /dev/null +++ b/backend/tests/unit/test_chat_first_blocks.py @@ -0,0 +1,207 @@ +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 +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort + + +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: + set_canonical_cohort(monkeypatch, 'user-1') + 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_admits_a_canonical_memory_link(monkeypatch): + _enable_chat_first(monkeypatch) + monkeypatch.setattr( + chat_first_router, + 'fetch_memory_dict', + lambda uid, memory_id, **kwargs: {'id': memory_id, 'content': 'private-content-not-returned'}, + ) + + response = _client().post( + '/v1/chat-first/blocks/validate', + json=_request(blocks=[{'type': 'memoryLink', 'memory_id': 'memory-1', 'summary': 'A useful memory'}]), + ) + + assert response.status_code == 200 + assert response.json()['accepted'] is True + assert response.json()['blocks'][0] == { + 'id': response.json()['blocks'][0]['id'], + 'type': 'memoryLink', + 'memory_id': 'memory-1', + 'summary': 'A useful memory', + } + + +def test_chat_first_validate_admits_only_an_active_omi_capture_link(monkeypatch): + _enable_chat_first(monkeypatch) + monkeypatch.setattr( + chat_first_router.conversations_db, + 'get_conversation', + lambda uid, conversation_id: { + 'id': conversation_id, + 'source': 'omi', + 'discarded': False, + }, + ) + + response = _client().post( + '/v1/chat-first/blocks/validate', + json=_request(blocks=[{'type': 'captureLink', 'conversation_id': 'capture-1', 'summary': 'Planning'}]), + ) + + assert response.status_code == 200 + assert response.json()['accepted'] is True + assert response.json()['blocks'][0]['type'] == 'captureLink' + + +def test_chat_first_validate_rejects_non_omi_or_discarded_capture_links(monkeypatch): + _enable_chat_first(monkeypatch) + monkeypatch.setattr( + chat_first_router.conversations_db, + 'get_conversation', + lambda uid, conversation_id: { + 'id': conversation_id, + 'source': 'desktop', + 'discarded': False, + }, + ) + + response = _client().post( + '/v1/chat-first/blocks/validate', + json=_request(blocks=[{'type': 'captureLink', 'conversation_id': 'desktop-1', 'summary': 'Wrong source'}]), + ) + + 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=False), + ) + 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/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..f59a902be9e --- /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_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 +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 + +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): + if transaction is not None: + raise AssertionError('fixture state must be read before opening its write-only transaction') + 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 _WriteBatch: + 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 batch(self): + return _WriteBatch(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() + (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_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_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_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.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 == 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 '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' + 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 False + assert out_of_cohort.intelligence_product_enabled is False 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..dcb40ee9d37 --- /dev/null +++ b/backend/tests/unit/test_chat_first_proactive_engine.py @@ -0,0 +1,239 @@ +"""Failure-isolated, content-free proactive-judgment contracts.""" + +from datetime import datetime, timezone + +import pytest + +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') + + +@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' + + 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_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, + '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..95c60ca051c --- /dev/null +++ b/backend/tests/unit/test_chat_first_proactive_intents.py @@ -0,0 +1,632 @@ +"""Hermetic contracts for server-only Chat-first proactive intent state.""" + +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +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, + ColdStartSequence, + 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): + set_canonical_cohort(monkeypatch, UID) + 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_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( + 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_workflow_mode_cannot_suppress_intent_but_stale_generation_still_rejects(firestore): + firestore.rows[('users', UID, 'task_intelligence_control', 'state')] = TaskWorkflowControl( + workflow_mode=TaskWorkflowMode.off, + account_generation=GENERATION, + ).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='stale-generation', + subject=question.subject, + blocks=[question], + account_generation=GENERATION, + now=NOW, + firestore_client=firestore, + ) + + 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): + 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_chat_first_proactive_router.py b/backend/tests/unit/test_chat_first_proactive_router.py new file mode 100644 index 00000000000..04377dc038a --- /dev/null +++ b/backend/tests/unit/test_chat_first_proactive_router.py @@ -0,0 +1,225 @@ +"""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 +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort + + +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, terminal_receipts=None) -> dict: + return { + 'source_surface': 'main_chat', + 'control_generation': generation, + 'owner_fence': owner_fence, + 'window_foreground': True, + 'initial_page_loaded': True, + 'receipts': receipts or [], + 'cold_start_sequence_terminal_receipts': terminal_receipts or [], + } + + +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', + 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=False), + ) + 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, + 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 = [] + 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'}], + terminal_receipts=[ + { + 'sequence_id': 'cold-start:7', + 'receipt_id': 'sequence-terminal-1', + 'terminal_state': 'completed', + } + ], + ), + ) + + 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 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, + '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=False), + ) + 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/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_conversations_status_filter_cap.py b/backend/tests/unit/test_conversations_status_filter_cap.py index 4f66bcad514..f0358cdde78 100644 --- a/backend/tests/unit/test_conversations_status_filter_cap.py +++ b/backend/tests/unit/test_conversations_status_filter_cap.py @@ -63,7 +63,7 @@ def test_list_oversized_statuses_rejected_before_db(db): oversized = ",".join(["completed"] * 40) with pytest.raises(HTTPException) as ei: - conv.get_conversations(statuses=oversized, start_date=None, end_date=None, uid="u1") + conv.get_conversations(statuses=oversized, sources=None, start_date=None, end_date=None, uid="u1") assert ei.value.status_code == 400 # The guard fires before the query is built: the Firestore-like fake never saw the filter. @@ -71,7 +71,9 @@ def test_list_oversized_statuses_rejected_before_db(db): def test_list_normal_statuses_reach_db(db): - result = conv.get_conversations(statuses="processing,completed", start_date=None, end_date=None, uid="u1") + result = conv.get_conversations( + statuses="processing,completed", sources=None, start_date=None, end_date=None, uid="u1" + ) assert result == [] assert db.last_statuses == ["processing", "completed"] diff --git a/backend/tests/unit/test_desktop_migration.py b/backend/tests/unit/test_desktop_migration.py index 64cfed8242a..086e7c6fb22 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") +setattr(rollout_stub, "effective_task_workflow_control", lambda control, rollout: control) +setattr(rollout_stub, "resolve_task_intelligence_for_user", 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_env_loader.py b/backend/tests/unit/test_env_loader.py index 29d8425c905..d55866f44d7 100644 --- a/backend/tests/unit/test_env_loader.py +++ b/backend/tests/unit/test_env_loader.py @@ -6,6 +6,7 @@ import pytest from utils.env_loader import ( + firebase_admin_options, STAGE_ENV_FILENAMES, load_backend_env, resolve_stage_from_env, @@ -15,6 +16,11 @@ ) +def test_firebase_admin_options_uses_explicit_auth_project_only() -> None: + assert firebase_admin_options({"FIREBASE_AUTH_PROJECT_ID": " based-hardware "}) == {"projectId": "based-hardware"} + assert firebase_admin_options({"FIREBASE_PROJECT_ID": "data-project"}) is None + + def test_stage_from_env_explicit() -> None: assert stage_from_env({"OMI_ENV_STAGE": "dev"}) == "dev" assert stage_from_env({"OMI_ENV_STAGE": "LOCAL"}) == "local" 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_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_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_memory_system_cohort.py b/backend/tests/unit/test_memory_system_cohort.py index 085abc11376..f8222bed47a 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 emulator fixture user # 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..36e913d9e9b 100644 --- a/backend/tests/unit/test_recurrence_inbox_control_skip_malformed.py +++ b/backend/tests/unit/test_recurrence_inbox_control_skip_malformed.py @@ -11,6 +11,12 @@ import database.recurrence_inbox as recurrence_inbox_db import database.read_boundary as read_boundary from database.recurrence_inbox import _validate_generation, RecurrenceGenerationMismatchError +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort + + +@pytest.fixture(autouse=True) +def canonical_user(monkeypatch): + set_canonical_cohort(monkeypatch, 'u1') class _FakeSnapshot: @@ -31,13 +37,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, account_generation=1, uid='u1') 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, account_generation=1, uid='u1') is None def test_unexpected_error_propagates(monkeypatch): @@ -50,4 +56,4 @@ 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, account_generation=1, uid='u1') diff --git a/backend/tests/unit/test_staged_candidate_migration.py b/backend/tests/unit/test_staged_candidate_migration.py index 3b6b85db554..8de01ce2590 100644 --- a/backend/tests/unit/test_staged_candidate_migration.py +++ b/backend/tests/unit/test_staged_candidate_migration.py @@ -5,11 +5,17 @@ import database.candidates as candidates_db from models.candidate import CandidateCreate, CandidateRecord, CandidateStatus from models.task_intelligence import TaskWorkflowControl +from tests.unit.canonical_cohort_test_helpers import set_canonical_cohort import routers.staged_tasks as staged_router from utils.task_intelligence import candidate_service from utils.task_intelligence.staged_migration import migrate_staged_tasks, proposal_from_legacy_staged +@pytest.fixture(autouse=True) +def canonical_test_users(monkeypatch): + set_canonical_cohort(monkeypatch, 'user-1', 'uid') + + def _rows(): now = datetime(2026, 7, 9, tzinfo=timezone.utc) return [ diff --git a/backend/tests/unit/test_staged_tasks_review_controls.py b/backend/tests/unit/test_staged_tasks_review_controls.py index f67313e6a7d..14a2eb9d63b 100644 --- a/backend/tests/unit/test_staged_tasks_review_controls.py +++ b/backend/tests/unit/test_staged_tasks_review_controls.py @@ -30,11 +30,13 @@ from datetime import datetime, timezone from models.candidate import CandidateCreate, CandidateRecord, CandidateStatus -from models.task_intelligence import TaskWorkflowControl +from models.task_intelligence import TaskWorkflowControl, TaskWorkflowMode +from tests.unit.canonical_cohort_test_helpers import clear_canonical_cohort @pytest.fixture(autouse=True) def legacy_workflow_mode(monkeypatch): + clear_canonical_cohort(monkeypatch) monkeypatch.setattr(r.task_control_db, 'get_task_workflow_control', lambda uid: TaskWorkflowControl()) @@ -185,11 +187,8 @@ def test_clear_returns_deleted_count(self, monkeypatch): class TestModeAwareSidecarReconciliation: @staticmethod def _write_control(monkeypatch): - monkeypatch.setattr( - r.task_control_db, - 'get_task_workflow_control', - lambda uid: TaskWorkflowControl(workflow_mode='write', account_generation=7), - ) + control = TaskWorkflowControl(workflow_mode=TaskWorkflowMode.write, account_generation=7) + monkeypatch.setattr(r, '_effective_control', lambda uid: control) def test_normal_and_duplicate_promotion_resolve_the_exact_sidecar(self, monkeypatch): self._write_control(monkeypatch) @@ -428,11 +427,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', - lambda uid: TaskWorkflowControl(workflow_mode='read', account_generation=7), - ) + control = TaskWorkflowControl(workflow_mode=TaskWorkflowMode.read, account_generation=7) + monkeypatch.setattr(r, '_effective_control', lambda uid: control) monkeypatch.setattr( r.staged_tasks_db, 'migrate_ai_tasks', @@ -448,11 +444,8 @@ def test_read_mode_legacy_migrations_are_noops(self, monkeypatch): assert r.migrate_conversation_items(uid='u1') == {'status': 'ok', 'migrated': 0, 'deleted': 0} def test_read_mode_by_id_routes_ignore_non_staged_candidates(self, monkeypatch): - monkeypatch.setattr( - r.task_control_db, - 'get_task_workflow_control', - lambda uid: TaskWorkflowControl(workflow_mode='read', account_generation=7), - ) + control = TaskWorkflowControl(workflow_mode=TaskWorkflowMode.read, account_generation=7) + monkeypatch.setattr(r, '_effective_control', lambda uid: control) proposal = CandidateCreate.model_validate( { 'subject_kind': 'task', diff --git a/backend/tests/unit/test_task_intelligence_rollout.py b/backend/tests/unit/test_task_intelligence_rollout.py index 4aca9bd0c3f..ecfbb38102c 100644 --- a/backend/tests/unit/test_task_intelligence_rollout.py +++ b/backend/tests/unit/test_task_intelligence_rollout.py @@ -1,15 +1,18 @@ 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_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 @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 ) @@ -17,13 +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]) +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) is memory_eligible def test_production_resolver_uses_authoritative_memory_selector(monkeypatch): @@ -50,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') - - 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 + decision = resolve_task_intelligence_for_user(uid='fixture-user', workflow_mode='read') + 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_what_matters_now_smoke_fixture.py b/backend/tests/unit/test_what_matters_now_smoke_fixture.py index a7d3ddd186e..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 @@ -47,12 +48,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 +63,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,10 +85,23 @@ 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() ] +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/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_core.py b/backend/tests/unit/test_workstream_core.py index b400c0e97ab..b024eb6d0b0 100644 --- a/backend/tests/unit/test_workstream_core.py +++ b/backend/tests/unit/test_workstream_core.py @@ -31,6 +31,7 @@ WorkstreamStatus, WorkstreamUpdate, ) +from tests.unit.canonical_cohort_test_helpers import clear_canonical_cohort, set_canonical_cohort class FakeSnapshot: @@ -157,6 +158,7 @@ def transaction(self): @pytest.fixture def fake_db(monkeypatch): + set_canonical_cohort(monkeypatch, 'u1') database = FakeDB() def transactional(function): @@ -278,12 +280,14 @@ 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_cohort_and_generation_fenced(fake_db, monkeypatch): create_goal(fake_db, 'g1') - seed_control(fake_db, mode='shadow') + clear_canonical_cohort(monkeypatch) + seed_control(fake_db) with pytest.raises(goals_db.GoalConflictError): goals_db.focus_goal('u1', 'g1', idempotency_key='focus-g1', account_generation=3, firestore_client=fake_db) + set_canonical_cohort(monkeypatch, 'u1') 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) @@ -912,8 +916,9 @@ def test_concurrent_journal_appends_allocate_stable_unique_sequences(fake_db): assert workstreams_db.get_workstream('u1', 'w1', firestore_client=fake_db).latest_event_sequence == 20 -def test_workstream_mutations_are_generation_fenced_and_receipt_idempotent(fake_db): - seed_control(fake_db, mode='shadow') +def test_workstream_mutations_are_cohort_generation_fenced_and_receipt_idempotent(fake_db, monkeypatch): + clear_canonical_cohort(monkeypatch) + seed_control(fake_db) seed_workstream(fake_db) with pytest.raises(workstreams_db.WorkstreamConflictError): workstreams_db.update_workstream( @@ -925,6 +930,7 @@ def test_workstream_mutations_are_generation_fenced_and_receipt_idempotent(fake_ firestore_client=fake_db, ) + set_canonical_cohort(monkeypatch, 'u1') seed_control(fake_db, generation=4, mode='read') with pytest.raises(workstreams_db.WorkstreamGenerationMismatchError): workstreams_db.update_workstream( diff --git a/backend/tests/unit/test_workstream_router_contract.py b/backend/tests/unit/test_workstream_router_contract.py index e30b685fa52..7cc123ba3a4 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) @@ -293,6 +302,33 @@ def get_all(uid, include_inactive=False): assert captured == {'uid': 'u1', 'include_inactive': True} +def test_canonical_goal_list_blocks_nonmembers_without_changing_legacy_reads(monkeypatch): + calls = [] + monkeypatch.setattr( + goals_router.goals_db, + 'get_all_goals', + lambda uid, include_inactive=False: calls.append((uid, include_inactive)) or [], + ) + client = _canonical_task_router_client() + + legacy = client.get('/v1/goals/all') + strict = client.get('/v1/goals/canonical/list') + + assert legacy.status_code == 200 + assert legacy.json() == [] + assert strict.status_code == 404 + assert strict.json() == {'detail': 'Not found'} + assert calls == [('not-enrolled', False)] + + +def test_canonical_goal_list_projects_enrolled_users(monkeypatch): + expected = [{'goal_id': 'goal_1'}] + monkeypatch.setattr(goals_router.goals_db, 'get_all_goals', lambda uid, include_inactive=False: expected) + monkeypatch.setattr(goals_router, 'normalize_goal_response', lambda goal: goal) + + assert goals_router.get_canonical_goals(include_ended=True, uid='enrolled') == expected + + def test_goal_update_rejects_null_required_fields(): with pytest.raises(ValueError): GoalUpdate.model_validate({'title': None}) @@ -367,3 +403,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/tests/unit/test_ws_i_write_convergence.py b/backend/tests/unit/test_ws_i_write_convergence.py index e3c166c41fd..c320d90f9c9 100644 --- a/backend/tests/unit/test_ws_i_write_convergence.py +++ b/backend/tests/unit/test_ws_i_write_convergence.py @@ -529,7 +529,7 @@ def test_extract_memories_inner_canonical_uses_memory_service(monkeypatch): association_mock.assert_not_called() -def test_v3_get_routes_canonical_user_to_memory_service(monkeypatch): +def test_v3_get_routes_canonical_device_scope_to_memory_service(monkeypatch): memories_router = _load_memories_router(monkeypatch) canonical_memories = [ @@ -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/backend/utils/env_loader.py b/backend/utils/env_loader.py index ea5b793ac6e..bebdc04664f 100644 --- a/backend/utils/env_loader.py +++ b/backend/utils/env_loader.py @@ -53,6 +53,22 @@ def is_provider_secret_key(key: str) -> bool: return bool(_PROVIDER_SECRET_RE.search(key)) +def firebase_admin_options(environ: dict[str, str] | None = None) -> dict[str, str] | None: + """Return Firebase Admin options for the configured authentication project. + + Dev services intentionally validate production Firebase identities while + their Google application credentials continue to select the dev data + project. Firebase Admin therefore needs the explicit auth project; Google + Cloud clients remain independently owned by ADC. + """ + + source = os.environ if environ is None else environ + project_id = source.get("FIREBASE_AUTH_PROJECT_ID", "").strip() + if not project_id: + return None + return {"projectId": project_id} + + def stage_from_env(environ: dict[str, str] | None = None) -> str | None: """Return the active stage name, or ``None`` when only ``backend/.env`` applies.""" 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/metrics.py b/backend/utils/metrics.py index afdadb0d2c7..7f0e9b12b02 100644 --- a/backend/utils/metrics.py +++ b/backend/utils/metrics.py @@ -234,6 +234,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/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..b555f0164a1 --- /dev/null +++ b/backend/utils/task_intelligence/chat_first_e2e_fixture.py @@ -0,0 +1,540 @@ +"""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 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 +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.chat_first_intents as intents_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.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), + '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, + ) + + +def _goal_payload(*, goal_id: str, title: str, focused: bool, now: datetime) -> dict[str, Any]: + """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 { + '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]: + """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.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', + 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.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', + 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.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', + 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.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', + 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) + # 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 + # 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): + 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), + ) + batch.set( + refs['secondary_goal'], + _goal_payload(goal_id=_SECONDARY_GOAL_ID, title='E2E fixture next goal', focused=False, 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: + batch.set(refs['cold_start_intent'], _cold_start_intent(uid, now=now).model_dump(mode='python')) + elif fixture_case is ChatFirstE2EFixtureCase.question: + batch.set( + refs['cold_start_intent'], + _completed_rich_cold_start_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: + batch.set( + refs['daily_opener_intent'], + _daily_opener_intent(uid, now=now).model_dump(mode='python'), + ) + elif fixture_case is ChatFirstE2EFixtureCase.unreachable_control: + 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, + } + batch.set(refs['state'], state) + batch.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) + batch = client.batch() + for ref in pending_refs: + 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 + batch.set(refs['state'], advanced_state) + batch.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/chat_first_eligibility.py b/backend/utils/task_intelligence/chat_first_eligibility.py new file mode 100644 index 00000000000..a54ae360ad1 --- /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 TaskIntelligenceRolloutDecision, 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[..., TaskIntelligenceRolloutDecision] = 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): + 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/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/proactive_engine.py b/backend/utils/task_intelligence/proactive_engine.py new file mode 100644 index 00000000000..eb4c2b34a04 --- /dev/null +++ b/backend/utils/task_intelligence/proactive_engine.py @@ -0,0 +1,401 @@ +"""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, + 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) +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', + 'suppressed_by_cold_start', + ] + 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') + + # 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, + 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 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: 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, + 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( + 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', + '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/backend/utils/task_intelligence/rollout.py b/backend/utils/task_intelligence/rollout.py index 576ec875680..9ef65bc921e 100644 --- a/backend/utils/task_intelligence/rollout.py +++ b/backend/utils/task_intelligence/rollout.py @@ -1,14 +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 models.task_intelligence import TaskIntelligenceRolloutDecision, TaskWorkflowMode +from models.task_intelligence import TaskIntelligenceRolloutDecision, TaskWorkflowControl, TaskWorkflowMode from utils.memory.memory_system import MemorySystem, resolve_memory_system @@ -25,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, ) @@ -66,9 +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 ( - 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, @@ -77,4 +57,38 @@ def resolve_task_intelligence_for_user( ) -__all__ = ['resolve_task_intelligence_for_user', 'resolve_task_intelligence_rollout'] +def resolve_chat_first_ui(rollout: TaskIntelligenceRolloutDecision) -> bool: + """Return the server-owned Chat-first capability for one resolved user. + + 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 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__ = [ + '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..ce94ba4fe8f 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( @@ -444,15 +407,12 @@ 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, 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/codemagic.yaml b/codemagic.yaml index 5b7a0180ec0..f3838f89ebd 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -2429,10 +2429,6 @@ workflows: exit 1 fi cp -Rf .harness/agent-runtime/agent-node_modules "$APP_BUNDLE/Contents/Resources/agent/node_modules" - mkdir -p "$APP_BUNDLE/Contents/Resources/agent/src/runtime" - cp -f agent/src/runtime/control-tool-manifest.ts "$APP_BUNDLE/Contents/Resources/agent/src/runtime/" - cp -f agent/src/runtime/node-tools.ts "$APP_BUNDLE/Contents/Resources/agent/src/runtime/" - cp -f agent/src/runtime/omi-tool-manifest.ts "$APP_BUNDLE/Contents/Resources/agent/src/runtime/" else echo "ERROR: agent/dist not found" exit 1 diff --git a/desktop/macos/Desktop/Sources/APIClient.swift b/desktop/macos/Desktop/Sources/APIClient.swift index e10aa494c36..3ccc120874c 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, @@ -645,6 +652,7 @@ extension APIClient { ] queryItems += Self.conversationFilterQueryItems( statuses: statuses, + sources: sources, includeDiscarded: includeDiscarded, startDate: startDate, endDate: endDate, @@ -661,6 +669,13 @@ extension APIClient { return try await get("v1/conversations/\(id)") } + /// Reads a capture detail through the archive's strict Omi-device provenance + /// contract. This is intentionally separate from the legacy mixed-source + /// conversation detail API. + func getOmiCapture(id: String) async throws -> ServerConversation { + try await get("v1/conversations/\(id)?source=omi&include_discarded=false") + } + /// Deletes a conversation by ID func deleteConversation(id: String) async throws { try await delete("v1/conversations/\(id)?cascade=true") @@ -750,6 +765,7 @@ extension APIClient { static func conversationsCountEndpoint( includeDiscarded: Bool = false, statuses: [ConversationStatus] = [.completed, .processing], + sources: [ConversationSource] = [], startDate: Date? = nil, endDate: Date? = nil, folderId: String? = nil, @@ -757,6 +773,7 @@ extension APIClient { ) -> String { let queryItems = Self.conversationFilterQueryItems( statuses: statuses, + sources: sources, includeDiscarded: includeDiscarded, startDate: startDate, endDate: endDate, @@ -775,6 +792,7 @@ extension APIClient { func getConversationsCount( includeDiscarded: Bool = false, statuses: [ConversationStatus] = [.completed, .processing], + sources: [ConversationSource] = [], startDate: Date? = nil, endDate: Date? = nil, folderId: String? = nil, @@ -783,6 +801,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/AnalyticsManager.swift b/desktop/macos/Desktop/Sources/AnalyticsManager.swift index 4d3977eee90..26aeda8e080 100644 --- a/desktop/macos/Desktop/Sources/AnalyticsManager.swift +++ b/desktop/macos/Desktop/Sources/AnalyticsManager.swift @@ -564,6 +564,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/AgentBridge+ChatFirst.swift b/desktop/macos/Desktop/Sources/Chat/AgentBridge+ChatFirst.swift new file mode 100644 index 00000000000..dd91994c7e7 --- /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, + intentsJSON: String, + 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, + intentsJSON: intentsJSON, + 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 a9fdcced23e..8011b6d9cda 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentBridge.swift @@ -708,8 +708,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? @@ -751,7 +751,7 @@ actor AgentBridge { return snapshot } - private func resolveAuthorization( + func resolveAuthorization( _ supplied: RuntimeOwnerAuthorizationSnapshot?, expectedOwnerID: String? = nil ) throws -> RuntimeOwnerAuthorizationSnapshot { @@ -810,7 +810,7 @@ actor AgentBridge { try await start(authorizationSnapshot: authorizationSnapshot, requiresCredentials: true) } - private func start( + func start( authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot, requiresCredentials: Bool = true ) async throws { @@ -1194,26 +1194,6 @@ actor AgentBridge { ) } - func resolveSurfaceSession( - _ surface: AgentSurfaceReference, - title: String? = nil, - creationProfile: AgentSessionCreationProfile? = 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, - authorizationSnapshot: authorization - ) - } - func migrateSessionExecutionProfile( sessionId: String, expectedProfileGeneration: Int, diff --git a/desktop/macos/Desktop/Sources/Chat/AgentClient.swift b/desktop/macos/Desktop/Sources/Chat/AgentClient.swift index 3a4b0e7263b..bc090a3a2c5 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 ) } @@ -239,6 +241,85 @@ 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 materializeChatFirstIntents( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + intentsJSON: String + ) async throws -> AgentRuntimeProcess.ChatFirstIntentsMaterialization { + try await bridge.materializeChatFirstIntents( + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration, + intentsJSON: intentsJSON + ) + } + + func listChatFirstMaterializationReceipts( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int + ) async throws -> ChatFirstPromptReceiptBatch { + try await bridge.listChatFirstMaterializationReceipts( + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration + ) + } + + @discardableResult + func acknowledgeChatFirstMaterializationReceipts( + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + controlGeneration: Int, + receipts: ChatFirstPromptReceiptBatch + ) async throws -> Int { + try await bridge.acknowledgeChatFirstMaterializationReceipts( + surface: surface, + ownerID: ownerID, + sessionID: sessionID, + controlGeneration: controlGeneration, + receipts: receipts + ) + } + + 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..7861e66efcf --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess+ChatFirstJournal.swift @@ -0,0 +1,537 @@ +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 { + /// 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 + } + + /// 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 + ) + } + + 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 + } + + 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, + blocksJSON: String, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> KernelJournalTurn { + 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 + 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 + } + + /// Attach one locally stored Rewind image to precisely the assistant turn + /// produced by the authorized Chat-first run. The local kernel owns the + /// turn selection and durability; Swift supplies only the evidence metadata. + func appendChatFirstEvidence( + clientId: String, + surface: AgentSurfaceReference, + ownerID: String, + sessionID: String, + runID: String, + attemptID: String, + capabilityRef: String, + controlGeneration: Int, + resource: ChatResource, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> KernelJournalTurn { + guard surface.surfaceKind == "main_chat", controlGeneration >= 0, resource.isImage, + let resourceJSON = + ChatResource.encodeResourcesForPersistence([resource]), + let resourceData = resourceJSON.data(using: .utf8), + let resources = try JSONSerialization.jsonObject(with: resourceData) as? [[String: Any]], + let wireResource = resources.first + else { + throw BridgeError.agentError("Invalid chat-first evidence append") + } + let result = try await journalOperation( + type: "append_chat_first_evidence", + operation: "append_chat_first_evidence", + clientId: clientId, + surface: surface, + ownerID: ownerID, + payload: [ + "sessionId": sessionID, + "runId": runID, + "attemptId": attemptID, + "capabilityRef": capabilityRef, + "controlGeneration": controlGeneration, + "resource": wireResource, + ], + authorizationSnapshot: authorizationSnapshot + ) + guard let turn = result.turn else { + throw BridgeError.agentError("Chat-first evidence 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, + intentsJSON: String, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot + ) async throws -> ChatFirstIntentsMaterialization { + 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, + 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 85baf5366d2..e3a3250706d 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift @@ -279,6 +279,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" @@ -380,6 +381,7 @@ actor AgentRuntimeProcess { case journalBackendSync case journalBackendDelete case journalBackendReconcile + case chatFirstDeferralDelivery case defaultExecutionProfileConfigured case surfaceSessionResolved case sessionExecutionProfileMigrated @@ -389,6 +391,7 @@ actor AgentRuntimeProcess { case externalSurfaceRunBeginResult case externalSurfaceToolResult case externalSurfaceRunCompleteResult + case chatFirstHarnessExecutorResult case ownerRuntimeRevoked case unknown(String) } @@ -440,6 +443,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 @@ -449,6 +453,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) } @@ -541,17 +546,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 - } - typealias JournalTurnChangedHandler = @Sendable (KernelJournalTurn) -> Void typealias AuthorizedRealtimeToolHandler = @Sendable (AuthorizedToolExecution) async -> AuthorizedRealtimeToolExecutionResult @@ -870,7 +864,7 @@ actor AgentRuntimeProcess { } } - private func assertAuthorization( + func assertAuthorization( _ snapshot: RuntimeOwnerAuthorizationSnapshot, expectedOwnerID: String? = nil ) throws { @@ -934,6 +928,7 @@ actor AgentRuntimeProcess { surface: AgentSurfaceReference, title: String?, creationProfile: AgentSessionCreationProfile?, + chatFirstCapability: ChatFirstCapabilityProjection? = nil, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot ) async throws -> AgentSurfaceSession { try assertAuthorization(authorizationSnapshot) @@ -943,7 +938,8 @@ actor AgentRuntimeProcess { ownerId: authorizationSnapshot.ownerID, surface: surface, title: title, - creationProfile: creationProfile + creationProfile: creationProfile, + chatFirstCapability: chatFirstCapability ) let result = try await kernelContractRequest( payload: payload, @@ -1443,7 +1439,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", @@ -1456,6 +1453,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 } @@ -1649,7 +1647,7 @@ actor AgentRuntimeProcess { return message } - private static func protocolEnvelope( + static func protocolEnvelope( type: String, clientId: String, requestId: String, @@ -1665,7 +1663,7 @@ actor AgentRuntimeProcess { return message } - private func kernelContractRequest( + func kernelContractRequest( payload: [String: Any], expectedKind: RuntimeMessage.Kind, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot?, @@ -1963,7 +1961,10 @@ actor AgentRuntimeProcess { ).clearedCount } - 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, @@ -2580,7 +2581,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 @@ -2925,7 +2926,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, @@ -3228,11 +3231,15 @@ actor AgentRuntimeProcess { case .journalBackendReconcile: if messageOwnerIsCurrentlyAuthorized(message) { handleJournalBackendReconcile(message) } + case .chatFirstDeferralDelivery: + if messageOwnerIsCurrentlyAuthorized(message) { handleChatFirstDeferralDelivery(message) } + case .defaultExecutionProfileConfigured, .surfaceSessionResolved, .sessionExecutionProfileMigrated, .contextSourceUpdated, .contextSnapshot, .legacyMainChatSessionsImported, .externalSurfaceRunBeginResult, .externalSurfaceToolResult, - .externalSurfaceRunCompleteResult, .ownerRuntimeRevoked: + .externalSurfaceRunCompleteResult, .chatFirstHarnessExecutorResult, + .ownerRuntimeRevoked: completeKernelContractRequest(message) case .result: @@ -3357,7 +3364,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, @@ -3627,7 +3638,20 @@ 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, + 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"] + ), + coldStartSequenceTerminalReceipts: Self.chatFirstColdStartSequenceTerminalReceipts( + from: message.payload["coldStartSequenceTerminalReceipts"] + ), + acknowledgedReceiptCount: message.payload["acknowledgedReceiptCount"] as? Int ?? 0 )) } @@ -3976,7 +4000,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 7519481b6f9..7d4a5a64ddc 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentRuntimeStatusStore.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentRuntimeStatusStore.swift @@ -332,12 +332,13 @@ final class AgentRuntimeStatusStore: ObservableObject { case .initMessage, .toolUse, .authorizedToolExecution, .authRequired, .authSuccess, .controlToolResult, .journalOperationResult, .journalTurnChanged, .journalBackendSync, .journalBackendDelete, - .journalBackendReconcile, + .journalBackendReconcile, .chatFirstDeferralDelivery, .defaultExecutionProfileConfigured, .surfaceSessionResolved, .sessionExecutionProfileMigrated, .contextSourceUpdated, .contextSnapshot, .legacyMainChatSessionsImported, .externalSurfaceRunBeginResult, .externalSurfaceToolResult, - .externalSurfaceRunCompleteResult, .ownerRuntimeRevoked, + .externalSurfaceRunCompleteResult, .chatFirstHarnessExecutorResult, + .ownerRuntimeRevoked, .unknown: break } diff --git a/desktop/macos/Desktop/Sources/Chat/AuthorizedToolExecution.swift b/desktop/macos/Desktop/Sources/Chat/AuthorizedToolExecution.swift index 9f87cfc9ca5..6e5e3a028cb 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,36 @@ 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 surfaceKind = try requiredString("surfaceKind") + let chatFirstControlGeneration = payload["chatFirstControlGeneration"] as? Int + // Validate chat-first capability before manifest digest selection so an + // invalid capability surfaces as .invalidChatFirstCapability rather than + // a misleading .staleManifest from the subsequent digest comparison. + if let chatFirstControlGeneration { + guard surfaceKind == "main_chat", chatFirstControlGeneration >= 0 else { + throw Rejection.invalidChatFirstCapability + } + } else if resolvedTool == .renderChatBlocks { + throw Rejection.invalidChatFirstCapability + } let manifestDigest = try requiredString("manifestDigest") - guard manifestDigest == GeneratedToolExecutors.manifestDigest else { + let expectedManifestDigest = + surfaceKind == "main_chat" && chatFirstControlGeneration != nil + ? 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")) @@ -170,6 +190,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 +198,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..308aa50cf85 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatContentBlockCodec.swift @@ -77,6 +77,42 @@ 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, + selectedOptionId: dict["selectedOptionId"] as? String + ) + ) + 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 "memoryLink": + guard let memoryId = dict["memoryId"] as? String, let summary = dict["summary"] as? String else { continue } + blocks.append(.memoryLink(id: id, memoryId: memoryId, summary: summary)) case "agentSpawn": guard let sessionId = dict["sessionId"] as? String, !sessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, @@ -192,6 +228,24 @@ enum ChatContentBlockCodec { "summary": summary, "fullText": fullText, ] + 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): + 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 .memoryLink(let id, let memoryId, let summary): + return ["type": "memoryLink", "id": id, "memoryId": memoryId, "summary": summary] case .agentSpawn( let id, let pillId, let sessionId, let runId, let title, let objective, let provider ): diff --git a/desktop/macos/Desktop/Sources/Chat/ChatFirstAnalytics.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstAnalytics.swift new file mode 100644 index 00000000000..2f4cb22d3bd --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstAnalytics.swift @@ -0,0 +1,186 @@ +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 memoryLink = "memory_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/ChatFirstBlockValidation.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift new file mode 100644 index 00000000000..1abb2b6d572 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstBlockValidation.swift @@ -0,0 +1,155 @@ +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. +/// 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 + 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 + } +} + +/// 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] +} + +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 + } + let convertedBlocks = blocks.compactMap(backendBlock) + return convertedBlocks.count == blocks.count ? convertedBlocks : nil + } + + 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 + case "memoryLink": + guard let memoryID = block["memoryId"] as? String, let summary = block["summary"] as? String else { return nil } + return ["type": type, "memory_id": memoryID, "summary": summary] + 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 + case "memoryLink": + guard let memoryID = block["memory_id"] as? String, let summary = block["summary"] as? String else { return nil } + return ["type": type, "id": id, "memoryId": memoryID, "summary": summary] + 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/Chat/ChatFirstDeferralOutboxDriver.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift new file mode 100644 index 00000000000..0f9608c9044 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstDeferralOutboxDriver.swift @@ -0,0 +1,166 @@ +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 isDeferred: Bool + + enum CodingKeys: String, CodingKey { + case optionID = "option_id" + case label + case preparedAnswer = "prepared_answer" + case isDeferred = "defer" + } + } + + struct Question: Codable, Sendable, Equatable { + let type: String = "questionCard" + let questionID: String + let text: String + let subject: Subject + let options: [Option] + + enum CodingKeys: String, CodingKey { + case type + 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, + isDeferred: option["defer"] as? Bool ?? false + ) + } + guard options.count == optionsPayload.count, + Set(options.map(\.optionID)).count == options.count, + options.filter(\.isDeferred).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 APIError.httpError(let 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/ChatFirstPromptMaterializationDriver.swift b/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift new file mode 100644 index 00000000000..0852752beae --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/ChatFirstPromptMaterializationDriver.swift @@ -0,0 +1,260 @@ +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" + } +} + +/// 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 { + let ownerID: String + let controlGeneration: Int +} + +/// 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" + case agentJudgment = "agent_judgment" + case coldStartRich = "cold_start_rich" + case coldStartSparse = "cold_start_sparse" + } + + let intentID: String + let continuityKey: String + let accountGeneration: Int + let source: Source + let blocks: [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, @unchecked Sendable { + 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 + let receiptID: String + + enum CodingKeys: String, CodingKey { + case intentID = "intent_id" + case receiptID = "receipt_id" + } + } + + 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" + } +} + +extension APIClient { + func materializeChatFirstPrompts( + ownerID: String, + controlGeneration: Int, + windowForeground: Bool, + receipts: ChatFirstPromptReceiptBatch + ) async throws -> ChatFirstMaterializePromptsResponse { + 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.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( + "v1/chat/materialize-prompts", + body: body, + includeBYOK: false, + expectedOwnerId: ownerID + ) + } +} diff --git a/desktop/macos/Desktop/Sources/Chat/ChatResource.swift b/desktop/macos/Desktop/Sources/Chat/ChatResource.swift index ee6674fa294..b5451e9b3ce 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatResource.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatResource.swift @@ -571,6 +571,8 @@ private struct ChatResourceCard: View { private var resourceImage: some View { if let data = resource.imageData, let img = NSImage(data: data) { Image(nsImage: img).resizable().scaledToFill() + } else if let fileURL = resource.fileURL, let image = NSImage(contentsOf: fileURL) { + Image(nsImage: image).resizable().scaledToFill() } else if let urlString = resource.thumbnailURL, let url = URL(string: urlString) { AsyncImage(url: url) { phase in switch phase { 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/Chat/ScreenContextTelemetry.swift b/desktop/macos/Desktop/Sources/Chat/ScreenContextTelemetry.swift index 84414e06075..21226ee5bb5 100644 --- a/desktop/macos/Desktop/Sources/Chat/ScreenContextTelemetry.swift +++ b/desktop/macos/Desktop/Sources/Chat/ScreenContextTelemetry.swift @@ -179,6 +179,7 @@ enum ScreenContextToolTelemetry { "get_work_context", "capture_screen", "get_screenshot", + "show_rewind_evidence", "search_screen_history", "semantic_search", ] @@ -338,7 +339,7 @@ enum ScreenContextToolTelemetry { ) } - if toolName == "get_screenshot" || toolName == "capture_screen" { + if toolName == "get_screenshot" || toolName == "capture_screen" || toolName == "show_rewind_evidence" { let failureRaw = (json["error"] as? String) ?? (json["code"] as? String) return ScreenContextToolFacts( requested: true, diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge+ChatFirst.swift new file mode 100644 index 00000000000..2cb66ebfd14 --- /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 dca3c1f8b86..9c5cade4a66 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift @@ -124,6 +124,22 @@ 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? + /// 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? + /// 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 @@ -431,6 +447,13 @@ final class DesktopAutomationStateStore { highlightedSettingId: nil, usesLegacyHomeDesign: false, homeMode: nil, + shellVariant: nil, + chatFirstRoute: nil, + visibleChatFirstRoute: nil, + pendingFocusKind: nil, + acknowledgedFocusKind: nil, + focusedEntityID: nil, + isFocusedEntityAcknowledged: false, showsPrimarySidebar: false, isSidebarCollapsed: true, hasCompletedOnboarding: false, @@ -517,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. @@ -772,7 +795,6 @@ final class DesktopAutomationActionRegistry { "window": window.map { $0.title.isEmpty ? "untitled" : $0.title } ?? "none", ] } - // 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( @@ -3745,8 +3767,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/FloatingControlBar/AIResponseView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift index c5cf7c54598..75428647855 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift @@ -106,6 +106,16 @@ 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 .memoryLink(let id, _, _): + return ["chatFirstMemory", id].joined(separator: "\u{1E}") case .agentSpawn( let id, let pillId, let sessionId, let runId, let title, let objective, let provider ): @@ -198,6 +208,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, .memoryLink: + EmptyView() case .agentSpawn( _, 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 65b3efaad1a..36739a78e49 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift @@ -2112,7 +2112,7 @@ final class AgentPillsManager: ObservableObject { if !trimmed.isEmpty { return String(trimmed.prefix(110)) } - case .thinking, .discoveryCard: + case .thinking, .discoveryCard, .questionCard, .taskCard, .goalLink, .captureLink, .memoryLink: continue } } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift index d1d48513103..8136f52e591 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, .memoryLink: + 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 a5fbdab3c33..9c5d2a88299 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift @@ -782,6 +782,11 @@ 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 .memoryLink(let id, _, _): return "m:\(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/FloatingControlBar/FloatingControlBarView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift index 8fffc586e81..d7606b2f8e6 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift @@ -2245,6 +2245,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, .memoryLink: + EmptyView() case .agentSpawn( _, let pillId, let sessionId, let runId, let title, let objective, let provider ): diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift index 327e42aace2..6d326be27af 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift @@ -2745,12 +2745,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 a7be3a8014b..6493f5dd7f2 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift @@ -32,6 +32,9 @@ enum GeneratedSwiftTool: String, CaseIterable { case reportScreenObservation = "report_screen_observation" case pointClick = "point_click" case getWorkContext = "get_work_context" + case getCanonicalGoals = "get_canonical_goals" + case renderChatBlocks = "render_chat_blocks" + case showRewindEvidence = "show_rewind_evidence" } enum GeneratedSwiftToolExecutor: String { @@ -42,6 +45,7 @@ enum GeneratedSwiftToolExecutor: String { enum GeneratedToolExecutors { static let manifestVersion = 1 static let manifestDigest = "sha256:6a1ecf294f385bf6a0a354fe10ab3ac9ef091e863cc73afca899f2dff560efe0" + static let chatFirstManifestDigest = "sha256:3b87f54b186ba114de98a6628492aad93c30ae4a08263f830238f5099f40f565" static let aliasToCanonical: [String: GeneratedSwiftTool] = [ "search_screen_history": .semanticSearch, @@ -79,7 +83,10 @@ enum GeneratedToolExecutors { .screenshot: .realtimeHub, .reportScreenObservation: .realtimeHub, .pointClick: .realtimeHub, - .getWorkContext: .chatToolExecutor + .getWorkContext: .chatToolExecutor, + .getCanonicalGoals: .chatToolExecutor, + .renderChatBlocks: .chatToolExecutor, + .showRewindEvidence: .chatToolExecutor ] static func resolve(_ name: String) -> GeneratedSwiftTool? { @@ -136,6 +143,9 @@ enum GeneratedToolExecutors { case getEmailInsights case createCalendarEvent case getWorkContext + case getCanonicalGoals + case renderChatBlocks + case showRewindEvidence case unhandled } @@ -169,6 +179,9 @@ enum GeneratedToolExecutors { case .getEmailInsights: return .getEmailInsights case .createCalendarEvent: return .createCalendarEvent case .getWorkContext: return .getWorkContext + case .getCanonicalGoals: return .getCanonicalGoals + case .renderChatBlocks: return .renderChatBlocks + case .showRewindEvidence: return .showRewindEvidence default: return .unhandled } } diff --git a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift index 3c3cf7316af..946a405efe2 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 @@ -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? = nil, chatFirstUi: Bool? = nil, workflowMode: TaskWorkflowMode? = nil) { self.accountGeneration = accountGeneration + self.chatFirstUi = chatFirstUi self.workflowMode = workflowMode } } @@ -3895,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 @@ -3958,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 @@ -3997,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 @@ -4050,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 @@ -4102,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 @@ -4183,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 @@ -4217,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 @@ -6709,7 +6713,59 @@ 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 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 let 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 let 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 { throw OmiApiError.invalidURL @@ -6727,6 +6783,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))) } @@ -6910,11 +6969,19 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } - public static func getConversationByIdV1ConversationsConversationIdGet(client: OmiApiClient, conversationId: String, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> Conversation { + public static func getConversationByIdV1ConversationsConversationIdGet(client: OmiApiClient, conversationId: String, source: String? = nil, includeDiscarded: Bool? = nil, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> Conversation { let _path = "/v1/conversations/\(conversationId)" - guard let components = URLComponents(string: client.baseURL + _path) else { + guard var components = URLComponents(string: client.baseURL + _path) else { throw OmiApiError.invalidURL } + var queryItems: [URLQueryItem] = [] + if let source { + queryItems.append(URLQueryItem(name: "source", value: String(source))) + } + if let includeDiscarded { + queryItems.append(URLQueryItem(name: "include_discarded", value: String(includeDiscarded))) + } + if !queryItems.isEmpty { components.queryItems = queryItems } guard let url = components.url else { throw OmiApiError.invalidURL } var req = URLRequest(url: url) req.httpMethod = "GET" @@ -8721,6 +8788,35 @@ public enum OmiAPI { return try JSONDecoder().decode(GoalResponse.self, from: data) } + public static func getCanonicalGoalsV1GoalsCanonicalListGet(client: OmiApiClient, includeEnded: Bool? = nil, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> [GoalResponse] { + let _path = "/v1/goals/canonical/list" + guard var components = URLComponents(string: client.baseURL + _path) else { + throw OmiApiError.invalidURL + } + var queryItems: [URLQueryItem] = [] + if let includeEnded { + queryItems.append(URLQueryItem(name: "include_ended", value: String(includeEnded))) + } + if !queryItems.isEmpty { components.queryItems = queryItems } + guard let url = components.url else { throw OmiApiError.invalidURL } + var req = URLRequest(url: url) + req.httpMethod = "GET" + 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") } + 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([GoalResponse].self, from: data) + } + public static func extractAndUpdateProgressV1GoalsExtractProgressPost(client: OmiApiClient, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil, body: OmiAnyCodable) async throws -> OmiAnyCodable { let _path = "/v1/goals/extract-progress" guard let components = URLComponents(string: client.baseURL + _path) else { @@ -14283,5 +14379,5 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } - // Total: 381 Swift client methods generated. + // Total: 384 Swift client methods generated. } 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..6e5c9cc2f64 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstContentBlockViews.swift @@ -0,0 +1,545 @@ +import OmiTheme +import SwiftUI + +// MARK: - Question card + +/// 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 + let label: String + let isDeferral: Bool + + 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 + self.isDeferral = dictionary["defer"] as? Bool ?? false + } + } + + let questionID: String + let text: String + let options: [[String: Any]] + let selectedOptionID: String? + let isActionable: Bool + let onSelect: (String, Bool) -> Void + + 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) + + // 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 + Button { + onSelect(option.id, option.isDeferral) + } 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)") + } + } + } + } + .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)") + .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)) + } + } +} + +// 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 + @State private var hydrationFinished = 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) + .onAppear { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .taskCard, outcome: .rendered, action: .none) + ) + } + } else if hydrationFinished { + ChatFirstUnavailableBlockView(entityName: "Task") + .onAppear { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .taskCard, outcome: .stalePlaceholder, action: .none) + ) + } + } else { + ChatFirstLoadingBlockView(entityName: "Task") + } + } + .accessibilityIdentifier("chat-first-task-\(taskID)") + .task(id: taskID) { + guard task == nil else { + hydrationFinished = true + return + } + _ = await tasksStore.resolveCanonicalTask(id: taskID) + hydrationFinished = true + } + } + + @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 = ChatFirstCaptureLinkPolicy.captureID(for: task) { + 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 + 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: reconciledTask + ) + 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 + @ObservedObject var goalsStore: CanonicalGoalsStore + + @State private var isOpening = false + @State private var isUnavailable = false + + var body: some View { + 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 { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .goalLink, outcome: .rendered, action: .none) + ) + } + } + + private func openGoal() { + guard !isOpening else { return } + isOpening = true + Task { @MainActor in + defer { isOpening = false } + // 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 + 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)) + } + } +} + +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 { + 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 { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .captureLink, outcome: .rendered, action: .none) + ) + } + } + + private func openCapture() { + guard !isOpening else { return } + isOpening = true + Task { @MainActor in + defer { isOpening = false } + do { + _ = try await APIClient.shared.getOmiCapture(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) + ) + } + } + } +} + +struct MemoryLinkView: View { + let memoryID: String + let summary: String + let navigation: ChatFirstShellNavigation + + var body: some View { + ChatFirstLinkBlockView( + eyebrow: "Memory", + systemImage: "brain.head.profile", + summary: summary, + actionTitle: "Open in Memories", + isOpening: false, + accessibilityID: "chat-first-memory-\(memoryID)-open" + ) { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .memoryLink, outcome: .acted, action: .open) + ) + navigation.open(focus: .memory(id: memoryID)) + } + .onAppear { + AnalyticsManager.shared.chatFirst( + .richBlock(kind: .memoryLink, outcome: .rendered, action: .none) + ) + } + } +} + +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 + ) + } +} + +/// 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 + 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") + } +} + +private struct ChatFirstLoadingBlockView: View { + let entityName: String + + var body: some View { + HStack(spacing: OmiSpacing.sm) { + ProgressView() + .controlSize(.small) + Text("Loading \(entityName.lowercased())") + .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("Loading \(entityName.lowercased())") + .accessibilityIdentifier("chat-first-\(entityName.lowercased())-loading") + } +} 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..b691f53521d --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/Blocks/ChatFirstRichBlockContext.swift @@ -0,0 +1,28 @@ +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 + let chatProvider: ChatProvider + let canonicalGoalsStore: CanonicalGoalsStore + let promptMaterializationCoordinator: ChatFirstPromptMaterializationCoordinator + + init( + navigation: ChatFirstShellNavigation, + tasksStore: TasksStore, + 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..094987435d0 --- /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, Sendable { + 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." + self.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 + self.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 } + self.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/CaptureArchivePage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift new file mode 100644 index 00000000000..b15114ac878 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchivePage.swift @@ -0,0 +1,447 @@ +import Foundation +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 +/// are inherited from the legacy Conversations page. +@MainActor +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, + automationRuntime: ChatFirstAutomationRuntime? = nil + ) { + self.navigation = navigation + self.chatProvider = chatProvider + self.automationRuntime = automationRuntime + _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() } + .onAppear { registerAutomationActions() } + .onDisappear { automationRuntime?.unregisterCapturePage() } + .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 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 + } +} + +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) + ) + } +} + +extension ServerConversation { + fileprivate var archiveDisplayDate: Date { startedAt ?? createdAt } + + fileprivate var listMetadata: String { + "\(archiveDisplayDate.formatted(.relative(presentation: .named))) · \(formattedDuration)" + } + + fileprivate var detailMetadata: String { + let date = archiveDisplayDate.formatted(date: .abbreviated, time: .shortened) + return "\(date) · \(formattedDuration)" + } + + fileprivate var participantLabels: [String] { + Array(Set(transcriptSegments.compactMap(\.speaker))).sorted() + } + + fileprivate var accessibilitySummary: String { + "\(title), \(listMetadata), Omi-device capture" + } +} + +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/CaptureArchiveRepository.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CaptureArchiveRepository.swift new file mode 100644 index 00000000000..1a582da1e6a --- /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 +} + +extension ServerConversation { + fileprivate var isOmiCaptureArchiveRecord: Bool { + source == .omi && !discarded && (status == .completed || status == .processing) + } +} + +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: Sendable { + 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.getOmiCapture(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..43cb0c609c7 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/CapturePlayback.swift @@ -0,0 +1,191 @@ +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: Sendable { + func resolvePlayback(for capture: ServerConversation) async -> CapturePlaybackResolution +} + +enum CapturePlaybackResolution: Equatable, Sendable { + 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, Sendable { + let id: String + let signedURL: URL + let duration: TimeInterval +} + +struct CapturePlaybackArtifact: Equatable, Sendable { + 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/ChatFirstAutomationRuntime.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift new file mode 100644 index 00000000000..6e09a1ca6ac --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstAutomationRuntime.swift @@ -0,0 +1,403 @@ +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 .questionCard(_, let questionID, _, _, _, _, let 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 .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) + } + 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/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/ChatFirstGoalsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift new file mode 100644 index 00000000000..579d27e6d09 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstGoalsPage.swift @@ -0,0 +1,432 @@ +import Foundation +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 +/// `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 + let automationRuntime: ChatFirstAutomationRuntime? + + @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() } + registerAutomationActions() + } + .onDisappear { automationRuntime?.unregisterGoalsPage() } + .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 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 { + 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 new file mode 100644 index 00000000000..73b5f1ede4e --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstPromptMaterializationCoordinator.swift @@ -0,0 +1,124 @@ +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( + hasChatFirstMainChatContext: Bool, + transcriptFirstPageLoaded: Bool, + isRunning: Bool, + lastAttemptAt: Date?, + now: Date + ) -> Bool { + guard hasChatFirstMainChatContext, 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 var driver: (any ChatFirstPromptMaterializationDriving)? + 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) { + 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 + /// 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. + @discardableResult + func mainWindowDidBecomeForeground() -> Bool { + requestMaterialization(windowForeground: true) + } + + @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 + else { return false } + + lastAttemptAt = now() + requestGeneration &+= 1 + let generation = requestGeneration + requestTask = Task { [weak self, driver] in + guard let self else { return } + await self.materialize(using: driver, windowForeground: windowForeground, generation: generation) + if self.requestGeneration == generation { + self.requestTask = nil + } + } + return true + } + + private func materialize( + using driver: any ChatFirstPromptMaterializationDriving, + windowForeground: Bool, + generation: Int + ) async { + guard isCurrentMaterialization(generation) else { return } + guard let context = driver.materializationContext() else { return } + do { + try await ChatFirstPromptMaterializationRunner.run( + driver: driver, + context: context, + windowForeground: windowForeground, + isCurrent: { [weak self] in + self?.isCurrentMaterialization(generation) ?? false + } + ) + } 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/ChatFirstRoute.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift new file mode 100644 index 00000000000..fc31c8811aa --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstRoute.swift @@ -0,0 +1,461 @@ +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 +/// 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 + } + } + + /// 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 { + 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" + } + } + + /// 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 +/// 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 goals + 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 .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): + 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 +} + +/// 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 + /// 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 + /// 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: @MainActor (ChatFirstAnalyticsEvent) -> Void + + init( + defaults: UserDefaults = .standard, + analytics: (@MainActor (ChatFirstAnalyticsEvent) -> Void)? = nil + ) { + self.defaults = defaults + 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) + { + route = persisted.route + isSidebarCollapsed = persisted.isSidebarCollapsed + } else { + route = .chat + isSidebarCollapsed = false + } + pendingFocus = nil + pendingFocusDestination = nil + visibleRoute = nil + lastAcknowledgedFocusKind = nil + focusedEntityID = nil + isFocusedEntityAcknowledged = false + } + + func selectPrimary( + _ destination: ChatFirstRoute, + origin: ChatFirstAnalyticsEvent.RouteOrigin = .sidebar + ) { + guard destination.isPrimaryDestination else { return } + route = destination + visibleRoute = nil + clearFocus() + persistNavigation() + analytics(.routeEntered(route: analyticsRoute(destination), origin: origin)) + } + + func selectMore(_ page: ChatFirstMorePage) { + route = .more(page) + visibleRoute = nil + clearFocus() + persistNavigation() + analytics(.routeEntered(route: .more, origin: .more)) + } + + /// 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) { + 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 + visibleRoute = nil + 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, origin: .chatDeeplink) + Task { + _ = await chatProvider.sendMessage(context.userMessage) + } + } + + @discardableResult + func acknowledgeFocus(_ focus: ChatFirstPendingFocus) -> Bool { + guard route == pendingFocusDestination, pendingFocus == focus else { return false } + pendingFocus = nil + pendingFocusDestination = nil + lastAcknowledgedFocusKind = focus.stableName + focusedEntityID = focus.entityID + isFocusedEntityAcknowledged = true + 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() + } + + 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) + } + + 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 +/// 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..32ba3b13e32 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstShell.swift @@ -0,0 +1,387 @@ +import AppKit +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. +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() + @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) { + 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) + .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() + } + .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)) { + navigation.selectPrimary(.chat) + } + } + } + + @ViewBuilder + private var destination: some View { + switch navigation.route { + case .chat: + ChatPage( + appProvider: viewModelContainer.appProvider, + chatProvider: viewModelContainer.chatProvider, + chatFirstRichBlockContext: ChatFirstRichBlockContext( + navigation: navigation, + tasksStore: viewModelContainer.tasksStore, + chatProvider: viewModelContainer.chatProvider, + canonicalGoalsStore: viewModelContainer.canonicalGoalsStore, + promptMaterializationCoordinator: promptMaterializationCoordinator + ) + ) + .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, + automationRuntime: automationRuntime + ) + .accessibilityIdentifier("chat-first-route-conversations") + .onAppear { navigation.markRouteVisible(.conversations) } + case .tasks: + ChatFirstTasksPage( + navigation: navigation, + tasksStore: viewModelContainer.tasksStore, + 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, + 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) + if case .memory(let id) = navigation.pendingFocus { + _ = navigation.acknowledgeFocus(.memory(id: id)) + } + } + case .more(let page): + moreDestination(page) + .accessibilityIdentifier("chat-first-route-more-\(page.stableName)") + .onAppear { navigation.markRouteVisible(.more(page)) } + } + } + + @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/ChatFirst/ChatFirstTasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift new file mode 100644 index 00000000000..634feb11515 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/ChatFirst/ChatFirstTasksPage.swift @@ -0,0 +1,651 @@ +import Foundation +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 +/// 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 + } + + 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) + 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 + let automationRuntime: ChatFirstAutomationRuntime? + + @State private var addDrafts: [ChatFirstTaskScheduleGroup: String] = [:] + @State private var addingGroups: Set = [] + @State private var highlightedTaskID: String? + + init( + navigation: ChatFirstShellNavigation, + tasksStore: TasksStore, + chatProvider: ChatProvider, + automationRuntime: ChatFirstAutomationRuntime? = nil + ) { + self.navigation = navigation + self.tasksStore = tasksStore + self.chatProvider = chatProvider + self.automationRuntime = automationRuntime + } + + 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 + } + + 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 + + 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() } + registerAutomationActions() + } + .onDisappear { + tasksStore.isActive = false + automationRuntime?.unregisterTasksPage() + } + .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 tasksStore.error != nil, !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 { scrollPendingFocusIntoView(proxy) } + .onChange(of: navigation.pendingFocus) { _, _ in scrollPendingFocusIntoView(proxy) } + .onChange(of: visibleTasks.map(\.id)) { _, _ in scrollPendingFocusIntoView(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) + } + } + .id(goalGroup.goalID.map(ChatFirstTaskPagePolicy.goalFocusAnchor) ?? goalGroup.id) + .onAppear { + guard let goalID = goalGroup.goalID else { return } + acknowledgeVisibleGoalIfNeeded(goalID) + } + } + + 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 + 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) + } + } + + 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(ChatFirstTaskPagePolicy.goalFocusAnchor(goalID), 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 func acknowledgeVisibleGoalIfNeeded(_ goalID: String) { + guard + let focus = ChatFirstTaskPagePolicy.goalFocusToAcknowledge( + pendingFocus: navigation.pendingFocus, + visibleGoalID: goalID + ) + 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 { + 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) { + 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 + 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 + } + } + + 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 + 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 + } + } + + private func cancelRename() { + titleDraft = task.description + titleIsFocused = false + isEditing = false + } + + private func move() { + guard !isSaving else { return } + isSaving = true + Task { @MainActor in + 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 + } + } +} + +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 03dfb26ceb1..ecd8625c999 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 isRowHovering = false @@ -31,7 +34,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 @@ -41,6 +45,7 @@ struct ChatBubble: View { self.onCancelTurn = onCancelTurn self.onOpenAgent = onOpenAgent self.onOpenAgentRef = onOpenAgentRef + self.chatFirstRichBlockContext = chatFirstRichBlockContext _lastSubmittedRating = State(initialValue: message.rating) } @@ -84,43 +89,53 @@ struct ChatBubble: View { } var body: some View { - let groupedBlocks = ContentBlockGroup.visibleChatGroups( - message.contentBlocks, - isStreaming: message.isStreaming - ) + 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) { - // Default omi replies render avatar-free for a quieter timeline; only - // app personas keep their identity mark. - if message.sender == .ai, 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) + HStack(alignment: .top, spacing: OmiSpacing.md) { + // Default omi replies render avatar-free for a quieter timeline; only + // app personas keep their identity mark. + if message.sender == .ai, 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()) } - .frame(width: 32, height: 32) - .clipShape(Circle()) - } - // Bubbles hug their content up to a readable cap — omi replies sit - // left, user messages sit right, neither spans the full column. - VStack(alignment: message.sender == .user ? .trailing : .leading, spacing: OmiSpacing.xxs) { - messageContentView(groupedBlocks) + // Bubbles hug their content up to a readable cap — omi replies sit + // left, user messages sit right, neither spans the full column. + VStack(alignment: message.sender == .user ? .trailing : .leading, spacing: OmiSpacing.xxs) { + messageContentView(groupedBlocks) + } + .frame( + maxWidth: 640, + alignment: message.sender == .user ? .trailing : .leading + ) } - .frame( - maxWidth: 640, - alignment: message.sender == .user ? .trailing : .leading - ) + .frame(maxWidth: .infinity, alignment: message.sender == .user ? .trailing : .leading) + .contentShape(Rectangle()) + .onHover { isRowHovering = $0 } } - .frame(maxWidth: .infinity, alignment: message.sender == .user ? .trailing : .leading) - .contentShape(Rectangle()) - .onHover { isRowHovering = $0 } } @ViewBuilder @@ -273,6 +288,73 @@ 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: chatFirstRichBlockContext.chatProvider.isQuestionCardActionable( + messageID: message.id, + questionID: questionID, + selectedOptionID: selectedOptionID + ), + 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 + ) + } + } + ) + ) + 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, + goalsStore: chatFirstRichBlockContext.canonicalGoalsStore + ) + ) + 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 .memoryLink(_, let memoryID, let summary): + guard let chatFirstRichBlockContext else { return AnyView(EmptyView()) } + return AnyView( + MemoryLinkView( + memoryID: memoryID, + summary: summary, + navigation: chatFirstRichBlockContext.navigation + ) + ) case .agentSpawn( _, let pillId, let sessionId, let runId, let title, let objective, let provider ): @@ -866,6 +948,11 @@ 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 memoryLink(id: String, memoryID: String, summary: String) case agentSpawn( id: String, pillId: UUID?, @@ -892,13 +979,21 @@ 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 .memoryLink(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] = [] @@ -922,6 +1017,35 @@ 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)) + 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 .memoryLink(let id, let memoryID, let summary): + flushToolCalls() + guard richBlockRenderingEnabled else { continue } + groups.append(.memoryLink(id: id, memoryID: memoryID, summary: summary)) case .agentSpawn( let id, let pillId, let sessionId, let runId, let title, let objective, let provider ): @@ -961,7 +1085,11 @@ enum ContentBlockGroup: Identifiable { /// Main chat keeps a durable tool trace, so streamed answers do not appear to lose completed work. /// A structured `.agentSpawn` replaces only its duplicate raw spawn call (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 @@ -985,11 +1113,12 @@ 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, .memoryLink, .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 7be0b257ab3..01de9c9b19d 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift @@ -91,6 +91,9 @@ struct ChatMessagesView: View { /// Horizontal inset of the message column. Home passes 0 so bubbles align /// exactly with the ask bar's edges; other surfaces keep the default gutter. var horizontalContentPadding: CGFloat = OmiSpacing.xxl + /// 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. @@ -474,7 +477,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/Components/ChatScrollBehavior.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift index 4cfc497f11f..802800d33d6 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatScrollBehavior.swift @@ -222,6 +222,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 c1f9684c170..9fe0824169a 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 @@ -56,6 +59,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? = { @@ -115,8 +119,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 @@ -124,8 +136,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 @@ -141,7 +152,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: { @@ -151,7 +162,7 @@ struct DesktopHomeView: View { appState.showUsageLimitPopup = false selectedSettingsSection = .advanced OmiMotion.withGated(Self.pageNavigationAnimation) { - selectedIndex = SidebarNavItem.settings.rawValue + navigateToLegacyDestination(.settings) } } ) @@ -321,6 +332,7 @@ struct DesktopHomeView: View { log( "DesktopHomeView: userDidSignOut — resetting hasCompletedOnboarding and stopping transcription" ) + chatFirstCapabilitySample.ownerDidChange(to: nil) resetSessionScopedStartupWarmups(preserveCrispReadState: false) appState.conversationRepository.reset() appState.folders = [] @@ -475,6 +487,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() @@ -573,6 +591,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 @@ -601,7 +620,7 @@ struct DesktopHomeView: View { } private var showsPrimarySidebar: Bool { - useLegacyHomeDesign && !hideSidebar + !usesChatFirstShell && useLegacyHomeDesign && !hideSidebar } private var currentAppStateLabel: String { @@ -620,19 +639,30 @@ 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, + visibleChatFirstRoute: usesChatFirstShell ? chatFirstNavigation.visibleRoute?.stableName : nil, + pendingFocusKind: chatFirstNavigation.pendingFocus?.stableName, + acknowledgedFocusKind: chatFirstNavigation.lastAcknowledgedFocusKind, + focusedEntityID: chatFirstNavigation.focusedEntityID, + isFocusedEntityAcknowledged: chatFirstNavigation.isFocusedEntityAcknowledged, showsPrimarySidebar: showsPrimarySidebar, - isSidebarCollapsed: isSidebarCollapsed, + isSidebarCollapsed: usesChatFirstShell + ? chatFirstNavigation.isSidebarCollapsed : isSidebarCollapsed, hasCompletedOnboarding: appState.hasCompletedOnboarding, isSignedIn: authState.isSignedIn, isRestoringAuth: authState.isRestoringAuth, @@ -678,8 +708,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() @@ -692,7 +726,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() } @@ -887,7 +921,270 @@ 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) + 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, + 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 + ) + capabilityErrorClass = .unavailable + 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() + 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() + } + + 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.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() + } + } + + /// 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(let capability) = chatFirstCapabilitySample.variant { + return AnyView( + ChatFirstShell( + navigation: chatFirstNavigation, + appState: appState, + viewModelContainer: viewModelContainer, + capability: capability, + 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 @@ -993,109 +1290,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 } @@ -1105,6 +1309,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/ChatPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/ChatPage.swift index be8716aac77..83eb3ce69ab 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/ChatPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/ChatPage.swift @@ -6,21 +6,27 @@ struct ChatPage: View { @ObservedObject var appProvider: AppProvider @ObservedObject var chatProvider: ChatProvider let onHome: () -> Void + /// 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? @State private var citedConversation: ServerConversation? @State private var isLoadingCitation = false @State private var copied = false + @State private var didReportChatFirstTranscriptPage = false init( appProvider: AppProvider, chatProvider: ChatProvider, - onHome: @escaping () -> Void = {} + onHome: @escaping () -> Void = {}, + chatFirstRichBlockContext: ChatFirstRichBlockContext? = nil ) { self.appProvider = appProvider self.chatProvider = chatProvider self.onHome = onHome + self.chatFirstRichBlockContext = chatFirstRichBlockContext } var selectedApp: OmiApp? { @@ -93,6 +99,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, @@ -167,6 +179,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 { @@ -413,6 +434,7 @@ struct ChatPage: View { onOpenAgentRef: { ref, completion in FloatingControlBarManager.shared.openAgentChatFromTimeline(ref: ref, completion: completion) }, + chatFirstRichBlockContext: chatFirstRichBlockContext, welcomeContent: { welcomeMessage } ) .overlay(alignment: .bottom) { diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 71163a5d98a..e1853e717a4 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 @ObservedObject private var homeSuggestionsStore = HomeSuggestionsStore.shared @StateObject private var intelligenceStore = DashboardIntelligenceStore() @@ -250,6 +254,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 } @@ -369,7 +377,7 @@ struct DashboardPage: View { private var homeSurface: some View { Group { - if useLegacyHomeDesign { + if useLegacyHomeDesign && !routesChatToPrimaryShell { legacyHome } else { redesignedHome @@ -1161,6 +1169,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 @@ -1220,6 +1232,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, @@ -1234,6 +1257,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/Onboarding/OnboardingChatView.swift b/desktop/macos/Desktop/Sources/Onboarding/OnboardingChatView.swift index d062e6d9152..ba8a3bfb40a 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, .memoryLink: + return false case .agentSpawn, .agentCompletion: return true } diff --git a/desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift b/desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift index f0e791fb9be..4b7dbf2c1e5 100644 --- a/desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift +++ b/desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift @@ -154,8 +154,10 @@ public class ProactiveAssistantsPlugin: NSObject { private override init() { super.init() - // Load environment variables - loadEnvironment() + // Environment ownership is centralized so explicit launch overrides (for + // example a local Python backend) cannot be replaced by a later singleton + // initialization. + BundleEnvironment.loadIfNeeded() // Set up the coordinator event callback AssistantCoordinator.shared.setEventCallback { [weak self] type, data in @@ -171,35 +173,6 @@ public class ProactiveAssistantsPlugin: NSObject { log("ProactiveAssistantsPlugin initialized") } - // MARK: - Environment Loading - - private func loadEnvironment() { - let envPaths = [ - Bundle.main.path(forResource: ".env", ofType: nil), - FileManager.default.currentDirectoryPath + "/.env", - NSHomeDirectory() + "/.omi.env", - NSHomeDirectory() + "/.hartford.env", - ].compactMap { $0 } - - for path in envPaths { - if let contents = try? String(contentsOfFile: path, encoding: .utf8) { - for line in contents.components(separatedBy: .newlines) { - let parts = line.split(separator: "=", maxSplits: 1) - if parts.count == 2 { - let key = String(parts[0]).trimmingCharacters(in: .whitespaces) - let value = String(parts[1]).trimmingCharacters(in: .whitespaces) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) - setenv(key, value, 1) - } - } - log("Loaded environment from: \(path)") - break - } - } - - DesktopBackendEnvironment.applyReleaseChannelDefaults() - } - // MARK: - Assistant Management private func enableAssistant(identifier: String, enabled: Bool) { diff --git a/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift b/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift new file mode 100644 index 00000000000..7eae2a64a5f --- /dev/null +++ b/desktop/macos/Desktop/Sources/Providers/ChatFirstBlockToolExecutor.swift @@ -0,0 +1,85 @@ +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"}}"# + } + 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, + ownerID: expectedOwnerID, + sessionID: sessionID, + runID: runID, + attemptID: attemptID, + capabilityRef: capabilityRef, + controlGeneration: controlGeneration, + blocksJSON: journalBlocksJSON, + 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/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index c1736c40661..f06467f7837 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -1,5 +1,6 @@ import Combine import CoreGraphics +import CryptoKit @preconcurrency import GRDB import OmiSupport import SwiftUI @@ -324,6 +325,19 @@ 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]], + 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) + case memoryLink(id: String, memoryId: String, summary: String) case agentSpawn( id: String, pillId: UUID?, @@ -350,6 +364,11 @@ 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 .memoryLink(let id, _, _): return id case .agentSpawn(let id, _, _, _, _, _, _): return id case .agentCompletion(let id, _, _, _, _, _, _, _): return id } @@ -830,13 +849,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 @@ -855,6 +878,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 + ) } } @@ -891,6 +970,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), .memoryLink(_, _, 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)" @@ -1072,6 +1159,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? @@ -1216,6 +1306,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). @@ -1570,6 +1664,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 } @@ -1661,9 +1765,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 @@ -1768,7 +1879,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, @@ -1779,6 +1890,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, @@ -1806,7 +1939,10 @@ class ChatProvider: ObservableObject { ) let workspacePath = session.profile.workingDirectory let memoryText = formatMemoriesSection() - let goalText = formatGoalSection() + // Canonical goals are retrieved through the capability-scoped tool. An + // enabled Chat-first session must not quietly inject legacy GoalStorage + // rows into the model context. + let goalText = isChatFirstEnabled(for: surface) ? "" : formatGoalSection() let taskText = formatTasksSection() let identityText = formatAIProfileSection() var surfacePayload: [String: Any] = [ @@ -2276,6 +2412,7 @@ class ChatProvider: ObservableObject { currentSession = session isInDefaultChat = false isLoading = true + isMainChatJournalFirstPageReady = false errorMessage = nil hasMoreMessages = false @@ -2292,6 +2429,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 } @@ -2431,6 +2569,16 @@ class ChatProvider: ObservableObject { } } + private var isChatFirstMainChatEnabled: Bool { + guard let ownerID = runtimeOwnerId else { return false } + return chatFirstMainChatProjectionGate.capability(for: mainChatSurfaceReference(), ownerID: ownerID) != nil + } + + private func isChatFirstEnabled(for surface: AgentSurfaceReference) -> Bool { + guard let ownerID = runtimeOwnerId else { return false } + return chatFirstMainChatProjectionGate.capability(for: surface, ownerID: ownerID) != nil + } + /// Formats goals into a prompt section private func formatGoalSection() -> String { let activeGoals = cachedGoals.filter { $0.isActive } @@ -2753,7 +2901,9 @@ class ChatProvider: ObservableObject { /// Warm local prompt context used by first send / bridge startup. func warmupPromptContext() async { await refreshMemoriesForPrompt() - await loadGoalsIfNeeded() + if !isChatFirstMainChatEnabled { + await loadGoalsIfNeeded() + } await loadTasksIfNeeded() await loadAIProfileIfNeeded() await loadSchemaIfNeeded() @@ -2986,6 +3136,7 @@ class ChatProvider: ObservableObject { /// by the bounded, checkpointed legacy importer on first migration. func loadDefaultChatMessages() async { isLoading = true + isMainChatJournalFirstPageReady = false errorMessage = nil hasMoreMessages = false @@ -3004,6 +3155,7 @@ class ChatProvider: ObservableObject { hasMoreMessages = false sessionsLoadError = nil log("ChatProvider loaded \(messages.count) default kernel journal messages") + isMainChatJournalFirstPageReady = true isLoading = false } @@ -3357,6 +3509,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) { @@ -3641,6 +3819,245 @@ 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 + ) + ) + } + + /// 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 -> ChatFirstPromptReceiptBatch { + guard let session = try await chatFirstMaterializationSession() else { return .empty } + return try await resolvedAgentClient().listChatFirstMaterializationReceipts( + surface: session.surface, + ownerID: session.ownerID, + sessionID: session.agentSession.sessionId, + controlGeneration: session.capability.controlGeneration + ) + } + + @discardableResult + func acknowledgeChatFirstMaterializationReceipts( + _ receipts: ChatFirstPromptReceiptBatch + ) 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 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, + intentsJSON: intentsJSON + ) + if result.accepted { + await kernelTurnProjection.refresh(surface: session.surface) + } + 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 + 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: @@ -3657,11 +4074,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 @@ -3753,6 +4173,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 { @@ -3997,6 +4418,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. @@ -4009,7 +4485,7 @@ class ChatProvider: ObservableObject { let userMessage = ChatMessage( id: userMessageId, clientTurnId: turnAttemptId, - text: trimmedText, + text: effectivePrompt, sender: .user, attachments: attachmentsForMessage, turnOwner: turnOwner @@ -4025,17 +4501,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( @@ -4078,7 +4560,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" ) } @@ -4113,7 +4595,7 @@ class ChatProvider: ObservableObject { var screenPayload: [String: Any]? if effectiveImageData == nil, let screenContextReason = ScreenContextAutoIncludePolicy.reason( - userText: trimmedText, + userText: effectivePrompt, systemPromptStyle: systemPromptStyle, turnOwner: turnOwner, onboardingActive: !UserDefaults.standard.bool(forKey: DefaultsKey.hasCompletedOnboarding) @@ -4434,7 +4916,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, @@ -4727,7 +5209,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) } @@ -4922,7 +5404,7 @@ class ChatProvider: ObservableObject { } AnalyticsManager.shared.onboardingChatMessageDetailed( role: onboardingRole, - text: trimmedText, + text: effectivePrompt, step: "chat", error: onboardingRole == "error" ? String(describing: error) : nil ) @@ -4971,7 +5453,7 @@ class ChatProvider: ObservableObject { let card = ChatErrorState.from(bridgeError) { currentError = card - lastFailedPrompt = trimmedText + lastFailedPrompt = effectivePrompt errorMessage = nil } else { errorMessage = error.localizedDescription @@ -5028,7 +5510,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 @@ -5499,8 +6002,14 @@ class ChatProvider: ObservableObject { private func localFileResources(fromToolName name: String, texts: [String]) -> [ChatResource] { let normalizedName = Self.normalizedToolNameHead(name) - guard ["write", "edit", "multiedit"].contains(normalizedName) else { return [] } - return localFileURLs(from: texts.joined(separator: "\n")).map { url in + // Rewind evidence is appended through its capability-bound journal + // operation; treating the returned cache path as a generic artifact would + // create a second, unbound resource on the assistant turn. + let supportedFileTools = ["write", "edit", "multiedit", "capture_screen"] + guard supportedFileTools.contains(normalizedName) else { return [] } + let urls = localFileURLs(from: texts.joined(separator: "\n")) + let displayURLs = normalizedName == "capture_screen" ? Array(urls.prefix(1)) : urls + return displayURLs.map { url in let mimeType = mimeType(forLocalFile: url) return ChatResource.localGeneratedFile( id: "generated-file:\(url.path)", diff --git a/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift b/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift index 04e37dfac54..ee3f335f3fd 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift @@ -1,5 +1,6 @@ @preconcurrency import AVFoundation import AppKit +import CryptoKit import Foundation @preconcurrency import GRDB @preconcurrency import UserNotifications @@ -132,7 +133,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 +174,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 +194,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 +246,39 @@ class ChatToolExecutor { toolCall.arguments, expectedOwnerID: expectedOwnerID) + case .renderChatBlocks: + return await ChatFirstBlockToolExecutor.execute( + toolCall.arguments, + surface: originatingSurfaceRef, + sessionID: originatingSessionID, + runID: originatingRunId, + attemptID: originatingAttemptId, + capabilityRef: toolCapabilityRef, + controlGeneration: chatFirstControlGeneration, + expectedOwnerID: expectedOwnerID, + authorizationSnapshot: currentOwnerAuthorizationSnapshot, + api: backendAPIClient) + + case .getCanonicalGoals: + return await executeGetCanonicalGoals( + toolCall.arguments, + expectedOwnerID: expectedOwnerID, + authorizationSnapshot: currentOwnerAuthorizationSnapshot, + api: backendAPIClient) + + case .showRewindEvidence: + return await executeShowRewindEvidence( + toolCall.arguments, + context: telemetryContext, + surface: originatingSurfaceRef, + sessionID: originatingSessionID, + runID: originatingRunId, + attemptID: originatingAttemptId, + capabilityRef: toolCapabilityRef, + controlGeneration: chatFirstControlGeneration, + expectedOwnerID: expectedOwnerID, + authorizationSnapshot: currentOwnerAuthorizationSnapshot) + // Onboarding tools case .requestPermission: let isOnboardingRequest = isOnboardingSurface @@ -397,6 +443,40 @@ class ChatToolExecutor { } } + private static func executeGetCanonicalGoals( + _ arguments: [String: Any], + expectedOwnerID: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot?, + api: APIClient + ) async -> String { + guard let expectedOwnerID, let authorizationSnapshot else { return authorizedOwnerChangedResult() } + let includeEnded = (arguments["include_ended"] as? Bool) ?? false + do { + let goals = try await api.getCanonicalGoals( + includeEnded: includeEnded, + expectedOwnerId: expectedOwnerID, + authorizationSnapshot: authorizationSnapshot + ) + guard isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { + return authorizedOwnerChangedResult() + } + let result = goals.prefix(20).map { goal -> [String: Any] in + var value: [String: Any] = [ + "goal_id": goal.id, + "title": goal.title, + "status": goal.status.rawValue, + ] + if !goal.desiredOutcome.isEmpty { value["description"] = goal.desiredOutcome } + if let focusRank = goal.focusRank { value["focus_rank"] = focusRank } + return value + } + let data = try JSONSerialization.data(withJSONObject: ["goals": result]) + return String(data: data, encoding: .utf8) ?? #"{"goals":[]}"# + } catch { + return #"{"ok":false,"error":"canonical_goals_unavailable"}"# + } + } + nonisolated static func isExpectedOwnerCurrent( _ expectedOwnerID: String?, authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? = nil @@ -495,7 +575,7 @@ class ChatToolExecutor { toolName: String ) -> PhysicalExecutionPrecondition { switch toolName { - case "capture_screen", "get_screenshot": + case "capture_screen", "get_screenshot", "show_rewind_evidence": if isChatScreenshotSharingEnabled { return .satisfied } @@ -627,6 +707,145 @@ class ChatToolExecutor { ) } + private static func executeShowRewindEvidence( + _ arguments: [String: Any], + context: ScreenContextTelemetryContext, + surface: AgentSurfaceReference?, + sessionID: String?, + runID: String?, + attemptID: String?, + capabilityRef: String?, + controlGeneration: Int?, + expectedOwnerID: String?, + authorizationSnapshot: RuntimeOwnerAuthorizationSnapshot? + ) 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, + isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) + else { return authorizedOwnerChangedResult() } + let rawID = arguments["screenshot_id"] + let screenshotID: Int64? + if let value = rawID as? Int64 { + screenshotID = value + } else if let value = rawID as? Int { + screenshotID = Int64(value) + } else if let value = rawID as? Double, value.rounded() == value { + screenshotID = Int64(value) + } else if let value = rawID as? String { + screenshotID = Int64(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } else { + screenshotID = nil + } + guard let screenshotID, screenshotID >= 0 else { + return "Error: screenshot_id is required" + } + + do { + guard let screenshot = try await RewindDatabase.shared.getScreenshot(id: screenshotID) else { + ScreenContextToolTelemetry.trackToolResult( + toolName: "show_rewind_evidence", + context: context, + ok: false, + failureCode: .imageUnavailable, + permissionTCCGranted: CGPreflightScreenCaptureAccess()) + return "Error: Screenshot not found" + } + let data: Data + do { + data = try await RewindStorage.shared.loadScreenshotData(for: screenshot) + } catch { + try await RewindStorage.shared.initialize() + data = try await RewindStorage.shared.loadScreenshotData(for: screenshot) + } + guard isExpectedOwnerCurrent(expectedOwnerID, authorizationSnapshot: authorizationSnapshot) else { + return authorizedOwnerChangedResult() + } + // Journal resources must survive cache eviction, and their paths must + // never alias across signed-in owners. Hashing the owner and attempt + // keeps raw account IDs out of the filesystem while preserving a stable + // snapshot for this exact producing turn. + let ownerDigest = SHA256.hash(data: Data(expectedOwnerID.utf8)) + .prefix(12) + .map { String(format: "%02x", $0) } + .joined() + let evidenceDigest = SHA256.hash( + data: Data("\(attemptID)\u{0}\(screenshotID)".utf8) + ) + .prefix(12) + .map { String(format: "%02x", $0) } + .joined() + let applicationSupport = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let evidenceRoot = + applicationSupport + .appendingPathComponent("Omi", isDirectory: true) + .appendingPathComponent("ChatEvidence", isDirectory: true) + .appendingPathComponent(ownerDigest, isDirectory: true) + try FileManager.default.createDirectory( + at: evidenceRoot, + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: evidenceRoot.path + ) + let outputURL = evidenceRoot.appendingPathComponent("rewind-\(evidenceDigest).jpg") + try data.write(to: outputURL, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o600))], + ofItemAtPath: outputURL.path + ) + let resource = ChatResource.localGeneratedFile( + id: "rewind-evidence:\(evidenceDigest)", + title: "Rewind evidence", + subtitle: "Screenshot evidence", + mimeType: "image/jpeg", + uri: outputURL.absoluteString + ) + _ = try await AgentRuntimeProcess.shared.appendChatFirstEvidence( + clientId: "chat-first-rewind-evidence", + surface: surface, + ownerID: expectedOwnerID, + sessionID: sessionID, + runID: runID, + attemptID: attemptID, + capabilityRef: capabilityRef, + controlGeneration: controlGeneration, + resource: resource, + authorizationSnapshot: authorizationSnapshot + ) + ScreenContextToolTelemetry.trackToolResult( + toolName: "show_rewind_evidence", + context: context, + ok: true, + imageBytes: data.count, + permissionTCCGranted: CGPreflightScreenCaptureAccess()) + return #"{"ok":true,"evidence_attached":true}"# + } catch { + ScreenContextToolTelemetry.trackToolResult( + toolName: "show_rewind_evidence", + context: context, + ok: false, + failureCode: .imageUnavailable, + permissionTCCGranted: CGPreflightScreenCaptureAccess()) + return "Error: Failed to load screenshot evidence" + } + } + /// Format the capture_screen tool result: the full-screen path first (the /// original single-line contract), then native-resolution detail tiles. Vision /// APIs downscale a full-Retina frame until dense UI text (product titles, diff --git a/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift b/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift index 4974c9bb7f1..0ecb622a3d4 100644 --- a/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift +++ b/desktop/macos/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift @@ -1065,6 +1065,36 @@ 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 +1126,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 1f324a8f1ee..2b68041e73a 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/Sources/Services/APIClient/APIClient+TaskCatalog.swift b/desktop/macos/Desktop/Sources/Services/APIClient/APIClient+TaskCatalog.swift index 70226fded0f..b0a69af8e6e 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/canonical/list?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 94249e5380d..7dd756790f6 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. @@ -2254,6 +2277,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, @@ -2860,6 +2948,7 @@ class TasksStore: ObservableObject { log("TasksStore: Restored local-only task via undo (unsynced, id: \(task.id))") } + @discardableResult func updateTask( _ task: TaskActionItem, description: String? = nil, @@ -2867,93 +2956,160 @@ 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 - } + // Unsynced local-only tasks have no backend row; the pending create-sync + // pushes their current state instead of sending an invalid remote update. + if operationOverrides == nil, ActionItemTaskIdentity(surfacedId: task.id).isLocalOnly { + log("TasksStore: Skipped backend update for unsynced local task \(task.id)") + return .updated + } + 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. Unsynced local-only tasks have no backend - // row; the pending create-sync pushes current row state instead. - if ActionItemTaskIdentity(surfacedId: task.id).isLocalOnly { - log("TasksStore: Skipped backend update for unsynced local task \(task.id)") - return - } + @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/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/APIClientRoutingTests.swift b/desktop/macos/Desktop/Tests/APIClientRoutingTests.swift index a19af54f468..a17c57f6a2f 100644 --- a/desktop/macos/Desktop/Tests/APIClientRoutingTests.swift +++ b/desktop/macos/Desktop/Tests/APIClientRoutingTests.swift @@ -481,6 +481,22 @@ final class APIClientRoutingTests: XCTestCase { label: "getConversation") } + func testGetOmiCaptureUsesStrictArchiveDetailContract() async { + let client = await makeTestClient() + _ = try? await client.getOmiCapture(id: "capture-123") as ServerConversation + let requests = URLCapture.capturedRequests + assertRoutes( + requests, host: "python-test", port: 9001, + pathContains: "v1/conversations/capture-123", method: "GET", + label: "getOmiCapture") + guard let firstRequest = requests.first else { + return XCTFail("Expected getOmiCapture to issue a request") + } + let queryItems = URLComponents(url: firstRequest.url, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(queryItems?.first(where: { $0.name == "source" })?.value, "omi") + XCTAssertEqual(queryItems?.first(where: { $0.name == "include_discarded" })?.value, "false") + } + func testDeleteConversationRoutesToPython() async { let client = await makeTestClient() try? await client.deleteConversation(id: "conv-456") diff --git a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift index 8be56c44a98..da9e5de32e7 100644 --- a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift +++ b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift @@ -373,7 +373,10 @@ import XCTest XCTAssertFalse(windowSource.contains("resolveDelegationAndDispatch")) XCTAssertTrue(windowSource.contains("await dispatchChatQuery(")) XCTAssertFalse(source.contains("AgentPillFollowUpRoutingPolicy")) - XCTAssertTrue(source.contains("manager.continueAgent(from: pill, text: trimmed, attachments: staged)")) + // The view-level manager.continueAgent call moved into AgentPillsManager + // itself (upstream: remove typing from the floating bar). The view now + // exposes a Continue in Omi affordance instead of an inline follow-up field. + XCTAssertTrue(source.contains("Continue in Omi")) } func testSubagentChatRendersMarkdownAndLargeBackHitTarget() throws { @@ -836,7 +839,10 @@ import XCTest let viewSource = try floatingControlBarViewSource() XCTAssertFalse(viewSource.contains("AgentPillFollowUpRoutingPolicy")) - XCTAssertTrue(viewSource.contains("manager.continueAgent(from: pill, text: trimmed, attachments: staged)")) + // The inline follow-up composer was replaced by a Continue in Omi button + // (upstream: remove typing from the floating bar). Verify the replacement + // exists rather than the removed manager.continueAgent view-level call. + XCTAssertTrue(viewSource.contains("Continue in Omi")) } func testSpawnAgentToolCallOpensSubagentChat() throws { @@ -921,7 +927,10 @@ import XCTest let inputSource = String(viewSource[inputRange.lowerBound.. 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 { + 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, @unchecked Sendable { + 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/CaptureArchiveTests.swift b/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift new file mode 100644 index 00000000000..2f3b337e2b8 --- /dev/null +++ b/desktop/macos/Desktop/Tests/CaptureArchiveTests.swift @@ -0,0 +1,359 @@ +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 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 provider = CapturePlaybackProviderFake(resolutions: [pending, ready]) + let controller = CapturePlaybackController(provider: provider) + let capture = archiveCapture(id: "omi-1") + + 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) + } + + 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 = "" + 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, @unchecked Sendable { + 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, @unchecked Sendable { + 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() + } +} + +extension Array { + fileprivate var single: Element? { count == 1 ? first : nil } +} diff --git a/desktop/macos/Desktop/Tests/ChatFirstAnalyticsTests.swift b/desktop/macos/Desktop/Tests/ChatFirstAnalyticsTests.swift new file mode 100644 index 00000000000..4f80a177578 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstAnalyticsTests.swift @@ -0,0 +1,88 @@ +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 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 + ) + } + +} 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/ChatFirstDeferralOutboxDriverTests.swift b/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift new file mode 100644 index 00000000000..e748708caf1 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstDeferralOutboxDriverTests.swift @@ -0,0 +1,91 @@ +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]) + XCTAssertEqual(wire["type"] as? String, "questionCard") + 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( + 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/ChatFirstPromptMaterializationCoordinatorTests.swift b/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift new file mode 100644 index 00000000000..91c073ffd33 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstPromptMaterializationCoordinatorTests.swift @@ -0,0 +1,196 @@ +import XCTest + +@testable import Omi_Computer + +final class ChatFirstPromptMaterializationCoordinatorTests: XCTestCase { + func testPolicyRequiresTranscriptReadinessAndDebouncesForegroundFlapping() { + let now = Date(timeIntervalSinceReferenceDate: 10_000) + + XCTAssertFalse( + ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: true, + transcriptFirstPageLoaded: false, + isRunning: false, + lastAttemptAt: nil, + now: now + ) + ) + XCTAssertTrue( + ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: true, + transcriptFirstPageLoaded: true, + isRunning: false, + lastAttemptAt: nil, + now: now + ) + ) + XCTAssertFalse( + ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: true, + transcriptFirstPageLoaded: true, + isRunning: true, + lastAttemptAt: now, + now: now.addingTimeInterval(120) + ) + ) + XCTAssertFalse( + ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: true, + transcriptFirstPageLoaded: true, + isRunning: false, + lastAttemptAt: now, + now: now.addingTimeInterval(59) + ) + ) + XCTAssertTrue( + ChatFirstPromptMaterializationPolicy.shouldStart( + hasChatFirstMainChatContext: true, + transcriptFirstPageLoaded: true, + isRunning: false, + lastAttemptAt: now, + now: now.addingTimeInterval(60) + ) + ) + } + + @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/ChatFirstRichBlockTests.swift b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift new file mode 100644 index 00000000000..87c207352f7 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstRichBlockTests.swift @@ -0,0 +1,272 @@ +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"], + ["type": "memoryLink", "memoryId": "memory-1", "summary": "Remember the launch constraint"], + ] + ] + ) + ) + + XCTAssertEqual(converted.count, 3) + XCTAssertEqual(converted[0]["task_id"] as? String, "task-1") + XCTAssertEqual(converted[1]["goal_id"] as? String, "goal-1") + XCTAssertEqual(converted[2]["memory_id"] as? String, "memory-1") + } + + 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" + ), + .memoryLink(id: "memory-link", memoryId: "memory-1", summary: "Launch constraint"), + ] + + 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, 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") + } + 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") + + guard case .memoryLink(_, let memoryID, let memorySummary) = restored[4] else { + return XCTFail("memory link should survive persisted replay") + } + XCTAssertEqual(memoryID, "memory-1") + XCTAssertEqual(memorySummary, "Launch constraint") + } + + 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"], + ["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"), + .memoryLink(id: "memory", memoryId: "memory-1", summary: "Memory"), + ] + + 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, 5) + 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 .memoryLink = $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 + ) + ) + } + + 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), + conversationId: conversationID, + source: source + ) + } +} diff --git a/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift new file mode 100644 index 00000000000..89cdb1d1863 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstShellTests.swift @@ -0,0 +1,243 @@ +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 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) + 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() throws { + let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.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) + 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 testRouteIsNotVisibleUntilTheMountedDestinationAcknowledgesIt() throws { + let suiteName = "ChatFirstShellTests.visible-route.\(UUID().uuidString)" + let defaults = try XCTUnwrap(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() throws { + let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.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) + XCTAssertNil(navigation.focusedEntityID) + XCTAssertFalse(navigation.isFocusedEntityAcknowledged) + + 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 testNavigationUsesTypedOriginsWithoutEntityIdentifiersInAnalytics() throws { + let suiteName = "ChatFirstShellTests.analytics.\(UUID().uuidString)" + let defaults = try XCTUnwrap(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() throws { + let suiteName = "ChatFirstShellTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(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) + XCTAssertNil(ChatFirstRoute.primaryAutomationDestination(named: "dashboard")) + 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")) + 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/Desktop/Tests/ChatFirstTasksPageTests.swift b/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift new file mode 100644 index 00000000000..ddf039bad15 --- /dev/null +++ b/desktop/macos/Desktop/Tests/ChatFirstTasksPageTests.swift @@ -0,0 +1,102 @@ +import XCTest + +@testable import Omi_Computer + +final class ChatFirstTasksPageTests: XCTestCase { + func testScheduleGroupingKeepsOverdueAndTodayWorkTogether() throws { + var calendar = Calendar(identifier: .gregorian) + 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)) + 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")) + } + + 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, + 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/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:")) diff --git a/desktop/macos/Desktop/Tests/FloatingBarTimingSignalTests.swift b/desktop/macos/Desktop/Tests/FloatingBarTimingSignalTests.swift index dd28810ef9d..80b3188e2cd 100644 --- a/desktop/macos/Desktop/Tests/FloatingBarTimingSignalTests.swift +++ b/desktop/macos/Desktop/Tests/FloatingBarTimingSignalTests.swift @@ -37,9 +37,12 @@ final class FloatingBarTimingSignalTests: XCTestCase { let source = try floatingBarViewSource() XCTAssertTrue( source.contains(".task {"), - "follow-up focus should be driven by the view lifecycle (.task), not asyncAfter") + "view-lifecycle transitions should be driven by .task, not asyncAfter") + // The follow-up text field was replaced by a "Continue in Omi" affordance + // (upstream: remove typing from the floating bar). Assert the replacement + // exists instead of the removed isFollowUpFocused state variable. XCTAssertTrue( - source.contains("isFollowUpFocused = true"), "the field must still be focused on appear") + source.contains("Continue in Omi"), "follow-up affordance should be the Continue in Omi button") } /// Anti-regression: no new fixed-delay `asyncAfter` may be added under 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/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/adapters/pi-mono.ts b/desktop/macos/agent/src/adapters/pi-mono.ts index 633684e8af7..2e2ddecaa07 100644 --- a/desktop/macos/agent/src/adapters/pi-mono.ts +++ b/desktop/macos/agent/src/adapters/pi-mono.ts @@ -352,6 +352,11 @@ export class PiMonoAdapter implements HarnessAdapter { * Pi has no set_system_prompt RPC, so changing this requires a subprocess restart. */ private currentSystemPrompt: string | undefined; private currentExecutionRole: "coordinator" | "leaf" = "coordinator"; + private currentToolProjection: { + surfaceKind?: string; + chatFirstUi: boolean; + controlGeneration: number | null; + } = { chatFirstUi: false, controlGeneration: null }; private readonly sessionPrefix: string; /** True when a token refresh was deferred because a prompt was active */ private pendingTokenRefresh = false; @@ -433,6 +438,20 @@ export class PiMonoAdapter implements HarnessAdapter { } env.OMI_ADAPTER_ID = "pi-mono"; env.OMI_EXECUTION_ROLE = this.currentExecutionRole; + if ( + this.currentToolProjection.surfaceKind === "main_chat" + && this.currentToolProjection.chatFirstUi + && Number.isSafeInteger(this.currentToolProjection.controlGeneration) + && (this.currentToolProjection.controlGeneration ?? -1) >= 0 + ) { + env.OMI_SURFACE_KIND = "main_chat"; + env.OMI_CHAT_FIRST_UI = "true"; + env.OMI_CHAT_FIRST_CONTROL_GENERATION = String(this.currentToolProjection.controlGeneration); + } else { + delete env.OMI_SURFACE_KIND; + delete env.OMI_CHAT_FIRST_UI; + delete env.OMI_CHAT_FIRST_CONTROL_GENERATION; + } env.OMI_CONTEXT_FILE = this.contextFilePath; // Forward OMI_BRIDGE_PIPE so the extension can register omi-tools // (execute_sql, semantic_search, etc.) that forward to Swift. @@ -549,6 +568,34 @@ export class PiMonoAdapter implements HarnessAdapter { } } + async setToolProjection(projection: { + surfaceKind?: string; + chatFirstUi: boolean; + controlGeneration: number | null; + }): Promise { + const normalized: { + surfaceKind?: string; + chatFirstUi: boolean; + controlGeneration: number | null; + } = projection.surfaceKind === "main_chat" + && projection.chatFirstUi + && Number.isSafeInteger(projection.controlGeneration) + && (projection.controlGeneration ?? -1) >= 0 + ? { + surfaceKind: "main_chat" as const, + chatFirstUi: true, + controlGeneration: projection.controlGeneration, + } + : { chatFirstUi: false, controlGeneration: null }; + if ( + normalized.surfaceKind === this.currentToolProjection.surfaceKind + && normalized.chatFirstUi === this.currentToolProjection.chatFirstUi + && normalized.controlGeneration === this.currentToolProjection.controlGeneration + ) return; + this.currentToolProjection = normalized; + if (this.process) await this.stop(); + } + async sendPrompt( sessionId: string, prompt: PromptBlock[], @@ -1208,6 +1255,21 @@ function relayReasoningEffort(metadata: Record | undefined): st return raw === "adaptive" || raw === "fast" ? raw : undefined; } +function toolProjectionFromMetadata(metadata: Record | undefined): { + surfaceKind?: string; + chatFirstUi: boolean; + controlGeneration: number | null; +} { + const generation = Number(metadata?.chatFirstControlGeneration); + const enabled = metadata?.surfaceKind === "main_chat" + && metadata?.chatFirstUi === true + && Number.isSafeInteger(generation) + && generation >= 0; + return enabled + ? { surfaceKind: "main_chat", chatFirstUi: true, controlGeneration: generation } + : { chatFirstUi: false, controlGeneration: null }; +} + export class PiMonoRuntimeAdapter implements RuntimeAdapter { readonly adapterId = "pi-mono"; readonly capabilities: AdapterCapabilities = adapterCapabilitiesFor("pi-mono"); @@ -1228,6 +1290,7 @@ export class PiMonoRuntimeAdapter implements RuntimeAdapter { } async openBinding(input: OpenBindingInput): Promise { + await this.harness.setToolProjection(toolProjectionFromMetadata(input.metadata)); const adapterNativeSessionId = await this.harness.createSession({ cwd: input.cwd, model: input.model, @@ -1239,6 +1302,7 @@ export class PiMonoRuntimeAdapter implements RuntimeAdapter { } async resumeBinding(input: ResumeBindingInput): Promise { + await this.harness.setToolProjection(toolProjectionFromMetadata(input.metadata)); await this.harness.setExecutionRole(input.metadata?.executionRole === "leaf" ? "leaf" : "coordinator"); await this.start(); // pi-mono has no native resume after daemon/process loss, but while this diff --git a/desktop/macos/agent/src/index.ts b/desktop/macos/agent/src/index.ts index 34f1e512138..b7ca4e3c501 100644 --- a/desktop/macos/agent/src/index.ts +++ b/desktop/macos/agent/src/index.ts @@ -58,10 +58,18 @@ import type { JournalTerminalizeTurnMessage, JournalListTurnsMessage, JournalClearTurnsMessage, + AppendChatFirstBlocksMessage, + AppendChatFirstEvidenceMessage, + RecordQuestionInteractionReplyMessage, + MaterializeChatFirstIntentsMessage, + ListChatFirstMaterializationReceiptsMessage, + AcknowledgeChatFirstMaterializationReceiptsMessage, EnsureAgentSpawnJournalMessage, JournalBackendSyncResultMessage, JournalBackendDeleteResultMessage, JournalBackendReconcileResultMessage, + ChatFirstDeferralDeliveryResultMessage, + ChatFirstHarnessExecutorBeginMessage, RefreshOwnerMessage, RevokeOwnerRuntimeMessage, RefreshTokenMessage, @@ -120,12 +128,15 @@ import { LEGACY_MAIN_CHAT_SESSION_COMPATIBILITY } from "./runtime/surface-sessio import { ackBackendConversationDeleteOutbox, ackBackendTurnOutboxWithWakes, + appendChatFirstBlocksToProducingTurn, + appendChatFirstEvidenceToProducingTurn, applyBackendReconcilePage, beginBackendReconcilesForOwner, clearJournalConversation, classifyBackendTurnResultDisposition, drainBackendConversationDeleteOutbox, drainBackendTurnOutbox, + drainChatFirstDeferralOutbox, failBackendConversationDeleteOutbox, failBackendReconcile, failBackendTurnOutbox, @@ -133,11 +144,16 @@ import { journalTurnChangedWakes, importRemoteJournalTurn, listJournalTurns, + listChatFirstMaterializationReceipts, + acknowledgeChatFirstMaterializationReceipts, + materializeChatFirstIntents, recordJournalExchange, + recordQuestionInteractionReply, recordJournalTurn, settleClearedBackendTurnClaim, assertPublicJournalUpdatePolicy, terminalizeJournalTurn, + settleChatFirstDeferralOutbox, updateJournalTurn, } from "./runtime/conversation-journal.js"; import { DirectControlExecutionBroker } from "./runtime/direct-control-execution.js"; @@ -286,6 +302,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", @@ -349,6 +400,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 { @@ -475,6 +619,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; @@ -543,6 +750,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. */ @@ -582,6 +795,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 { @@ -789,6 +1012,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, @@ -840,6 +1110,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 +1123,9 @@ function startOmiToolsRelay(): Promise { precedingAssistantText: authorized.precedingAssistantText, runMode: authorized.runMode, chatMode: authorized.chatMode, + ...(authorized.chatFirstControlGeneration !== null + ? { chatFirstControlGeneration: authorized.chatFirstControlGeneration } + : {}), }); } } catch { @@ -1081,6 +1355,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", @@ -1496,6 +1779,38 @@ 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 })) { + 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}`, + 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: deferralSubject, + options: delivery.question.options, + }, + attemptCount: delivery.attemptCount, + deliveryGeneration: delivery.deliveryGeneration, + payloadHash: delivery.payloadHash, + }); + } + } } catch (error) { logErr(`Journal outbox pump failed: ${error}`); } finally { @@ -1637,6 +1952,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 +1978,7 @@ async function main(): Promise { defaultCwd: selectedProfile.workingDirectory, executionRole: executionRoleForSurface(resolve), title: resolve.title ?? null, + chatFirstCapability, }); const profile = kernel.sessionExecutionProfile(resolved.agentSessionId, ownerId); send({ @@ -1750,6 +2079,79 @@ 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.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; @@ -1930,6 +2332,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 +2345,9 @@ async function main(): Promise { precedingAssistantText: authorized.precedingAssistantText, runMode: authorized.runMode, chatMode: authorized.chatMode, + ...(authorized.chatFirstControlGeneration !== null + ? { chatFirstControlGeneration: authorized.chatFirstControlGeneration } + : {}), ...(routed.recoveredFromDelegation ? { policyRecovery: "permission_delegation_to_native" as const } : {}), @@ -2321,6 +2727,425 @@ 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 "append_chat_first_evidence": { + const request = msg as AppendChatFirstEvidenceMessage; + try { + const ownerId = resolveActiveOwner(request.ownerId); + if (!request.resource || typeof request.resource !== "object") { + throw new Error("Chat-first evidence append requires one resource"); + } + if (!Number.isSafeInteger(request.controlGeneration) || request.controlGeneration < 0) { + throw new Error("Chat-first evidence 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("show_rewind_evidence") + ) { + throw new Error("Chat-first evidence capability does not match the producing run"); + } + const turn = appendChatFirstEvidenceToProducingTurn(store, { + ownerId, + sessionId: request.sessionId, + runId: request.runId, + attemptId: request.attemptId, + resource: request.resource as ConversationResource, + }); + 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_evidence", + 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 "record_question_interaction_reply": { + const request = msg as RecordQuestionInteractionReplyMessage; + try { + const ownerId = resolveActiveOwner(request.ownerId); + if ( + 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 + ) { + 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 "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, + ...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 + || !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({ + 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 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, + coldStartSequenceTerminalReceipts, + }); + 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 { @@ -2898,6 +3723,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/omi-tools-stdio.ts b/desktop/macos/agent/src/omi-tools-stdio.ts index eb7e8acc5ec..9b907b381b0 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); @@ -347,6 +356,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/protocol.ts b/desktop/macos/agent/src/protocol.ts index bcb621595be..72d991729de 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 { @@ -170,6 +174,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 { @@ -365,6 +377,108 @@ 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[]; +} + +/** + * Privileged local-only append for one Rewind evidence resource. The same + * capability/run binding prevents a tool from attaching an image to an + * arbitrary Chat turn. + */ +export interface AppendChatFirstEvidenceMessage extends ProtocolEnvelope { + type: "append_chat_first_evidence"; + ownerId: string; + sessionId: string; + runId: string; + attemptId: string; + capabilityRef: string; + controlGeneration: number; + resource: unknown; +} + +/** 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; + optionId: string; + 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" + | "cold_start_rich" + | "cold_start_sparse"; + 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 }>; + coldStartSequenceTerminalReceipts: Array<{ + sequenceId: string; + receiptId: string; + terminalState: "completed" | "abandoned"; + }>; +} + export interface EnsureAgentSpawnJournalMessage extends ProtocolEnvelope { type: "ensure_agent_spawn_journal"; ownerId: string; @@ -412,6 +526,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"; @@ -425,6 +550,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 @@ -451,10 +590,18 @@ export type InboundMessage = | JournalTerminalizeTurnMessage | JournalListTurnsMessage | JournalClearTurnsMessage + | AppendChatFirstBlocksMessage + | AppendChatFirstEvidenceMessage + | RecordQuestionInteractionReplyMessage + | MaterializeChatFirstIntentsMessage + | ListChatFirstMaterializationReceiptsMessage + | AcknowledgeChatFirstMaterializationReceiptsMessage | EnsureAgentSpawnJournalMessage | JournalBackendSyncResultMessage | JournalBackendDeleteResultMessage | JournalBackendReconcileResultMessage + | ChatFirstDeferralDeliveryResultMessage + | ChatFirstHarnessExecutorBeginMessage | RefreshTokenMessage | RefreshOwnerMessage; @@ -463,6 +610,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. */ @@ -523,6 +671,7 @@ export interface AuthorizedToolExecutionMessage extends OutboundEnvelope { manifestDigest: string; daemonBootEpoch: string; executionGeneration: number; + capabilityRef: string; toolName: string; input: Record; inputHash: string; @@ -535,6 +684,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"; } @@ -580,6 +731,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; @@ -812,6 +977,8 @@ export interface ContextSnapshotProjection { manifestVersion: number; manifestDigest: string; allowedToolNames: string[]; + chatFirstUi: boolean; + chatFirstControlGeneration: number | null; }; } @@ -874,7 +1041,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" | "append_chat_first_evidence" | "record_question_interaction_reply" | "materialize_chat_first_intents" | "list_chat_first_materialization_receipts" | "acknowledge_chat_first_materialization_receipts"; conversationId: string; surfaceKind: string; externalRefKind: string; @@ -886,6 +1053,19 @@ export interface JournalOperationResultMessage extends OutboundEnvelope { generationBaseTurnSeq: number; conversationGeneration: number; backendDeleteOperationId?: string; + accepted?: boolean; + duplicate?: boolean; + continuityKey?: string | null; + suppressedByTailQuestion?: boolean; + suppressedByStreamingTail?: boolean; + materializationStoppedByTail?: boolean; + materializationReceipts?: Array<{ intentId: string; receiptId: string }>; + coldStartSequenceTerminalReceipts?: Array<{ + sequenceId: string; + receiptId: string; + terminalState: "completed" | "abandoned"; + }>; + acknowledgedReceiptCount?: number; } export interface JournalTurnChangedMessage extends OutboundEnvelope { @@ -946,6 +1126,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 @@ -962,6 +1159,7 @@ export type OutboundMessage = | ExternalSurfaceRunBeginResultMessage | ExternalSurfaceToolResultMessage | ExternalSurfaceRunCompleteResultMessage + | ChatFirstHarnessExecutorResultMessage | OwnerRuntimeRevokedMessage | ControlToolResultMessage | DefaultExecutionProfileConfiguredMessage @@ -975,7 +1173,8 @@ export type OutboundMessage = | JournalTurnChangedMessage | JournalBackendSyncMessage | JournalBackendDeleteMessage - | JournalBackendReconcileMessage; + | JournalBackendReconcileMessage + | ChatFirstDeferralDeliveryMessage; type OutboundWithEnvelope = Exclude; @@ -998,6 +1197,7 @@ export type OutboundMessageDraft = | DraftEnvelope | DraftEnvelope | DraftEnvelope + | DraftEnvelope | DraftEnvelope | DraftEnvelope | DraftEnvelope @@ -1011,7 +1211,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/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 b8b8c7ecbb3..85f45a25843 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,9 +404,33 @@ export function inheritContextSnapshotForSession( }, nowMs, surfaceKind: String(session.surface_kind), + // 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: { @@ -398,6 +443,7 @@ function projectContextSnapshot( baseMaterial: Pick; nowMs: number; surfaceKind: string; + chatFirstCapability?: ChatFirstCapabilityProjection; }, ): ContextSnapshotProjection { const profile = readSessionExecutionProfile(store, input.sessionId); @@ -405,18 +451,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..9c7e9f6dc56 100644 --- a/desktop/macos/agent/src/runtime/conversation-journal.ts +++ b/desktop/macos/agent/src/runtime/conversation-journal.ts @@ -94,6 +94,118 @@ 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 AppendChatFirstEvidenceInput { + ownerId: string; + sessionId: string; + runId: string; + attemptId: string; + resource: ConversationResource; +} + +/** + * 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; +} + +/** A restart-safe receipt for a locally committed deterministic-tier intent. */ +export interface ChatFirstMaterializationReceipt { + intentId: string; + 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" + | "cold_start_rich" + | "cold_start_sparse"; + 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; @@ -228,6 +340,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; @@ -302,7 +439,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 ( @@ -333,6 +469,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, @@ -428,6 +566,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 ( @@ -567,6 +761,581 @@ 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, + }); + }); +} + +/** Appends one capability-bound local evidence resource to the producing turn. */ +export function appendChatFirstEvidenceToProducingTurn( + store: AgentStore, + input: AppendChatFirstEvidenceInput, +): ConversationTurn { + 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 evidence 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 evidence found multiple producing assistant turns"); + const producing = bound[0] ?? (() => { + // The assistant placeholder is created before the runtime publishes its + // run/attempt IDs. Resolve it exactly as rich blocks do: owner/session + // scoped and only while there is one live main-Chat target. + 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 evidence requires exactly one live producing assistant turn"); + } + return pending[0]!; + })(); + const resource = validateChatFirstEvidenceResource(input.resource); + return updateJournalTurn(store, { + ownerId: input.ownerId, + conversationId: String(producing.conversation_id), + turnId: String(producing.turn_id), + producingRunId: input.runId, + producingAttemptId: input.attemptId, + appendResources: [{ ...resource, runId: input.runId }], + }); + }); +} + +/** + * 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 + && block.coldStartSequence?.retired !== true + )); + 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, + ...(question.coldStartSequence ? { coldStartSequence: question.coldStartSequence } : {}), + }), + createdAtMs: now, + }, + { + turnId: assistantTurnId, + role: "assistant", + surfaceKind: "main_chat", + origin: "typed_chat", + status: "streaming", + content: "", + contentBlocks: [], + metadataJson: JSON.stringify({ + continuityKey, + hiddenUntilOutput: true, + ...(question.coldStartSequence ? { coldStartSequence: question.coldStartSequence } : {}), + }), + 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, + }; + }); +} + +/** + * 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", + "cold_start_rich", + "cold_start_sparse", + ].includes(input.source)) { + throw new Error("Chat-first materialization source is invalid"); + } + const blocks = chatFirstIntentBlocks(intentId, input.controlGeneration, input.source, 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 }, +): 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)); + 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[]; + coldStartSequenceTerminalReceipts: readonly ChatFirstColdStartSequenceTerminalReceipt[]; + }, +): 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; + } + 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; + }); +} + +/** 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 <= ?) + 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, 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, @@ -817,10 +1586,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 @@ -1089,6 +2063,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 }, @@ -2333,7 +3402,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 @@ -2383,6 +3452,302 @@ 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 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 + && block.coldStartSequence?.retired !== true + )), + 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, + controlGeneration: number, + source: MaterializeChatFirstIntentInput["source"], + 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 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"); + 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: subjectKind, + id: subjectId, + }, + options, + ...(coldStartSequence ? { coldStartSequence } : {}), + }; + } + 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 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`); + } + 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"); +} + +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); + 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; + 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) => { @@ -2390,6 +3755,54 @@ 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", "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; + 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"); + 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") { + 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); }); } @@ -2405,6 +3818,29 @@ function validateResources(resources: readonly ConversationResource[]): Conversa }); } +function validateChatFirstEvidenceResource(resource: ConversationResource): ConversationResource { + const [validated] = validateResources([resource]); + if ( + validated.origin !== "generatedArtifact" + || validated.state !== "ready" + || typeof validated.mimeType !== "string" + || !validated.mimeType.startsWith("image/") + || typeof validated.uri !== "string" + ) { + throw new Error("Chat-first evidence requires one ready generated image resource"); + } + let uri: URL; + try { + uri = new URL(validated.uri); + } catch { + throw new Error("Chat-first evidence requires a valid local file URI"); + } + if (uri.protocol !== "file:") { + throw new Error("Chat-first evidence requires a valid local file URI"); + } + return validated; +} + function mergeById(current: readonly T[], updates: readonly T[]): T[] { const result = current.map((value) => structuredClone(value)); const indexes = new Map(result.map((value, index) => [value.id, index])); @@ -2505,13 +3941,28 @@ 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; + // Rewind evidence is a device-local journal resource. The backend receives + // enough metadata to preserve the card, but never the user's absolute local + // filesystem path. Same-device reconciliation keeps the authoritative local + // resource by ID through monotonicAcceptResources. + const backendResources = turn.resources.map((resource) => { + if (!resource.id.startsWith("rewind-evidence:") || resource.uri === undefined) { + return resource; + } + const { uri: _localFileURI, ...pathless } = resource; + return pathless; + }); const backendMetadata = { ...metadata, ...(turn.contentBlocks.length > 0 ? { content_blocks: turn.contentBlocks } : {}), - ...(turn.resources.length > 0 ? { resources: turn.resources } : {}), + ...(backendResources.length > 0 ? { resources: backendResources } : {}), }; const projectedText = turn.content.trim() ? turn.content + : isChatFirstMaterialization + ? "" : turn.role === "assistant" && turn.status === "completed" && (turn.contentBlocks.length > 0 || turn.resources.length > 0) @@ -2540,6 +3991,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/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/agent/src/runtime/jsonl-transport.ts b/desktop/macos/agent/src/runtime/jsonl-transport.ts index f6e286a6f87..395902eac25 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 = ( @@ -466,6 +469,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 f9142ed3403..40f61515db5 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 { @@ -140,6 +142,18 @@ import type { import { ExternalSurfaceAuthorityError, StaleAdapterBindingError } from "./kernel-types.js"; import { providerBoundaryForAdapter, resolveAdapterWithinBoundary } from "./execution-policy.js"; import type { SurfaceRef } from "./surface-session.js"; + +function runtimeAdapterMetadata(input: ExecuteAgentRunInput, session: AgentSession): Record { + return { + ...(input.metadata ?? {}), + executionRole: session.executionRole, + providerBoundary: session.providerBoundary, + surfaceKind: session.surfaceKind, + chatFirstUi: input.admittedContextSnapshot?.capabilities.chatFirstUi === true, + chatFirstControlGeneration: + input.admittedContextSnapshot?.capabilities.chatFirstControlGeneration ?? null, + }; +} import { RunToolCapabilityBroker, type AuthorizedRunToolInvocation, @@ -241,6 +255,62 @@ export class KernelCore { return decision; } + assertLiveRunToolCapability(input: { capabilityRef: string; activeOwnerId: string }) { + 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); } @@ -1644,11 +1714,7 @@ export class KernelCore { model: input.input.model ?? binding.modelId ?? undefined, systemPrompt: input.input.systemPrompt, mcpServers: mcpServersForBinding(input.input.mcpServers ?? [], input.session.sessionId, input.adapterId, this.runtimeNodeId), - metadata: { - ...(input.input.metadata ?? {}), - executionRole: input.session.executionRole, - providerBoundary: input.session.providerBoundary, - }, + metadata: runtimeAdapterMetadata(input.input, input.session), }); this.withTransaction(() => { this.updateBinding(binding.bindingId, { @@ -1704,11 +1770,7 @@ export class KernelCore { model: input.input.model, systemPrompt: input.input.systemPrompt, mcpServers: mcpServersForBinding(input.input.mcpServers ?? [], input.session.sessionId, input.adapterId, this.runtimeNodeId), - metadata: { - ...(input.input.metadata ?? {}), - executionRole: input.session.executionRole, - providerBoundary: input.session.providerBoundary, - }, + metadata: runtimeAdapterMetadata(input.input, input.session), }); const binding = this.withTransaction(() => { this.closeConflictingNativeBinding( @@ -2643,6 +2705,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/kernel-sessions.ts b/desktop/macos/agent/src/runtime/kernel-sessions.ts index f1fa452d3a6..4517a704a0c 100644 --- a/desktop/macos/agent/src/runtime/kernel-sessions.ts +++ b/desktop/macos/agent/src/runtime/kernel-sessions.ts @@ -137,19 +137,58 @@ 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, 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. */ + 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); 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); } @@ -165,7 +204,132 @@ 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), + ); + } + + /** + * 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( @@ -177,17 +341,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 +453,30 @@ 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"); + } + // Capability-less lookups can happen while auth and root-shell control are + // still converging (for example, a journal projection read during startup). + // They remain capability-off for that read, but must not consume the one + // immutable server-derived sample for the runtime session. + if (capability === undefined) return resolved; + const key = `${input.ownerId}:${resolved.agentSessionId}`; + const previous = this.chatFirstCapabilities.get(key); + const sampled: ChatFirstCapabilityProjection = capability; + if ( + previous + && (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 +486,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 ed20feb33d3..d53875bf1c7 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 { @@ -1561,6 +1565,160 @@ 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: "get_canonical_goals", + label: "Get Canonical Goals", + description: "Retrieve canonical goals for this Chat-first user. For any question about the user's goals, goal progress, or focus, call this first. It returns opaque canonical goal IDs that must be rendered as goalLink blocks with render_chat_blocks. Do not use execute_sql, legacy local goals, memories, or inferred goals as a substitute.", + promptSnippet: "get_canonical_goals - Retrieve canonical goals with IDs for native goal links", + promptGuidelines: [ + "For goal questions, call this before answering and use only returned canonical goals.", + "Render every returned goal the user should act on as a goalLink in the same response.", + "If it returns no goals, state that plainly; do not infer goals from memories or local SQL.", + ], + latency: "fast network", + inputSchema: schema({ include_ended: { type: "boolean", description: "Include completed or archived canonical goals." } }), + annotations: readOnlyLocal, + timeoutClass: "normal", + executor: { kind: "swiftTool" }, + surfaces: ["desktop_chat"], + capabilityDoc: doc("Get Canonical Goals", "Read canonical goal records for a Chat-first 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."], + adapters: piAndStdio(), + }, + { + name: "render_chat_blocks", + label: "Render Chat Blocks", + description: "Render native, interactive Omi components on the producing main Chat turn. In Chat-first UI, call this in the same turn whenever you retrieve, create, or summarize tasks, goals, memories, or captured conversations; do not leave those entities as a Markdown table/list or ask whether the user wants cards. For taskCard, taskId MUST be the opaque canonical ID returned by get_action_items or create_action_item; never use a local SQLite/execute_sql numeric row ID. If another lookup found task text, call get_action_items before rendering. Supported shapes include {type:'taskCard', taskId:'...'}, {type:'goalLink', goalId:'...', summary:'...'}, {type:'memoryLink', memoryId:'...', summary:'...'}, and {type:'captureLink', conversationId:'...', summary:'...'}.", + promptSnippet: "render_chat_blocks - Render native interactive Omi components in this main Chat response; use by default for entity results", + promptGuidelines: [ + "After reading or mutating tasks, goals, memories, or captured conversations, render the relevant native components before finishing the same response.", + "Do not ask whether the user wants cards and do not substitute Markdown tables or lists for entities that have canonical IDs.", + "For task cards, obtain opaque canonical task IDs from get_action_items or create_action_item; execute_sql numeric row IDs are invalid.", + "Use only for a compact actionable question, task, goal, memory, 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", additionalProperties: true }, + }, + }, ["blocks"]), + mcpInputSchema: schema({ + blocks: { + type: "array", + description: "1-8 declarative chat blocks validated by the Omi backend.", + items: { type: "object", additionalProperties: true }, + }, + }, ["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: piAndStdio(), + }, + { + name: "show_rewind_evidence", + label: "Show Rewind Evidence", + description: "Attach one local Rewind screenshot to the producing main Chat turn as visual evidence.", + promptSnippet: "show_rewind_evidence - Show a Rewind screenshot as evidence in this Chat response", + promptGuidelines: [ + "Use a screenshot_id returned by a local screen-history search; never invent an id.", + "Attach only evidence that materially supports the answer, and explain what it demonstrates.", + "This exposes raw screenshot pixels in Chat and requires the user's screenshot-sharing setting.", + ], + latency: "fast local", + inputSchema: schema({ + screenshot_id: { + type: "number", + description: "Screenshot ID returned by search_screen_history or the local screenshots table.", + }, + }, ["screenshot_id"]), + annotations: localWrite, + timeoutClass: "normal", + executor: { kind: "swiftTool" }, + surfaces: ["desktop_chat"], + capabilityDoc: doc( + "Show Rewind Evidence", + "Attach one local historical screenshot as evidence on 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.", + "Requires a valid local Rewind screenshot and screenshot sharing enabled.", + ], + adapters: piAndStdio(), + }, + { + 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: piAndStdio(), + }, +] 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(",")}]`; @@ -1577,6 +1735,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 = {}, @@ -1594,7 +1756,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( @@ -1616,7 +1783,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( @@ -1627,7 +1794,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 ?? [])]); @@ -1648,8 +1815,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 ?? [])]) { @@ -1668,7 +1837,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/sqlite-store.ts b/desktop/macos/agent/src/runtime/sqlite-store.ts index f41dfeca129..ab2fa473f44 100644 --- a/desktop/macos/agent/src/runtime/sqlite-store.ts +++ b/desktop/macos/agent/src/runtime/sqlite-store.ts @@ -69,6 +69,9 @@ 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 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; @@ -375,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", @@ -507,6 +513,15 @@ 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()); + } + 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 { @@ -735,6 +750,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 = 0, lease_expires_at_ms = NULL, + last_error_code = 'daemon_restart', updated_at_ms = ? + WHERE status = 'delivering'`, + ).run(now); + } this.db.prepare( `UPDATE backend_reconcile_state SET in_flight_id = NULL, page_cursor = NULL, page_count = 0, @@ -3140,6 +3166,110 @@ 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, + ); + }); +} + +/** + * 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, + ); + }); +} + +/** + * 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 ad633f2e079..08d68d2137c 100644 --- a/desktop/macos/agent/src/runtime/types.ts +++ b/desktop/macos/agent/src/runtime/types.ts @@ -105,6 +105,24 @@ 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" | "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. + */ + selectedOptionId?: string; + } + | { 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/chat-first-capability-projection.test.ts b/desktop/macos/agent/tests/chat-first-capability-projection.test.ts new file mode 100644 index 00000000000..4644c138527 --- /dev/null +++ b/desktop/macos/agent/tests/chat-first-capability-projection.test.ts @@ -0,0 +1,215 @@ +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", + "show_rewind_evidence", +] as const; + +afterEach(() => { + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +describe("chat-first admitted capability projection", () => { + it("forwards the enabled projection when opening and resuming an adapter binding", async () => { + const { store, adapter, kernel } = createKernelHarness(newDatabasePath(), "acp"); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "binding-projection" }, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 23 }, + }); + const admittedContextSnapshot = kernel.contextSnapshot(resolved.agentSessionId, "owner", "main_chat"); + const runInput = { + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "binding-projection", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "binding-client", + prompt: "Use a rich Chat card.", + cwd: "/tmp/chat-first-binding-projection", + admittedContextSnapshot, + } as const; + + await kernel.executeRun({ ...runInput, requestId: "binding-request-1" }); + await kernel.executeRun({ ...runInput, requestId: "binding-request-2" }); + + const expected = { + surfaceKind: "main_chat", + chatFirstUi: true, + chatFirstControlGeneration: 23, + }; + expect(adapter.opened[0]?.metadata).toMatchObject(expected); + expect(adapter.resumed[0]?.metadata).toMatchObject(expected); + store.close(); + }); + + 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, + }); + expect(kernel.hasChatFirstMainCapability("owner")).toBe(true); + expect(kernel.hasChatFirstMainCapability("other-owner")).toBe(false); + + 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(kernel.hasChatFirstMainCapability("owner")).toBe(false); + 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); + } + }); + + it("leaves chat block payloads open for the backend's authoritative validator", () => { + const projected = mcpToolDefinitionsForAdapter("omi-tools-stdio", { + surfaceKind: "main_chat", + chatFirstUi: true, + controlGeneration: 19, + }); + const render = projected.find((tool) => tool.name === "render_chat_blocks"); + expect(render?.inputSchema.properties?.blocks?.items).toMatchObject({ + type: "object", + additionalProperties: true, + }); + }); + + it("adds canonical goal retrieval only to the enabled main-chat projection", () => { + const enabled = mcpToolDefinitionsForAdapter("omi-tools-stdio", { + surfaceKind: "main_chat", chatFirstUi: true, controlGeneration: 19, + }); + expect(enabled.find((tool) => tool.name === "get_canonical_goals")?.description).toContain( + "Do not use execute_sql, legacy local goals, memories, or inferred goals", + ); + expect(JSON.stringify(mcpToolDefinitionsForAdapter("omi-tools-stdio"))).not.toContain("get_canonical_goals"); + }); +}); + +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"); +} 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/codemagic-pi-mono-extension-ci.test.ts b/desktop/macos/agent/tests/codemagic-pi-mono-extension-ci.test.ts index d245b7cb0a7..eacc60de6cc 100644 --- a/desktop/macos/agent/tests/codemagic-pi-mono-extension-ci.test.ts +++ b/desktop/macos/agent/tests/codemagic-pi-mono-extension-ci.test.ts @@ -27,18 +27,13 @@ describe("macOS release CI", () => { const codemagic = readFileSync(new URL("../../../../codemagic.yaml", import.meta.url), "utf8"); const runScript = readFileSync(new URL("../../run.sh", import.meta.url), "utf8"); - for (const manifestFile of [ - "control-tool-manifest.ts", - "node-tools.ts", - "omi-tool-manifest.ts", - ]) { - expect(codemagic).toContain( - `cp -f agent/src/runtime/${manifestFile} "$APP_BUNDLE/Contents/Resources/agent/src/runtime/"` - ); - expect(runScript).toContain( - `cp -f "$AGENT_DIR/src/runtime/${manifestFile}" "$APP_BUNDLE/Contents/Resources/agent/src/runtime/"` - ); - } + const extension = readFileSync(new URL("../../pi-mono-extension/index.ts", import.meta.url), "utf8"); + expect(extension).toContain('../agent/dist/runtime/omi-tool-manifest.js'); + expect(extension).toContain('../agent/dist/runtime/node-tools.js'); + expect(codemagic).toContain('cp -Rf agent/dist "$APP_BUNDLE/Contents/Resources/agent/"'); + expect(runScript).toContain('macos_copy_tree "$AGENT_DIR/dist" "$APP_BUNDLE/Contents/Resources/agent/dist"'); + expect(codemagic).not.toContain("agent/src/runtime/"); + expect(runScript).not.toContain('$AGENT_DIR/src/runtime/'); expect(codemagic).toContain( 'cp -Rf .harness/agent-runtime/agent-node_modules "$APP_BUNDLE/Contents/Resources/agent/node_modules"' ); diff --git a/desktop/macos/agent/tests/conversation-journal.test.ts b/desktop/macos/agent/tests/conversation-journal.test.ts index 8f3f803a583..9c9902db1d6 100644 --- a/desktop/macos/agent/tests/conversation-journal.test.ts +++ b/desktop/macos/agent/tests/conversation-journal.test.ts @@ -8,12 +8,16 @@ import { ackBackendTurnOutbox, ackBackendTurnOutboxWithWakes, applyBackendReconcilePage, + appendChatFirstBlocksToProducingTurn, + appendChatFirstEvidenceToProducingTurn, + acknowledgeChatFirstMaterializationReceipts, beginBackendReconcile, beginBackendReconcilesForOwner, clearJournalConversation, classifyBackendTurnResultDisposition, drainBackendConversationDeleteOutbox, drainBackendTurnOutbox, + drainChatFirstDeferralOutbox, failBackendTurnOutbox, failBackendReconcile, getJournalObservability, @@ -21,10 +25,16 @@ import { journalTurnForSurfaceProjection, journalTurnChangedWakes, listJournalTurns, + listChatFirstMaterializationReceipts, + materializeChatFirstIntent, + materializeChatFirstIntents, migrateJournalConversation, recordJournalExchange, recordJournalTurn, + recordQuestionInteractionReply, + searchJournalConversation, settleClearedBackendTurnClaim, + settleChatFirstDeferralOutbox, assertPublicJournalUpdatePolicy, terminalizeJournalTurn, updateJournalTurn, @@ -40,6 +50,802 @@ 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({ + materializationReceipts: [first.receipt], + coldStartSequenceTerminalReceipts: [], + }); + + 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!], + coldStartSequenceTerminalReceipts: [], + })).toBe(1); + expect(listChatFirstMaterializationReceipts(fixture.store, { + ownerId: fixture.ownerId, controlGeneration: 7, + })).toEqual({ materializationReceipts: [], coldStartSequenceTerminalReceipts: [] }); + 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"); + 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("attaches only a ready local generated image to the producing Chat-first turn", () => { + const fixture = newSurface("main_chat", "chat", "chat-first-evidence"); + const { run, attempt } = insertActiveRunAttempt(fixture, "chat-first-evidence"); + recordStreamingAssistantPlaceholder(fixture, "turn-chat-first-evidence"); + const resource: ConversationResource = { + id: "rewind-evidence:abc", + origin: "generatedArtifact", + title: "Rewind evidence", + state: "ready", + mimeType: "image/jpeg", + uri: "file:///tmp/rewind-evidence.jpg", + }; + + const appended = appendChatFirstEvidenceToProducingTurn(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + resource, + }); + expect(appended).toMatchObject({ + turnId: "turn-chat-first-evidence", + producingRunId: run.runId, + producingAttemptId: attempt.attemptId, + resources: [{ ...resource, runId: run.runId }], + }); + const before = journalStorageSnapshot(fixture.store); + expect(() => appendChatFirstEvidenceToProducingTurn(fixture.store, { + ownerId: fixture.ownerId, + sessionId: fixture.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + resource: { ...resource, id: "remote", uri: "https://example.com/evidence.jpg" }, + })).toThrow(/local file URI/i); + expect(journalStorageSnapshot(fixture.store)).toEqual(before); + + 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: appended.turnId, + producingRunId: run.runId, + producingAttemptId: attempt.attemptId, + disposition: "accept", + nowMs: 20, + }); + const delivery = drainBackendTurnOutbox(fixture.store, { ownerId: fixture.ownerId, nowMs: 21 }) + .find((candidate) => candidate.turnId === appended.turnId); + const projectedResources = JSON.parse(delivery!.payload.metadata!).resources; + expect(projectedResources).toEqual([{ ...resource, uri: undefined, runId: run.runId }]); + expect(delivery!.payload.metadata).not.toContain("/tmp/rewind-evidence.jpg"); + 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(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. + 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("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 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, + }).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", [ + { 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("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<{ + 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("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({ @@ -2625,6 +3431,7 @@ interface SurfaceFixture { ownerId: string; sessionId: string; conversationId: string; + surfaceKind: string; } function newSurface(surfaceKind: string, externalRefKind: string, externalRefId: string): SurfaceFixture { @@ -2651,7 +3458,7 @@ function insertSurface( createdAtMs: 1, lastActiveAtMs: 1, }); - return { store, ownerId, sessionId: session.sessionId, conversationId }; + return { store, ownerId, sessionId: session.sessionId, conversationId, surfaceKind }; } function recordCompletedTextTurn( @@ -2674,6 +3481,80 @@ 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, + }); +} + +/** 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, + 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( @@ -2710,3 +3591,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/agent/tests/omi-tool-manifest.test.ts b/desktop/macos/agent/tests/omi-tool-manifest.test.ts index cfacd1af839..6e2aad5ff0e 100644 --- a/desktop/macos/agent/tests/omi-tool-manifest.test.ts +++ b/desktop/macos/agent/tests/omi-tool-manifest.test.ts @@ -172,6 +172,49 @@ 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"); + expect(capabilityOffBytes).not.toContain("get_canonical_goals"); + expect(capabilityOffBytes).not.toContain("search_chat_history"); + }); + + 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, + }); + const snapshot = buildToolAvailabilitySnapshot("omi-tools-stdio", { + surfaceKind: "main_chat", chatFirstUi: true, controlGeneration: 7, + }); + + expect(enabled.map((tool) => tool.name)).toContain("render_chat_blocks"); + expect(enabled.map((tool) => tool.name)).toContain("get_canonical_goals"); + expect(enabled.map((tool) => tool.name)).toContain("search_chat_history"); + expect(snapshot.advertisedToolNames).toContain("render_chat_blocks"); + expect(snapshot.advertisedToolNames).toContain("get_canonical_goals"); + expect(snapshot.advertisedToolNames).toContain("search_chat_history"); + expect(snapshot.advertisedToolNames).toContain("show_rewind_evidence"); + expect(snapshot.manifestDigest).not.toBe(buildToolAvailabilitySnapshot("omi-tools-stdio").manifestDigest); + expect(toolNamesForAdapter("pi-mono", { + surfaceKind: "main_chat", chatFirstUi: true, controlGeneration: 7, + })).toEqual(expect.arrayContaining(["get_canonical_goals", "render_chat_blocks", "search_chat_history", "show_rewind_evidence"])); + expect(enabled.find((tool) => tool.name === "render_chat_blocks")?.description).toContain( + "call this in the same turn whenever you retrieve, create, or summarize tasks", + ); + expect(enabled.find((tool) => tool.name === "render_chat_blocks")?.description).toContain( + "never use a local SQLite/execute_sql numeric row ID", + ); + }); + 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/pi-mono-adapter.test.ts b/desktop/macos/agent/tests/pi-mono-adapter.test.ts index 291a9e62174..d7d589f300d 100644 --- a/desktop/macos/agent/tests/pi-mono-adapter.test.ts +++ b/desktop/macos/agent/tests/pi-mono-adapter.test.ts @@ -834,6 +834,39 @@ describe("PiMonoAdapter spawn args (behavioral)", () => { await adapter.stop(); }); + + it("projects chat-first tools into the child env only for an enabled main Chat", async () => { + const adapter = new PiMonoAdapter({ authToken: "test-token" }, "/fake/pi", "/fake/ext.ts"); + await adapter.setToolProjection({ + surfaceKind: "main_chat", + chatFirstUi: true, + controlGeneration: 7, + }); + await adapter.start(); + + const [, , options] = vi.mocked(spawn).mock.calls[0] as [string, string[], { env: Record }]; + expect(options.env.OMI_SURFACE_KIND).toBe("main_chat"); + expect(options.env.OMI_CHAT_FIRST_UI).toBe("true"); + expect(options.env.OMI_CHAT_FIRST_CONTROL_GENERATION).toBe("7"); + await adapter.stop(); + + vi.mocked(spawn).mockClear(); + await adapter.setToolProjection({ + surfaceKind: "floating_chat", + chatFirstUi: true, + controlGeneration: 7, + }); + await adapter.start(); + const [, , legacyOptions] = vi.mocked(spawn).mock.calls[0] as [ + string, + string[], + { env: Record }, + ]; + expect(legacyOptions.env.OMI_SURFACE_KIND).toBeUndefined(); + expect(legacyOptions.env.OMI_CHAT_FIRST_UI).toBeUndefined(); + expect(legacyOptions.env.OMI_CHAT_FIRST_CONTROL_GENERATION).toBeUndefined(); + await adapter.stop(); + }); }); describe("PiMonoAdapter capabilities", () => { 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/surface-session.test.ts b/desktop/macos/agent/tests/surface-session.test.ts index 866f82c3bf7..51967f7d7f2 100644 --- a/desktop/macos/agent/tests/surface-session.test.ts +++ b/desktop/macos/agent/tests/surface-session.test.ts @@ -446,4 +446,65 @@ 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("does not let an early capability-less lookup consume the server-derived main-chat sample", () => { + const kernel = new AgentRuntimeKernel({ store, registry: new AdapterRegistry() }); + const surfaceRef = { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "pending-sample" }; + const resolved = kernel.resolveSurfaceSession({ ownerId: "owner-a", surfaceRef, defaultAdapterId: "acp" }); + expect(kernel.contextSnapshot(resolved.agentSessionId, "owner-a", "main_chat").capabilities.chatFirstUi).toBe(false); + + kernel.resolveSurfaceSession({ + ownerId: "owner-a", + surfaceRef, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 7 }, + }); + + const sampled = kernel.contextSnapshot(resolved.agentSessionId, "owner-a", "main_chat"); + expect(sampled.capabilities.chatFirstUi).toBe(true); + expect(sampled.capabilities.chatFirstControlGeneration).toBe(7); + }); + + it("keeps an explicit main-chat capability sample immutable", () => { + const kernel = new AgentRuntimeKernel({ store, registry: new AdapterRegistry() }); + const surfaceRef = { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "sampled-off" }; + kernel.resolveSurfaceSession({ + ownerId: "owner-a", + surfaceRef, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: false, controlGeneration: 7 }, + }); + + expect(() => kernel.resolveSurfaceSession({ + ownerId: "owner-a", + surfaceRef, + defaultAdapterId: "acp", + chatFirstCapability: { chatFirstUi: true, controlGeneration: 7 }, + })).toThrow(/immutable/); + }); + }); 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/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" +} 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/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" +} diff --git a/desktop/macos/changelog/unreleased/20260720-chat-first-rich-widgets.json b/desktop/macos/changelog/unreleased/20260720-chat-first-rich-widgets.json new file mode 100644 index 00000000000..c7da7bc677e --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260720-chat-first-rich-widgets.json @@ -0,0 +1,3 @@ +{ + "change": "Made Chat-first responses reliably show interactive tasks, goals, memories, conversations, and screen evidence" +} 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..7ff195ae113 --- /dev/null +++ b/desktop/macos/e2e/flows/chat-first-capability-isolation.yaml @@ -0,0 +1,58 @@ +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 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=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-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 +# 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..50c823f998b --- /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-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 + # 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..1ec155d014a --- /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-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 + # 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..27d2784b3e9 --- /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-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 + # 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..615b96bc1fb 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-local-emulator-chat-first-enabled-v1 +``` + +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 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 +`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/pi-mono-extension/index.test.ts b/desktop/macos/pi-mono-extension/index.test.ts index bd554aa4cd6..523de2c634a 100644 --- a/desktop/macos/pi-mono-extension/index.test.ts +++ b/desktop/macos/pi-mono-extension/index.test.ts @@ -24,6 +24,7 @@ import { __resetAuditWarnedForTest, OMI_TOOLS, omiToolsForExecutionRole, + omiToolsForProjectionContext, OMI_TOOL_TIMEOUT_MS, OMI_LONG_CONTROL_TOOL_TIMEOUT_MS, isSafeSkillName, @@ -37,13 +38,13 @@ import { omiReasoningEffortFromRelayContext, } from "./index.ts"; import type { ToolCallEvent } from "@earendil-works/pi-coding-agent"; -import { agentControlCapabilityManifest } from "../agent/src/runtime/control-tool-manifest.ts"; -import { discoverSkillCatalog, searchSkills } from "../agent/src/runtime/node-tools.ts"; +import { agentControlCapabilityManifest } from "../agent/dist/runtime/control-tool-manifest.js"; +import { discoverSkillCatalog, searchSkills } from "../agent/dist/runtime/node-tools.js"; import { buildToolAvailabilitySnapshot, toolNamesForAdapter, toolsForAdapter, -} from "../agent/src/runtime/omi-tool-manifest.ts"; +} from "../agent/dist/runtime/omi-tool-manifest.js"; // --------------------------------------------------------------------------- // classifyBash — allow-by-default for normal dev commands @@ -1042,6 +1043,33 @@ test("OMI_TOOLS: leaf projection omits every agent-management tool", () => { assert.ok(!names.includes("send_agent_message")); }); +test("OMI_TOOLS: chat-first main Chat projects rich blocks without leaking to other surfaces", () => { + const enabled = omiToolsForProjectionContext({ + executionRole: "coordinator", + surfaceKind: "main_chat", + chatFirstUi: true, + controlGeneration: 7, + }).map((tool) => tool.name); + const disabled = omiToolsForProjectionContext({ + executionRole: "coordinator", + surfaceKind: "main_chat", + chatFirstUi: false, + controlGeneration: 7, + }).map((tool) => tool.name); + const otherSurface = omiToolsForProjectionContext({ + executionRole: "coordinator", + surfaceKind: "floating_chat", + chatFirstUi: true, + controlGeneration: 7, + }).map((tool) => tool.name); + + assert.ok(enabled.includes("render_chat_blocks")); + assert.ok(enabled.includes("search_chat_history")); + assert.ok(enabled.includes("show_rewind_evidence")); + assert.ok(!disabled.includes("render_chat_blocks")); + assert.ok(!otherSurface.includes("render_chat_blocks")); +}); + test("OMI_TOOLS: all tools preserve canonical pi-mono projection metadata and schema shape", () => { const canonicalTools = toolsForAdapter("pi-mono"); diff --git a/desktop/macos/pi-mono-extension/index.ts b/desktop/macos/pi-mono-extension/index.ts index e28ff1e2e8b..98ab9346eaa 100644 --- a/desktop/macos/pi-mono-extension/index.ts +++ b/desktop/macos/pi-mono-extension/index.ts @@ -32,14 +32,15 @@ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { createConnection, type Socket } from "node:net"; import { dirname, join, resolve } from "node:path"; -import { isSafeSkillName, loadSkillInstructions, searchSkills } from "../agent/src/runtime/node-tools.ts"; +import { isSafeSkillName, loadSkillInstructions, searchSkills } from "../agent/dist/runtime/node-tools.js"; import { buildToolAvailabilitySnapshot, toolNamesForAdapter, toolsForAdapter, type OmiToolInputSchema, type OmiToolManifestEntry, -} from "../agent/src/runtime/omi-tool-manifest.ts"; + type OmiToolProjectionContext, +} from "../agent/dist/runtime/omi-tool-manifest.js"; /** * Opaque, request-scoped correlation ids are the only context forwarded to @@ -733,17 +734,29 @@ function searchSkillsTool() { } const executionRole = process.env.OMI_EXECUTION_ROLE === "leaf" ? "leaf" : "coordinator"; -const projectionContext = { executionRole } as const; +const chatFirstControlGeneration = Number(process.env.OMI_CHAT_FIRST_CONTROL_GENERATION); +const projectionContext = { + executionRole, + surfaceKind: process.env.OMI_SURFACE_KIND, + chatFirstUi: process.env.OMI_CHAT_FIRST_UI === "true" && process.env.OMI_SURFACE_KIND === "main_chat", + controlGeneration: Number.isSafeInteger(chatFirstControlGeneration) && chatFirstControlGeneration >= 0 + ? chatFirstControlGeneration + : null, +} as const; export function omiToolsForExecutionRole(role: "coordinator" | "leaf") { - return toolsForAdapter("pi-mono", { executionRole: role }).map((tool) => { + return omiToolsForProjectionContext({ executionRole: role }); +} + +export function omiToolsForProjectionContext(context: OmiToolProjectionContext) { + return toolsForAdapter("pi-mono", context).map((tool) => { if (tool.name === "load_skill") return loadSkillTool(); if (tool.name === "search_skills") return searchSkillsTool(); return omiManifestTool(tool); }); } -export const OMI_TOOLS = omiToolsForExecutionRole(executionRole); +export const OMI_TOOLS = omiToolsForProjectionContext(projectionContext); async function registerOmiTools(pi: ExtensionAPI): Promise { const pipePath = process.env.OMI_BRIDGE_PIPE; diff --git a/desktop/macos/run.sh b/desktop/macos/run.sh index b911c20b3d9..68bd32b00f9 100755 --- a/desktop/macos/run.sh +++ b/desktop/macos/run.sh @@ -1053,10 +1053,6 @@ if [ -d "$AGENT_DIR/dist" ]; then exit 1 fi macos_copy_tree "$AGENT_PACKAGED_NODE_MODULES" "$APP_BUNDLE/Contents/Resources/agent/node_modules" - mkdir -p "$APP_BUNDLE/Contents/Resources/agent/src/runtime" - cp -f "$AGENT_DIR/src/runtime/control-tool-manifest.ts" "$APP_BUNDLE/Contents/Resources/agent/src/runtime/" - cp -f "$AGENT_DIR/src/runtime/node-tools.ts" "$APP_BUNDLE/Contents/Resources/agent/src/runtime/" - cp -f "$AGENT_DIR/src/runtime/omi-tool-manifest.ts" "$APP_BUNDLE/Contents/Resources/agent/src/runtime/" fi substep "Copying pi-mono-extension (for piMono harness)" 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." + } } diff --git a/desktop/macos/scripts/desktop-flow-lint.py b/desktop/macos/scripts/desktop-flow-lint.py index 804a58b097f..d55195d185f 100755 --- a/desktop/macos/scripts/desktop-flow-lint.py +++ b/desktop/macos/scripts/desktop-flow-lint.py @@ -27,6 +27,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 = { @@ -37,6 +38,7 @@ "state.expect", "log.expect", "trace.expect", + "ax.activate", "ax.expect", "power.sample", } @@ -70,6 +72,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 @@ -116,6 +162,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-ctl b/desktop/macos/scripts/omi-ctl index eee92898edf..ec00f9b61e6 100755 --- a/desktop/macos/scripts/omi-ctl +++ b/desktop/macos/scripts/omi-ctl @@ -113,7 +113,7 @@ raise SystemExit(0 if ready else 1) done echo "omi-ctl: timed out waiting for a live signed-in owner-ready 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 < 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: @@ -309,7 +312,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: @@ -341,7 +346,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]: @@ -439,7 +449,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))) @@ -494,7 +506,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, @@ -505,7 +522,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]: @@ -560,6 +579,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" @@ -568,12 +642,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 @@ -673,7 +813,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))) @@ -755,6 +897,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) @@ -1004,8 +1156,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 @@ -1050,7 +1201,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 @@ -1160,6 +1314,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 898fc15f055..8734cfbe6a0 100644 --- a/desktop/macos/tests/test-omi-harness.sh +++ b/desktop/macos/tests/test-omi-harness.sh @@ -89,7 +89,6 @@ except RuntimeError: else: raise AssertionError("relative health log path must fail loudly") - class FakeResponse: def __init__(self, payload): self.payload = payload @@ -153,6 +152,130 @@ assert [(request["method"], request["route"]) for request in requests] == [ assert requests[0]["authorization"] is None assert requests[1]["authorization"] == "Bearer test-automation-token" assert requests[2]["authorization"] == "Bearer test-automation-token" + +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/desktop/windows/src/renderer/src/lib/omiApi.generated.ts b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts index 96a1b8ba145..7c6c4c6ece2 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" | "cold_start"; +} + export interface ChatMessageCountResponse { count: number; } @@ -946,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 { @@ -1329,6 +1352,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; @@ -1821,6 +1859,12 @@ export interface GoalLifecycleRequest { status: GoalStatus; } +export interface GoalLinkSpec { + goal_id: string; + summary: string; + type: "goalLink"; +} + export interface GoalMetric { current: number; max?: number | null; @@ -2020,6 +2064,20 @@ export interface LlmUsageResponse { top_features?: Array; } +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"; + window_foreground?: boolean; +} + +export interface MaterializePromptsResponse { + intents?: Array; +} + export interface McpAddServerResponse { app_id: string; auth_url?: string | null; @@ -2209,6 +2267,12 @@ export interface MemoryDB { export type MemoryLayer = "short_term" | "long_term" | "archive"; +export interface MemoryLinkSpec { + memory_id: string; + summary: string; + type: "memoryLink"; +} + export interface MemoryMutationResponse { status: string; } @@ -2546,6 +2610,26 @@ export interface PrivateCloudSyncResponse { private_cloud_sync_enabled: boolean; } +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" | "pending_kernel_receipt" | "delivered"; + intent_id: string; + materialization_receipt_id?: string | null; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment" | "cold_start_rich" | "cold_start_sparse"; + subject?: ChatFirstSubject | null; +} + +export interface ProactiveMaterializationReceipt { + intent_id: string; + receipt_id: string; +} + export interface ProactiveNotification { scopes: Array; } @@ -2581,6 +2665,22 @@ export interface PublicFairUseCaseStatusResponse { updated_at?: string | null; } +export interface QuestionCardSpec { + cold_start_sequence?: ColdStartSequence | null; + 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; } @@ -3087,6 +3187,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; @@ -3227,6 +3332,7 @@ export interface TaskUpdateCandidate { export interface TaskWorkflowControl { account_generation?: number; + chat_first_ui?: boolean; workflow_mode?: TaskWorkflowMode; } @@ -3805,10 +3911,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; @@ -3820,6 +3928,8 @@ export interface OmiApiSchemas { "ClickUpListsResponse": ClickUpListsResponse; "ClickUpSpacesResponse": ClickUpSpacesResponse; "ClickUpTeamsResponse": ClickUpTeamsResponse; + "ColdStartSequence": ColdStartSequence; + "ColdStartSequenceTerminalReceipt": ColdStartSequenceTerminalReceipt; "ContextMatchSignal": ContextMatchSignal; "ContinuationCheckpoint": ContinuationCheckpoint; "ContinuationCheckpointUpsert": ContinuationCheckpointUpsert; @@ -3873,6 +3983,8 @@ export interface OmiApiSchemas { "DecisionRecord": DecisionRecord; "DefaultTaskIntegrationRequest": DefaultTaskIntegrationRequest; "DefaultTaskIntegrationResponse": DefaultTaskIntegrationResponse; + "DeferralCreateRequest": DeferralCreateRequest; + "DeferralReceipt": DeferralReceipt; "DeleteAccountRequest": DeleteAccountRequest; "DeleteActionItemRequest": DeleteActionItemRequest; "DeleteImportJobResponse": DeleteImportJobResponse; @@ -3936,6 +4048,7 @@ export interface OmiApiSchemas { "GoalFocusRequest": GoalFocusRequest; "GoalHistoryEntryResponse": GoalHistoryEntryResponse; "GoalLifecycleRequest": GoalLifecycleRequest; + "GoalLinkSpec": GoalLinkSpec; "GoalMetric": GoalMetric; "GoalOriginWorkIntent": GoalOriginWorkIntent; "GoalProgressEvent": GoalProgressEvent; @@ -3966,6 +4079,8 @@ export interface OmiApiSchemas { "LlmUsageFeatureResponse": LlmUsageFeatureResponse; "LlmUsageRecordResponse": LlmUsageRecordResponse; "LlmUsageResponse": LlmUsageResponse; + "MaterializePromptsRequest": MaterializePromptsRequest; + "MaterializePromptsResponse": MaterializePromptsResponse; "McpAddServerResponse": McpAddServerResponse; "McpApiKey": McpApiKey; "McpApiKeyCreate": McpApiKeyCreate; @@ -3989,6 +4104,7 @@ export interface OmiApiSchemas { "MemoryCategory": MemoryCategory; "MemoryDB": MemoryDB; "MemoryLayer": MemoryLayer; + "MemoryLinkSpec": MemoryLinkSpec; "MemoryMutationResponse": MemoryMutationResponse; "MemoryReviewItemResponse": MemoryReviewItemResponse; "MemorySummaryRatingResponse": MemorySummaryRatingResponse; @@ -4041,12 +4157,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; @@ -4114,6 +4234,7 @@ export interface OmiApiSchemas { "TaskAssistantSettings": TaskAssistantSettings; "TaskCancelCandidate": TaskCancelCandidate; "TaskCandidate": TaskCandidate; + "TaskCardSpec": TaskCardSpec; "TaskChangePayload": TaskChangePayload; "TaskCompleteCandidate": TaskCompleteCandidate; "TaskCreateCandidate": TaskCreateCandidate; @@ -5091,6 +5212,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"; @@ -5870,6 +6011,16 @@ export interface OmiApiPaths { }; }; }; + "/v1/goals/canonical/list": { + get: { + operationId: "get_canonical_goals_v1_goals_canonical_list_get"; + responses: { + "200": Array; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/goals/extract-progress": { post: { operationId: "extract_and_update_progress_v1_goals_extract_progress_post"; @@ -9805,7 +9956,49 @@ 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 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`; const _params = query ? Object.entries(query) @@ -9933,10 +10126,13 @@ export async function search_conversations_endpoint_v1_conversations_search_post return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_conversation_by_id_v1_conversations__conversation_id__get(path: { conversation_id: string }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { +export async function get_conversation_by_id_v1_conversations__conversation_id__get(path: { conversation_id: string }, query: { source?: string | null, include_discarded?: boolean }, 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/${path.conversation_id}`; - const _search = ""; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; const _res = await fetch(`${_base}${_path}${_search}`, { method: "GET", headers: { @@ -11293,6 +11489,28 @@ export async function create_canonical_goal_v1_goals_canonical_post(header: { Id return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function get_canonical_goals_v1_goals_canonical_list_get(query: { include_ended?: boolean }, 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/goals/canonical/list`; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(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) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function extract_and_update_progress_v1_goals_extract_progress_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: ProgressExtractRequest, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v1/goals/extract-progress`; @@ -15539,4 +15757,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: 384 client methods generated. diff --git a/docs/api-reference/app-client-openapi.json b/docs/api-reference/app-client-openapi.json index 0f58a12708a..3c38d73c7c6 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,34 @@ "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", + "cold_start" + ], + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "title": "ChatFirstSubject", + "type": "object" + }, "ChatMessageCountResponse": { "properties": { "count": { @@ -6035,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", @@ -8466,6 +8596,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": { @@ -11309,6 +11517,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": { @@ -12423,6 +12661,75 @@ "title": "LlmUsageResponse", "type": "object" }, + "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, + "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": { @@ -13651,6 +13958,36 @@ "title": "MemoryLayer", "type": "string" }, + "MemoryLinkSpec": { + "additionalProperties": false, + "properties": { + "memory_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "title": "Memory Id", + "type": "string" + }, + "summary": { + "maxLength": 200, + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "type": { + "const": "memoryLink", + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "memory_id", + "summary" + ], + "title": "MemoryLinkSpec", + "type": "object" + }, "MemoryMutationResponse": { "properties": { "status": { @@ -15467,15 +15804,203 @@ "title": "PrivateCloudSyncResponse", "type": "object" }, - "ProactiveNotification": { + "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 deliverable until that kernel has committed and\nacknowledged its stable ``intent_id``; cold-start intents make that\nexplicit as ``pending_kernel_receipt``.", "properties": { - "scopes": { + "account_generation": { + "minimum": 0.0, + "title": "Account Generation", + "type": "integer" + }, + "blocks": { "items": { - "type": "string" - }, - "title": "Scopes", - "type": "array", - "uniqueItems": true + "discriminator": { + "mapping": { + "captureLink": "#/components/schemas/CaptureLinkSpec", + "goalLink": "#/components/schemas/GoalLinkSpec", + "memoryLink": "#/components/schemas/MemoryLinkSpec", + "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" + }, + { + "$ref": "#/components/schemas/MemoryLinkSpec" + } + ] + }, + "maxItems": 8, + "minItems": 1, + "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, + "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", + "pending_kernel_receipt", + "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", + "cold_start_rich", + "cold_start_sparse" + ], + "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" + }, + "ProactiveMaterializationReceipt": { + "additionalProperties": false, + "description": "Content-free receipt emitted only after the local journal commits.", + "properties": { + "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": [ + "intent_id", + "receipt_id" + ], + "title": "ProactiveMaterializationReceipt", + "type": "object" + }, + "ProactiveNotification": { + "properties": { + "scopes": { + "items": { + "type": "string" + }, + "title": "Scopes", + "type": "array", + "uniqueItems": true } }, "required": [ @@ -15664,6 +16189,96 @@ "title": "PublicFairUseCaseStatusResponse", "type": "object" }, + "QuestionCardSpec": { + "additionalProperties": false, + "properties": { + "cold_start_sequence": { + "anyOf": [ + { + "$ref": "#/components/schemas/ColdStartSequence" + }, + { + "type": "null" + } + ] + }, + "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": { @@ -18699,6 +19314,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": { @@ -19674,7 +20312,7 @@ }, "TaskWorkflowControl": { "additionalProperties": false, - "description": "Per-user universal-contract migration control; defaults preserve legacy behavior.", + "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, @@ -19682,6 +20320,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" @@ -30240,6 +30883,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.", @@ -30295,6 +31114,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", @@ -30637,7 +31474,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, @@ -30650,7 +31487,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" } }, @@ -31101,6 +31938,34 @@ "type": "string" } }, + { + "description": "Optional provenance constraint for a detail read", + "in": "query", + "name": "source", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional provenance constraint for a detail read", + "title": "Source" + } + }, + { + "in": "query", + "name": "include_discarded", + "required": false, + "schema": { + "default": true, + "title": "Include Discarded", + "type": "boolean" + } + }, { "in": "header", "name": "authorization", @@ -36851,6 +37716,98 @@ ] } }, + "/v1/goals/canonical/list": { + "get": { + "description": "List goals for enrolled canonical-memory task-system clients only.", + "operationId": "get_canonical_goals_v1_goals_canonical_list_get", + "parameters": [ + { + "in": "query", + "name": "include_ended", + "required": false, + "schema": { + "default": false, + "title": "Include Ended", + "type": "boolean" + } + }, + { + "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" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/GoalResponse" + }, + "title": "Response Get Canonical Goals V1 Goals Canonical List Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "401": { + "$ref": "#/components/responses/Error401" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "firebaseBearer": [] + } + ], + "summary": "Get Canonical Goals", + "tags": [ + "goals" + ] + } + }, "/v1/goals/extract-progress": { "post": { "description": "Extract goal progress from conversation/chat text and update if found.\nUses LLM to understand context and extract numeric progress.", 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/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/scripts/dev-harness/MEMORY_SCENARIOS.md b/scripts/dev-harness/MEMORY_SCENARIOS.md index 18a2621495a..edfd6a73084 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-local-emulator-chat-first-enabled-v1 +``` + +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..f8d26029093 --- /dev/null +++ b/scripts/dev-harness/chat-first-e2e-fixture.sh @@ -0,0 +1,145 @@ +#!/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', '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-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') + +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..0ff22fe54fa 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-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" @@ -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: @@ -1298,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 @@ -1334,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 d85842fca52..631ef0a17ed 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-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" assert happy.report_metadata.activation_eligible is False @@ -104,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) @@ -113,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', @@ -127,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: diff --git a/web/admin/lib/services/omi-api/omiApi.generated.ts b/web/admin/lib/services/omi-api/omiApi.generated.ts index 96a1b8ba145..7c6c4c6ece2 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" | "cold_start"; +} + export interface ChatMessageCountResponse { count: number; } @@ -946,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 { @@ -1329,6 +1352,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; @@ -1821,6 +1859,12 @@ export interface GoalLifecycleRequest { status: GoalStatus; } +export interface GoalLinkSpec { + goal_id: string; + summary: string; + type: "goalLink"; +} + export interface GoalMetric { current: number; max?: number | null; @@ -2020,6 +2064,20 @@ export interface LlmUsageResponse { top_features?: Array; } +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"; + window_foreground?: boolean; +} + +export interface MaterializePromptsResponse { + intents?: Array; +} + export interface McpAddServerResponse { app_id: string; auth_url?: string | null; @@ -2209,6 +2267,12 @@ export interface MemoryDB { export type MemoryLayer = "short_term" | "long_term" | "archive"; +export interface MemoryLinkSpec { + memory_id: string; + summary: string; + type: "memoryLink"; +} + export interface MemoryMutationResponse { status: string; } @@ -2546,6 +2610,26 @@ export interface PrivateCloudSyncResponse { private_cloud_sync_enabled: boolean; } +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" | "pending_kernel_receipt" | "delivered"; + intent_id: string; + materialization_receipt_id?: string | null; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment" | "cold_start_rich" | "cold_start_sparse"; + subject?: ChatFirstSubject | null; +} + +export interface ProactiveMaterializationReceipt { + intent_id: string; + receipt_id: string; +} + export interface ProactiveNotification { scopes: Array; } @@ -2581,6 +2665,22 @@ export interface PublicFairUseCaseStatusResponse { updated_at?: string | null; } +export interface QuestionCardSpec { + cold_start_sequence?: ColdStartSequence | null; + 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; } @@ -3087,6 +3187,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; @@ -3227,6 +3332,7 @@ export interface TaskUpdateCandidate { export interface TaskWorkflowControl { account_generation?: number; + chat_first_ui?: boolean; workflow_mode?: TaskWorkflowMode; } @@ -3805,10 +3911,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; @@ -3820,6 +3928,8 @@ export interface OmiApiSchemas { "ClickUpListsResponse": ClickUpListsResponse; "ClickUpSpacesResponse": ClickUpSpacesResponse; "ClickUpTeamsResponse": ClickUpTeamsResponse; + "ColdStartSequence": ColdStartSequence; + "ColdStartSequenceTerminalReceipt": ColdStartSequenceTerminalReceipt; "ContextMatchSignal": ContextMatchSignal; "ContinuationCheckpoint": ContinuationCheckpoint; "ContinuationCheckpointUpsert": ContinuationCheckpointUpsert; @@ -3873,6 +3983,8 @@ export interface OmiApiSchemas { "DecisionRecord": DecisionRecord; "DefaultTaskIntegrationRequest": DefaultTaskIntegrationRequest; "DefaultTaskIntegrationResponse": DefaultTaskIntegrationResponse; + "DeferralCreateRequest": DeferralCreateRequest; + "DeferralReceipt": DeferralReceipt; "DeleteAccountRequest": DeleteAccountRequest; "DeleteActionItemRequest": DeleteActionItemRequest; "DeleteImportJobResponse": DeleteImportJobResponse; @@ -3936,6 +4048,7 @@ export interface OmiApiSchemas { "GoalFocusRequest": GoalFocusRequest; "GoalHistoryEntryResponse": GoalHistoryEntryResponse; "GoalLifecycleRequest": GoalLifecycleRequest; + "GoalLinkSpec": GoalLinkSpec; "GoalMetric": GoalMetric; "GoalOriginWorkIntent": GoalOriginWorkIntent; "GoalProgressEvent": GoalProgressEvent; @@ -3966,6 +4079,8 @@ export interface OmiApiSchemas { "LlmUsageFeatureResponse": LlmUsageFeatureResponse; "LlmUsageRecordResponse": LlmUsageRecordResponse; "LlmUsageResponse": LlmUsageResponse; + "MaterializePromptsRequest": MaterializePromptsRequest; + "MaterializePromptsResponse": MaterializePromptsResponse; "McpAddServerResponse": McpAddServerResponse; "McpApiKey": McpApiKey; "McpApiKeyCreate": McpApiKeyCreate; @@ -3989,6 +4104,7 @@ export interface OmiApiSchemas { "MemoryCategory": MemoryCategory; "MemoryDB": MemoryDB; "MemoryLayer": MemoryLayer; + "MemoryLinkSpec": MemoryLinkSpec; "MemoryMutationResponse": MemoryMutationResponse; "MemoryReviewItemResponse": MemoryReviewItemResponse; "MemorySummaryRatingResponse": MemorySummaryRatingResponse; @@ -4041,12 +4157,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; @@ -4114,6 +4234,7 @@ export interface OmiApiSchemas { "TaskAssistantSettings": TaskAssistantSettings; "TaskCancelCandidate": TaskCancelCandidate; "TaskCandidate": TaskCandidate; + "TaskCardSpec": TaskCardSpec; "TaskChangePayload": TaskChangePayload; "TaskCompleteCandidate": TaskCompleteCandidate; "TaskCreateCandidate": TaskCreateCandidate; @@ -5091,6 +5212,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"; @@ -5870,6 +6011,16 @@ export interface OmiApiPaths { }; }; }; + "/v1/goals/canonical/list": { + get: { + operationId: "get_canonical_goals_v1_goals_canonical_list_get"; + responses: { + "200": Array; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/goals/extract-progress": { post: { operationId: "extract_and_update_progress_v1_goals_extract_progress_post"; @@ -9805,7 +9956,49 @@ 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 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`; const _params = query ? Object.entries(query) @@ -9933,10 +10126,13 @@ export async function search_conversations_endpoint_v1_conversations_search_post return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_conversation_by_id_v1_conversations__conversation_id__get(path: { conversation_id: string }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { +export async function get_conversation_by_id_v1_conversations__conversation_id__get(path: { conversation_id: string }, query: { source?: string | null, include_discarded?: boolean }, 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/${path.conversation_id}`; - const _search = ""; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; const _res = await fetch(`${_base}${_path}${_search}`, { method: "GET", headers: { @@ -11293,6 +11489,28 @@ export async function create_canonical_goal_v1_goals_canonical_post(header: { Id return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function get_canonical_goals_v1_goals_canonical_list_get(query: { include_ended?: boolean }, 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/goals/canonical/list`; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(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) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function extract_and_update_progress_v1_goals_extract_progress_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: ProgressExtractRequest, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v1/goals/extract-progress`; @@ -15539,4 +15757,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: 384 client methods generated. diff --git a/web/app/src/lib/omiApi.generated.ts b/web/app/src/lib/omiApi.generated.ts index 96a1b8ba145..7c6c4c6ece2 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" | "cold_start"; +} + export interface ChatMessageCountResponse { count: number; } @@ -946,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 { @@ -1329,6 +1352,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; @@ -1821,6 +1859,12 @@ export interface GoalLifecycleRequest { status: GoalStatus; } +export interface GoalLinkSpec { + goal_id: string; + summary: string; + type: "goalLink"; +} + export interface GoalMetric { current: number; max?: number | null; @@ -2020,6 +2064,20 @@ export interface LlmUsageResponse { top_features?: Array; } +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"; + window_foreground?: boolean; +} + +export interface MaterializePromptsResponse { + intents?: Array; +} + export interface McpAddServerResponse { app_id: string; auth_url?: string | null; @@ -2209,6 +2267,12 @@ export interface MemoryDB { export type MemoryLayer = "short_term" | "long_term" | "archive"; +export interface MemoryLinkSpec { + memory_id: string; + summary: string; + type: "memoryLink"; +} + export interface MemoryMutationResponse { status: string; } @@ -2546,6 +2610,26 @@ export interface PrivateCloudSyncResponse { private_cloud_sync_enabled: boolean; } +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" | "pending_kernel_receipt" | "delivered"; + intent_id: string; + materialization_receipt_id?: string | null; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment" | "cold_start_rich" | "cold_start_sparse"; + subject?: ChatFirstSubject | null; +} + +export interface ProactiveMaterializationReceipt { + intent_id: string; + receipt_id: string; +} + export interface ProactiveNotification { scopes: Array; } @@ -2581,6 +2665,22 @@ export interface PublicFairUseCaseStatusResponse { updated_at?: string | null; } +export interface QuestionCardSpec { + cold_start_sequence?: ColdStartSequence | null; + 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; } @@ -3087,6 +3187,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; @@ -3227,6 +3332,7 @@ export interface TaskUpdateCandidate { export interface TaskWorkflowControl { account_generation?: number; + chat_first_ui?: boolean; workflow_mode?: TaskWorkflowMode; } @@ -3805,10 +3911,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; @@ -3820,6 +3928,8 @@ export interface OmiApiSchemas { "ClickUpListsResponse": ClickUpListsResponse; "ClickUpSpacesResponse": ClickUpSpacesResponse; "ClickUpTeamsResponse": ClickUpTeamsResponse; + "ColdStartSequence": ColdStartSequence; + "ColdStartSequenceTerminalReceipt": ColdStartSequenceTerminalReceipt; "ContextMatchSignal": ContextMatchSignal; "ContinuationCheckpoint": ContinuationCheckpoint; "ContinuationCheckpointUpsert": ContinuationCheckpointUpsert; @@ -3873,6 +3983,8 @@ export interface OmiApiSchemas { "DecisionRecord": DecisionRecord; "DefaultTaskIntegrationRequest": DefaultTaskIntegrationRequest; "DefaultTaskIntegrationResponse": DefaultTaskIntegrationResponse; + "DeferralCreateRequest": DeferralCreateRequest; + "DeferralReceipt": DeferralReceipt; "DeleteAccountRequest": DeleteAccountRequest; "DeleteActionItemRequest": DeleteActionItemRequest; "DeleteImportJobResponse": DeleteImportJobResponse; @@ -3936,6 +4048,7 @@ export interface OmiApiSchemas { "GoalFocusRequest": GoalFocusRequest; "GoalHistoryEntryResponse": GoalHistoryEntryResponse; "GoalLifecycleRequest": GoalLifecycleRequest; + "GoalLinkSpec": GoalLinkSpec; "GoalMetric": GoalMetric; "GoalOriginWorkIntent": GoalOriginWorkIntent; "GoalProgressEvent": GoalProgressEvent; @@ -3966,6 +4079,8 @@ export interface OmiApiSchemas { "LlmUsageFeatureResponse": LlmUsageFeatureResponse; "LlmUsageRecordResponse": LlmUsageRecordResponse; "LlmUsageResponse": LlmUsageResponse; + "MaterializePromptsRequest": MaterializePromptsRequest; + "MaterializePromptsResponse": MaterializePromptsResponse; "McpAddServerResponse": McpAddServerResponse; "McpApiKey": McpApiKey; "McpApiKeyCreate": McpApiKeyCreate; @@ -3989,6 +4104,7 @@ export interface OmiApiSchemas { "MemoryCategory": MemoryCategory; "MemoryDB": MemoryDB; "MemoryLayer": MemoryLayer; + "MemoryLinkSpec": MemoryLinkSpec; "MemoryMutationResponse": MemoryMutationResponse; "MemoryReviewItemResponse": MemoryReviewItemResponse; "MemorySummaryRatingResponse": MemorySummaryRatingResponse; @@ -4041,12 +4157,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; @@ -4114,6 +4234,7 @@ export interface OmiApiSchemas { "TaskAssistantSettings": TaskAssistantSettings; "TaskCancelCandidate": TaskCancelCandidate; "TaskCandidate": TaskCandidate; + "TaskCardSpec": TaskCardSpec; "TaskChangePayload": TaskChangePayload; "TaskCompleteCandidate": TaskCompleteCandidate; "TaskCreateCandidate": TaskCreateCandidate; @@ -5091,6 +5212,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"; @@ -5870,6 +6011,16 @@ export interface OmiApiPaths { }; }; }; + "/v1/goals/canonical/list": { + get: { + operationId: "get_canonical_goals_v1_goals_canonical_list_get"; + responses: { + "200": Array; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/goals/extract-progress": { post: { operationId: "extract_and_update_progress_v1_goals_extract_progress_post"; @@ -9805,7 +9956,49 @@ 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 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`; const _params = query ? Object.entries(query) @@ -9933,10 +10126,13 @@ export async function search_conversations_endpoint_v1_conversations_search_post return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_conversation_by_id_v1_conversations__conversation_id__get(path: { conversation_id: string }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { +export async function get_conversation_by_id_v1_conversations__conversation_id__get(path: { conversation_id: string }, query: { source?: string | null, include_discarded?: boolean }, 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/${path.conversation_id}`; - const _search = ""; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; const _res = await fetch(`${_base}${_path}${_search}`, { method: "GET", headers: { @@ -11293,6 +11489,28 @@ export async function create_canonical_goal_v1_goals_canonical_post(header: { Id return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function get_canonical_goals_v1_goals_canonical_list_get(query: { include_ended?: boolean }, 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/goals/canonical/list`; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(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) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function extract_and_update_progress_v1_goals_extract_progress_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: ProgressExtractRequest, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v1/goals/extract-progress`; @@ -15539,4 +15757,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: 384 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 96a1b8ba145..7c6c4c6ece2 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" | "cold_start"; +} + export interface ChatMessageCountResponse { count: number; } @@ -946,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 { @@ -1329,6 +1352,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; @@ -1821,6 +1859,12 @@ export interface GoalLifecycleRequest { status: GoalStatus; } +export interface GoalLinkSpec { + goal_id: string; + summary: string; + type: "goalLink"; +} + export interface GoalMetric { current: number; max?: number | null; @@ -2020,6 +2064,20 @@ export interface LlmUsageResponse { top_features?: Array; } +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"; + window_foreground?: boolean; +} + +export interface MaterializePromptsResponse { + intents?: Array; +} + export interface McpAddServerResponse { app_id: string; auth_url?: string | null; @@ -2209,6 +2267,12 @@ export interface MemoryDB { export type MemoryLayer = "short_term" | "long_term" | "archive"; +export interface MemoryLinkSpec { + memory_id: string; + summary: string; + type: "memoryLink"; +} + export interface MemoryMutationResponse { status: string; } @@ -2546,6 +2610,26 @@ export interface PrivateCloudSyncResponse { private_cloud_sync_enabled: boolean; } +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" | "pending_kernel_receipt" | "delivered"; + intent_id: string; + materialization_receipt_id?: string | null; + source: "daily_opener" | "capture_arrival" | "deferral_reraise" | "agent_judgment" | "cold_start_rich" | "cold_start_sparse"; + subject?: ChatFirstSubject | null; +} + +export interface ProactiveMaterializationReceipt { + intent_id: string; + receipt_id: string; +} + export interface ProactiveNotification { scopes: Array; } @@ -2581,6 +2665,22 @@ export interface PublicFairUseCaseStatusResponse { updated_at?: string | null; } +export interface QuestionCardSpec { + cold_start_sequence?: ColdStartSequence | null; + 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; } @@ -3087,6 +3187,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; @@ -3227,6 +3332,7 @@ export interface TaskUpdateCandidate { export interface TaskWorkflowControl { account_generation?: number; + chat_first_ui?: boolean; workflow_mode?: TaskWorkflowMode; } @@ -3805,10 +3911,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; @@ -3820,6 +3928,8 @@ export interface OmiApiSchemas { "ClickUpListsResponse": ClickUpListsResponse; "ClickUpSpacesResponse": ClickUpSpacesResponse; "ClickUpTeamsResponse": ClickUpTeamsResponse; + "ColdStartSequence": ColdStartSequence; + "ColdStartSequenceTerminalReceipt": ColdStartSequenceTerminalReceipt; "ContextMatchSignal": ContextMatchSignal; "ContinuationCheckpoint": ContinuationCheckpoint; "ContinuationCheckpointUpsert": ContinuationCheckpointUpsert; @@ -3873,6 +3983,8 @@ export interface OmiApiSchemas { "DecisionRecord": DecisionRecord; "DefaultTaskIntegrationRequest": DefaultTaskIntegrationRequest; "DefaultTaskIntegrationResponse": DefaultTaskIntegrationResponse; + "DeferralCreateRequest": DeferralCreateRequest; + "DeferralReceipt": DeferralReceipt; "DeleteAccountRequest": DeleteAccountRequest; "DeleteActionItemRequest": DeleteActionItemRequest; "DeleteImportJobResponse": DeleteImportJobResponse; @@ -3936,6 +4048,7 @@ export interface OmiApiSchemas { "GoalFocusRequest": GoalFocusRequest; "GoalHistoryEntryResponse": GoalHistoryEntryResponse; "GoalLifecycleRequest": GoalLifecycleRequest; + "GoalLinkSpec": GoalLinkSpec; "GoalMetric": GoalMetric; "GoalOriginWorkIntent": GoalOriginWorkIntent; "GoalProgressEvent": GoalProgressEvent; @@ -3966,6 +4079,8 @@ export interface OmiApiSchemas { "LlmUsageFeatureResponse": LlmUsageFeatureResponse; "LlmUsageRecordResponse": LlmUsageRecordResponse; "LlmUsageResponse": LlmUsageResponse; + "MaterializePromptsRequest": MaterializePromptsRequest; + "MaterializePromptsResponse": MaterializePromptsResponse; "McpAddServerResponse": McpAddServerResponse; "McpApiKey": McpApiKey; "McpApiKeyCreate": McpApiKeyCreate; @@ -3989,6 +4104,7 @@ export interface OmiApiSchemas { "MemoryCategory": MemoryCategory; "MemoryDB": MemoryDB; "MemoryLayer": MemoryLayer; + "MemoryLinkSpec": MemoryLinkSpec; "MemoryMutationResponse": MemoryMutationResponse; "MemoryReviewItemResponse": MemoryReviewItemResponse; "MemorySummaryRatingResponse": MemorySummaryRatingResponse; @@ -4041,12 +4157,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; @@ -4114,6 +4234,7 @@ export interface OmiApiSchemas { "TaskAssistantSettings": TaskAssistantSettings; "TaskCancelCandidate": TaskCancelCandidate; "TaskCandidate": TaskCandidate; + "TaskCardSpec": TaskCardSpec; "TaskChangePayload": TaskChangePayload; "TaskCompleteCandidate": TaskCompleteCandidate; "TaskCreateCandidate": TaskCreateCandidate; @@ -5091,6 +5212,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"; @@ -5870,6 +6011,16 @@ export interface OmiApiPaths { }; }; }; + "/v1/goals/canonical/list": { + get: { + operationId: "get_canonical_goals_v1_goals_canonical_list_get"; + responses: { + "200": Array; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/goals/extract-progress": { post: { operationId: "extract_and_update_progress_v1_goals_extract_progress_post"; @@ -9805,7 +9956,49 @@ 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 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`; const _params = query ? Object.entries(query) @@ -9933,10 +10126,13 @@ export async function search_conversations_endpoint_v1_conversations_search_post return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_conversation_by_id_v1_conversations__conversation_id__get(path: { conversation_id: string }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { +export async function get_conversation_by_id_v1_conversations__conversation_id__get(path: { conversation_id: string }, query: { source?: string | null, include_discarded?: boolean }, 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/${path.conversation_id}`; - const _search = ""; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; const _res = await fetch(`${_base}${_path}${_search}`, { method: "GET", headers: { @@ -11293,6 +11489,28 @@ export async function create_canonical_goal_v1_goals_canonical_post(header: { Id return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function get_canonical_goals_v1_goals_canonical_list_get(query: { include_ended?: boolean }, 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/goals/canonical/list`; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(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) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function extract_and_update_progress_v1_goals_extract_progress_post(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, body: ProgressExtractRequest, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v1/goals/extract-progress`; @@ -15539,4 +15757,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: 384 client methods generated.