personal memory agent
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