personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / tests / test_home_needs_you.py
28 kB 833 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6import json 7import logging 8from dataclasses import fields 9from datetime import datetime 10from pathlib import Path 11from typing import Any 12 13from solstone.apps.home.needs_you import ( 14 DISABLED_EMPTY_PROMPT_REASON, 15 DISABLED_INVALID_ROUTE_REASON, 16 NeedsYouItem, 17 _chat_item, 18 _normalize_route_payload, 19 classify_needs_you, 20 format_degraded_capture_line, 21 needs_dedup_key, 22) 23 24 25def _june_22_ms() -> float: 26 return datetime(2026, 6, 22, 12, 0, 0).timestamp() * 1000 27 28 29def _use_tmp_journal(tmp_path: Path, monkeypatch) -> Path: 30 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 31 import solstone.think.utils as think_utils 32 33 think_utils._journal_path_cache = None 34 config_path = tmp_path / "config" / "journal.json" 35 config_path.parent.mkdir(parents=True, exist_ok=True) 36 config_path.write_text( 37 json.dumps({"setup": {"completed_at": 1700000000000}}) + "\n", 38 encoding="utf-8", 39 ) 40 return tmp_path 41 42 43def _seed_owner_candidate( 44 journal: Path, 45 *, 46 recommendation: str, 47 streams_represented: int = 2, 48) -> None: 49 from solstone.think.awareness import update_state 50 51 candidate_path = journal / "awareness" / "owner_candidate.npz" 52 candidate_path.parent.mkdir(parents=True, exist_ok=True) 53 candidate_path.write_bytes(b"candidate") 54 update_state( 55 "voiceprint", 56 { 57 "status": "candidate", 58 "cluster_size": 40, 59 "streams_represented": streams_represented, 60 "recommendation": recommendation, 61 "samples": [], 62 }, 63 ) 64 65 66def _seed_principal_centroid(journal: Path) -> None: 67 entity_dir = journal / "entities" / "self_person" 68 entity_dir.mkdir(parents=True, exist_ok=True) 69 (entity_dir / "entity.json").write_text( 70 json.dumps( 71 { 72 "id": "self_person", 73 "name": "Self Person", 74 "type": "Person", 75 "is_principal": True, 76 } 77 ) 78 + "\n", 79 encoding="utf-8", 80 ) 81 (entity_dir / "owner_centroid.npz").write_bytes(b"centroid") 82 83 84def _discovery_record( 85 day: str, 86 stream: str, 87 segment_key: str, 88 *, 89 source: str = "mic_audio", 90 sentence_id: int = 1, 91) -> dict[str, Any]: 92 return { 93 "day": day, 94 "stream": stream, 95 "segment_key": segment_key, 96 "source": source, 97 "sentence_id": sentence_id, 98 } 99 100 101def _seed_discovery_segments( 102 journal: Path, 103 records: list[dict[str, Any]], 104 *, 105 settings: dict[tuple[str, str, str], str] | None = None, 106) -> None: 107 for record in records: 108 day = record.get("day") 109 stream = record.get("stream") 110 segment_key = record.get("segment_key") 111 if not all(isinstance(value, str) for value in (day, stream, segment_key)): 112 continue 113 segment_dir = journal / "chronicle" / day / stream / segment_key 114 segment_dir.mkdir(parents=True, exist_ok=True) 115 setting = (settings or {}).get((day, stream, segment_key)) 116 if setting is not None: 117 (segment_dir / "imported_audio.jsonl").write_text( 118 json.dumps({"setting": setting}) + "\n", 119 encoding="utf-8", 120 ) 121 122 123def _write_discovery_cache_records( 124 journal: Path, 125 clusters: dict[str, list[dict[str, Any]]], 126 *, 127 settings: dict[tuple[str, str, str], str] | None = None, 128) -> None: 129 cache_path = journal / "awareness" / "discovery_clusters.json" 130 cache_path.parent.mkdir(parents=True, exist_ok=True) 131 for records in clusters.values(): 132 _seed_discovery_segments(journal, records, settings=settings) 133 cache_path.write_text( 134 json.dumps( 135 { 136 "version": "2026-07-20T00:00:00", 137 "clusters": clusters, 138 } 139 ) 140 + "\n", 141 encoding="utf-8", 142 ) 143 144 145def _write_discovery_cache(journal: Path, triples: list[tuple[str, str, str]]) -> None: 146 _write_discovery_cache_records( 147 journal, 148 { 149 "1": [ 150 _discovery_record(day, stream, segment_key) 151 for day, stream, segment_key in triples 152 ] 153 }, 154 ) 155 156 157def _degraded_capture( 158 *, 159 name: str = "fedora", 160 active_count=79, 161 first_ts=None, 162 include_rejection: bool = True, 163) -> dict: 164 observer = { 165 "name": name, 166 "status": "degraded", 167 } 168 if include_rejection: 169 observer["ingest_rejection"] = { 170 "reason_code": "ingest_contract_invalid", 171 "active_count": active_count, 172 "first_ts": _june_22_ms() if first_ts is None else first_ts, 173 "latest_ts": _june_22_ms(), 174 "summary": "/tmp/private/screen.jsonl:2: value is invalid", 175 "stream": "fedora", 176 "version": "0.3.1", 177 "segment": "20260622/120000_300", 178 } 179 return {"status": "degraded", "observers": [observer]} 180 181 182def test_classify_needs_you_locked_shape_and_order(): 183 attention = {"placeholder_text": "Pipeline needs review"} 184 pulse_needs = ["Review the launch checklist"] 185 items = classify_needs_you(attention, pulse_needs) 186 187 assert [item.text for item in items] == [ 188 "Pipeline needs review", 189 "Review the launch checklist", 190 ] 191 assert [field.name for field in fields(NeedsYouItem)] == [ 192 "text", 193 "kind", 194 "payload", 195 "disabled", 196 "reason", 197 ] 198 for item in items: 199 data = item.to_dict() 200 assert list(data) == ["text", "kind", "payload", "disabled", "reason"] 201 assert data["kind"] in ["chat", "confirm", "route"] 202 assert data["disabled"] is False 203 assert data["reason"] == "" 204 205 206def test_needs_dedup_key_same_source_identity_matches_across_text(): 207 source = "sol://20260313/archon/091500_300" 208 209 assert needs_dedup_key( 210 {"text": "Q3 report needs your review", "source_id": source} 211 ) == needs_dedup_key({"text": "Look at the Q3 numbers", "source_id": source}) 212 213 214def test_needs_dedup_key_identity_beats_identical_text(): 215 source_a = "sol://20260313/archon/091500_300" 216 source_b = "sol://facets/work/news/20260326" 217 218 assert needs_dedup_key( 219 {"text": "Review the report", "source_id": source_a} 220 ) != needs_dedup_key({"text": "Review the report", "source_id": source_b}) 221 222 223def test_needs_dedup_key_source_id_matches_inline_sol_ref(): 224 source = "sol://20260313/archon/091500_300" 225 226 assert needs_dedup_key({"text": "whatever", "source_id": source}) == source 227 assert needs_dedup_key(f"Look at the numbers {source}") == source 228 229 230def test_needs_dedup_key_legacy_strings_normalize_text(): 231 assert needs_dedup_key("Follow up with Acme") == needs_dedup_key( 232 "follow up with acme" 233 ) 234 235 236def test_format_degraded_capture_line_returns_flat_sentence(): 237 line = format_degraded_capture_line(_degraded_capture()) 238 239 assert line == "one of your devices isn't reaching your journal." 240 assert "segment" not in line 241 assert "screen.jsonl" not in line 242 assert "/tmp/private" not in line 243 assert "fedora" not in line 244 245 246def test_format_degraded_capture_line_multiple_stays_flat(): 247 capture = _degraded_capture() 248 capture["observers"].append( 249 { 250 "name": "phone", 251 "status": "degraded", 252 "ingest_rejection": { 253 "reason_code": "ingest_contract_invalid", 254 "active_count": 2, 255 "first_ts": _june_22_ms(), 256 "latest_ts": _june_22_ms(), 257 "summary": "screen.jsonl:2: value is invalid", 258 "stream": "phone", 259 "version": None, 260 }, 261 } 262 ) 263 264 assert format_degraded_capture_line(capture) == ( 265 "one of your devices isn't reaching your journal." 266 ) 267 268 269def test_format_degraded_capture_line_ignores_missing_count_or_date(): 270 missing_ts = _degraded_capture() 271 del missing_ts["observers"][0]["ingest_rejection"]["first_ts"] 272 assert ( 273 format_degraded_capture_line(missing_ts) 274 == "one of your devices isn't reaching your journal." 275 ) 276 assert ( 277 format_degraded_capture_line(_degraded_capture(active_count=None)) 278 == "one of your devices isn't reaching your journal." 279 ) 280 281 282def test_format_degraded_capture_line_fallbacks_and_non_degraded(): 283 assert ( 284 format_degraded_capture_line(_degraded_capture(include_rejection=False)) 285 == "one of your devices isn't reaching your journal." 286 ) 287 assert ( 288 format_degraded_capture_line({"status": "degraded", "observers": []}) 289 == "one of your devices isn't reaching your journal." 290 ) 291 assert format_degraded_capture_line({"status": "active", "observers": []}) is None 292 293 294def test_classify_needs_you_no_longer_emits_capture_route(): 295 items = classify_needs_you({"placeholder_text": "x"}, ["y"]) 296 297 assert all(item.payload != {"href": "/app/health"} for item in items) 298 299 300def test_classify_needs_you_warns_and_omits_malformed(caplog): 301 caplog.set_level("WARNING", logger="solstone.apps.home.needs_you") 302 303 items = classify_needs_you( 304 None, 305 [None, ""], 306 ) 307 308 assert items == [] 309 assert any( 310 "omitting malformed needs-you" in record.message for record in caplog.records 311 ) 312 313 314def test_classify_needs_you_route_same_origin_only(caplog): 315 caplog.set_level("WARNING", logger="solstone.apps.home.needs_you") 316 317 route_items = classify_needs_you( 318 None, 319 [ 320 { 321 "text": "Open the settings page", 322 "kind": "route", 323 "payload": {"href": "/app/settings"}, 324 } 325 ], 326 ) 327 328 assert route_items == [ 329 NeedsYouItem( 330 text="Open the settings page", 331 kind="route", 332 payload={"href": "/app/settings"}, 333 ) 334 ] 335 assert _normalize_route_payload({"href": "/app/foo"}) == {"href": "/app/foo"} 336 assert _normalize_route_payload({"href": "//evil.com/foo"}) is None 337 assert _normalize_route_payload({"href": "https://evil.com"}) is None 338 assert any("off-origin href" in record.message for record in caplog.records) 339 340 341def test_owner_voice_needs_require_ready_not_candidate_available(tmp_path, monkeypatch): 342 from solstone.apps.home.owner_voice import build_owner_voice_needs 343 344 journal = _use_tmp_journal(tmp_path, monkeypatch) 345 _seed_owner_candidate( 346 journal, recommendation="single_stream", streams_represented=1 347 ) 348 349 assert build_owner_voice_needs("20260720") == [] 350 351 352def test_owner_voice_needs_truth_table_ready_only(tmp_path, monkeypatch): 353 from datetime import datetime 354 355 from solstone.apps.home.owner_voice import build_owner_voice_needs 356 from solstone.think.awareness import update_state 357 358 states: list[tuple[str, bool]] = [ 359 ("centroid_exists", False), 360 ("cooldown", False), 361 ("candidate_found", True), 362 ("single_stream", False), 363 ("candidate_not_ready", False), 364 ("no_candidate", False), 365 ] 366 367 for state, expected_item in states: 368 journal = _use_tmp_journal(tmp_path / state, monkeypatch) 369 if state == "centroid_exists": 370 _seed_principal_centroid(journal) 371 elif state == "cooldown": 372 update_state( 373 "voiceprint", 374 {"status": "rejected", "rejected_at": datetime.now().isoformat()}, 375 ) 376 elif state == "candidate_found": 377 _seed_owner_candidate(journal, recommendation="ready") 378 elif state == "single_stream": 379 _seed_owner_candidate( 380 journal, recommendation="single_stream", streams_represented=1 381 ) 382 elif state == "candidate_not_ready": 383 _seed_owner_candidate(journal, recommendation="needs_more_segments") 384 385 items = build_owner_voice_needs("20260720") 386 assert bool(items) is expected_item, state 387 if items: 388 assert items == [ 389 { 390 "text": "sol found a voice that sounds like you. confirm it in speakers", 391 "kind": "route", 392 "payload": {"href": "/app/speakers/20260720"}, 393 "source_id": "owner_voice:candidate", 394 } 395 ] 396 classified = classify_needs_you(None, items) 397 assert classified[0].kind == "route" 398 assert classified[0].payload == {"href": "/app/speakers/20260720"} 399 400 401def test_owner_voice_needs_cheap_negatives_skip_cohesion(tmp_path, monkeypatch): 402 from solstone.apps.home.owner_voice import build_owner_voice_needs 403 from solstone.think.awareness import update_state 404 405 def fail_cohesion(*args, **kwargs): 406 raise AssertionError("home owner collector ran centroid cohesion") 407 408 monkeypatch.setattr( 409 "solstone.apps.speakers.owner.compute_intra_cosine_p25", 410 fail_cohesion, 411 ) 412 413 journal = _use_tmp_journal(tmp_path / "absent", monkeypatch) 414 assert build_owner_voice_needs("20260720") == [] 415 416 journal = _use_tmp_journal(tmp_path / "status", monkeypatch) 417 _seed_principal_centroid(journal) 418 (journal / "awareness").mkdir(parents=True, exist_ok=True) 419 (journal / "awareness" / "owner_candidate.npz").write_bytes(b"candidate") 420 update_state("voiceprint", {"status": "confirmed"}) 421 assert build_owner_voice_needs("20260720") == [] 422 423 journal = _use_tmp_journal(tmp_path / "candidate-status", monkeypatch) 424 update_state("voiceprint", {"status": "candidate", "recommendation": "ready"}) 425 assert build_owner_voice_needs("20260720") == [] 426 427 428def test_owner_voice_recurring_needs_from_cache_only(tmp_path, monkeypatch): 429 from solstone.apps.home.owner_voice import build_owner_voice_needs 430 431 journal = _use_tmp_journal(tmp_path / "missing", monkeypatch) 432 _seed_principal_centroid(journal) 433 assert build_owner_voice_needs("20260720") == [] 434 435 journal = _use_tmp_journal(tmp_path / "below", monkeypatch) 436 _seed_principal_centroid(journal) 437 _write_discovery_cache( 438 journal, 439 [ 440 ("20260718", "mic", "090000_300"), 441 ("20260718", "mic", "091000_300"), 442 ], 443 ) 444 assert build_owner_voice_needs("20260720") == [] 445 446 journal = _use_tmp_journal(tmp_path / "unconfirmed", monkeypatch) 447 _write_discovery_cache( 448 journal, 449 [ 450 ("20260718", "mic", "090000_300"), 451 ("20260718", "mic", "091000_300"), 452 ("20260719", "sys", "090000_300"), 453 ], 454 ) 455 assert build_owner_voice_needs("20260720") == [] 456 457 journal = _use_tmp_journal(tmp_path / "recurring", monkeypatch) 458 _seed_principal_centroid(journal) 459 _write_discovery_cache( 460 journal, 461 [ 462 ("20260718", "mic", "090000_300"), 463 ("20260718", "mic", "091000_300"), 464 ("20260719", "sys", "090000_300"), 465 ], 466 ) 467 monkeypatch.setattr( 468 "solstone.apps.home.owner_voice.load_owner_centroid", 469 lambda: object(), 470 ) 471 472 assert build_owner_voice_needs("20260720") == [ 473 { 474 "text": "a voice keeps showing up. sol has heard it in 3 conversations, kept in your journal.", 475 "kind": "route", 476 "payload": {"href": "/app/speakers?voice_cluster_id=1"}, 477 "source_id": "owner_voice:recurring:1", 478 } 479 ] 480 481 482def test_owner_voice_recurring_need_requires_authoritative_owner_centroid( 483 tmp_path, monkeypatch 484): 485 from solstone.apps.home.owner_voice import build_owner_voice_needs 486 487 journal = _use_tmp_journal(tmp_path, monkeypatch) 488 _seed_principal_centroid(journal) 489 _write_discovery_cache( 490 journal, 491 [ 492 ("20260718", "mic", "090000_300"), 493 ("20260718", "mic", "091000_300"), 494 ("20260719", "sys", "090000_300"), 495 ], 496 ) 497 monkeypatch.setattr( 498 "solstone.apps.home.owner_voice.load_owner_centroid", 499 lambda: None, 500 raising=False, 501 ) 502 503 assert build_owner_voice_needs("20260720") == [] 504 505 506def test_owner_voice_recurring_uses_conversation_count_for_copy(tmp_path, monkeypatch): 507 from solstone.apps.home.owner_voice import build_owner_voice_needs 508 509 journal = _use_tmp_journal(tmp_path, monkeypatch) 510 _seed_principal_centroid(journal) 511 records = [ 512 _discovery_record("20260718", "mic", "090000_300", source="imported_audio"), 513 _discovery_record("20260718", "mic", "091000_300", source="imported_audio"), 514 _discovery_record("20260718", "mic", "092000_300", source="imported_audio"), 515 ] 516 _write_discovery_cache_records( 517 journal, 518 {"4": records}, 519 settings={ 520 ("20260718", "mic", "090000_300"): "Daily Standup", 521 ("20260718", "mic", "091000_300"): "Daily Standup", 522 ("20260718", "mic", "092000_300"): "Daily Standup", 523 }, 524 ) 525 monkeypatch.setattr( 526 "solstone.apps.home.owner_voice.load_owner_centroid", 527 lambda: object(), 528 ) 529 530 assert build_owner_voice_needs("20260720") == [ 531 { 532 "text": "a voice keeps showing up. sol has heard it in 1 conversation, kept in your journal.", 533 "kind": "route", 534 "payload": {"href": "/app/speakers?voice_cluster_id=4"}, 535 "source_id": "owner_voice:recurring:4", 536 } 537 ] 538 539 540def test_owner_voice_recurring_selects_deterministically(tmp_path, monkeypatch): 541 from solstone.apps.home.owner_voice import build_owner_voice_needs 542 543 journal = _use_tmp_journal(tmp_path, monkeypatch) 544 _seed_principal_centroid(journal) 545 clusters = { 546 "4": [ 547 _discovery_record("20260718", "mic", "090000_300"), 548 _discovery_record("20260718", "mic", "091000_300"), 549 _discovery_record("20260718", "mic", "092000_300"), 550 ], 551 "6": [ 552 _discovery_record("20260718", "sys", "090000_300", sentence_id=1), 553 _discovery_record("20260718", "sys", "090000_300", sentence_id=2), 554 _discovery_record("20260718", "sys", "091000_300", sentence_id=1), 555 _discovery_record("20260718", "sys", "092000_300", sentence_id=1), 556 ], 557 "7": [ 558 _discovery_record("20260719", "mic", "090000_300", sentence_id=1), 559 _discovery_record("20260719", "mic", "091000_300", sentence_id=1), 560 _discovery_record("20260719", "mic", "092000_300", sentence_id=1), 561 _discovery_record("20260719", "mic", "093000_300", sentence_id=1), 562 ], 563 "3": [ 564 _discovery_record("20260720", "mic", "090000_300", sentence_id=1), 565 _discovery_record("20260720", "mic", "091000_300", sentence_id=1), 566 _discovery_record("20260720", "mic", "092000_300", sentence_id=1), 567 _discovery_record("20260720", "mic", "093000_300", sentence_id=1), 568 ], 569 } 570 _write_discovery_cache_records(journal, clusters) 571 monkeypatch.setattr( 572 "solstone.apps.home.owner_voice.load_owner_centroid", 573 lambda: object(), 574 ) 575 576 [item] = build_owner_voice_needs("20260720") 577 578 assert item["payload"] == {"href": "/app/speakers?voice_cluster_id=3"} 579 assert item["source_id"] == "owner_voice:recurring:3" 580 581 582def test_owner_voice_recurring_skips_non_integer_cluster_ids( 583 tmp_path, monkeypatch, caplog 584): 585 from solstone.apps.home.owner_voice import build_owner_voice_needs 586 587 journal = _use_tmp_journal(tmp_path, monkeypatch) 588 _seed_principal_centroid(journal) 589 _write_discovery_cache_records( 590 journal, 591 { 592 "not-a-route-id": [ 593 _discovery_record("20260718", "mic", "090000_300"), 594 _discovery_record("20260718", "mic", "091000_300"), 595 _discovery_record("20260718", "mic", "092000_300"), 596 ], 597 "2": [ 598 _discovery_record("20260719", "mic", "090000_300"), 599 _discovery_record("20260719", "mic", "091000_300"), 600 _discovery_record("20260719", "mic", "092000_300"), 601 ], 602 }, 603 ) 604 monkeypatch.setattr( 605 "solstone.apps.home.owner_voice.load_owner_centroid", 606 lambda: object(), 607 ) 608 609 with caplog.at_level(logging.WARNING, logger="solstone.apps.home.owner_voice"): 610 [item] = build_owner_voice_needs("20260720") 611 612 assert item["payload"] == {"href": "/app/speakers?voice_cluster_id=2"} 613 assert "non-integer discovery cluster ids" in caplog.text 614 615 616def test_owner_voice_recurring_filters_dismissed_clusters(tmp_path, monkeypatch): 617 from solstone.apps.home.owner_voice import build_owner_voice_needs 618 from solstone.think.speaker_cluster_dismissals import record_cluster_dismissal 619 620 for disposition in ("not_a_person", "quiet"): 621 journal = _use_tmp_journal(tmp_path / disposition, monkeypatch) 622 _seed_principal_centroid(journal) 623 dismissed = [ 624 _discovery_record("20260718", "mic", "090000_300"), 625 _discovery_record("20260718", "mic", "091000_300"), 626 _discovery_record("20260718", "mic", "092000_300"), 627 ] 628 kept = [ 629 _discovery_record("20260719", "sys", "090000_300"), 630 _discovery_record("20260719", "sys", "091000_300"), 631 _discovery_record("20260719", "sys", "092000_300"), 632 ] 633 _write_discovery_cache_records(journal, {"1": dismissed, "2": kept}) 634 record_cluster_dismissal(dismissed, disposition) 635 monkeypatch.setattr( 636 "solstone.apps.home.owner_voice.load_owner_centroid", 637 lambda: object(), 638 ) 639 640 assert build_owner_voice_needs("20260720")[0]["payload"] == { 641 "href": "/app/speakers?voice_cluster_id=2" 642 } 643 644 645def test_owner_voice_recurring_omits_when_only_dismissed(tmp_path, monkeypatch): 646 from solstone.apps.home.owner_voice import build_owner_voice_needs 647 from solstone.think.speaker_cluster_dismissals import record_cluster_dismissal 648 649 journal = _use_tmp_journal(tmp_path, monkeypatch) 650 _seed_principal_centroid(journal) 651 records = [ 652 _discovery_record("20260718", "mic", "090000_300"), 653 _discovery_record("20260718", "mic", "091000_300"), 654 _discovery_record("20260718", "mic", "092000_300"), 655 ] 656 _write_discovery_cache_records(journal, {"1": records}) 657 record_cluster_dismissal(records, "quiet") 658 monkeypatch.setattr( 659 "solstone.apps.home.owner_voice.load_owner_centroid", 660 lambda: object(), 661 ) 662 663 assert build_owner_voice_needs("20260720") == [] 664 665 666def test_owner_voice_recurring_dismissal_store_error_fails_closed( 667 tmp_path, monkeypatch, caplog 668): 669 from solstone.apps.home.owner_voice import build_owner_voice_needs 670 671 journal = _use_tmp_journal(tmp_path, monkeypatch) 672 _seed_principal_centroid(journal) 673 _write_discovery_cache( 674 journal, 675 [ 676 ("20260718", "mic", "090000_300"), 677 ("20260718", "mic", "091000_300"), 678 ("20260718", "mic", "092000_300"), 679 ], 680 ) 681 dismissal_path = journal / "speakers" / "cluster-dismissals.jsonl" 682 dismissal_path.parent.mkdir(parents=True, exist_ok=True) 683 dismissal_path.write_text("{not json}\n", encoding="utf-8") 684 monkeypatch.setattr( 685 "solstone.apps.home.owner_voice.load_owner_centroid", 686 lambda: object(), 687 ) 688 689 with caplog.at_level(logging.WARNING, logger="solstone.apps.home.owner_voice"): 690 assert build_owner_voice_needs("20260720") == [] 691 692 assert "dismissal store unreadable or candidate provenance invalid" in caplog.text 693 assert "malformed cluster dismissal JSONL" in caplog.text 694 695 696def test_owner_voice_recurring_count_less_than_one_fails_closed( 697 tmp_path, monkeypatch, caplog 698): 699 from solstone.apps.home.owner_voice import build_owner_voice_needs 700 701 journal = _use_tmp_journal(tmp_path, monkeypatch) 702 _seed_principal_centroid(journal) 703 _write_discovery_cache_records( 704 journal, 705 { 706 "1": [ 707 { 708 "day": "20260718", 709 "stream": "mic", 710 "segment_key": "090000_300", 711 "source": None, 712 "sentence_id": 1, 713 }, 714 { 715 "day": "20260718", 716 "stream": "mic", 717 "segment_key": "091000_300", 718 "source": None, 719 "sentence_id": 1, 720 }, 721 { 722 "day": "20260718", 723 "stream": "mic", 724 "segment_key": "092000_300", 725 "source": None, 726 "sentence_id": 1, 727 }, 728 ] 729 }, 730 ) 731 monkeypatch.setattr( 732 "solstone.apps.home.owner_voice.load_owner_centroid", 733 lambda: object(), 734 ) 735 736 with caplog.at_level(logging.WARNING, logger="solstone.apps.home.owner_voice"): 737 assert build_owner_voice_needs("20260720") == [] 738 739 assert "selected cluster has no valid conversations" in caplog.text 740 741 742def test_classify_needs_you_invalid_route_returns_disabled_item(): 743 items = classify_needs_you( 744 None, 745 [ 746 { 747 "text": "Open the offsite link", 748 "kind": "route", 749 "payload": {"href": "https://evil.com"}, 750 } 751 ], 752 ) 753 754 assert items == [ 755 NeedsYouItem( 756 text="Open the offsite link", 757 kind="route", 758 payload={}, 759 disabled=True, 760 reason=DISABLED_INVALID_ROUTE_REASON, 761 ) 762 ] 763 764 765def test_chat_item_with_empty_prompt_returns_disabled_item(): 766 assert _chat_item("Review this", " ") == NeedsYouItem( 767 text="Review this", 768 kind="chat", 769 payload={}, 770 disabled=True, 771 reason=DISABLED_EMPTY_PROMPT_REASON, 772 ) 773 774 775def test_classify_needs_you_folds_confirm_to_chat(): 776 items = classify_needs_you( 777 None, 778 [{"text": "Confirm the next step", "kind": "confirm", "payload": {}}], 779 ) 780 781 assert items == [ 782 NeedsYouItem( 783 text="Confirm the next step", 784 kind="chat", 785 payload={"prompt": "let's dig into Confirm the next step"}, 786 ) 787 ] 788 789 790def _home_render_js() -> str: 791 return ( 792 Path(__file__).resolve().parents[1] 793 / "solstone" 794 / "apps" 795 / "home" 796 / "static" 797 / "home.js" 798 ).read_text(encoding="utf-8") 799 800 801def test_unknown_kind_renders_inert(): 802 render_js = _home_render_js() 803 804 dispatch_start = render_js.index("function dispatchNeedsYouItem(item)") 805 # The dispatch body runs until the next top-level function in the module. 806 next_fn = render_js.index("\n function ", dispatch_start + 1) 807 dispatch_body = render_js[dispatch_start:next_fn] 808 809 assert "if (item.kind === 'chat')" in dispatch_body 810 assert "if (item.kind === 'route')" in dispatch_body 811 assert "if (item.kind === 'confirm')" in dispatch_body 812 assert "unsupported confirm needs-you item" in dispatch_body 813 # No catch-all else — an unknown kind falls through inert. 814 assert "else" not in dispatch_body 815 816 817def test_disabled_items_render_noninteractive(): 818 render_js = _home_render_js() 819 820 # The disabled needs-you item renders client-side with the reason and no 821 # interactive affordances; the interactive path carries them. 822 assert "pulse-needs-item-disabled" in render_js 823 assert "pulse-needs-reason" in render_js 824 disabled_start = render_js.index("if (item && item.disabled)") 825 disabled_end = render_js.index( 826 "return", render_js.index("return", disabled_start) + 1 827 ) 828 disabled_branch = render_js[disabled_start:disabled_end] 829 assert 'role="button"' not in disabled_branch 830 assert "tabindex" not in disabled_branch 831 assert "data-needs-you-item" not in disabled_branch 832 # Dispatch is a no-op for a disabled item. 833 assert "if (item.disabled) return;" in render_js