personal memory agent
0

Configure Feed

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

solstone / tests / test_activity_state_machine.py
30 kB 749 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""Tests for the deterministic activity state machine.""" 5 6 7def _sense( 8 content_type="coding", 9 density="active", 10 facets=None, 11 summary="Working on code.", 12 facet_activity="Reviewing the open diff.", 13 entities=None, 14 meeting=False, 15 speakers=None, 16): 17 """Build a Sense output payload for testing.""" 18 if facets is None: 19 facets = [{"facet": "work", "activity": facet_activity, "level": "high"}] 20 return { 21 "density": density, 22 "content_type": content_type, 23 "activity_summary": summary, 24 "entities": entities or [], 25 "facets": facets, 26 "meeting_detected": meeting, 27 "speakers": speakers or [], 28 "recommend": {}, 29 } 30 31 32class TestNewActivity: 33 def test_first_segment_starts_new(self): 34 from solstone.think.activity_state_machine import ActivityStateMachine 35 36 sm = ActivityStateMachine() 37 changes = sm.update(_sense(), "090000_300", "20260304") 38 39 assert len(changes) == 1 40 assert changes[0]["_change"] == "new" 41 assert changes[0]["activity"] == "coding" 42 assert changes[0]["state"] == "active" 43 assert changes[0]["since"] == "090000_300" 44 assert changes[0]["facet"] == "work" 45 46 47class TestContinuation: 48 def test_same_type_continues(self): 49 from solstone.think.activity_state_machine import ActivityStateMachine 50 51 sm = ActivityStateMachine() 52 sm.update(_sense(), "090000_300", "20260304") 53 changes = sm.update( 54 _sense(facet_activity="Still reviewing the diff."), 55 "090500_300", 56 "20260304", 57 ) 58 59 assert len(changes) == 1 60 assert changes[0]["_change"] == "continuing" 61 assert changes[0]["since"] == "090000_300" 62 assert changes[0]["description"] == "Still reviewing the diff." 63 64 65class TestContentTypeChange: 66 def test_type_change_ends_old_starts_new(self): 67 from solstone.think.activity_state_machine import ActivityStateMachine 68 69 sm = ActivityStateMachine() 70 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 71 pending = sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 72 changes = sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 73 74 assert all(c["state"] != "ended" for c in pending) 75 assert pending[0]["_change"] == "type_change_pending" 76 assert len(changes) == 2 77 ended = [c for c in changes if c["state"] == "ended"] 78 started = [c for c in changes if c["state"] == "active"] 79 assert len(ended) == 1 80 assert ended[0]["_change"] == "ended_type_change" 81 assert ended[0]["activity"] == "coding" 82 assert len(started) == 1 83 assert started[0]["_change"] == "new" 84 assert started[0]["activity"] == "meeting" 85 86 87class TestIdleTransition: 88 def test_idle_ends_all(self): 89 from solstone.think.activity_state_machine import ActivityStateMachine 90 91 sm = ActivityStateMachine() 92 sm.update(_sense(), "090000_300", "20260304") 93 changes = sm.update(_sense(density="idle"), "090500_300", "20260304") 94 95 ended = [c for c in changes if c["state"] == "ended"] 96 assert len(ended) == 1 97 assert ended[0]["_change"] == "ended_idle" 98 assert sm.state == {} 99 100 101class TestTimeGap: 102 def test_gap_over_600s_resets(self): 103 from solstone.think.activity_state_machine import ActivityStateMachine 104 105 sm = ActivityStateMachine() 106 sm.update(_sense(), "090000_300", "20260304") 107 changes = sm.update(_sense(), "100600_300", "20260304") 108 109 ended_gap = [c for c in changes if c["_change"] == "ended_gap"] 110 assert len(ended_gap) == 1 111 new = [c for c in changes if c["_change"] == "new"] 112 assert len(new) == 1 113 114 def test_gap_equal_600s_no_reset(self): 115 from solstone.think.activity_state_machine import ActivityStateMachine 116 117 sm = ActivityStateMachine() 118 sm.update(_sense(), "090000_300", "20260304") 119 changes = sm.update(_sense(), "091500_300", "20260304") 120 121 assert all(c["_change"] != "ended_gap" for c in changes) 122 123 124class TestDayBoundary: 125 def test_day_change_resets(self): 126 from solstone.think.activity_state_machine import ActivityStateMachine 127 128 sm = ActivityStateMachine() 129 sm.update(_sense(), "230000_300", "20260304") 130 changes = sm.update(_sense(), "000000_300", "20260305") 131 132 ended_gap = [c for c in changes if c["_change"] == "ended_gap"] 133 assert len(ended_gap) == 1 134 135 136class TestMultiFacet: 137 def test_independent_facet_tracking(self): 138 from solstone.think.activity_state_machine import ActivityStateMachine 139 140 sm = ActivityStateMachine() 141 facets = [ 142 {"facet": "work", "activity": "coding", "level": "high"}, 143 {"facet": "personal", "activity": "browsing", "level": "medium"}, 144 ] 145 changes = sm.update(_sense(facets=facets), "090000_300", "20260304") 146 147 assert len(changes) == 2 148 facet_names = {c["facet"] for c in changes} 149 assert facet_names == {"work", "personal"} 150 151 152class TestLevelGate: 153 def test_explicit_low_facets_are_excluded_from_active_set(self): 154 from solstone.think.activity_state_machine import ActivityStateMachine 155 156 sm = ActivityStateMachine() 157 facets = [ 158 {"facet": "solpbc", "activity": "Planning launch work.", "level": "high"}, 159 { 160 "facet": "personal", 161 "activity": "Coordinating a personal errand.", 162 "level": "medium", 163 }, 164 { 165 "facet": "bluesky", 166 "activity": "Checking a social thread.", 167 "level": "low", 168 }, 169 { 170 "facet": "kognova", 171 "activity": "Skimming background notes.", 172 "level": "low", 173 }, 174 ] 175 176 changes = sm.update( 177 _sense(facets=facets, summary="Mixed activity summary."), 178 "090000_300", 179 "20260304", 180 ) 181 182 assert {change["facet"] for change in changes} == {"solpbc", "personal"} 183 assert set(sm.state) == {"solpbc", "personal"} 184 assert not any( 185 change.get("facet") in {"bluesky", "kognova"} for change in changes 186 ) 187 188 def test_description_uses_each_engaged_facets_activity(self): 189 from solstone.think.activity_state_machine import ActivityStateMachine 190 191 sm = ActivityStateMachine() 192 summary = "Segment-level summary for multiple domains." 193 facets = [ 194 { 195 "facet": "solpbc", 196 "activity": "Reviewing release blockers.", 197 "level": "high", 198 }, 199 { 200 "facet": "personal", 201 "activity": "Sorting out afternoon plans.", 202 "level": "medium", 203 }, 204 ] 205 206 changes = sm.update( 207 _sense(facets=facets, summary=summary), 208 "090000_300", 209 "20260304", 210 ) 211 212 by_facet = {change["facet"]: change for change in changes} 213 assert by_facet["solpbc"]["description"] == "Reviewing release blockers." 214 assert by_facet["personal"]["description"] == "Sorting out afternoon plans." 215 assert all(change["description"] != summary for change in changes) 216 217 def test_description_falls_back_to_summary_when_facet_activity_missing(self): 218 from solstone.think.activity_state_machine import ActivityStateMachine 219 220 sm = ActivityStateMachine() 221 summary = "Segment summary fills missing facet activity." 222 facets = [ 223 {"facet": "work", "activity": "", "level": "high"}, 224 {"facet": "personal", "level": "medium"}, 225 ] 226 227 changes = sm.update( 228 _sense(facets=facets, summary=summary), 229 "090000_300", 230 "20260304", 231 ) 232 233 assert {change["facet"] for change in changes} == {"work", "personal"} 234 assert all(change["description"] == summary for change in changes) 235 assert all(change["description"] for change in changes) 236 237 def test_one_low_segment_does_not_end_prior_high_facet(self): 238 from solstone.think.activity_state_machine import ActivityStateMachine 239 240 sm = ActivityStateMachine() 241 high = [{"facet": "work", "activity": "Writing the parser.", "level": "high"}] 242 low = [{"facet": "work", "activity": "Barely checking parser.", "level": "low"}] 243 244 sm.update(_sense(facets=high), "090000_300", "20260304") 245 pending = sm.update(_sense(facets=low), "090500_300", "20260304") 246 247 assert len(pending) == 1 248 assert pending[0]["_change"] == "facet_gone_pending" 249 assert pending[0]["facet"] == "work" 250 assert sm.state["work"]["_pending_facet_misses"] == 1 251 assert not any( 252 change.get("_change") == "ended_facet_gone" for change in pending 253 ) 254 255 returned = sm.update(_sense(facets=high), "091000_300", "20260304") 256 257 assert len(returned) == 1 258 assert returned[0]["_change"] == "continuing" 259 assert returned[0]["since"] == "090000_300" 260 assert sm.state["work"]["_pending_facet_misses"] == 0 261 assert sm.get_completed_activities() == [] 262 263 def test_two_low_segments_end_prior_high_facet(self): 264 from solstone.think.activity_state_machine import ActivityStateMachine 265 266 sm = ActivityStateMachine() 267 high = [{"facet": "work", "activity": "Writing the parser.", "level": "high"}] 268 low = [{"facet": "work", "activity": "Barely checking parser.", "level": "low"}] 269 270 sm.update(_sense(facets=high), "090000_300", "20260304") 271 sm.update(_sense(facets=low), "090500_300", "20260304") 272 changes = sm.update(_sense(facets=low), "091000_300", "20260304") 273 274 ended = [ 275 change for change in changes if change.get("_change") == "ended_facet_gone" 276 ] 277 assert len(ended) == 1 278 assert ended[0]["state"] == "ended" 279 assert ended[0]["facet"] == "work" 280 assert sm.state == {} 281 assert len(sm.get_completed_activities()) == 1 282 283 def test_all_low_fresh_segment_creates_no_activity(self): 284 from solstone.think.activity_state_machine import ActivityStateMachine 285 286 sm = ActivityStateMachine() 287 facets = [ 288 {"facet": "work", "activity": "Light inbox scan.", "level": "low"}, 289 {"facet": "personal", "activity": "Background reading.", "level": "low"}, 290 ] 291 292 changes = sm.update( 293 _sense(facets=facets, summary="Low-attention activity."), 294 "090000_300", 295 "20260304", 296 ) 297 298 assert changes == [] 299 assert sm.state == {} 300 assert sm.get_completed_activities() == [] 301 302 def test_all_low_segments_wind_down_prior_active_facet(self): 303 from solstone.think.activity_state_machine import ActivityStateMachine 304 305 sm = ActivityStateMachine() 306 high = [ 307 {"facet": "work", "activity": "Implementing the parser.", "level": "high"} 308 ] 309 all_low = [ 310 {"facet": "work", "activity": "Glancing at parser logs.", "level": "low"}, 311 { 312 "facet": "personal", 313 "activity": "Skimming personal email.", 314 "level": "low", 315 }, 316 ] 317 318 sm.update(_sense(facets=high), "090000_300", "20260304") 319 pending = sm.update(_sense(facets=all_low), "090500_300", "20260304") 320 ended_changes = sm.update(_sense(facets=all_low), "091000_300", "20260304") 321 322 assert len(pending) == 1 323 assert pending[0]["_change"] == "facet_gone_pending" 324 assert pending[0]["_pending_facet_misses"] == 1 325 ended = [ 326 change 327 for change in ended_changes 328 if change.get("_change") == "ended_facet_gone" 329 ] 330 assert len(ended) == 1 331 assert ended[0]["facet"] == "work" 332 assert sm.state == {} 333 334 335class TestFacetDisappearing: 336 def test_facet_gone_emits_ended(self): 337 from solstone.think.activity_state_machine import ActivityStateMachine 338 339 sm = ActivityStateMachine() 340 two_facets = [ 341 {"facet": "work", "activity": "coding", "level": "high"}, 342 {"facet": "personal", "activity": "browsing", "level": "medium"}, 343 ] 344 sm.update(_sense(facets=two_facets), "090000_300", "20260304") 345 one_facet = [{"facet": "work", "activity": "coding", "level": "high"}] 346 pending = sm.update(_sense(facets=one_facet), "090500_300", "20260304") 347 changes = sm.update(_sense(facets=one_facet), "091000_300", "20260304") 348 349 assert pending[0]["_change"] == "facet_gone_pending" 350 ended = [c for c in changes if c["_change"] == "ended_facet_gone"] 351 assert len(ended) == 1 352 assert ended[0]["facet"] == "personal" 353 354 355class TestStateShape: 356 def test_active_entries_use_persisted_field_names(self): 357 from solstone.think.activity_state_machine import ActivityStateMachine 358 359 sm = ActivityStateMachine() 360 sm.update(_sense(), "090000_300", "20260304") 361 362 assert set(sm.state) == {"work"} 363 entry = sm.state["work"] 364 assert "id" in entry 365 assert "activity" in entry 366 assert "state" in entry and entry["state"] == "active" 367 assert "since" in entry 368 assert "level" in entry 369 assert "active_entities" in entry 370 assert entry["facet"] == "work" 371 assert entry["segment"] == "090000_300" 372 assert entry["segments"] == ["090000_300"] 373 assert entry["_change"] == "new" 374 assert "_facet" not in entry 375 assert "_segment" not in entry 376 assert "_segments" not in entry 377 378 379class TestGetCompletedActivities: 380 def test_completed_format(self): 381 from solstone.think.activity_state_machine import ActivityStateMachine 382 383 sm = ActivityStateMachine() 384 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 385 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 386 sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 387 completed = sm.get_completed_activities() 388 389 assert len(completed) == 1 390 rec = completed[0] 391 assert "id" in rec 392 assert "activity" in rec and rec["activity"] == "coding" 393 assert "segments" in rec and isinstance(rec["segments"], list) 394 assert rec["segments"] == ["090000_300", "090500_300"] 395 assert "level_avg" in rec and isinstance(rec["level_avg"], float) 396 assert "description" in rec 397 assert "active_entities" in rec 398 assert "created_at" in rec 399 assert isinstance(rec["created_at"], int) 400 401 402class TestSegmentAccumulation: 403 def test_continuing_accumulates_segments(self): 404 from solstone.think.activity_state_machine import ActivityStateMachine 405 406 sm = ActivityStateMachine() 407 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 408 sm.update(_sense(content_type="coding"), "090500_300", "20260304") 409 sm.update(_sense(content_type="coding"), "091000_300", "20260304") 410 # End by type change 411 sm.update(_sense(content_type="meeting"), "091500_300", "20260304") 412 sm.update(_sense(content_type="meeting"), "092000_300", "20260304") 413 414 completed = sm.get_completed_activities() 415 assert len(completed) == 1 416 rec = completed[0] 417 assert rec["segments"] == [ 418 "090000_300", 419 "090500_300", 420 "091000_300", 421 "091500_300", 422 ] 423 424 def test_ten_segments_produces_ten_keys(self): 425 from solstone.think.activity_state_machine import ActivityStateMachine 426 427 sm = ActivityStateMachine() 428 for i in range(10): 429 minutes = i * 5 430 seg = f"09{minutes:02d}00_300" 431 sm.update(_sense(content_type="coding"), seg, "20260304") 432 # End with idle 433 sm.update(_sense(density="idle"), "095000_300", "20260304") 434 435 completed = sm.get_completed_activities() 436 assert len(completed) == 1 437 assert len(completed[0]["segments"]) == 10 438 439 def test_segments_accumulate_in_state(self): 440 from solstone.think.activity_state_machine import ActivityStateMachine 441 442 sm = ActivityStateMachine() 443 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 444 sm.update(_sense(content_type="coding"), "090500_300", "20260304") 445 446 assert sm.state["work"]["segments"] == ["090000_300", "090500_300"] 447 448 449class TestEntityTracking: 450 def test_extracts_names(self): 451 from solstone.think.activity_state_machine import ActivityStateMachine 452 453 sm = ActivityStateMachine() 454 entities = [ 455 {"type": "Person", "name": "Alice", "context": "colleague"}, 456 {"type": "Tool", "name": "VSCode", "context": "editor"}, 457 ] 458 changes = sm.update(_sense(entities=entities), "090000_300", "20260304") 459 460 assert changes[0]["active_entities"] == ["Alice", "VSCode"] 461 462 def test_skips_blank_names(self): 463 from solstone.think.activity_state_machine import ActivityStateMachine 464 465 sm = ActivityStateMachine() 466 entities = [ 467 {"type": "Person", "name": "", "context": "unknown"}, 468 {"type": "Tool", "context": "no name key"}, 469 ] 470 changes = sm.update(_sense(entities=entities), "090000_300", "20260304") 471 472 assert changes[0]["active_entities"] == [] 473 474 475class TestSegmentAccumulationEdgeCases: 476 """Regression tests for segment accumulation across all ending paths.""" 477 478 def test_idle_ending_preserves_accumulated_segments(self): 479 """Idle transition after multi-segment activity must carry all segments.""" 480 from solstone.think.activity_state_machine import ActivityStateMachine 481 482 sm = ActivityStateMachine() 483 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 484 sm.update(_sense(content_type="coding"), "090500_300", "20260304") 485 sm.update(_sense(content_type="coding"), "091000_300", "20260304") 486 # End via idle 487 sm.update(_sense(density="idle"), "091500_300", "20260304") 488 489 completed = sm.get_completed_activities() 490 assert len(completed) == 1 491 assert completed[0]["segments"] == [ 492 "090000_300", 493 "090500_300", 494 "091000_300", 495 ] 496 497 def test_facet_gone_preserves_accumulated_segments(self): 498 """Facet disappearing after multi-segment activity must carry all segments. 499 500 Note: the state machine uses the top-level content_type for all facets, 501 not the per-facet activity field. So both facets have activity=="coding". 502 """ 503 from solstone.think.activity_state_machine import ActivityStateMachine 504 505 sm = ActivityStateMachine() 506 two = [ 507 {"facet": "work", "activity": "coding", "level": "high"}, 508 {"facet": "personal", "activity": "browsing", "level": "medium"}, 509 ] 510 one = [{"facet": "work", "activity": "coding", "level": "high"}] 511 sm.update(_sense(facets=two), "090000_300", "20260304") 512 sm.update(_sense(facets=two), "090500_300", "20260304") 513 sm.update(_sense(facets=two), "091000_300", "20260304") 514 # personal disappears 515 sm.update(_sense(facets=one), "091500_300", "20260304") 516 sm.update(_sense(facets=one), "092000_300", "20260304") 517 518 completed = sm.get_completed_activities() 519 assert len(completed) == 1 520 # Activity type comes from top-level content_type, not per-facet 521 assert completed[0]["activity"] == "coding" 522 assert completed[0]["segments"] == [ 523 "090000_300", 524 "090500_300", 525 "091000_300", 526 "091500_300", 527 ] 528 529 def test_gap_ending_preserves_accumulated_segments(self): 530 """Time gap after multi-segment activity must carry all segments.""" 531 from solstone.think.activity_state_machine import ActivityStateMachine 532 533 sm = ActivityStateMachine() 534 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 535 sm.update(_sense(content_type="coding"), "090500_300", "20260304") 536 # 20-minute gap triggers reset (> 600s threshold) 537 sm.update(_sense(content_type="coding"), "093000_300", "20260304") 538 539 completed = sm.get_completed_activities() 540 assert len(completed) == 1 541 assert completed[0]["segments"] == ["090000_300", "090500_300"] 542 543 def test_type_change_preserves_accumulated_segments(self): 544 """Type change after multi-segment activity must carry all segments.""" 545 from solstone.think.activity_state_machine import ActivityStateMachine 546 547 sm = ActivityStateMachine() 548 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 549 sm.update(_sense(content_type="coding"), "090500_300", "20260304") 550 sm.update(_sense(content_type="coding"), "091000_300", "20260304") 551 sm.update(_sense(content_type="coding"), "091500_300", "20260304") 552 sm.update(_sense(content_type="meeting"), "092000_300", "20260304") 553 sm.update(_sense(content_type="meeting"), "092500_300", "20260304") 554 555 completed = sm.get_completed_activities() 556 assert len(completed) == 1 557 assert completed[0]["segments"] == [ 558 "090000_300", 559 "090500_300", 560 "091000_300", 561 "091500_300", 562 "092000_300", 563 ] 564 565 def test_single_segment_activity_has_one_segment(self): 566 """Activity lasting exactly one segment has segments: [that_segment].""" 567 from solstone.think.activity_state_machine import ActivityStateMachine 568 569 sm = ActivityStateMachine() 570 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 571 sm.update(_sense(density="idle"), "090500_300", "20260304") 572 573 completed = sm.get_completed_activities() 574 assert len(completed) == 1 575 assert completed[0]["segments"] == ["090000_300"] 576 577 def test_duplicate_segment_key_not_appended(self): 578 """Same segment key processed twice doesn't duplicate in _segments.""" 579 from solstone.think.activity_state_machine import ActivityStateMachine 580 581 sm = ActivityStateMachine() 582 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 583 # Same segment key again (shouldn't happen in practice, but defensive) 584 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 585 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 586 sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 587 588 completed = sm.get_completed_activities() 589 assert len(completed) == 1 590 assert completed[0]["segments"] == ["090000_300", "090500_300"] 591 592 def test_multi_facet_simultaneous_ending_all_have_segments(self): 593 """Multiple facets ending simultaneously each have their own segments.""" 594 from solstone.think.activity_state_machine import ActivityStateMachine 595 596 sm = ActivityStateMachine() 597 two = [ 598 {"facet": "work", "activity": "coding", "level": "high"}, 599 {"facet": "personal", "activity": "browsing", "level": "medium"}, 600 ] 601 sm.update(_sense(facets=two), "090000_300", "20260304") 602 sm.update(_sense(facets=two), "090500_300", "20260304") 603 sm.update(_sense(facets=two), "091000_300", "20260304") 604 # Both end via idle 605 sm.update(_sense(density="idle"), "091500_300", "20260304") 606 607 completed = sm.get_completed_activities() 608 assert len(completed) == 2 609 for rec in completed: 610 assert rec["segments"] == [ 611 "090000_300", 612 "090500_300", 613 "091000_300", 614 ] 615 616 617class TestCompletedRecordFields: 618 """Verify all fields in completed records have correct types and values.""" 619 620 def test_created_at_is_millisecond_int(self): 621 """created_at must be an integer in milliseconds (not ISO string).""" 622 import time 623 624 from solstone.think.activity_state_machine import ActivityStateMachine 625 626 before = int(time.time() * 1000) 627 sm = ActivityStateMachine() 628 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 629 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 630 sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 631 after = int(time.time() * 1000) 632 633 rec = sm.get_completed_activities()[0] 634 assert isinstance(rec["created_at"], int) 635 assert before <= rec["created_at"] <= after 636 637 def test_created_at_comparable_to_float_cutoff(self): 638 """created_at must be comparable with float (routes.py cutoff_ts).""" 639 from datetime import datetime, timedelta 640 641 from solstone.think.activity_state_machine import ActivityStateMachine 642 643 sm = ActivityStateMachine() 644 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 645 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 646 sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 647 648 rec = sm.get_completed_activities()[0] 649 # Simulate routes.py cutoff calculation 650 cutoff_ts = (datetime.now() - timedelta(hours=4)).timestamp() * 1000 651 # This must not raise TypeError 652 assert isinstance(rec["created_at"] < cutoff_ts, bool) 653 # Also must support division (routes.py line 283) 654 dt = datetime.fromtimestamp(rec["created_at"] / 1000) 655 assert isinstance(dt, datetime) 656 657 def test_level_avg_is_float(self): 658 from solstone.think.activity_state_machine import ActivityStateMachine 659 660 sm = ActivityStateMachine() 661 sm.update( 662 _sense( 663 content_type="coding", 664 facets=[{"facet": "work", "activity": "coding", "level": "high"}], 665 ), 666 "090000_300", 667 "20260304", 668 ) 669 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 670 sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 671 672 rec = sm.get_completed_activities()[0] 673 assert isinstance(rec["level_avg"], float) 674 assert rec["level_avg"] == 1.0 # "high" maps to 1.0 675 676 def test_all_required_fields_present(self): 677 """Completed record must have every field run_activity_prompts expects.""" 678 from solstone.think.activity_state_machine import ActivityStateMachine 679 680 sm = ActivityStateMachine() 681 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 682 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 683 sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 684 685 rec = sm.get_completed_activities()[0] 686 required = { 687 "id", 688 "facet", 689 "activity", 690 "segments", 691 "level_avg", 692 "description", 693 "active_entities", 694 "created_at", 695 } 696 assert required.issubset(rec.keys()) 697 # No internal _fields should leak 698 assert not any(k.startswith("_") for k in rec.keys()) 699 700 def test_active_entities_preserved_in_completed(self): 701 from solstone.think.activity_state_machine import ActivityStateMachine 702 703 sm = ActivityStateMachine() 704 entities = [ 705 {"type": "Person", "name": "Alice", "context": "dev"}, 706 {"type": "Tool", "name": "VSCode", "context": "editor"}, 707 ] 708 sm.update( 709 _sense(content_type="coding", entities=entities), "090000_300", "20260304" 710 ) 711 sm.update(_sense(density="idle"), "090500_300", "20260304") 712 713 rec = sm.get_completed_activities()[0] 714 assert rec["active_entities"] == ["Alice", "VSCode"] 715 716 717class TestCumulativeCompletedList: 718 """Verify get_completed_activities() is cumulative and correct.""" 719 720 def test_multiple_endings_accumulate(self): 721 from solstone.think.activity_state_machine import ActivityStateMachine 722 723 sm = ActivityStateMachine() 724 # Activity 1: coding 725 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 726 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 727 sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 728 # Activity 2: meeting 729 sm.update(_sense(content_type="coding"), "091500_300", "20260304") 730 sm.update(_sense(content_type="coding"), "092000_300", "20260304") 731 732 completed = sm.get_completed_activities() 733 assert len(completed) == 2 734 assert completed[0]["activity"] == "coding" 735 assert completed[1]["activity"] == "meeting" 736 737 def test_completed_list_is_copy(self): 738 """get_completed_activities returns a copy, not internal state.""" 739 from solstone.think.activity_state_machine import ActivityStateMachine 740 741 sm = ActivityStateMachine() 742 sm.update(_sense(content_type="coding"), "090000_300", "20260304") 743 sm.update(_sense(content_type="meeting"), "090500_300", "20260304") 744 sm.update(_sense(content_type="meeting"), "091000_300", "20260304") 745 746 list1 = sm.get_completed_activities() 747 list2 = sm.get_completed_activities() 748 assert list1 is not list2 749 assert list1 == list2