fix(tasks): promote conversation summary items through canonical candidates

Merged
AR
11d ago
+1687-84

What changed and why

Fixes the conversation-summary Add to Tasks path covered by criterion 1 of #19566. It previously called the offline/manual task writer, left the matching extraction Candidate pending, omitted the conversation/provenance link, and tracked success only in view-local state. Reopening could offer Add again.

This is only the summary-task slice. It does not close #19566 or change floating-bar Execute, TaskPromotionService, mobile, hardware, or the already-merged expiry work (#19603).

Canonical flow

  1. POST /v1/candidates/from-conversation reads the authenticated owner's stored summary row. The client sends a conversation ID, raw row index and expected description—not an authoritative title, owner or due date. Preparation reuses the existing task-create semantic claim and does not write a task.
  2. The existing Candidate accept endpoint optionally accepts summary_item. It rereads the summary and workflow generation inside its existing transaction, validates the semantic match, then commits Candidate resolution, canonical task, conversation_id and summary target_task_id together.
  3. macOS publishes the accepted receipt to the conversation projection/cache. The saved target ID drives Open Task across remounts. It uses existing row controls, keeps loading/retry states visible, refreshes Suggested and Tasks after acceptance, and never falls back to manual creation.

Bodyless Candidate acceptance remains supported. Contract snapshots and generated Swift/TypeScript clients are regenerated. The new route is registered in the route-policy manifest; writer ownership and architecture documentation are updated.

The typed boundary uses concrete owner/evidence enums and explicit optional acceptance arguments. Pyright now resolves the same vendored SDK source path loaded at runtime. That exposes one existing enum-identity mismatch in the deterministic minimum; its CategoryEnum import is aligned with Structured through the existing SDK/fallback module, with no change to serialized category values and no type-check suppression.

Failure and concurrency behavior

  • Concurrent prepare gestures reuse one pending Candidate; simultaneous Suggested/summary accept resolves one task.
  • Lost-response/reopened-summary replay returns the same task even if it was edited or completed.
  • Changed description/due date/owner, deleted or locked conversation, mismatched target, missing task, malformed stored evidence and stale account generation fail without partial writes.
  • Every desktop await is fenced by immutable owner/session authority. Revoked work cannot continue or publish success.
  • In-flight double-clicks are suppressed. Failures leave retry available; disabled or missing control cannot use a legacy writer.
  • Due date, capture owner and transcript provenance are retained. The transaction does not decrypt, rewrite or transmit the transcript payload.

Product invariants affected

INV-TASK-2: capture still proposes only; preparation and acceptance remain separate requests initiated by an explicit reader gesture. No automatic acceptance or manual-writer fallback is added. Regression guards are registered on the invariant.

How it was verified

  • Backend focused contract/capture suites: 183 passed.
    PYTHONPATH=backend backend/.venv/bin/pytest -q backend/tests/unit/test_summary_task_promotion.py backend/tests/unit/test_candidate_lifecycle.py backend/tests/unit/test_candidates_router.py backend/tests/unit/test_app_client_swift_generator.py backend/tests/unit/test_backend_candidate_capture.py backend/tests/unit/test_conversation_suggestion_visibility.py
    
  • Real loopback Firestore integration: 20 passed, including 4 new summary tests plus 16 expiry regressions. Concurrent accepts use the real SDK/emulator. Synthetic aborted commits prove the retry rereads changed summary/control state and commits nothing.
    FIRESTORE_EMULATOR_HOST=127.0.0.1:10286 PYTHONPATH=backend backend/.venv/bin/pytest -q backend/tests/integration/test_summary_task_promotion_emulator.py backend/tests/integration/test_candidate_accept_expiry_emulator.py
    
  • macOS focused tests: 27 passed, including 8 new promoter tests, 3 summary composition tests and 16 provenance-state tests.
    cd desktop/macos
    xcrun swift test -c debug --package-path Desktop --filter 'ConversationSummaryTaskPromoterTests|ConversationSummaryCompositionTests|ConversationSummaryProvenanceStateTests'
    
  • Local backend serves the production Candidate router and Firestore storage on loopback. curl preparation returned the seeded extraction Candidate and left the task collection empty. Other startup/conversation-list projections and auth are synthetic local fixtures; this is not a deployed-backend, OAuth or transcription proof. Reminder/integration side effects are disabled in the fixture.
  • Named macOS bundle com.omi.omi-summary-tasks is locally built/signed with loopback endpoints and Auth emulator; no production account data, normal Omi app, mobile device or pendant is touched.
  • Final make preflight: 51 checks passed with the Node runtime setting described below. History-dependent recurrence checking explicitly skips on the shallow checkout; mobile historical capture/replay reports pending coverage, not an exercised proof.
  • SDK/fallback model and deterministic-minimum tests: 73 passed, run from the backend directory. A root-directory invocation of the source-reading model-split tests initially failed six relative-path lookups; the documented backend working directory passes all of them, without changing those tests.
  • Full enforced backend Pyright: 0 errors (8,427 existing/typed-boundary warnings). The first normal pre-push blocked on five new typing errors; these were corrected, not bypassed.

