personal memory agent
0

Configure Feed

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

solstone / tests / test_activities.py
89 kB 2545 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""Tests for the activities module and activities agent hooks.""" 5 6import json 7import logging 8import tempfile 9from pathlib import Path 10 11import pytest 12 13 14def test_get_default_activities(): 15 """Test that default activities are returned correctly.""" 16 from solstone.think.activities import get_default_activities 17 18 defaults = get_default_activities() 19 20 # Should return a list 21 assert isinstance(defaults, list) 22 assert len(defaults) > 0 23 24 # Each activity should have required fields 25 for activity in defaults: 26 assert "id" in activity 27 assert "name" in activity 28 assert "description" in activity 29 assert "emoji" in activity 30 assert "icon" in activity 31 32 # Check some known activities exist 33 ids = [a["id"] for a in defaults] 34 assert "meeting" in ids 35 assert "coding" in ids 36 assert "browsing" in ids 37 38 # All defaults should have instructions 39 for activity in defaults: 40 assert "instructions" in activity, f"{activity['id']} missing instructions" 41 assert isinstance(activity["instructions"], str) 42 assert len(activity["instructions"]) > 0 43 44 45def test_default_activities_have_canonical_icon_shape(): 46 """Default activities keep emoji fallback and explicit Lucide icon name.""" 47 from solstone.think.activities import DEFAULT_ACTIVITIES, LUCIDE_ICON_NAME_RE 48 49 assert len(DEFAULT_ACTIVITIES) == 25 50 for activity in DEFAULT_ACTIVITIES: 51 assert activity["emoji"] 52 assert LUCIDE_ICON_NAME_RE.fullmatch(activity["icon"]) 53 54 55def test_get_default_activities_returns_copy(): 56 """Test that get_default_activities returns a copy, not the original.""" 57 from solstone.think.activities import get_default_activities 58 59 defaults1 = get_default_activities() 60 defaults2 = get_default_activities() 61 62 # Should be equal but not the same object 63 assert defaults1 == defaults2 64 assert defaults1 is not defaults2 65 66 # Modifying one should not affect the other 67 defaults1[0]["id"] = "modified" 68 assert defaults2[0]["id"] != "modified" 69 70 71def test_fallback_activity_title_uses_folded_untitled_activity(): 72 from solstone.think.activities import _fallback_activity_title 73 74 assert _fallback_activity_title({}) == "untitled activity" 75 76 77def test_always_on_activities(monkeypatch): 78 """Test that always-on activities are auto-included for all facets.""" 79 from solstone.think.activities import DEFAULT_ACTIVITIES, get_facet_activities 80 81 always_on = [a for a in DEFAULT_ACTIVITIES if a.get("always_on")] 82 assert len(always_on) >= 2 # messaging and email 83 84 always_on_ids = {a["id"] for a in always_on} 85 assert "messaging" in always_on_ids 86 assert "email" in always_on_ids 87 88 with tempfile.TemporaryDirectory() as tmpdir: 89 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 90 91 facet_path = Path(tmpdir) / "facets" / "test_facet" 92 facet_path.mkdir(parents=True) 93 94 # Empty facet should still have always-on activities 95 activities = get_facet_activities("test_facet") 96 activity_ids = {a["id"] for a in activities} 97 assert always_on_ids <= activity_ids 98 99 # Explicitly attaching one should not duplicate it 100 from solstone.think.activities import add_activity_to_facet 101 102 add_activity_to_facet("test_facet", "messaging") 103 activities = get_facet_activities("test_facet") 104 messaging_count = sum(1 for a in activities if a["id"] == "messaging") 105 assert messaging_count == 1 106 107 108def test_generate_activity_id(): 109 """Test activity ID generation from names.""" 110 from solstone.think.activities import generate_activity_id 111 112 assert generate_activity_id("My Activity") == "my_activity" 113 assert generate_activity_id("Research & Development") == "research_development" 114 assert generate_activity_id(" Spaces ") == "spaces" 115 assert generate_activity_id("123-Numbers!") == "123_numbers" 116 assert generate_activity_id("") == "activity" 117 118 119def test_facet_activities_empty(): 120 """Test loading activities from a facet with no activities file. 121 122 With no activities.jsonl, all defaults are returned as the vocabulary. 123 """ 124 from solstone.think.activities import DEFAULT_ACTIVITIES, get_facet_activities 125 126 activities = get_facet_activities("personal") 127 assert isinstance(activities, list) 128 129 # Should contain all defaults (full vocabulary for unconfigured facets) 130 all_default_ids = {a["id"] for a in DEFAULT_ACTIVITIES} 131 assert {a["id"] for a in activities} == all_default_ids 132 133 134def test_meeting_is_always_on(): 135 """Test that meeting is marked always_on in DEFAULT_ACTIVITIES.""" 136 from solstone.think.activities import DEFAULT_ACTIVITIES 137 138 always_on_ids = {a["id"] for a in DEFAULT_ACTIVITIES if a.get("always_on")} 139 assert "meeting" in always_on_ids 140 assert "email" in always_on_ids 141 assert "messaging" in always_on_ids 142 143 144def test_unconfigured_facet_returns_all_defaults(monkeypatch): 145 """Test that a facet with no activities.jsonl gets all 16 defaults.""" 146 from solstone.think.activities import DEFAULT_ACTIVITIES, get_facet_activities 147 148 with tempfile.TemporaryDirectory() as tmpdir: 149 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 150 151 facet_path = Path(tmpdir) / "facets" / "new_facet" 152 facet_path.mkdir(parents=True) 153 154 activities = get_facet_activities("new_facet") 155 assert len(activities) == len(DEFAULT_ACTIVITIES) 156 157 activity_ids = {a["id"] for a in activities} 158 default_ids = {a["id"] for a in DEFAULT_ACTIVITIES} 159 assert activity_ids == default_ids 160 161 # All should be marked as not custom 162 for activity in activities: 163 assert activity.get("custom") is False 164 165 166def test_configured_facet_includes_meeting_always_on(monkeypatch): 167 """Test that a facet with explicit activities auto-includes meeting.""" 168 from solstone.think.activities import get_facet_activities, save_facet_activities 169 170 with tempfile.TemporaryDirectory() as tmpdir: 171 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 172 173 facet_path = Path(tmpdir) / "facets" / "work" 174 facet_path.mkdir(parents=True) 175 176 # Save only coding — meeting, email, messaging should auto-include 177 save_facet_activities("work", [{"id": "coding"}]) 178 179 activities = get_facet_activities("work") 180 activity_ids = {a["id"] for a in activities} 181 182 assert "coding" in activity_ids 183 assert "meeting" in activity_ids 184 assert "email" in activity_ids 185 assert "messaging" in activity_ids 186 187 188def test_facet_activities_roundtrip(monkeypatch): 189 """Test saving and loading activities.""" 190 from solstone.think.activities import ( 191 DEFAULT_ACTIVITIES, 192 _get_activities_path, 193 get_facet_activities, 194 save_facet_activities, 195 ) 196 197 # Create a temp journal 198 with tempfile.TemporaryDirectory() as tmpdir: 199 # Temporarily override SOLSTONE_JOURNAL 200 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 201 202 # Create facet directory 203 facet_path = Path(tmpdir) / "facets" / "test_facet" 204 facet_path.mkdir(parents=True) 205 206 # Save some activities 207 activities = [ 208 {"id": "meeting", "priority": "high"}, 209 {"id": "coding", "description": "Custom coding description"}, 210 { 211 "id": "browsing", 212 "instructions": "Custom browsing instructions for this facet", 213 }, 214 { 215 "id": "custom_activity", 216 "name": "Custom", 217 "description": "A custom activity", 218 "instructions": "Custom activity detection hints", 219 "custom": True, 220 "emoji": "🎯", 221 "icon": "sparkles", 222 }, 223 ] 224 save_facet_activities("test_facet", activities) 225 226 # Verify file was created 227 path = _get_activities_path("test_facet") 228 assert path.exists() 229 230 # Load and verify (4 saved + always-on defaults not already saved) 231 loaded = get_facet_activities("test_facet") 232 loaded_ids = {a["id"] for a in loaded} 233 saved_ids = {a["id"] for a in activities} 234 always_on_ids = {a["id"] for a in DEFAULT_ACTIVITIES if a.get("always_on")} 235 assert loaded_ids == saved_ids | always_on_ids 236 237 # Check meeting (predefined with priority override) 238 meeting = next(a for a in loaded if a["id"] == "meeting") 239 assert meeting["priority"] == "high" 240 assert meeting["custom"] is False 241 assert "name" in meeting # Should have default name 242 # Should have default instructions (no override) 243 assert "instructions" in meeting 244 assert "Levels:" in meeting["instructions"] 245 246 # Check coding (predefined with description override) 247 coding = next(a for a in loaded if a["id"] == "coding") 248 assert coding["description"] == "Custom coding description" 249 # Should keep default instructions (only description overridden) 250 assert "instructions" in coding 251 252 # Check browsing (predefined with instructions override) 253 browsing = next(a for a in loaded if a["id"] == "browsing") 254 assert browsing["instructions"] == "Custom browsing instructions for this facet" 255 256 # Check custom activity with instructions 257 custom = next(a for a in loaded if a["id"] == "custom_activity") 258 assert custom["custom"] is True 259 assert custom["name"] == "Custom" 260 assert custom["instructions"] == "Custom activity detection hints" 261 assert custom["emoji"] == "🎯" 262 assert custom["icon"] == "sparkles" 263 264 265def test_get_facet_activities_normalizes_legacy_custom_icon_without_writing( 266 monkeypatch, 267): 268 """Legacy custom records with glyph in icon are normalized in memory only.""" 269 from solstone.think.activities import get_facet_activities 270 271 with tempfile.TemporaryDirectory() as tmpdir: 272 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 273 activities_dir = Path(tmpdir) / "facets" / "work" / "activities" 274 activities_dir.mkdir(parents=True) 275 activities_file = activities_dir / "activities.jsonl" 276 activities_file.write_text( 277 json.dumps( 278 { 279 "id": "legacy_custom", 280 "custom": True, 281 "name": "Legacy custom", 282 "description": "Old stored shape", 283 "icon": "🎯", 284 } 285 ) 286 + "\n", 287 encoding="utf-8", 288 ) 289 290 activities = get_facet_activities("work") 291 292 legacy = next( 293 activity for activity in activities if activity["id"] == "legacy_custom" 294 ) 295 assert legacy["emoji"] == "🎯" 296 assert "icon" not in legacy 297 assert '"icon":' in activities_file.read_text(encoding="utf-8") 298 assert '"emoji":' not in activities_file.read_text(encoding="utf-8") 299 300 301def test_migrate_custom_activity_icons_to_emoji_is_idempotent(monkeypatch): 302 from solstone.think.activities import migrate_custom_activity_icons_to_emoji 303 304 with tempfile.TemporaryDirectory() as tmpdir: 305 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 306 activities_dir = Path(tmpdir) / "facets" / "work" / "activities" 307 activities_dir.mkdir(parents=True) 308 activities_file = activities_dir / "activities.jsonl" 309 activities_file.write_text( 310 json.dumps( 311 { 312 "id": "legacy_custom", 313 "custom": True, 314 "name": "Legacy custom", 315 "description": "Old stored shape", 316 "icon": "🎯", 317 } 318 ) 319 + "\n", 320 encoding="utf-8", 321 ) 322 323 first = migrate_custom_activity_icons_to_emoji() 324 second = migrate_custom_activity_icons_to_emoji() 325 326 assert first["files_scanned"] == 1 327 assert first["files_changed"] == 1 328 assert first["records_changed"] == 1 329 assert second["files_scanned"] == 1 330 assert second["files_changed"] == 0 331 assert second["records_changed"] == 0 332 333 records = [ 334 json.loads(line) 335 for line in activities_file.read_text(encoding="utf-8").splitlines() 336 ] 337 assert records == [ 338 { 339 "id": "legacy_custom", 340 "custom": True, 341 "name": "Legacy custom", 342 "description": "Old stored shape", 343 "emoji": "🎯", 344 } 345 ] 346 347 348def test_migrate_custom_activity_icons_to_emoji_skips_hybrid_record( 349 monkeypatch, 350 caplog, 351): 352 from solstone.think.activities import migrate_custom_activity_icons_to_emoji 353 354 with tempfile.TemporaryDirectory() as tmpdir: 355 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 356 activities_dir = Path(tmpdir) / "facets" / "work" / "activities" 357 activities_dir.mkdir(parents=True) 358 activities_file = activities_dir / "activities.jsonl" 359 original = ( 360 json.dumps( 361 { 362 "id": "hybrid_custom", 363 "custom": True, 364 "name": "Hybrid custom", 365 "description": "Ambiguous stored shape", 366 "emoji": "🎯", 367 "icon": "🎨", 368 }, 369 ensure_ascii=False, 370 ) 371 + "\n" 372 ) 373 activities_file.write_text(original, encoding="utf-8") 374 375 with caplog.at_level(logging.INFO, logger="solstone.think.activities"): 376 first = migrate_custom_activity_icons_to_emoji() 377 second = migrate_custom_activity_icons_to_emoji() 378 379 assert first["files_scanned"] == 1 380 assert first["files_changed"] == 0 381 assert first["records_changed"] == 0 382 assert second["files_scanned"] == 1 383 assert second["files_changed"] == 0 384 assert second["records_changed"] == 0 385 assert activities_file.read_text(encoding="utf-8") == original 386 assert ( 387 sum( 388 "skipping activity icon migration for facet work activity hybrid_custom" 389 in record.getMessage() 390 for record in caplog.records 391 ) 392 == 2 393 ) 394 395 396def test_add_activity_to_facet(monkeypatch): 397 """Test adding an activity to a facet.""" 398 from solstone.think.activities import ( 399 add_activity_to_facet, 400 get_facet_activities, 401 remove_activity_from_facet, 402 ) 403 404 with tempfile.TemporaryDirectory() as tmpdir: 405 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 406 407 facet_path = Path(tmpdir) / "facets" / "test_facet" 408 facet_path.mkdir(parents=True) 409 410 # Add a predefined activity 411 result = add_activity_to_facet("test_facet", "meeting", priority="high") 412 assert result["id"] == "meeting" 413 414 # Verify it was added (+ always-on defaults) 415 activities = get_facet_activities("test_facet") 416 activity_ids = {a["id"] for a in activities} 417 assert "meeting" in activity_ids 418 419 # Adding same activity again should not duplicate 420 prev_count = len(activities) 421 add_activity_to_facet("test_facet", "meeting") 422 activities = get_facet_activities("test_facet") 423 assert len(activities) == prev_count 424 425 # Add a predefined activity with custom instructions 426 add_activity_to_facet( 427 "test_facet", 428 "coding", 429 instructions="Focus on Python and Rust only", 430 ) 431 coding = next( 432 a for a in get_facet_activities("test_facet") if a["id"] == "coding" 433 ) 434 assert coding["instructions"] == "Focus on Python and Rust only" 435 436 # Add a custom activity with instructions 437 add_activity_to_facet( 438 "test_facet", 439 "3d_modeling", 440 name="3D Modeling", 441 description="Blender and CAD work", 442 instructions="Detect via: Blender, FreeCAD, OpenSCAD windows", 443 ) 444 modeling = next( 445 a for a in get_facet_activities("test_facet") if a["id"] == "3d_modeling" 446 ) 447 assert ( 448 modeling["instructions"] == "Detect via: Blender, FreeCAD, OpenSCAD windows" 449 ) 450 451 # Remove it 452 removed = remove_activity_from_facet("test_facet", "meeting") 453 assert removed is True 454 455 # Removing non-existent should return False 456 removed = remove_activity_from_facet("test_facet", "meeting") 457 assert removed is False 458 459 460def test_update_activity_in_facet(monkeypatch): 461 """Test updating an activity in a facet.""" 462 from solstone.think.activities import ( 463 add_activity_to_facet, 464 get_activity_by_id, 465 update_activity_in_facet, 466 ) 467 468 with tempfile.TemporaryDirectory() as tmpdir: 469 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 470 471 facet_path = Path(tmpdir) / "facets" / "test_facet" 472 facet_path.mkdir(parents=True) 473 474 # Add an activity 475 add_activity_to_facet("test_facet", "meeting") 476 477 # Update it 478 updated = update_activity_in_facet( 479 "test_facet", "meeting", priority="low", description="Updated desc" 480 ) 481 assert updated is not None 482 assert updated["priority"] == "low" 483 assert updated["description"] == "Updated desc" 484 485 # Update instructions 486 updated = update_activity_in_facet( 487 "test_facet", 488 "meeting", 489 instructions="Only detect scheduled meetings, not ad-hoc calls", 490 ) 491 assert updated is not None 492 assert ( 493 updated["instructions"] 494 == "Only detect scheduled meetings, not ad-hoc calls" 495 ) 496 # Other fields should be preserved 497 assert updated["priority"] == "low" 498 499 # Verify via lookup 500 activity = get_activity_by_id("test_facet", "meeting") 501 assert activity["priority"] == "low" 502 assert ( 503 activity["instructions"] 504 == "Only detect scheduled meetings, not ad-hoc calls" 505 ) 506 507 # Reset instructions to default via empty string 508 from solstone.think.activities import DEFAULT_ACTIVITIES 509 510 default_instructions = next( 511 a["instructions"] for a in DEFAULT_ACTIVITIES if a["id"] == "meeting" 512 ) 513 updated = update_activity_in_facet("test_facet", "meeting", instructions="") 514 assert updated is not None 515 assert updated["instructions"] == default_instructions 516 517 # Reset description to default via empty string 518 default_desc = next( 519 a["description"] for a in DEFAULT_ACTIVITIES if a["id"] == "meeting" 520 ) 521 updated = update_activity_in_facet("test_facet", "meeting", description="") 522 assert updated is not None 523 assert updated["description"] == default_desc 524 525 # Reset priority to default via "normal" 526 updated = update_activity_in_facet("test_facet", "meeting", priority="normal") 527 assert updated is not None 528 assert updated["priority"] == "normal" 529 530 # Update non-existent should return None 531 result = update_activity_in_facet("test_facet", "nonexistent", priority="high") 532 assert result is None 533 534 535def test_format_activities_context_includes_instructions(monkeypatch): 536 """Test that format_activities_context renders instructions inline.""" 537 from solstone.think.activities import save_facet_activities 538 539 with tempfile.TemporaryDirectory() as tmpdir: 540 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 541 542 facet_path = Path(tmpdir) / "facets" / "test_facet" 543 facet_path.mkdir(parents=True) 544 545 save_facet_activities( 546 "test_facet", 547 [ 548 {"id": "coding"}, 549 { 550 "id": "custom_task", 551 "name": "Custom", 552 "description": "A custom activity", 553 "instructions": "Detect via: specific app UI", 554 "custom": True, 555 }, 556 ], 557 ) 558 559 from solstone.talent.activity_state import format_activities_context 560 561 output = format_activities_context("test_facet") 562 563 # Predefined coding should include its default instructions 564 assert "**coding**" in output 565 assert "IDE or editor open" in output # from default instructions 566 567 # Custom activity should include its custom instructions 568 assert "**custom_task**" in output 569 assert "Detect via: specific app UI" in output 570 571 572# --------------------------------------------------------------------------- 573# Activity Records (think/activities.py) 574# --------------------------------------------------------------------------- 575 576 577class TestLevelAvg: 578 """Tests for level_avg computation.""" 579 580 def test_all_high(self): 581 from solstone.think.activities import level_avg 582 583 assert level_avg(["high", "high", "high"]) == 1.0 584 585 def test_all_medium(self): 586 from solstone.think.activities import level_avg 587 588 assert level_avg(["medium", "medium"]) == 0.5 589 590 def test_all_low(self): 591 from solstone.think.activities import level_avg 592 593 assert level_avg(["low", "low"]) == 0.25 594 595 def test_mixed(self): 596 from solstone.think.activities import level_avg 597 598 assert level_avg(["high", "medium"]) == 0.75 599 600 def test_empty_defaults_to_medium(self): 601 from solstone.think.activities import level_avg 602 603 assert level_avg([]) == 0.5 604 605 def test_unknown_defaults_to_medium(self): 606 from solstone.think.activities import level_avg 607 608 assert level_avg(["unknown", "high"]) == 0.75 609 610 611class TestActivityRecordIO: 612 """Tests for append/load/update of activity records.""" 613 614 def test_append_and_load(self, monkeypatch): 615 from solstone.think.activities import ( 616 append_activity_record, 617 load_activity_records, 618 ) 619 620 with tempfile.TemporaryDirectory() as tmpdir: 621 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 622 623 record = { 624 "id": "coding_100000_300", 625 "activity": "coding", 626 "segments": ["100000_300", "100500_300"], 627 "level_avg": 0.75, 628 "description": "Test coding session", 629 "active_entities": ["VS Code"], 630 "created_at": 1234567890000, 631 } 632 633 assert append_activity_record("work", "20260209", record) is True 634 records = load_activity_records("work", "20260209") 635 assert len(records) == 1 636 assert records[0]["id"] == "coding_100000_300" 637 assert records[0]["segments"] == ["100000_300", "100500_300"] 638 assert records[0]["title"] == "Test coding session" 639 assert records[0]["details"] == "" 640 assert records[0]["hidden"] is False 641 assert records[0]["edits"] == [] 642 643 def test_append_idempotent(self, monkeypatch): 644 from solstone.think.activities import ( 645 append_activity_record, 646 load_activity_records, 647 ) 648 649 with tempfile.TemporaryDirectory() as tmpdir: 650 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 651 652 record = { 653 "id": "coding_100000_300", 654 "activity": "coding", 655 "segments": ["100000_300"], 656 "created_at": 1234567890000, 657 } 658 659 assert append_activity_record("work", "20260209", record) is True 660 assert append_activity_record("work", "20260209", record) is False 661 662 records = load_activity_records("work", "20260209") 663 assert len(records) == 1 664 665 def test_load_nonexistent_returns_empty(self, monkeypatch): 666 from solstone.think.activities import load_activity_records 667 668 with tempfile.TemporaryDirectory() as tmpdir: 669 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 670 assert load_activity_records("work", "20260209") == [] 671 672 def test_update_description(self, monkeypatch): 673 from solstone.think.activities import ( 674 append_activity_record, 675 load_activity_records, 676 update_record_description, 677 ) 678 679 with tempfile.TemporaryDirectory() as tmpdir: 680 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 681 682 record = { 683 "id": "coding_100000_300", 684 "activity": "coding", 685 "description": "Original description", 686 "segments": ["100000_300"], 687 "created_at": 1234567890000, 688 } 689 690 append_activity_record("work", "20260209", record) 691 result = update_record_description( 692 "work", "20260209", "coding_100000_300", "Updated description" 693 ) 694 assert result is True 695 696 records = load_activity_records("work", "20260209") 697 assert records[0]["description"] == "Updated description" 698 assert records[0]["title"] == "Updated description" 699 assert records[0]["details"] == "" 700 701 def test_update_description_with_title_and_details(self, monkeypatch): 702 from solstone.think.activities import ( 703 append_activity_record, 704 load_activity_records, 705 update_record_description, 706 ) 707 708 with tempfile.TemporaryDirectory() as tmpdir: 709 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 710 711 record = { 712 "id": "coding_100000_300", 713 "activity": "coding", 714 "description": "Original description", 715 "segments": ["100000_300"], 716 "created_at": 1234567890000, 717 } 718 719 append_activity_record("work", "20260209", record) 720 result = update_record_description( 721 "work", 722 "20260209", 723 "coding_100000_300", 724 "Updated description", 725 title="Focused coding", 726 details="Pairing with Alex on tests.", 727 ) 728 729 assert result is True 730 records = load_activity_records("work", "20260209") 731 assert records[0]["description"] == "Updated description" 732 assert records[0]["title"] == "Focused coding" 733 assert records[0]["details"] == "Pairing with Alex on tests." 734 735 def test_update_description_none_title_and_details_only_updates_description( 736 self, monkeypatch 737 ): 738 from solstone.think.activities import ( 739 append_activity_record, 740 load_activity_records, 741 update_record_description, 742 ) 743 744 with tempfile.TemporaryDirectory() as tmpdir: 745 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 746 747 append_activity_record( 748 "work", 749 "20260209", 750 { 751 "id": "coding_100000_300", 752 "activity": "coding", 753 "title": "Existing title", 754 "details": "Existing details", 755 "description": "Original description", 756 "segments": ["100000_300"], 757 "created_at": 1234567890000, 758 }, 759 ) 760 761 assert ( 762 update_record_description( 763 "work", 764 "20260209", 765 "coding_100000_300", 766 "Updated description", 767 title=None, 768 details=None, 769 ) 770 is True 771 ) 772 773 records = load_activity_records("work", "20260209") 774 assert records[0]["description"] == "Updated description" 775 assert records[0]["title"] == "Existing title" 776 assert records[0]["details"] == "Existing details" 777 778 def test_update_nonexistent_returns_false(self, monkeypatch): 779 from solstone.think.activities import update_record_description 780 781 with tempfile.TemporaryDirectory() as tmpdir: 782 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 783 assert ( 784 update_record_description("work", "20260209", "nonexistent", "desc") 785 is False 786 ) 787 788 def test_update_preserves_other_records(self, monkeypatch): 789 from solstone.think.activities import ( 790 append_activity_record, 791 load_activity_records, 792 update_record_description, 793 ) 794 795 with tempfile.TemporaryDirectory() as tmpdir: 796 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 797 798 r1 = { 799 "id": "coding_100000_300", 800 "activity": "coding", 801 "description": "First", 802 "segments": ["100000_300"], 803 "created_at": 1, 804 } 805 r2 = { 806 "id": "meeting_110000_300", 807 "activity": "meeting", 808 "description": "Second", 809 "segments": ["110000_300"], 810 "created_at": 2, 811 } 812 813 append_activity_record("work", "20260209", r1) 814 append_activity_record("work", "20260209", r2) 815 816 update_record_description( 817 "work", "20260209", "coding_100000_300", "Updated first" 818 ) 819 820 records = load_activity_records("work", "20260209") 821 assert len(records) == 2 822 assert records[0]["description"] == "Updated first" 823 assert records[1]["description"] == "Second" 824 825 def test_update_activity_record_appends_edit(self, monkeypatch): 826 from solstone.think.activities import ( 827 append_activity_record, 828 load_activity_records, 829 update_activity_record, 830 ) 831 832 with tempfile.TemporaryDirectory() as tmpdir: 833 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 834 835 append_activity_record( 836 "work", 837 "20260209", 838 { 839 "id": "coding_100000_300", 840 "activity": "coding", 841 "description": "Original description", 842 "segments": ["100000_300"], 843 "created_at": 1234567890000, 844 }, 845 ) 846 847 updated = update_activity_record( 848 "work", 849 "20260209", 850 "coding_100000_300", 851 {"title": "Focused coding", "details": "Updated details"}, 852 actor="cli:update", 853 note="updated fields: details, title", 854 ) 855 856 assert updated is not None 857 assert updated["title"] == "Focused coding" 858 assert updated["details"] == "Updated details" 859 assert updated["edits"][-1]["actor"] == "cli:update" 860 assert updated["edits"][-1]["fields"] == ["title", "details"] 861 862 records = load_activity_records("work", "20260209") 863 assert records[0]["edits"][-1]["note"] == "updated fields: details, title" 864 865 def test_update_activity_record_validates_patch(self, monkeypatch): 866 from solstone.think.activities import update_activity_record 867 868 with tempfile.TemporaryDirectory() as tmpdir: 869 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 870 871 with pytest.raises(ValueError, match="patch cannot be empty"): 872 update_activity_record( 873 "work", 874 "20260209", 875 "coding_100000_300", 876 {}, 877 actor="cli:update", 878 note="no-op", 879 ) 880 881 with pytest.raises(ValueError, match="disallowed fields"): 882 update_activity_record( 883 "work", 884 "20260209", 885 "coding_100000_300", 886 {"activity": "meeting"}, 887 actor="cli:update", 888 note="bad field", 889 ) 890 891 def test_format_activities_renders_story(self): 892 from solstone.think.activities import format_activities 893 894 chunks, _meta = format_activities( 895 [ 896 { 897 "id": "meeting_090000_300", 898 "activity": "meeting", 899 "description": "Team sync", 900 "segments": ["090000_300"], 901 "created_at": 1, 902 "participation": [{"name": "Mina"}], 903 "story": { 904 "body": "Aligned on the launch plan and assigned owners.", 905 "topics": ["launch", "owners"], 906 "confidence": 0.9, 907 }, 908 }, 909 { 910 "id": "coding_100000_300", 911 "activity": "coding", 912 "description": "Implementation block", 913 "segments": ["100000_300"], 914 "created_at": 2, 915 }, 916 ] 917 ) 918 919 assert ( 920 "Aligned on the launch plan and assigned owners." in chunks[0]["markdown"] 921 ) 922 assert "Topics: launch, owners" in chunks[0]["markdown"] 923 assert "Topics:" not in chunks[1]["markdown"] 924 assert ( 925 "Aligned on the launch plan and assigned owners." 926 not in chunks[1]["markdown"] 927 ) 928 929 def test_hidden_records_filtered_by_default(self, monkeypatch): 930 from solstone.think.activities import ( 931 append_activity_record, 932 load_activity_records, 933 mute_activity_record, 934 unmute_activity_record, 935 ) 936 937 with tempfile.TemporaryDirectory() as tmpdir: 938 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 939 940 append_activity_record( 941 "work", 942 "20260209", 943 { 944 "id": "coding_100000_300", 945 "activity": "coding", 946 "description": "Original description", 947 "segments": ["100000_300"], 948 "created_at": 1234567890000, 949 }, 950 ) 951 952 muted = mute_activity_record( 953 "work", 954 "20260209", 955 "coding_100000_300", 956 actor="cli:mute", 957 reason="too noisy", 958 ) 959 assert muted is not None 960 assert muted["hidden"] is True 961 assert muted["edits"][-1]["note"] == "too noisy" 962 963 assert load_activity_records("work", "20260209") == [] 964 hidden_records = load_activity_records( 965 "work", "20260209", include_hidden=True 966 ) 967 assert len(hidden_records) == 1 968 assert hidden_records[0]["hidden"] is True 969 970 hidden_count = len(hidden_records[0]["edits"]) 971 muted_again = mute_activity_record( 972 "work", 973 "20260209", 974 "coding_100000_300", 975 actor="cli:mute", 976 reason="still noisy", 977 ) 978 assert muted_again is not None 979 assert len(muted_again["edits"]) == hidden_count 980 981 unmuted = unmute_activity_record( 982 "work", 983 "20260209", 984 "coding_100000_300", 985 actor="cli:unmute", 986 reason=None, 987 ) 988 assert unmuted is not None 989 assert unmuted["hidden"] is False 990 assert unmuted["edits"][-1]["note"] == "unmuted" 991 assert len(load_activity_records("work", "20260209")) == 1 992 993 994# --------------------------------------------------------------------------- 995# Activities Agent Hooks (talent/activities.py) 996# --------------------------------------------------------------------------- 997 998 999def _setup_segment(tmpdir, day, segment, facet, state): 1000 """Helper to create an activity_state.json file in a segment.""" 1001 talents_dir = ( 1002 Path(tmpdir) / "chronicle" / day / "default" / segment / "talents" / facet 1003 ) 1004 talents_dir.mkdir(parents=True, exist_ok=True) 1005 state_file = talents_dir / "activity_state.json" 1006 state_file.write_text(json.dumps(state)) 1007 1008 1009class TestMakeActivityId: 1010 def test_basic(self): 1011 from solstone.think.activities import make_activity_id 1012 1013 assert make_activity_id("coding", "095809_303") == "coding_095809_303" 1014 1015 def test_with_custom_type(self): 1016 from solstone.think.activities import make_activity_id 1017 1018 assert ( 1019 make_activity_id("video_editing", "120000_300") 1020 == "video_editing_120000_300" 1021 ) 1022 1023 1024class TestListFacetsWithActivityState: 1025 def test_finds_facets(self, monkeypatch): 1026 from solstone.talent.activities import _list_facets_with_activity_state 1027 1028 with tempfile.TemporaryDirectory() as tmpdir: 1029 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1030 1031 _setup_segment(tmpdir, "20260209", "100000_300", "personal", []) 1032 _setup_segment(tmpdir, "20260209", "100000_300", "work", []) 1033 1034 facets = _list_facets_with_activity_state( 1035 "20260209", "100000_300", stream="default" 1036 ) 1037 assert facets == ["personal", "work"] 1038 1039 def test_returns_empty_for_nonexistent(self, monkeypatch): 1040 from solstone.talent.activities import _list_facets_with_activity_state 1041 1042 with tempfile.TemporaryDirectory() as tmpdir: 1043 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1044 assert ( 1045 _list_facets_with_activity_state( 1046 "20260209", "100000_300", stream="default" 1047 ) 1048 == [] 1049 ) 1050 1051 1052class TestDetectEndedActivities: 1053 def test_explicit_ended(self): 1054 from solstone.talent.activities import _detect_ended_activities 1055 1056 prev = [ 1057 {"activity": "coding", "state": "active", "since": "100000_300"}, 1058 ] 1059 curr = [ 1060 {"activity": "coding", "state": "ended", "since": "100000_300"}, 1061 ] 1062 ended = _detect_ended_activities(prev, curr, timed_out=False) 1063 assert len(ended) == 1 1064 assert ended[0]["activity"] == "coding" 1065 1066 def test_implicit_ended(self): 1067 from solstone.talent.activities import _detect_ended_activities 1068 1069 prev = [ 1070 {"activity": "coding", "state": "active", "since": "100000_300"}, 1071 {"activity": "meeting", "state": "active", "since": "100000_300"}, 1072 ] 1073 curr = [ 1074 {"activity": "meeting", "state": "active", "since": "100000_300"}, 1075 ] 1076 ended = _detect_ended_activities(prev, curr, timed_out=False) 1077 assert len(ended) == 1 1078 assert ended[0]["activity"] == "coding" 1079 1080 def test_timeout_ends_all(self): 1081 from solstone.talent.activities import _detect_ended_activities 1082 1083 prev = [ 1084 {"activity": "coding", "state": "active", "since": "100000_300"}, 1085 {"activity": "meeting", "state": "active", "since": "100000_300"}, 1086 ] 1087 ended = _detect_ended_activities(prev, [], timed_out=True) 1088 assert len(ended) == 2 1089 1090 def test_continuing_not_ended(self): 1091 from solstone.talent.activities import _detect_ended_activities 1092 1093 prev = [ 1094 {"activity": "coding", "state": "active", "since": "100000_300"}, 1095 ] 1096 curr = [ 1097 {"activity": "coding", "state": "active", "since": "100000_300"}, 1098 ] 1099 ended = _detect_ended_activities(prev, curr, timed_out=False) 1100 assert len(ended) == 0 1101 1102 def test_ignores_previously_ended(self): 1103 from solstone.talent.activities import _detect_ended_activities 1104 1105 prev = [ 1106 {"activity": "coding", "state": "ended", "since": "090000_300"}, 1107 {"activity": "meeting", "state": "active", "since": "100000_300"}, 1108 ] 1109 curr = [] 1110 ended = _detect_ended_activities(prev, curr, timed_out=False) 1111 assert len(ended) == 1 1112 assert ended[0]["activity"] == "meeting" 1113 1114 def test_new_activity_same_type(self): 1115 """A new activity of same type with different since is not the same.""" 1116 from solstone.talent.activities import _detect_ended_activities 1117 1118 prev = [ 1119 {"activity": "coding", "state": "active", "since": "100000_300"}, 1120 ] 1121 curr = [ 1122 {"activity": "coding", "state": "active", "since": "110000_300"}, 1123 ] 1124 ended = _detect_ended_activities(prev, curr, timed_out=False) 1125 assert len(ended) == 1 1126 assert ended[0]["since"] == "100000_300" 1127 1128 1129class TestWalkActivitySegments: 1130 def test_walks_segments(self, monkeypatch): 1131 from solstone.talent.activities import _walk_activity_segments 1132 1133 with tempfile.TemporaryDirectory() as tmpdir: 1134 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1135 1136 _setup_segment( 1137 tmpdir, 1138 "20260209", 1139 "100000_300", 1140 "work", 1141 [ 1142 { 1143 "activity": "coding", 1144 "state": "active", 1145 "since": "100000_300", 1146 "description": "Starting work", 1147 "level": "high", 1148 "active_entities": ["VS Code"], 1149 } 1150 ], 1151 ) 1152 _setup_segment( 1153 tmpdir, 1154 "20260209", 1155 "100500_300", 1156 "work", 1157 [ 1158 { 1159 "activity": "coding", 1160 "state": "active", 1161 "since": "100000_300", 1162 "description": "Continuing work", 1163 "level": "medium", 1164 "active_entities": ["VS Code", "Claude Code"], 1165 } 1166 ], 1167 ) 1168 1169 result = _walk_activity_segments( 1170 "20260209", "work", "coding", "100000_300", "100500_300" 1171 ) 1172 1173 assert result["segments"] == ["100000_300", "100500_300"] 1174 assert len(result["descriptions"]) == 2 1175 assert result["levels"] == ["high", "medium"] 1176 assert result["active_entities"] == ["VS Code", "Claude Code"] 1177 1178 def test_deduplicates_entities(self, monkeypatch): 1179 from solstone.talent.activities import _walk_activity_segments 1180 1181 with tempfile.TemporaryDirectory() as tmpdir: 1182 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1183 1184 _setup_segment( 1185 tmpdir, 1186 "20260209", 1187 "100000_300", 1188 "work", 1189 [ 1190 { 1191 "activity": "coding", 1192 "state": "active", 1193 "since": "100000_300", 1194 "level": "high", 1195 "active_entities": ["VS Code", "Git"], 1196 } 1197 ], 1198 ) 1199 _setup_segment( 1200 tmpdir, 1201 "20260209", 1202 "100500_300", 1203 "work", 1204 [ 1205 { 1206 "activity": "coding", 1207 "state": "active", 1208 "since": "100000_300", 1209 "level": "high", 1210 "active_entities": ["VS Code", "Claude Code"], 1211 } 1212 ], 1213 ) 1214 1215 result = _walk_activity_segments( 1216 "20260209", "work", "coding", "100000_300", "100500_300" 1217 ) 1218 1219 assert result["active_entities"] == ["VS Code", "Git", "Claude Code"] 1220 1221 def test_empty_when_no_match(self, monkeypatch): 1222 from solstone.talent.activities import _walk_activity_segments 1223 1224 with tempfile.TemporaryDirectory() as tmpdir: 1225 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1226 (Path(tmpdir) / "chronicle" / "20260209").mkdir(parents=True) 1227 1228 result = _walk_activity_segments( 1229 "20260209", "work", "coding", "100000_300", "100500_300" 1230 ) 1231 assert result["segments"] == [] 1232 1233 1234class TestPreProcess: 1235 """Tests for the activities pre_process hook.""" 1236 1237 def test_skips_when_no_previous_segment(self, monkeypatch): 1238 from solstone.talent.activities import pre_process 1239 1240 with tempfile.TemporaryDirectory() as tmpdir: 1241 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1242 1243 day_dir = Path(tmpdir) / "chronicle" / "20260209" 1244 day_dir.mkdir(parents=True) 1245 (day_dir / "default" / "100000_300").mkdir(parents=True) 1246 1247 result = pre_process( 1248 {"day": "20260209", "segment": "100000_300", "stream": "default"} 1249 ) 1250 assert result is not None 1251 assert "skip_reason" in result 1252 1253 def test_skips_when_no_ended_activities(self, monkeypatch): 1254 from solstone.talent.activities import pre_process 1255 1256 with tempfile.TemporaryDirectory() as tmpdir: 1257 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1258 1259 _setup_segment( 1260 tmpdir, 1261 "20260209", 1262 "100000_300", 1263 "work", 1264 [ 1265 { 1266 "activity": "coding", 1267 "state": "active", 1268 "since": "100000_300", 1269 "level": "high", 1270 } 1271 ], 1272 ) 1273 _setup_segment( 1274 tmpdir, 1275 "20260209", 1276 "100500_300", 1277 "work", 1278 [ 1279 { 1280 "activity": "coding", 1281 "state": "active", 1282 "since": "100000_300", 1283 "level": "high", 1284 } 1285 ], 1286 ) 1287 1288 result = pre_process( 1289 {"day": "20260209", "segment": "100500_300", "stream": "default"} 1290 ) 1291 assert result is not None 1292 assert result.get("skip_reason") == "no_ended_activities" 1293 1294 def test_detects_ended_and_writes_record(self, monkeypatch): 1295 from solstone.talent.activities import pre_process 1296 from solstone.think.activities import load_activity_records 1297 1298 with tempfile.TemporaryDirectory() as tmpdir: 1299 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1300 1301 _setup_segment( 1302 tmpdir, 1303 "20260209", 1304 "100000_300", 1305 "work", 1306 [ 1307 { 1308 "activity": "coding", 1309 "state": "active", 1310 "since": "100000_300", 1311 "description": "Starting work", 1312 "level": "high", 1313 "active_entities": ["VS Code"], 1314 } 1315 ], 1316 ) 1317 _setup_segment(tmpdir, "20260209", "100500_300", "work", []) 1318 1319 result = pre_process( 1320 {"day": "20260209", "segment": "100500_300", "stream": "default"} 1321 ) 1322 1323 assert "skip_reason" not in result 1324 assert "transcript" in result 1325 assert "coding_100000_300" in result["transcript"] 1326 1327 records = load_activity_records("work", "20260209") 1328 assert len(records) == 1 1329 assert records[0]["id"] == "coding_100000_300" 1330 assert records[0]["segments"] == ["100000_300"] 1331 1332 def test_idempotent_on_rerun(self, monkeypatch): 1333 from solstone.talent.activities import pre_process 1334 from solstone.think.activities import load_activity_records 1335 1336 with tempfile.TemporaryDirectory() as tmpdir: 1337 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1338 1339 _setup_segment( 1340 tmpdir, 1341 "20260209", 1342 "100000_300", 1343 "work", 1344 [ 1345 { 1346 "activity": "coding", 1347 "state": "active", 1348 "since": "100000_300", 1349 "description": "Coding", 1350 "level": "high", 1351 } 1352 ], 1353 ) 1354 _setup_segment(tmpdir, "20260209", "100500_300", "work", []) 1355 1356 context = {"day": "20260209", "segment": "100500_300", "stream": "default"} 1357 pre_process(context) 1358 pre_process(context) 1359 1360 records = load_activity_records("work", "20260209") 1361 assert len(records) == 1 1362 1363 def test_multi_facet_detection(self, monkeypatch): 1364 from solstone.talent.activities import pre_process 1365 from solstone.think.activities import load_activity_records 1366 1367 with tempfile.TemporaryDirectory() as tmpdir: 1368 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1369 1370 _setup_segment( 1371 tmpdir, 1372 "20260209", 1373 "100000_300", 1374 "work", 1375 [ 1376 { 1377 "activity": "coding", 1378 "state": "active", 1379 "since": "100000_300", 1380 "description": "Work coding", 1381 "level": "high", 1382 } 1383 ], 1384 ) 1385 _setup_segment( 1386 tmpdir, 1387 "20260209", 1388 "100000_300", 1389 "personal", 1390 [ 1391 { 1392 "activity": "meeting", 1393 "state": "active", 1394 "since": "100000_300", 1395 "description": "Team standup", 1396 "level": "medium", 1397 } 1398 ], 1399 ) 1400 1401 _setup_segment(tmpdir, "20260209", "100500_300", "work", []) 1402 _setup_segment(tmpdir, "20260209", "100500_300", "personal", []) 1403 1404 result = pre_process( 1405 {"day": "20260209", "segment": "100500_300", "stream": "default"} 1406 ) 1407 1408 assert "skip_reason" not in result 1409 assert "#work" in result["transcript"] 1410 assert "#personal" in result["transcript"] 1411 1412 work_records = load_activity_records("work", "20260209") 1413 personal_records = load_activity_records("personal", "20260209") 1414 assert len(work_records) == 1 1415 assert len(personal_records) == 1 1416 1417 def test_multi_segment_span(self, monkeypatch): 1418 """Activity spanning multiple segments should collect all segments.""" 1419 from solstone.talent.activities import pre_process 1420 from solstone.think.activities import load_activity_records 1421 1422 with tempfile.TemporaryDirectory() as tmpdir: 1423 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1424 1425 _setup_segment( 1426 tmpdir, 1427 "20260209", 1428 "100000_300", 1429 "work", 1430 [ 1431 { 1432 "activity": "coding", 1433 "state": "active", 1434 "since": "100000_300", 1435 "description": "Starting", 1436 "level": "high", 1437 "active_entities": ["VS Code"], 1438 } 1439 ], 1440 ) 1441 _setup_segment( 1442 tmpdir, 1443 "20260209", 1444 "100500_300", 1445 "work", 1446 [ 1447 { 1448 "activity": "coding", 1449 "state": "active", 1450 "since": "100000_300", 1451 "description": "Continuing", 1452 "level": "medium", 1453 "active_entities": ["VS Code", "Git"], 1454 } 1455 ], 1456 ) 1457 _setup_segment( 1458 tmpdir, 1459 "20260209", 1460 "101000_300", 1461 "work", 1462 [ 1463 { 1464 "activity": "coding", 1465 "state": "active", 1466 "since": "100000_300", 1467 "description": "Finishing", 1468 "level": "high", 1469 "active_entities": ["Claude Code"], 1470 } 1471 ], 1472 ) 1473 # Coding ends 1474 _setup_segment(tmpdir, "20260209", "101500_300", "work", []) 1475 1476 pre_process( 1477 {"day": "20260209", "segment": "101500_300", "stream": "default"} 1478 ) 1479 1480 records = load_activity_records("work", "20260209") 1481 assert len(records) == 1 1482 r = records[0] 1483 assert r["segments"] == ["100000_300", "100500_300", "101000_300"] 1484 assert r["active_entities"] == ["VS Code", "Git", "Claude Code"] 1485 assert r["level_avg"] == 0.83 # (1.0 + 0.5 + 1.0) / 3 1486 1487 1488class TestPostProcess: 1489 """Tests for the activities post_process hook.""" 1490 1491 def test_updates_descriptions(self, monkeypatch): 1492 from solstone.talent.activities import post_process 1493 from solstone.think.activities import ( 1494 append_activity_record, 1495 load_activity_records, 1496 ) 1497 1498 with tempfile.TemporaryDirectory() as tmpdir: 1499 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1500 1501 record = { 1502 "id": "coding_100000_300", 1503 "activity": "coding", 1504 "description": "Preliminary description", 1505 "segments": ["100000_300"], 1506 "created_at": 1, 1507 } 1508 append_activity_record("work", "20260209", record) 1509 1510 llm_result = json.dumps( 1511 { 1512 "work": [ 1513 { 1514 "id": "coding_100000_300", 1515 "title": "Coding summary", 1516 "details": "Worked through test failures and cleanup.", 1517 "description": "Synthesized full description of coding session", 1518 } 1519 ] 1520 } 1521 ) 1522 1523 post_process(llm_result, {"day": "20260209"}) 1524 1525 records = load_activity_records("work", "20260209") 1526 assert ( 1527 records[0]["description"] 1528 == "Synthesized full description of coding session" 1529 ) 1530 assert records[0]["title"] == "Coding summary" 1531 assert records[0]["details"] == "Worked through test failures and cleanup." 1532 1533 def test_updates_descriptions_without_optional_fields(self, monkeypatch): 1534 from solstone.talent.activities import post_process 1535 from solstone.think.activities import ( 1536 append_activity_record, 1537 load_activity_records, 1538 ) 1539 1540 with tempfile.TemporaryDirectory() as tmpdir: 1541 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1542 1543 append_activity_record( 1544 "work", 1545 "20260209", 1546 { 1547 "id": "coding_100000_300", 1548 "activity": "coding", 1549 "title": "Existing title", 1550 "details": "Existing details", 1551 "description": "Preliminary description", 1552 "segments": ["100000_300"], 1553 "created_at": 1, 1554 }, 1555 ) 1556 1557 llm_result = json.dumps( 1558 { 1559 "work": [ 1560 { 1561 "id": "coding_100000_300", 1562 "description": "Only description changed", 1563 } 1564 ] 1565 } 1566 ) 1567 1568 post_process(llm_result, {"day": "20260209"}) 1569 1570 records = load_activity_records("work", "20260209") 1571 assert records[0]["description"] == "Only description changed" 1572 assert records[0]["title"] == "Existing title" 1573 assert records[0]["details"] == "Existing details" 1574 1575 def test_handles_invalid_json(self): 1576 from solstone.talent.activities import post_process 1577 1578 result = post_process("not json", {"day": "20260209"}) 1579 assert result is None 1580 1581 def test_handles_non_object(self): 1582 from solstone.talent.activities import post_process 1583 1584 result = post_process("[]", {"day": "20260209"}) 1585 assert result is None 1586 1587 def test_returns_none(self, monkeypatch): 1588 from solstone.talent.activities import post_process 1589 1590 with tempfile.TemporaryDirectory() as tmpdir: 1591 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1592 1593 result = post_process("{}", {"day": "20260209"}) 1594 assert result is None 1595 1596 1597class TestEstimateDurationMinutes: 1598 def test_single_segment(self): 1599 from solstone.think.activities import estimate_duration_minutes 1600 1601 assert estimate_duration_minutes(["100000_300"]) == 5 1602 1603 def test_multiple_segments(self): 1604 from solstone.think.activities import estimate_duration_minutes 1605 1606 assert estimate_duration_minutes(["100000_300", "100500_300"]) == 10 1607 1608 def test_empty_returns_1(self): 1609 from solstone.think.activities import estimate_duration_minutes 1610 1611 assert estimate_duration_minutes([]) == 1 1612 1613 1614class TestPreProcessMeta: 1615 """Tests for pre-hook stashing record data in meta.""" 1616 1617 def test_meta_contains_activity_records(self, monkeypatch): 1618 from solstone.talent.activities import pre_process 1619 1620 with tempfile.TemporaryDirectory() as tmpdir: 1621 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1622 1623 _setup_segment( 1624 tmpdir, 1625 "20260209", 1626 "100000_300", 1627 "work", 1628 [ 1629 { 1630 "activity": "coding", 1631 "state": "active", 1632 "since": "100000_300", 1633 "level": "high", 1634 "description": "Writing code", 1635 "active_entities": ["VS Code"], 1636 } 1637 ], 1638 ) 1639 _setup_segment(tmpdir, "20260209", "100500_300", "work", []) 1640 1641 result = pre_process( 1642 { 1643 "day": "20260209", 1644 "segment": "100500_300", 1645 "stream": "default", 1646 "meta": {}, 1647 } 1648 ) 1649 1650 assert "meta" in result 1651 records = result["meta"]["activity_records"] 1652 assert "work" in records 1653 assert "coding_100000_300" in records["work"] 1654 rec = records["work"]["coding_100000_300"] 1655 assert rec["activity"] == "coding" 1656 assert rec["segments"] == ["100000_300"] 1657 assert rec["level_avg"] == 1.0 1658 assert rec["active_entities"] == ["VS Code"] 1659 assert rec["description"] == "Writing code" 1660 1661 def test_meta_multiple_facets(self, monkeypatch): 1662 from solstone.talent.activities import pre_process 1663 1664 with tempfile.TemporaryDirectory() as tmpdir: 1665 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1666 1667 _setup_segment( 1668 tmpdir, 1669 "20260209", 1670 "100000_300", 1671 "work", 1672 [ 1673 { 1674 "activity": "coding", 1675 "state": "active", 1676 "since": "100000_300", 1677 "level": "high", 1678 } 1679 ], 1680 ) 1681 _setup_segment( 1682 tmpdir, 1683 "20260209", 1684 "100000_300", 1685 "personal", 1686 [ 1687 { 1688 "activity": "browsing", 1689 "state": "active", 1690 "since": "100000_300", 1691 "level": "low", 1692 } 1693 ], 1694 ) 1695 _setup_segment(tmpdir, "20260209", "100500_300", "work", []) 1696 _setup_segment(tmpdir, "20260209", "100500_300", "personal", []) 1697 1698 result = pre_process( 1699 {"day": "20260209", "segment": "100500_300", "stream": "default"} 1700 ) 1701 1702 records = result["meta"]["activity_records"] 1703 assert "work" in records 1704 assert "personal" in records 1705 1706 1707class TestPostProcessEvents: 1708 """Tests for post-hook callosum event emission.""" 1709 1710 def test_emits_events_with_llm_description(self, monkeypatch): 1711 from unittest.mock import patch 1712 1713 from solstone.talent.activities import post_process 1714 from solstone.think.activities import append_activity_record 1715 1716 with tempfile.TemporaryDirectory() as tmpdir: 1717 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1718 1719 record = { 1720 "id": "coding_100000_300", 1721 "activity": "coding", 1722 "description": "Preliminary", 1723 "segments": ["100000_300"], 1724 "created_at": 1, 1725 } 1726 append_activity_record("work", "20260209", record) 1727 1728 llm_result = json.dumps( 1729 { 1730 "work": [ 1731 { 1732 "id": "coding_100000_300", 1733 "description": "Full synthesized description", 1734 } 1735 ] 1736 } 1737 ) 1738 1739 meta = { 1740 "activity_records": { 1741 "work": { 1742 "coding_100000_300": { 1743 "activity": "coding", 1744 "segments": ["100000_300"], 1745 "description": "Preliminary", 1746 "level_avg": 1.0, 1747 "active_entities": ["VS Code"], 1748 } 1749 } 1750 } 1751 } 1752 1753 with patch("solstone.talent.activities.callosum_send") as mock_send: 1754 mock_send.return_value = True 1755 post_process( 1756 llm_result, 1757 {"day": "20260209", "segment": "100500_300", "meta": meta}, 1758 ) 1759 1760 mock_send.assert_called_once_with( 1761 "activity", 1762 "recorded", 1763 facet="work", 1764 day="20260209", 1765 segment="100500_300", 1766 id="coding_100000_300", 1767 activity="coding", 1768 segments=["100000_300"], 1769 level_avg=1.0, 1770 description="Full synthesized description", 1771 active_entities=["VS Code"], 1772 ) 1773 1774 def test_falls_back_to_prehook_description_with_warning(self, monkeypatch, caplog): 1775 from unittest.mock import patch 1776 1777 from solstone.talent.activities import post_process 1778 1779 with tempfile.TemporaryDirectory() as tmpdir: 1780 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1781 1782 meta = { 1783 "activity_records": { 1784 "work": { 1785 "coding_100000_300": { 1786 "activity": "coding", 1787 "segments": ["100000_300"], 1788 "description": "Pre-hook fallback desc", 1789 "level_avg": 0.5, 1790 "active_entities": [], 1791 } 1792 } 1793 } 1794 } 1795 1796 # LLM returns empty — no descriptions to update 1797 with patch("solstone.talent.activities.callosum_send") as mock_send: 1798 mock_send.return_value = True 1799 import logging 1800 1801 with caplog.at_level( 1802 logging.WARNING, logger="solstone.talent.activities" 1803 ): 1804 post_process( 1805 "{}", 1806 {"day": "20260209", "segment": "100500_300", "meta": meta}, 1807 ) 1808 1809 mock_send.assert_called_once() 1810 call_kwargs = mock_send.call_args[1] 1811 assert call_kwargs["description"] == "Pre-hook fallback desc" 1812 assert "No LLM description" in caplog.text 1813 1814 def test_no_events_without_meta(self, monkeypatch): 1815 from unittest.mock import patch 1816 1817 from solstone.talent.activities import post_process 1818 1819 with tempfile.TemporaryDirectory() as tmpdir: 1820 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1821 1822 with patch("solstone.talent.activities.callosum_send") as mock_send: 1823 post_process("{}", {"day": "20260209", "segment": "100500_300"}) 1824 mock_send.assert_not_called() 1825 1826 def test_event_emission_failure_does_not_raise(self, monkeypatch): 1827 from unittest.mock import patch 1828 1829 from solstone.talent.activities import post_process 1830 1831 with tempfile.TemporaryDirectory() as tmpdir: 1832 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1833 1834 meta = { 1835 "activity_records": { 1836 "work": { 1837 "coding_100000_300": { 1838 "activity": "coding", 1839 "segments": ["100000_300"], 1840 "description": "desc", 1841 "level_avg": 0.5, 1842 "active_entities": [], 1843 } 1844 } 1845 } 1846 } 1847 1848 with patch("solstone.talent.activities.callosum_send") as mock_send: 1849 mock_send.side_effect = OSError("socket error") 1850 # Should not raise 1851 result = post_process( 1852 "{}", 1853 {"day": "20260209", "segment": "100500_300", "meta": meta}, 1854 ) 1855 assert result is None 1856 1857 1858class TestHandleActivityRecorded: 1859 """Tests for supervisor's _handle_activity_recorded handler.""" 1860 1861 def test_queues_think_task(self): 1862 from unittest.mock import MagicMock, patch 1863 1864 from solstone.think.supervisor import _handle_activity_recorded 1865 1866 mock_queue = MagicMock() 1867 with patch("solstone.think.supervisor._task_queue", mock_queue): 1868 _handle_activity_recorded( 1869 { 1870 "tract": "activity", 1871 "event": "recorded", 1872 "id": "coding_100000_300", 1873 "facet": "work", 1874 "day": "20260209", 1875 } 1876 ) 1877 1878 mock_queue.submit.assert_called_once_with( 1879 [ 1880 "journal", 1881 "think", 1882 "--activity", 1883 "coding_100000_300", 1884 "--facet", 1885 "work", 1886 "--day", 1887 "20260209", 1888 ], 1889 day="20260209", 1890 ) 1891 1892 def test_ignores_wrong_tract(self): 1893 from unittest.mock import MagicMock, patch 1894 1895 from solstone.think.supervisor import _handle_activity_recorded 1896 1897 mock_queue = MagicMock() 1898 with patch("solstone.think.supervisor._task_queue", mock_queue): 1899 _handle_activity_recorded( 1900 { 1901 "tract": "think", 1902 "event": "recorded", 1903 "id": "x", 1904 "facet": "w", 1905 "day": "d", 1906 } 1907 ) 1908 mock_queue.submit.assert_not_called() 1909 1910 def test_ignores_wrong_event(self): 1911 from unittest.mock import MagicMock, patch 1912 1913 from solstone.think.supervisor import _handle_activity_recorded 1914 1915 mock_queue = MagicMock() 1916 with patch("solstone.think.supervisor._task_queue", mock_queue): 1917 _handle_activity_recorded( 1918 { 1919 "tract": "activity", 1920 "event": "other", 1921 "id": "x", 1922 "facet": "w", 1923 "day": "d", 1924 } 1925 ) 1926 mock_queue.submit.assert_not_called() 1927 1928 def test_warns_on_missing_fields(self, caplog): 1929 from unittest.mock import MagicMock, patch 1930 1931 from solstone.think.supervisor import _handle_activity_recorded 1932 1933 mock_queue = MagicMock() 1934 import logging 1935 1936 with patch("solstone.think.supervisor._task_queue", mock_queue): 1937 with caplog.at_level(logging.WARNING): 1938 _handle_activity_recorded( 1939 {"tract": "activity", "event": "recorded", "id": "x"} 1940 ) 1941 mock_queue.submit.assert_not_called() 1942 1943 def test_warns_when_no_task_queue(self, caplog): 1944 import logging 1945 from unittest.mock import patch 1946 1947 from solstone.think.supervisor import _handle_activity_recorded 1948 1949 with patch("solstone.think.supervisor._task_queue", None): 1950 with caplog.at_level(logging.WARNING): 1951 _handle_activity_recorded( 1952 { 1953 "tract": "activity", 1954 "event": "recorded", 1955 "id": "coding_100000_300", 1956 "facet": "work", 1957 "day": "20260209", 1958 } 1959 ) 1960 assert "No task queue" in caplog.text 1961 1962 1963# --------------------------------------------------------------------------- 1964# Flush Mode Tests 1965# --------------------------------------------------------------------------- 1966 1967 1968class TestPreProcessFlush: 1969 """Tests for the activities pre_process hook in flush mode.""" 1970 1971 def test_flush_ends_all_active_activities(self, monkeypatch): 1972 from solstone.talent.activities import pre_process 1973 from solstone.think.activities import load_activity_records 1974 1975 with tempfile.TemporaryDirectory() as tmpdir: 1976 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 1977 1978 # Set up a segment with an active activity (no following segment) 1979 _setup_segment( 1980 tmpdir, 1981 "20260209", 1982 "100000_300", 1983 "work", 1984 [ 1985 { 1986 "activity": "coding", 1987 "state": "active", 1988 "since": "100000_300", 1989 "description": "Working on feature", 1990 "level": "high", 1991 "active_entities": ["VS Code"], 1992 } 1993 ], 1994 ) 1995 1996 result = pre_process( 1997 { 1998 "day": "20260209", 1999 "segment": "100000_300", 2000 "stream": "default", 2001 "flush": True, 2002 } 2003 ) 2004 2005 # Should detect the active activity as ended 2006 assert "skip_reason" not in result 2007 assert "transcript" in result 2008 assert "coding_100000_300" in result["transcript"] 2009 2010 # Record should be written 2011 records = load_activity_records("work", "20260209") 2012 assert len(records) == 1 2013 assert records[0]["id"] == "coding_100000_300" 2014 assert records[0]["segments"] == ["100000_300"] 2015 2016 def test_flush_skips_when_no_active_activities(self, monkeypatch): 2017 from solstone.talent.activities import pre_process 2018 2019 with tempfile.TemporaryDirectory() as tmpdir: 2020 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 2021 2022 # Set up a segment with only ended activities 2023 _setup_segment( 2024 tmpdir, 2025 "20260209", 2026 "100000_300", 2027 "work", 2028 [ 2029 { 2030 "activity": "coding", 2031 "state": "ended", 2032 "since": "090000_300", 2033 } 2034 ], 2035 ) 2036 2037 result = pre_process( 2038 { 2039 "day": "20260209", 2040 "segment": "100000_300", 2041 "stream": "default", 2042 "flush": True, 2043 } 2044 ) 2045 assert result["skip_reason"] == "no_active_activities" 2046 2047 def test_flush_skips_when_no_activity_state(self, monkeypatch): 2048 from solstone.talent.activities import pre_process 2049 2050 with tempfile.TemporaryDirectory() as tmpdir: 2051 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 2052 2053 # Create segment dir but no activity_state files 2054 seg_dir = Path(tmpdir) / "chronicle" / "20260209" / "default" / "100000_300" 2055 seg_dir.mkdir(parents=True) 2056 2057 result = pre_process( 2058 { 2059 "day": "20260209", 2060 "segment": "100000_300", 2061 "stream": "default", 2062 "flush": True, 2063 } 2064 ) 2065 assert result["skip_reason"] == "no_activity_state" 2066 2067 def test_flush_handles_multiple_facets(self, monkeypatch): 2068 from solstone.talent.activities import pre_process 2069 from solstone.think.activities import load_activity_records 2070 2071 with tempfile.TemporaryDirectory() as tmpdir: 2072 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 2073 2074 _setup_segment( 2075 tmpdir, 2076 "20260209", 2077 "100000_300", 2078 "work", 2079 [ 2080 { 2081 "activity": "coding", 2082 "state": "active", 2083 "since": "100000_300", 2084 "description": "Work coding", 2085 "level": "high", 2086 } 2087 ], 2088 ) 2089 _setup_segment( 2090 tmpdir, 2091 "20260209", 2092 "100000_300", 2093 "personal", 2094 [ 2095 { 2096 "activity": "browsing", 2097 "state": "active", 2098 "since": "100000_300", 2099 "description": "Personal browsing", 2100 "level": "low", 2101 } 2102 ], 2103 ) 2104 2105 result = pre_process( 2106 { 2107 "day": "20260209", 2108 "segment": "100000_300", 2109 "stream": "default", 2110 "flush": True, 2111 } 2112 ) 2113 2114 assert "skip_reason" not in result 2115 assert "transcript" in result 2116 2117 work_records = load_activity_records("work", "20260209") 2118 personal_records = load_activity_records("personal", "20260209") 2119 assert len(work_records) == 1 2120 assert len(personal_records) == 1 2121 2122 def test_flush_is_idempotent(self, monkeypatch): 2123 from solstone.talent.activities import pre_process 2124 from solstone.think.activities import load_activity_records 2125 2126 with tempfile.TemporaryDirectory() as tmpdir: 2127 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 2128 2129 _setup_segment( 2130 tmpdir, 2131 "20260209", 2132 "100000_300", 2133 "work", 2134 [ 2135 { 2136 "activity": "coding", 2137 "state": "active", 2138 "since": "100000_300", 2139 "description": "Coding", 2140 "level": "high", 2141 } 2142 ], 2143 ) 2144 2145 context = { 2146 "day": "20260209", 2147 "segment": "100000_300", 2148 "stream": "default", 2149 "flush": True, 2150 } 2151 pre_process(context) 2152 pre_process(context) 2153 2154 records = load_activity_records("work", "20260209") 2155 assert len(records) == 1 2156 2157 def test_flush_stashes_meta_for_post_hook(self, monkeypatch): 2158 from solstone.talent.activities import pre_process 2159 2160 with tempfile.TemporaryDirectory() as tmpdir: 2161 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 2162 2163 _setup_segment( 2164 tmpdir, 2165 "20260209", 2166 "100000_300", 2167 "work", 2168 [ 2169 { 2170 "activity": "coding", 2171 "state": "active", 2172 "since": "100000_300", 2173 "description": "Coding", 2174 "level": "high", 2175 } 2176 ], 2177 ) 2178 2179 result = pre_process( 2180 { 2181 "day": "20260209", 2182 "segment": "100000_300", 2183 "stream": "default", 2184 "flush": True, 2185 } 2186 ) 2187 2188 assert "meta" in result 2189 records = result["meta"]["activity_records"] 2190 assert "work" in records 2191 assert "coding_100000_300" in records["work"] 2192 2193 2194class TestCheckSegmentFlush: 2195 """Tests for supervisor's _check_segment_flush.""" 2196 2197 def test_queues_flush_after_timeout(self): 2198 import time as time_mod 2199 from unittest.mock import MagicMock, patch 2200 2201 from solstone.think.supervisor import _check_segment_flush, _flush_state 2202 2203 # Set up state as if a segment arrived over an hour ago 2204 _flush_state["last_segment_ts"] = time_mod.time() - 4000 2205 _flush_state["day"] = "20260209" 2206 _flush_state["segment"] = "100000_300" 2207 _flush_state["flushed"] = False 2208 2209 mock_queue = MagicMock() 2210 with ( 2211 patch("solstone.think.supervisor._task_queue", mock_queue), 2212 patch("solstone.think.supervisor._is_remote_mode", False), 2213 ): 2214 _check_segment_flush() 2215 2216 mock_queue.submit.assert_called_once_with( 2217 [ 2218 "journal", 2219 "think", 2220 "-v", 2221 "--day", 2222 "20260209", 2223 "--segment", 2224 "100000_300", 2225 "--flush", 2226 ], 2227 day="20260209", 2228 ) 2229 assert _flush_state["flushed"] is True 2230 2231 def test_does_not_flush_before_timeout(self): 2232 import time as time_mod 2233 from unittest.mock import MagicMock, patch 2234 2235 from solstone.think.supervisor import _check_segment_flush, _flush_state 2236 2237 _flush_state["last_segment_ts"] = time_mod.time() - 100 # Only 100s ago 2238 _flush_state["day"] = "20260209" 2239 _flush_state["segment"] = "100000_300" 2240 _flush_state["flushed"] = False 2241 2242 mock_queue = MagicMock() 2243 with ( 2244 patch("solstone.think.supervisor._task_queue", mock_queue), 2245 patch("solstone.think.supervisor._is_remote_mode", False), 2246 ): 2247 _check_segment_flush() 2248 2249 mock_queue.submit.assert_not_called() 2250 assert _flush_state["flushed"] is False 2251 2252 def test_does_not_flush_twice(self): 2253 import time as time_mod 2254 from unittest.mock import MagicMock, patch 2255 2256 from solstone.think.supervisor import _check_segment_flush, _flush_state 2257 2258 _flush_state["last_segment_ts"] = time_mod.time() - 4000 2259 _flush_state["day"] = "20260209" 2260 _flush_state["segment"] = "100000_300" 2261 _flush_state["flushed"] = True # Already flushed 2262 2263 mock_queue = MagicMock() 2264 with ( 2265 patch("solstone.think.supervisor._task_queue", mock_queue), 2266 patch("solstone.think.supervisor._is_remote_mode", False), 2267 ): 2268 _check_segment_flush() 2269 2270 mock_queue.submit.assert_not_called() 2271 2272 def test_skips_in_remote_mode(self): 2273 import time as time_mod 2274 from unittest.mock import MagicMock, patch 2275 2276 from solstone.think.supervisor import _check_segment_flush, _flush_state 2277 2278 _flush_state["last_segment_ts"] = time_mod.time() - 4000 2279 _flush_state["day"] = "20260209" 2280 _flush_state["segment"] = "100000_300" 2281 _flush_state["flushed"] = False 2282 2283 mock_queue = MagicMock() 2284 with ( 2285 patch("solstone.think.supervisor._task_queue", mock_queue), 2286 patch("solstone.think.supervisor._is_remote_mode", True), 2287 ): 2288 _check_segment_flush() 2289 2290 mock_queue.submit.assert_not_called() 2291 2292 def test_force_flushes_before_timeout(self): 2293 import time as time_mod 2294 from unittest.mock import MagicMock, patch 2295 2296 from solstone.think.supervisor import _check_segment_flush, _flush_state 2297 2298 # Only 100s ago — would NOT flush normally 2299 _flush_state["last_segment_ts"] = time_mod.time() - 100 2300 _flush_state["day"] = "20260209" 2301 _flush_state["segment"] = "100000_300" 2302 _flush_state["flushed"] = False 2303 2304 mock_queue = MagicMock() 2305 with ( 2306 patch("solstone.think.supervisor._task_queue", mock_queue), 2307 patch("solstone.think.supervisor._is_remote_mode", False), 2308 ): 2309 _check_segment_flush(force=True) 2310 2311 mock_queue.submit.assert_called_once() 2312 assert _flush_state["flushed"] is True 2313 2314 def test_segment_observed_resets_flush_state(self): 2315 from solstone.think.supervisor import _flush_state, _handle_segment_observed 2316 2317 _flush_state["flushed"] = True 2318 _flush_state["last_segment_ts"] = 0 2319 2320 # We need to mock the thread start to avoid side effects 2321 from unittest.mock import patch 2322 2323 with patch("solstone.think.supervisor.threading"): 2324 _handle_segment_observed( 2325 { 2326 "tract": "observe", 2327 "event": "observed", 2328 "day": "20260209", 2329 "segment": "110000_300", 2330 } 2331 ) 2332 2333 assert _flush_state["flushed"] is False 2334 assert _flush_state["day"] == "20260209" 2335 assert _flush_state["segment"] == "110000_300" 2336 assert _flush_state["last_segment_ts"] > 0 2337 2338 2339def _seed_activity_records( 2340 tmpdir: str, facet: str, day: str, records: list[dict] 2341) -> None: 2342 from solstone.think.activities import append_activity_record 2343 2344 for record in records: 2345 append_activity_record(facet, day, record) 2346 2347 2348def test_make_anticipation_id_builds_stable_id(): 2349 from solstone.think.activities import make_anticipation_id 2350 2351 assert make_anticipation_id("meeting", "16:30:00", "2026-04-20") == ( 2352 "anticipated_meeting_163000_0420" 2353 ) 2354 assert make_anticipation_id("deadline", None, "2026-05-05") == ( 2355 "anticipated_deadline_000000_0505" 2356 ) 2357 2358 2359@pytest.mark.parametrize( 2360 ("activity_type", "start", "target_date"), 2361 [ 2362 ("meeting", "9:00", "2026-04-20"), 2363 ("meeting", "09:00:00", "2026/04/20"), 2364 ("", "09:00:00", "2026-04-20"), 2365 ], 2366) 2367def test_make_anticipation_id_rejects_malformed_inputs( 2368 activity_type, 2369 start, 2370 target_date, 2371): 2372 from solstone.think.activities import make_anticipation_id 2373 2374 with pytest.raises(ValueError): 2375 make_anticipation_id(activity_type, start, target_date) 2376 2377 2378def test_dedup_anticipation_returns_empty_for_first_record(monkeypatch): 2379 from solstone.think.activities import dedup_anticipation 2380 2381 with tempfile.TemporaryDirectory() as tmpdir: 2382 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 2383 2384 should_write, superseded_ids = dedup_anticipation( 2385 "work", 2386 "20260420", 2387 {"id": "anticipated_meeting_163000_0420", "title": "Yuri intro"}, 2388 ) 2389 2390 assert should_write is True 2391 assert superseded_ids == [] 2392 2393 2394def test_dedup_anticipation_rejects_exact_id_collision(monkeypatch): 2395 from solstone.think.activities import dedup_anticipation 2396 2397 with tempfile.TemporaryDirectory() as tmpdir: 2398 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 2399 _seed_activity_records( 2400 tmpdir, 2401 "work", 2402 "20260420", 2403 [ 2404 { 2405 "id": "anticipated_meeting_163000_0420", 2406 "activity": "meeting", 2407 "title": "Yuri intro", 2408 "description": "Original", 2409 "source": "anticipated", 2410 } 2411 ], 2412 ) 2413 2414 should_write, superseded_ids = dedup_anticipation( 2415 "work", 2416 "20260420", 2417 {"id": "anticipated_meeting_163000_0420", "title": "Yuri intro"}, 2418 ) 2419 2420 assert should_write is False 2421 assert superseded_ids == [] 2422 2423 2424def test_dedup_anticipation_returns_fuzzy_supersede_matches(monkeypatch): 2425 from solstone.think.activities import dedup_anticipation 2426 2427 with tempfile.TemporaryDirectory() as tmpdir: 2428 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 2429 _seed_activity_records( 2430 tmpdir, 2431 "work", 2432 "20260420", 2433 [ 2434 { 2435 "id": "anticipated_meeting_160000_0420", 2436 "activity": "meeting", 2437 "title": "Yuri Namikawa intro call", 2438 "description": "Original", 2439 "source": "anticipated", 2440 } 2441 ], 2442 ) 2443 2444 should_write, superseded_ids = dedup_anticipation( 2445 "work", 2446 "20260420", 2447 { 2448 "id": "anticipated_meeting_163000_0420", 2449 "title": "Yuri Namikawa intro call", 2450 }, 2451 ) 2452 2453 assert should_write is True 2454 assert superseded_ids == ["anticipated_meeting_160000_0420"] 2455 2456 2457def test_dedup_anticipation_ignores_below_threshold_and_hidden_rows(monkeypatch): 2458 from solstone.think.activities import dedup_anticipation 2459 2460 with tempfile.TemporaryDirectory() as tmpdir: 2461 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 2462 _seed_activity_records( 2463 tmpdir, 2464 "work", 2465 "20260420", 2466 [ 2467 { 2468 "id": "anticipated_meeting_090000_0420", 2469 "activity": "meeting", 2470 "title": "Quarterly planning summit", 2471 "description": "Visible", 2472 "source": "anticipated", 2473 }, 2474 { 2475 "id": "anticipated_meeting_100000_0420", 2476 "activity": "meeting", 2477 "title": "Yuri Namikawa intro call", 2478 "description": "Hidden", 2479 "source": "anticipated", 2480 "hidden": True, 2481 }, 2482 ], 2483 ) 2484 2485 should_write, superseded_ids = dedup_anticipation( 2486 "work", 2487 "20260420", 2488 { 2489 "id": "anticipated_meeting_163000_0420", 2490 "title": "Scott Ward standup", 2491 }, 2492 ) 2493 2494 assert should_write is True 2495 assert superseded_ids == [] 2496 2497 2498def test_dedup_anticipation_returns_all_matching_supersedes(monkeypatch): 2499 from solstone.think.activities import dedup_anticipation 2500 2501 with tempfile.TemporaryDirectory() as tmpdir: 2502 monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 2503 _seed_activity_records( 2504 tmpdir, 2505 "work", 2506 "20260420", 2507 [ 2508 { 2509 "id": "anticipated_call_090000_0420", 2510 "activity": "call", 2511 "title": "Mari Zumbro intro", 2512 "description": "Old 1", 2513 "source": "anticipated", 2514 }, 2515 { 2516 "id": "anticipated_call_093000_0420", 2517 "activity": "call", 2518 "title": "Mari Zumbro intro", 2519 "description": "Old 2", 2520 "source": "anticipated", 2521 }, 2522 { 2523 "id": "cogitate_call_100000_300", 2524 "activity": "call", 2525 "title": "Mari Zumbro intro", 2526 "description": "Non-anticipated", 2527 "source": "cogitate", 2528 }, 2529 ], 2530 ) 2531 2532 should_write, superseded_ids = dedup_anticipation( 2533 "work", 2534 "20260420", 2535 { 2536 "id": "anticipated_call_103000_0420", 2537 "title": "Mari Zumbro intro", 2538 }, 2539 ) 2540 2541 assert should_write is True 2542 assert superseded_ids == [ 2543 "anticipated_call_090000_0420", 2544 "anticipated_call_093000_0420", 2545 ]