Mounted macOS button and durable reopen proof

Ran the manual flow on the signed named bundle, whose health endpoint reported clean source 878e292c59c64f7199e13895beba2d2ca5045165. The synthetic row was displayed by the actual conversation detail view. The existing bridge action invoked that mounted row's task handler—not a replacement task-creation stub.

ObservationResult
Before the explicit gestureOne pending extraction Candidate; zero tasks; no summary target
Actual desktop requestsControl read, POST /v1/candidates/from-conversation 200, then existing Candidate /accept 200
After acceptanceOne accepted Candidate, one canonical task, summary target equals that task ID
Preserved fieldsconversation_id=summary-proof, owner user, due 2026-10-01T09:00:00Z, canonical conversation provenance with segment-1
Suggested projectionEmpty; the matching extraction suggestion is no longer pending
Navigate to Chat and reopenOpen Task remains rendered; reopening does not prepare or accept again
Click Open Task after reopeningTasks opens the same canonical task ID; the desktop focus acknowledgement is true and no focus remains pending
Explicit HTTP replaySame task ID; newly_resolved=false; task count remains one

Synthetic acceptance replay receipt:

{"candidate_id":"cand_ac193a81573ee1156eafe30ce6d19560","status":"accepted","task_id":"task_7bdd7596a68ba4e107d64f7dcdb384cf","newly_resolved":false}

Window-only PNGs were captured locally for before-acceptance, after-acceptance and reopened states; they are retained with the disposable fixture and are not attached to this PR. They are synthetic local acceptance/reopen evidence, not screenshots of the original reporter's account or a deployed fix. The incumbent Add to Tasks control is hover/focus-only when idle; the accepted Open Task state remains visible.

After the typing-only follow-up, relaunched the signed bundle on clean head 0184d6a5ff52ba446d0ad324fd5a92921e68cef1 and re-exercised the saved link. It navigates to Tasks with the same canonical task ID, reports isFocusedEntityAcknowledged=true with no pending focus, and does not issue another prepare/accept request. For this extended task-view check the fixture also mounts the real action-items and inert legacy-restore routers; conversation/startup/auth projections remain synthetic. The dashboard snapshot counts only Today/Overdue/No Deadline, so this October 1 task is intentionally not included in that counter.

Tests

  • backend/tests/unit/test_summary_task_promotion.py: strict read-before-write transaction fake, separate preparation/acceptance, extraction reuse, durable linkage/replay, preserved fields and no-write rejection cases.
  • backend/tests/integration/test_summary_task_promotion_emulator.py: real transaction concurrency and aborted-commit retry fencing.
  • desktop/macos/Desktop/Tests/ConversationSummaryTaskPromoterTests.swift: call ordering, captured authorization, double-click, session revocation at every await, retry, disabled control and persisted link decoding.
  • desktop/macos/e2e/flows/conversation-summary-task-promotion.yaml: manual repeatable mounted-row/reopen evidence procedure.

Failure class

Failure-Class: FC-split-mutation-authority

The summary writer now converges on the canonical Candidate transaction rather than synchronizing a competing manual task writer. Added behavioral tests to the existing class's reusable guard-artifact list; no class lifecycle or prevention-owner change.

Verification boundaries

No deploy, merge, hardware/mobile test or umbrella issue closure. A fresh local bundle needed its ignored universal ffmpeg runtime resource; it was reused from the verified installed Omi dependency and passed the normal bundle audit. No runtime binary is committed.

The first web-app preflight run under local Node v26.7.0 failed 11 unchanged Sidebar/startup-modal tests because its experimental global localStorage was undefined. All 11 pass with NODE_OPTIONS=--no-experimental-webstorage; no unrelated web UI/test code was changed.