personal memory agent
0

Configure Feed

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

solstone / tests / test_chat_runtime.py
83 kB 2587 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6import json 7import logging 8from datetime import datetime 9 10import pytest 11from flask import Flask 12 13from solstone.apps.chat.copy import CHAT_CLOSER_SUPPORT_SEND_FAILED 14from solstone.convey.chat_stream import ( 15 append_chat_event, 16 read_chat_events, 17 reduce_chat_state, 18) 19from tests.helpers.module_mocks import module_mock 20 21 22def _reset_chat_state(chat_module) -> None: 23 chat_module.stop_all_chat_runtime() 24 with chat_module._state_lock: 25 chat_module._current_chat_use_id = None 26 chat_module._current_chat_state = None 27 chat_module._queued_triggers.clear() 28 chat_module._active_talents.clear() 29 chat_module._reserved_use_ids.clear() 30 chat_module._abandoned_raw_use_ids.clear() 31 for timer in chat_module._watchdog_timers.values(): 32 timer.cancel() 33 chat_module._watchdog_timers.clear() 34 chat_module._last_use_id = 0 35 36 37def _setup_journal(tmp_path, monkeypatch): 38 journal = tmp_path / "journal" 39 journal.mkdir() 40 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) 41 return journal 42 43 44def _ms(year: int, month: int, day: int, hour: int, minute: int, second: int) -> int: 45 return int(datetime(year, month, day, hour, minute, second).timestamp() * 1000) 46 47 48def _install_fake_timers(monkeypatch): 49 import solstone.convey.chat as chat 50 51 timers: list[FakeTimer] = [] 52 53 class FakeTimer: 54 def __init__(self, interval, function, args=None, kwargs=None): 55 self.interval = interval 56 self.function = function 57 self.args = tuple(args or ()) 58 self.kwargs = dict(kwargs or {}) 59 self.started = False 60 self.cancelled = False 61 self.daemon = False 62 timers.append(self) 63 64 def start(self) -> None: 65 self.started = True 66 67 def cancel(self) -> None: 68 self.cancelled = True 69 70 def fire(self) -> None: 71 if self.cancelled: 72 return 73 self.function(*self.args, **self.kwargs) 74 75 monkeypatch.setattr( 76 chat, 77 "threading", 78 module_mock(chat.threading, Timer=FakeTimer), 79 ) 80 return timers 81 82 83def _append_recoverable_talent_events( 84 chat_use_id: str, 85 talent_use_id: str, 86 *, 87 target: str = "exec", 88 task: str = "research it", 89) -> None: 90 now = datetime.now() 91 start = _ms(now.year, now.month, now.day, 12, 0, 0) 92 append_chat_event( 93 "owner_message", 94 ts=start, 95 text="Help me with this", 96 app="home", 97 path="/app/home", 98 facet="work", 99 ) 100 append_chat_event( 101 "sol_message", 102 ts=start + 1, 103 use_id=chat_use_id, 104 text="I am looking into that.", 105 notes="need exec", 106 requested_target=target, 107 requested_task=task, 108 ) 109 append_chat_event( 110 "talent_spawned", 111 ts=start + 1_000, 112 use_id=talent_use_id, 113 name=target, 114 task=task, 115 started_at=start + 1_000, 116 ) 117 118 119def test_chat_result_with_two_active_talents_queues_deferred_spawn( 120 tmp_path, monkeypatch 121): 122 import solstone.convey.chat as chat 123 124 _setup_journal(tmp_path, monkeypatch) 125 _reset_chat_state(chat) 126 127 append_chat_event( 128 "talent_spawned", 129 use_id="1713620000001", 130 name="exec", 131 task="first task", 132 started_at=1713620000001, 133 ) 134 append_chat_event( 135 "talent_spawned", 136 use_id="1713620000002", 137 name="exec", 138 task="second task", 139 started_at=1713620000002, 140 ) 141 142 actions: list[dict] = [] 143 monkeypatch.setattr( 144 "solstone.convey.chat._run_next_action", 145 lambda action: actions.append(action) if action is not None else None, 146 ) 147 monkeypatch.setattr( 148 "solstone.convey.chat._emit_finish", lambda *args, **kwargs: None 149 ) 150 monkeypatch.setattr( 151 "solstone.convey.chat._emit_error", lambda *args, **kwargs: None 152 ) 153 154 with chat._state_lock: 155 chat._current_chat_use_id = "1713620000100" 156 chat._current_chat_state = { 157 "raw_use_id": "1713620000101", 158 "raw_use_ids_seen": {"1713620000101"}, 159 "trigger": {"type": "owner_message", "message": "help"}, 160 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 161 "retry_count": 0, 162 } 163 164 chat._on_cortex_finish( 165 { 166 "use_id": "1713620000101", 167 "result": json.dumps( 168 { 169 "message": "I am looking into that.", 170 "notes": "need exec", 171 "talent_request": { 172 "target": "exec", 173 "task": "research it", 174 "context": json.dumps({"k": "v"}), 175 }, 176 } 177 ), 178 } 179 ) 180 181 assert actions == [] 182 183 sol_messages = [ 184 e for e in read_chat_events(chat._today_day()) if e["kind"] == "sol_message" 185 ] 186 assert sol_messages[-1]["requested_target"] == "exec" 187 assert sol_messages[-1]["requested_task"] == "research it" 188 queued = [ 189 e for e in read_chat_events(chat._today_day()) if e["kind"] == "talent_queued" 190 ] 191 assert queued[-1]["name"] == "exec" 192 assert queued[-1]["task"] == "research it" 193 assert queued[-1]["chat_use_id"] == "1713620000100" 194 assert queued[-1]["ask"] == "help" 195 assert queued[-1]["context"] == {"k": "v"} 196 assert reduce_chat_state(chat._today_day())["queued_talents"] == [ 197 { 198 "use_id": queued[-1]["use_id"], 199 "name": "exec", 200 "task": "research it", 201 "queued_at": queued[-1]["queued_at"], 202 } 203 ] 204 205 206def test_post_talent_finished_request_is_forced_terminal(tmp_path, monkeypatch): 207 import solstone.convey.chat as chat 208 209 _setup_journal(tmp_path, monkeypatch) 210 _reset_chat_state(chat) 211 212 actions: list[dict | None] = [] 213 finishes: list[tuple[str, str]] = [] 214 monkeypatch.setattr( 215 "solstone.convey.chat._run_next_action", 216 lambda action: actions.append(action) if action is not None else None, 217 ) 218 monkeypatch.setattr( 219 "solstone.convey.chat._emit_finish", 220 lambda use_id, message: finishes.append((use_id, message)), 221 ) 222 monkeypatch.setattr( 223 "solstone.convey.chat._emit_error", 224 lambda *args, **kwargs: None, 225 ) 226 227 with chat._state_lock: 228 chat._current_chat_use_id = "1713621999999" 229 chat._current_chat_state = { 230 "raw_use_id": "1713622000000", 231 "raw_use_ids_seen": {"1713622000000"}, 232 "trigger": {"type": "talent_finished", "summary": "summary"}, 233 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 234 "retry_count": 0, 235 } 236 237 chat._on_cortex_finish( 238 { 239 "use_id": "1713622000000", 240 "result": json.dumps( 241 { 242 "message": "Let me check logs. Found three relevant notes.", 243 "notes": "blocked redispatch", 244 "talent_request": { 245 "target": "exec", 246 "task": "one more pass", 247 "context": json.dumps({}), 248 }, 249 } 250 ), 251 } 252 ) 253 254 assert actions == [] 255 expected_text = ( 256 "Here's what I have so far: Found three relevant notes. " 257 "Want me to try a different angle?" 258 ) 259 events = read_chat_events(chat._today_day()) 260 sol_messages = [event for event in events if event["kind"] == "sol_message"] 261 assert len(sol_messages) == 1 262 assert sol_messages[-1]["text"] == expected_text 263 assert sol_messages[-1]["requested_target"] is None 264 assert sol_messages[-1]["requested_task"] is None 265 assert [event for event in events if event["kind"] == "talent_spawned"] == [] 266 assert finishes == [("1713621999999", expected_text)] 267 with chat._state_lock: 268 assert chat._current_chat_state is None 269 assert chat._current_chat_use_id is None 270 271 272def test_post_talent_errored_request_is_forced_terminal(tmp_path, monkeypatch): 273 import solstone.convey.chat as chat 274 275 _setup_journal(tmp_path, monkeypatch) 276 _reset_chat_state(chat) 277 278 actions: list[dict | None] = [] 279 finishes: list[tuple[str, str]] = [] 280 monkeypatch.setattr( 281 "solstone.convey.chat._run_next_action", 282 lambda action: actions.append(action) if action is not None else None, 283 ) 284 monkeypatch.setattr( 285 "solstone.convey.chat._emit_finish", 286 lambda use_id, message: finishes.append((use_id, message)), 287 ) 288 monkeypatch.setattr( 289 "solstone.convey.chat._emit_error", 290 lambda *args, **kwargs: None, 291 ) 292 293 with chat._state_lock: 294 chat._current_chat_use_id = "1713622100000" 295 chat._current_chat_state = { 296 "raw_use_id": "1713622100001", 297 "raw_use_ids_seen": {"1713622100001"}, 298 "trigger": { 299 "type": "talent_errored", 300 "reason": "talent timed out waiting for provider response", 301 }, 302 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 303 "retry_count": 0, 304 } 305 306 chat._on_cortex_finish( 307 { 308 "use_id": "1713622100001", 309 "result": json.dumps( 310 { 311 "message": "I'll check again.", 312 "notes": "blocked redispatch", 313 "talent_request": { 314 "target": "exec", 315 "task": "one more pass", 316 "context": json.dumps({}), 317 }, 318 } 319 ), 320 } 321 ) 322 323 expected_text = ( 324 "I couldn't finish that lookup — talent timed out waiting for provider " 325 "response. Want to try a different angle, or rephrase the question?" 326 ) 327 assert actions == [] 328 events = read_chat_events(chat._today_day()) 329 sol_messages = [event for event in events if event["kind"] == "sol_message"] 330 assert len(sol_messages) == 1 331 assert sol_messages[-1]["text"] == expected_text 332 assert sol_messages[-1]["requested_target"] is None 333 assert sol_messages[-1]["requested_task"] is None 334 assert [event for event in events if event["kind"] == "talent_spawned"] == [] 335 assert finishes == [("1713622100000", expected_text)] 336 with chat._state_lock: 337 assert chat._current_chat_state is None 338 assert chat._current_chat_use_id is None 339 340 341def test_post_support_talent_errored_request_uses_send_failed_closer( 342 tmp_path, monkeypatch 343): 344 import solstone.convey.chat as chat 345 346 _setup_journal(tmp_path, monkeypatch) 347 _reset_chat_state(chat) 348 349 actions: list[dict | None] = [] 350 finishes: list[tuple[str, str]] = [] 351 monkeypatch.setattr( 352 "solstone.convey.chat._run_next_action", 353 lambda action: actions.append(action) if action is not None else None, 354 ) 355 monkeypatch.setattr( 356 "solstone.convey.chat._emit_finish", 357 lambda use_id, message: finishes.append((use_id, message)), 358 ) 359 monkeypatch.setattr( 360 "solstone.convey.chat._emit_error", 361 lambda *args, **kwargs: None, 362 ) 363 364 with chat._state_lock: 365 chat._current_chat_use_id = "1713622150000" 366 chat._current_chat_state = { 367 "raw_use_id": "1713622150001", 368 "raw_use_ids_seen": {"1713622150001"}, 369 "trigger": { 370 "type": "talent_errored", 371 "name": "support", 372 "reason": "Traceback (most recent call last)", 373 "reason_code": "wall_clock_exceeded", 374 }, 375 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 376 "retry_count": 0, 377 } 378 379 chat._on_cortex_finish( 380 { 381 "use_id": "1713622150001", 382 "result": json.dumps( 383 { 384 "message": "I drafted a ticket and will file it via live chat.", 385 "notes": "blocked redispatch", 386 "talent_request": { 387 "target": "exec", 388 "task": "one more pass", 389 "context": json.dumps({}), 390 }, 391 } 392 ), 393 } 394 ) 395 396 assert actions == [] 397 events = read_chat_events(chat._today_day()) 398 sol_messages = [event for event in events if event["kind"] == "sol_message"] 399 assert len(sol_messages) == 1 400 assert sol_messages[-1]["text"] == CHAT_CLOSER_SUPPORT_SEND_FAILED 401 assert sol_messages[-1]["requested_target"] is None 402 assert sol_messages[-1]["requested_task"] is None 403 assert finishes == [("1713622150000", CHAT_CLOSER_SUPPORT_SEND_FAILED)] 404 with chat._state_lock: 405 assert chat._current_chat_state is None 406 assert chat._current_chat_use_id is None 407 408 409def test_owner_message_direct_reply_keeps_raw_model_text(tmp_path, monkeypatch): 410 import solstone.convey.chat as chat 411 412 _setup_journal(tmp_path, monkeypatch) 413 _reset_chat_state(chat) 414 415 finishes: list[tuple[str, str]] = [] 416 monkeypatch.setattr("solstone.convey.chat._run_next_action", lambda action: None) 417 monkeypatch.setattr( 418 "solstone.convey.chat._emit_finish", 419 lambda use_id, message: finishes.append((use_id, message)), 420 ) 421 monkeypatch.setattr( 422 "solstone.convey.chat._emit_error", 423 lambda *args, **kwargs: None, 424 ) 425 426 with chat._state_lock: 427 chat._current_chat_use_id = "1713622200000" 428 chat._current_chat_state = { 429 "raw_use_id": "1713622200001", 430 "raw_use_ids_seen": {"1713622200001"}, 431 "trigger": {"type": "owner_message", "message": "status?"}, 432 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 433 "retry_count": 0, 434 } 435 436 raw_text = "Nothing new showed up in the digest." 437 chat._on_cortex_finish( 438 { 439 "use_id": "1713622200001", 440 "result": json.dumps( 441 { 442 "message": raw_text, 443 "notes": "answered directly", 444 "talent_request": None, 445 } 446 ), 447 } 448 ) 449 450 sol_messages = [ 451 event 452 for event in read_chat_events(chat._today_day()) 453 if event["kind"] == "sol_message" 454 ] 455 assert sol_messages[-1]["text"] == raw_text 456 assert finishes == [("1713622200000", raw_text)] 457 458 459def test_owner_message_request_still_spawns_talent(tmp_path, monkeypatch): 460 import solstone.convey.chat as chat 461 462 _setup_journal(tmp_path, monkeypatch) 463 _reset_chat_state(chat) 464 465 actions: list[dict] = [] 466 monkeypatch.setattr( 467 "solstone.convey.chat._run_next_action", 468 lambda action: actions.append(action) if action is not None else None, 469 ) 470 monkeypatch.setattr( 471 "solstone.convey.chat._emit_finish", lambda *args, **kwargs: None 472 ) 473 monkeypatch.setattr( 474 "solstone.convey.chat._emit_error", 475 lambda *args, **kwargs: None, 476 ) 477 478 with chat._state_lock: 479 chat._current_chat_use_id = "1713622300000" 480 chat._current_chat_state = { 481 "raw_use_id": "1713622300001", 482 "raw_use_ids_seen": {"1713622300001"}, 483 "trigger": {"type": "owner_message", "message": "research this"}, 484 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 485 "retry_count": 0, 486 } 487 488 chat._on_cortex_finish( 489 { 490 "use_id": "1713622300001", 491 "result": json.dumps( 492 { 493 "message": "I am looking into that.", 494 "notes": "dispatch", 495 "talent_request": { 496 "target": "exec", 497 "task": "research it", 498 "context": json.dumps({}), 499 }, 500 } 501 ), 502 } 503 ) 504 505 assert actions[-1]["kind"] == "talent" 506 events = read_chat_events(chat._today_day()) 507 talent_spawns = [event for event in events if event["kind"] == "talent_spawned"] 508 assert len(talent_spawns) == 1 509 assert talent_spawns[-1]["name"] == "exec" 510 511 512@pytest.mark.parametrize( 513 "trigger", 514 [ 515 None, 516 {}, 517 ], 518) 519def test_non_post_talent_triggers_allow_dispatch(tmp_path, monkeypatch, trigger): 520 import solstone.convey.chat as chat 521 522 _setup_journal(tmp_path, monkeypatch) 523 _reset_chat_state(chat) 524 525 actions: list[dict] = [] 526 monkeypatch.setattr( 527 "solstone.convey.chat._run_next_action", 528 lambda action: actions.append(action) if action is not None else None, 529 ) 530 monkeypatch.setattr( 531 "solstone.convey.chat._emit_finish", lambda *args, **kwargs: None 532 ) 533 monkeypatch.setattr( 534 "solstone.convey.chat._emit_error", 535 lambda *args, **kwargs: None, 536 ) 537 538 with chat._state_lock: 539 chat._current_chat_use_id = "1713622400000" 540 chat._current_chat_state = { 541 "raw_use_id": "1713622400001", 542 "raw_use_ids_seen": {"1713622400001"}, 543 "trigger": trigger, 544 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 545 "retry_count": 0, 546 } 547 548 chat._on_cortex_finish( 549 { 550 "use_id": "1713622400001", 551 "result": json.dumps( 552 { 553 "message": "I am looking into that.", 554 "notes": "dispatch", 555 "talent_request": { 556 "target": "exec", 557 "task": "research it", 558 "context": json.dumps({}), 559 }, 560 } 561 ), 562 } 563 ) 564 565 assert actions[-1]["kind"] == "talent" 566 events = read_chat_events(chat._today_day()) 567 talent_spawns = [event for event in events if event["kind"] == "talent_spawned"] 568 assert len(talent_spawns) == 1 569 assert talent_spawns[-1]["name"] == "exec" 570 571 572def test_cortex_finish_and_error_append_exec_terminal_events_by_use_id( 573 tmp_path, monkeypatch 574): 575 import solstone.convey.chat as chat 576 577 _setup_journal(tmp_path, monkeypatch) 578 _reset_chat_state(chat) 579 580 actions: list[dict] = [] 581 monkeypatch.setattr( 582 "solstone.convey.chat._run_next_action", 583 lambda action: actions.append(action) if action is not None else None, 584 ) 585 monkeypatch.setattr( 586 "solstone.convey.chat._emit_finish", lambda *args, **kwargs: None 587 ) 588 monkeypatch.setattr( 589 "solstone.convey.chat._emit_error", lambda *args, **kwargs: None 590 ) 591 592 with chat._state_lock: 593 chat._current_chat_use_id = "1713623000000" 594 chat._current_chat_state = { 595 "raw_use_id": None, 596 "raw_use_ids_seen": set(), 597 "trigger": {"type": "owner_message", "message": "help"}, 598 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 599 "retry_count": 0, 600 } 601 chat._active_talents["1713623000001"] = { 602 "chat_use_id": "1713623000000", 603 "target": "exec", 604 "task": "summarize", 605 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 606 "ask": "help", 607 } 608 609 chat._on_cortex_finish({"use_id": "1713623000001", "result": "done"}) 610 finished_events = [ 611 e for e in read_chat_events(chat._today_day()) if e["kind"] == "talent_finished" 612 ] 613 assert finished_events[-1]["use_id"] == "1713623000001" 614 assert actions == [] 615 with chat._state_lock: 616 queued = chat._queued_triggers[-1] 617 assert queued["trigger"]["type"] == "talent_finished" 618 assert queued["trigger"]["origin"] == { 619 "logical_use_id": "1713623000000", 620 "ask": "help", 621 } 622 623 _reset_chat_state(chat) 624 actions.clear() 625 with chat._state_lock: 626 chat._current_chat_use_id = "1713624000000" 627 chat._current_chat_state = { 628 "raw_use_id": None, 629 "raw_use_ids_seen": set(), 630 "trigger": {"type": "owner_message", "message": "help"}, 631 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 632 "retry_count": 0, 633 } 634 chat._active_talents["1713624000001"] = { 635 "chat_use_id": "1713624000000", 636 "target": "exec", 637 "task": "summarize", 638 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 639 "ask": "help", 640 } 641 642 chat._on_cortex_error( 643 { 644 "use_id": "1713624000001", 645 "error": "boom", 646 "reason_code": "wall_clock_exceeded", 647 } 648 ) 649 errored_events = [ 650 e for e in read_chat_events(chat._today_day()) if e["kind"] == "talent_errored" 651 ] 652 assert errored_events[-1]["use_id"] == "1713624000001" 653 assert actions == [] 654 with chat._state_lock: 655 queued = chat._queued_triggers[-1] 656 assert queued["trigger"]["type"] == "talent_errored" 657 assert queued["trigger"]["reason"] == "boom" 658 assert queued["trigger"]["reason_code"] == "wall_clock_exceeded" 659 assert queued["trigger"]["origin"] == { 660 "logical_use_id": "1713624000000", 661 "ask": "help", 662 } 663 664 665def test_talent_errored_trigger_recovers_reason_code(): 666 import solstone.convey.chat as chat 667 668 trigger = chat._trigger_from_stream_event( 669 "20260420", 670 { 671 "kind": "talent_errored", 672 "use_id": "1713624500001", 673 "name": "support", 674 "reason": "Traceback (most recent call last)", 675 "reason_code": "wall_clock_exceeded", 676 }, 677 ) 678 679 assert trigger["type"] == "talent_errored" 680 assert trigger["name"] == "support" 681 assert trigger["reason_code"] == "wall_clock_exceeded" 682 683 684@pytest.mark.parametrize( 685 ("terminal_kind", "result_field_name", "result_field_label", "result_value"), 686 [ 687 ("talent_finished", "summary", "result", "Found the answer."), 688 ("talent_errored", "reason", "reason", "The lookup failed."), 689 ], 690) 691def test_terminal_talent_reports_back_without_redispatch( 692 tmp_path, 693 monkeypatch, 694 terminal_kind, 695 result_field_name, 696 result_field_label, 697 result_value, 698): 699 import solstone.convey.chat as chat 700 from solstone.talent import chat_context 701 702 _setup_journal(tmp_path, monkeypatch) 703 _reset_chat_state(chat) 704 _install_fake_timers(monkeypatch) 705 706 monkeypatch.setattr( 707 "solstone.convey.chat._emit_cortex_event", lambda *args, **kwargs: None 708 ) 709 710 spawns: list[dict] = [] 711 712 def fake_spawn_agent(prompt, name, config, use_id): 713 spawns.append( 714 { 715 "prompt": prompt, 716 "name": name, 717 "config": dict(config), 718 "use_id": str(use_id), 719 } 720 ) 721 return use_id 722 723 monkeypatch.setattr("solstone.convey.utils.spawn_agent", fake_spawn_agent) 724 725 app = Flask(__name__) 726 app.register_blueprint(chat.chat_bp) 727 app.testing = True 728 client = app.test_client() 729 730 response = client.post( 731 "/api/chat", 732 json={ 733 "message": "Can you check this?", 734 "app": "sol", 735 "path": "/app/sol", 736 "facet": "work", 737 }, 738 ) 739 assert response.status_code == 200 740 assert response.get_json()["queued"] is False 741 742 first_chat_spawn = spawns[-1] 743 assert first_chat_spawn["name"] == "chat" 744 chat._on_cortex_finish( 745 { 746 "use_id": first_chat_spawn["use_id"], 747 "result": json.dumps( 748 { 749 "message": "I am checking.", 750 "notes": "dispatch exec", 751 "talent_request": { 752 "target": "exec", 753 "task": "Check the thing", 754 "context": json.dumps({"facet": "work"}), 755 }, 756 } 757 ), 758 } 759 ) 760 761 talent_spawn = spawns[-1] 762 assert talent_spawn["name"] == "exec" 763 if terminal_kind == "talent_finished": 764 chat._on_cortex_finish( 765 {"use_id": talent_spawn["use_id"], "result": result_value} 766 ) 767 else: 768 chat._on_cortex_error({"use_id": talent_spawn["use_id"], "error": result_value}) 769 770 report_back_spawn = spawns[-1] 771 assert report_back_spawn["name"] == "chat" 772 assert report_back_spawn["config"]["trigger"]["type"] == terminal_kind 773 assert report_back_spawn["config"]["trigger"][result_field_name] == result_value 774 775 context_result = chat_context.pre_process(report_back_spawn["config"]) 776 followup = context_result["messages"][-1]["content"] 777 trigger_context = context_result["template_vars"]["trigger_context"] 778 stop_and_report = ( 779 "stop-and-report turn, not a dispatch turn. Do not retry this task " 780 "or request another talent for it. Stop here and report to the owner " 781 "directly using the" 782 ) 783 assert stop_and_report in followup 784 assert stop_and_report in trigger_context 785 assert f"{result_field_label.capitalize()}: {result_value}" in followup 786 787 raw_report_use_id = report_back_spawn["use_id"] 788 chat._on_cortex_finish( 789 { 790 "use_id": raw_report_use_id, 791 "result": json.dumps( 792 { 793 "message": "Here is the summary.", 794 "notes": "reported terminal talent result", 795 "talent_request": None, 796 } 797 ), 798 } 799 ) 800 801 events = read_chat_events(chat._today_day()) 802 sol_messages = [event for event in events if event["kind"] == "sol_message"] 803 if terminal_kind == "talent_finished": 804 expected_text = ( 805 "Here's what I have so far: Here is the summary. " 806 "Want me to try a different angle?" 807 ) 808 else: 809 expected_text = ( 810 "I couldn't finish that lookup — The lookup failed. " 811 "Want to try a different angle, or rephrase the question?" 812 ) 813 assert sol_messages[-1]["text"] == expected_text 814 assert sol_messages[-1]["requested_target"] is None 815 talent_spawns = [event for event in events if event["kind"] == "talent_spawned"] 816 assert len(talent_spawns) == 1 817 assert [spawn["name"] for spawn in spawns] == ["chat", "exec", "chat"] 818 819 820def test_start_chat_runtime_recovers_exactly_one_unresponded_trigger( 821 tmp_path, monkeypatch 822): 823 import solstone.convey.chat as chat 824 825 _setup_journal(tmp_path, monkeypatch) 826 _reset_chat_state(chat) 827 828 append_chat_event( 829 "owner_message", 830 text="recover me", 831 app="sol", 832 path="/app/sol", 833 facet="work", 834 ) 835 836 starts: list[dict] = [] 837 monkeypatch.setattr( 838 "solstone.convey.chat.CallosumConnection.start", 839 lambda self, callback=None: None, 840 ) 841 monkeypatch.setattr( 842 "solstone.convey.chat.CallosumConnection.stop", lambda self: None 843 ) 844 monkeypatch.setattr( 845 "solstone.convey.chat._spawn_chat_generate", 846 lambda action: starts.append(action) or chat.ChatSpawnResult(ok=True), 847 ) 848 849 app = Flask(__name__) 850 chat.start_chat_runtime(app) 851 chat.start_chat_runtime(app) 852 853 assert len(starts) == 1 854 855 856def test_start_chat_runtime_skips_debug_reloader_parent(tmp_path, monkeypatch, caplog): 857 import solstone.convey.chat as chat 858 859 _setup_journal(tmp_path, monkeypatch) 860 _reset_chat_state(chat) 861 862 starts: list[object] = [] 863 monkeypatch.setattr( 864 "solstone.convey.chat.CallosumConnection.start", 865 lambda self, callback=None: starts.append(callback), 866 ) 867 monkeypatch.setattr( 868 "solstone.convey.chat.CallosumConnection.stop", lambda self: None 869 ) 870 871 app = Flask(__name__) 872 app.debug = True 873 874 monkeypatch.delenv("WERKZEUG_RUN_MAIN", raising=False) 875 with caplog.at_level("INFO"): 876 chat.start_chat_runtime(app) 877 878 assert chat._runtime is None 879 assert starts == [] 880 assert app.chat_runtime_started is False 881 assert "skipping chat runtime startup in Werkzeug reloader parent" in caplog.text 882 883 monkeypatch.setenv("WERKZEUG_RUN_MAIN", "true") 884 chat.start_chat_runtime(app) 885 886 assert chat._runtime is not None 887 assert len(starts) == 1 888 assert app.chat_runtime_started is True 889 890 891def test_recover_active_talents_repopulates_from_chat_stream(tmp_path, monkeypatch): 892 import solstone.convey.chat as chat 893 894 _setup_journal(tmp_path, monkeypatch) 895 _reset_chat_state(chat) 896 timers = _install_fake_timers(monkeypatch) 897 day = datetime.now().strftime("%Y%m%d") 898 monkeypatch.setattr("solstone.convey.chat._today_day", lambda: day) 899 900 chat_use_id = "1713624500000" 901 talent_use_id = "1713624500001" 902 _append_recoverable_talent_events(chat_use_id, talent_use_id) 903 904 chat._recover_chat_if_needed() 905 906 with chat._state_lock: 907 assert chat._active_talents[talent_use_id] == { 908 "chat_use_id": chat_use_id, 909 "target": "exec", 910 "task": "research it", 911 "trigger": "sol_message", 912 "location": {"app": "home", "path": "/app/home", "facet": "work"}, 913 "ask": "Help me with this", 914 } 915 assert talent_use_id in chat._watchdog_timers 916 assert len(timers) == 1 917 918 919def test_late_talent_finish_after_recovery_routes_to_chat_continuation( 920 tmp_path, monkeypatch, caplog 921): 922 import solstone.convey.chat as chat 923 924 _setup_journal(tmp_path, monkeypatch) 925 _reset_chat_state(chat) 926 _install_fake_timers(monkeypatch) 927 day = datetime.now().strftime("%Y%m%d") 928 monkeypatch.setattr("solstone.convey.chat._today_day", lambda: day) 929 930 chat_use_id = "1713624600000" 931 talent_use_id = "1713624600001" 932 _append_recoverable_talent_events(chat_use_id, talent_use_id) 933 chat._recover_chat_if_needed() 934 935 actions: list[dict | None] = [] 936 monkeypatch.setattr( 937 "solstone.convey.chat._run_next_action", 938 lambda action: actions.append(action) if action is not None else None, 939 ) 940 monkeypatch.setattr( 941 "solstone.convey.chat._emit_finish", lambda *args, **kwargs: None 942 ) 943 monkeypatch.setattr( 944 "solstone.convey.chat._emit_error", lambda *args, **kwargs: None 945 ) 946 947 with chat._state_lock: 948 chat._current_chat_use_id = chat_use_id 949 chat._current_chat_state = { 950 "raw_use_id": None, 951 "raw_use_ids_seen": set(), 952 "trigger": {"type": "owner_message", "message": "help"}, 953 "location": {"app": "home", "path": "/app/home", "facet": "work"}, 954 "retry_count": 0, 955 } 956 957 with caplog.at_level("WARNING"): 958 chat._on_cortex_finish({"use_id": talent_use_id, "result": "done"}) 959 960 assert "unrouteable cortex event" not in caplog.text 961 finished_events = [ 962 e for e in read_chat_events(chat._today_day()) if e["kind"] == "talent_finished" 963 ] 964 assert finished_events[-1]["use_id"] == talent_use_id 965 assert actions == [] 966 with chat._state_lock: 967 queued = chat._queued_triggers[-1] 968 assert queued["trigger"]["type"] == "talent_finished" 969 assert queued["trigger"]["origin"] == { 970 "logical_use_id": chat_use_id, 971 "ask": "Help me with this", 972 } 973 next_action = chat._clear_current_locked() 974 assert next_action["logical_use_id"] == queued["use_id"] 975 assert next_action["trigger"]["type"] == "talent_finished" 976 977 978def test_recovery_is_idempotent_for_active_talents(tmp_path, monkeypatch): 979 import solstone.convey.chat as chat 980 981 _setup_journal(tmp_path, monkeypatch) 982 _reset_chat_state(chat) 983 timers = _install_fake_timers(monkeypatch) 984 day = datetime.now().strftime("%Y%m%d") 985 monkeypatch.setattr("solstone.convey.chat._today_day", lambda: day) 986 987 chat_use_id = "1713624700000" 988 talent_use_id = "1713624700001" 989 _append_recoverable_talent_events(chat_use_id, talent_use_id) 990 991 chat._recover_chat_if_needed() 992 chat._recover_chat_if_needed() 993 994 with chat._state_lock: 995 assert list(chat._active_talents) == [talent_use_id] 996 assert talent_use_id in chat._watchdog_timers 997 assert len(timers) == 1 998 999 1000def test_recovery_emits_info_log_per_reactivation(tmp_path, monkeypatch, caplog): 1001 import solstone.convey.chat as chat 1002 1003 _setup_journal(tmp_path, monkeypatch) 1004 _reset_chat_state(chat) 1005 _install_fake_timers(monkeypatch) 1006 day = datetime.now().strftime("%Y%m%d") 1007 monkeypatch.setattr("solstone.convey.chat._today_day", lambda: day) 1008 1009 chat_use_id = "1713624750000" 1010 talent_use_id = "1713624750001" 1011 _append_recoverable_talent_events(chat_use_id, talent_use_id) 1012 1013 with caplog.at_level(logging.INFO, logger="solstone.convey.chat"): 1014 chat._recover_chat_if_needed() 1015 1016 records = [ 1017 record 1018 for record in caplog.records 1019 if record.name == "solstone.convey.chat" 1020 and "reactivated" in record.getMessage() 1021 ] 1022 assert len(records) == 1 1023 record = records[0] 1024 assert record.use_id == talent_use_id 1025 assert record.day == day 1026 assert record.trigger == "sol_message" 1027 1028 caplog.clear() 1029 empty_journal = tmp_path / "empty-journal" 1030 empty_journal.mkdir() 1031 monkeypatch.setenv("SOLSTONE_JOURNAL", str(empty_journal)) 1032 _reset_chat_state(chat) 1033 1034 with caplog.at_level(logging.INFO, logger="solstone.convey.chat"): 1035 chat._recover_chat_if_needed() 1036 1037 assert [ 1038 record 1039 for record in caplog.records 1040 if record.name == "solstone.convey.chat" and record.levelno == logging.INFO 1041 ] == [] 1042 1043 1044def test_chat_generate_schema_violation_retries_once_then_chat_errors( 1045 tmp_path, monkeypatch 1046): 1047 import solstone.convey.chat as chat 1048 1049 _setup_journal(tmp_path, monkeypatch) 1050 _reset_chat_state(chat) 1051 1052 actions: list[dict | None] = [] 1053 emitted_errors: list[tuple[str, str]] = [] 1054 monkeypatch.setattr( 1055 "solstone.convey.chat._run_next_action", 1056 lambda action: actions.append(action) if action is not None else None, 1057 ) 1058 monkeypatch.setattr( 1059 "solstone.convey.chat._emit_finish", lambda *args, **kwargs: None 1060 ) 1061 monkeypatch.setattr( 1062 "solstone.convey.chat._emit_error", 1063 lambda use_id, reason: emitted_errors.append((use_id, reason)), 1064 ) 1065 1066 with chat._state_lock: 1067 chat._current_chat_use_id = "1713625000000" 1068 chat._current_chat_state = { 1069 "raw_use_id": "1713625000001", 1070 "raw_use_ids_seen": {"1713625000001"}, 1071 "trigger": {"type": "owner_message", "message": "help"}, 1072 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1073 "retry_count": 0, 1074 } 1075 1076 chat._on_cortex_finish({"use_id": "1713625000001", "result": "not json"}) 1077 1078 assert actions and actions[-1]["kind"] == "chat" 1079 assert actions[-1]["logical_use_id"] == "1713625000000" 1080 assert emitted_errors == [] 1081 1082 with chat._state_lock: 1083 retry_use_id = chat._current_chat_state["raw_use_id"] 1084 1085 chat._on_cortex_finish({"use_id": retry_use_id, "result": "still not json"}) 1086 1087 assert emitted_errors == [("1713625000000", "provider_response_invalid")] 1088 errors = [ 1089 e for e in read_chat_events(chat._today_day()) if e["kind"] == "chat_error" 1090 ] 1091 assert errors[-1]["use_id"] == "1713625000000" 1092 1093 1094def test_chat_generate_absorbs_prose_context_and_dispatches_talent( 1095 tmp_path, monkeypatch 1096): 1097 import solstone.convey.chat as chat 1098 1099 _setup_journal(tmp_path, monkeypatch) 1100 _reset_chat_state(chat) 1101 1102 actions: list[dict | None] = [] 1103 emitted_errors: list[tuple[str, str]] = [] 1104 monkeypatch.setattr( 1105 "solstone.convey.chat._run_next_action", 1106 lambda action: actions.append(action) if action is not None else None, 1107 ) 1108 monkeypatch.setattr( 1109 "solstone.convey.chat._emit_finish", lambda *args, **kwargs: None 1110 ) 1111 monkeypatch.setattr( 1112 "solstone.convey.chat._emit_error", 1113 lambda use_id, reason: emitted_errors.append((use_id, reason)), 1114 ) 1115 1116 with chat._state_lock: 1117 chat._current_chat_use_id = "1713625050000" 1118 chat._current_chat_state = { 1119 "raw_use_id": "1713625050001", 1120 "raw_use_ids_seen": {"1713625050001"}, 1121 "trigger": {"type": "owner_message", "message": "help"}, 1122 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1123 "retry_count": 0, 1124 } 1125 1126 prose_context_result = json.dumps( 1127 { 1128 "message": "I am looking into that.", 1129 "notes": "need exec", 1130 "talent_request": { 1131 "target": "exec", 1132 "task": "research it", 1133 "context": "not json{", 1134 }, 1135 } 1136 ) 1137 chat._on_cortex_finish({"use_id": "1713625050001", "result": prose_context_result}) 1138 1139 assert actions and actions[-1]["kind"] == "talent" 1140 assert actions[-1]["logical_use_id"] == "1713625050000" 1141 assert actions[-1]["context"] == {"_raw": "not json{"} 1142 assert emitted_errors == [] 1143 spawned_events = [ 1144 e for e in read_chat_events(chat._today_day()) if e["kind"] == "talent_spawned" 1145 ] 1146 assert spawned_events[-1]["use_id"] == actions[-1]["use_id"] 1147 1148 1149def test_superseded_raw_finish_after_retry_is_dropped_without_warning( 1150 tmp_path, monkeypatch, caplog 1151): 1152 import solstone.convey.chat as chat 1153 1154 _setup_journal(tmp_path, monkeypatch) 1155 _reset_chat_state(chat) 1156 1157 actions: list[dict | None] = [] 1158 monkeypatch.setattr( 1159 "solstone.convey.chat._run_next_action", 1160 lambda action: actions.append(action) if action is not None else None, 1161 ) 1162 monkeypatch.setattr( 1163 "solstone.convey.chat._emit_finish", lambda *args, **kwargs: None 1164 ) 1165 monkeypatch.setattr( 1166 "solstone.convey.chat._emit_error", lambda *args, **kwargs: None 1167 ) 1168 1169 raw_use_id = "1713625100001" 1170 with chat._state_lock: 1171 chat._current_chat_use_id = "1713625100000" 1172 chat._current_chat_state = { 1173 "raw_use_id": raw_use_id, 1174 "raw_use_ids_seen": {raw_use_id}, 1175 "trigger": {"type": "owner_message", "message": "help"}, 1176 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1177 "retry_count": 0, 1178 } 1179 1180 chat._on_cortex_finish({"use_id": raw_use_id, "result": "not json"}) 1181 1182 with chat._state_lock: 1183 retry_use_id = str(chat._current_chat_state["raw_use_id"]) 1184 1185 events_before = list(read_chat_events(chat._today_day())) 1186 with caplog.at_level("DEBUG"): 1187 chat._on_cortex_finish({"use_id": raw_use_id, "result": "still not json"}) 1188 1189 assert ( 1190 "superseded raw cortex event use_id=1713625100001 event=finish reason=raw rotated" 1191 in caplog.text 1192 ) 1193 assert "unrouteable cortex event" not in caplog.text 1194 assert read_chat_events(chat._today_day()) == events_before 1195 1196 chat._on_cortex_finish( 1197 { 1198 "use_id": retry_use_id, 1199 "result": '{"message":"done","notes":"ok","talent_request":null}', 1200 } 1201 ) 1202 1203 sol_messages = [ 1204 event 1205 for event in read_chat_events(chat._today_day()) 1206 if event["kind"] == "sol_message" 1207 ] 1208 assert sol_messages[-1]["text"] == "done" 1209 1210 1211def test_superseded_raw_error_after_followup_rotation_is_dropped_without_warning( 1212 tmp_path, monkeypatch, caplog 1213): 1214 import solstone.convey.chat as chat 1215 1216 _setup_journal(tmp_path, monkeypatch) 1217 _reset_chat_state(chat) 1218 1219 actions: list[dict | None] = [] 1220 monkeypatch.setattr( 1221 "solstone.convey.chat._run_next_action", 1222 lambda action: actions.append(action) if action is not None else None, 1223 ) 1224 monkeypatch.setattr( 1225 "solstone.convey.chat._emit_finish", lambda *args, **kwargs: None 1226 ) 1227 monkeypatch.setattr( 1228 "solstone.convey.chat._emit_error", lambda *args, **kwargs: None 1229 ) 1230 1231 stale_raw_use_id = "1713625200001" 1232 with chat._state_lock: 1233 chat._current_chat_use_id = "1713625200000" 1234 chat._current_chat_state = { 1235 "raw_use_id": None, 1236 "raw_use_ids_seen": {stale_raw_use_id}, 1237 "trigger": {"type": "owner_message", "message": "help"}, 1238 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1239 "retry_count": 0, 1240 } 1241 chat._active_talents["1713625200002"] = { 1242 "chat_use_id": "1713625200000", 1243 "target": "exec", 1244 "task": "summarize", 1245 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1246 } 1247 1248 chat._on_cortex_finish({"use_id": "1713625200002", "result": "summary"}) 1249 1250 with chat._state_lock: 1251 assert chat._current_chat_state["raw_use_id"] is None 1252 queued = chat._queued_triggers[-1] 1253 assert queued["trigger"]["type"] == "talent_finished" 1254 1255 events_before = list(read_chat_events(chat._today_day())) 1256 with caplog.at_level("DEBUG"): 1257 chat._on_cortex_error({"use_id": stale_raw_use_id, "error": "boom"}) 1258 1259 assert ( 1260 "superseded raw cortex event use_id=1713625200001 event=error reason=raw rotated" 1261 in caplog.text 1262 ) 1263 assert "unrouteable cortex event" not in caplog.text 1264 assert read_chat_events(chat._today_day()) == events_before 1265 assert actions == [] 1266 1267 1268def test_reserved_unknown_raw_use_id_still_warns(tmp_path, monkeypatch, caplog): 1269 import solstone.convey.chat as chat 1270 1271 _setup_journal(tmp_path, monkeypatch) 1272 _reset_chat_state(chat) 1273 1274 use_id = "1713625300009" 1275 with chat._state_lock: 1276 chat._current_chat_use_id = "1713625300000" 1277 chat._current_chat_state = { 1278 "raw_use_id": "1713625300002", 1279 "raw_use_ids_seen": {"1713625300001", "1713625300002"}, 1280 "trigger": {"type": "owner_message", "message": "help"}, 1281 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1282 "retry_count": 0, 1283 } 1284 chat._reserved_use_ids[use_id] = None 1285 1286 caplog.set_level(logging.WARNING, logger="solstone.convey.chat") 1287 chat._on_cortex_finish({"use_id": use_id, "result": "done"}) 1288 1289 chat_records = [ 1290 record 1291 for record in caplog.records 1292 if record.name == "solstone.convey.chat" and record.levelno == logging.WARNING 1293 ] 1294 assert len(chat_records) == 1 1295 assert ( 1296 "unrouteable cortex event use_id=1713625300009 event=finish " 1297 "reason=no matching active chat-generate or talent" 1298 ) == chat_records[0].getMessage() 1299 1300 1301def test_exec_dispatch_appends_sol_message_and_spawns_talent_real_path( 1302 tmp_path, monkeypatch 1303): 1304 import solstone.convey.chat as chat 1305 1306 _setup_journal(tmp_path, monkeypatch) 1307 _reset_chat_state(chat) 1308 timers = _install_fake_timers(monkeypatch) 1309 1310 spawn_calls: list[dict[str, object]] = [] 1311 monkeypatch.setattr( 1312 "solstone.convey.chat._emit_cortex_event", lambda *args, **kwargs: None 1313 ) 1314 1315 def fake_spawn_agent(prompt, name, provider=None, config=None, use_id=None): 1316 spawn_calls.append( 1317 { 1318 "prompt": prompt, 1319 "name": name, 1320 "provider": provider, 1321 "config": config, 1322 "use_id": use_id, 1323 } 1324 ) 1325 return use_id 1326 1327 monkeypatch.setattr("solstone.convey.utils.spawn_agent", fake_spawn_agent) 1328 1329 with chat._state_lock: 1330 start_info = chat._activate_current_locked( 1331 "1713625500000", 1332 {"type": "owner_message", "message": "help"}, 1333 {"app": "sol", "path": "/app/sol", "facet": "work"}, 1334 ) 1335 1336 raw_use_id = start_info["raw_use_id"] 1337 chat._on_cortex_finish( 1338 { 1339 "use_id": raw_use_id, 1340 "result": json.dumps( 1341 { 1342 "message": "I am looking into that.", 1343 "notes": "need exec", 1344 "talent_request": { 1345 "target": "exec", 1346 "task": "research it", 1347 "context": json.dumps({"k": "v"}), 1348 }, 1349 } 1350 ), 1351 } 1352 ) 1353 1354 events = read_chat_events(chat._today_day()) 1355 sol_messages = [event for event in events if event["kind"] == "sol_message"] 1356 spawned_events = [event for event in events if event["kind"] == "talent_spawned"] 1357 1358 assert sol_messages[-1]["text"] == "I am looking into that." 1359 assert sol_messages[-1]["requested_target"] == "exec" 1360 assert sol_messages[-1]["requested_task"] == "research it" 1361 assert spawned_events[-1]["name"] == "exec" 1362 assert spawned_events[-1]["task"] == "research it" 1363 assert len(spawn_calls) == 1 1364 spawn_call = spawn_calls[0] 1365 assert spawn_call["name"] == "exec" 1366 assert spawn_call["use_id"] == spawned_events[-1]["use_id"] 1367 assert spawn_call["config"] == { 1368 "app": "sol", 1369 "path": "/app/sol", 1370 "facet": "work", 1371 "chat_parent_use_id": "1713625500000", 1372 } 1373 assert "research it" in str(spawn_call["prompt"]) 1374 assert "Context hints:\n{'k': 'v'}" in str(spawn_call["prompt"]) 1375 assert len(timers) == 2 1376 assert timers[0].cancelled is True 1377 with chat._state_lock: 1378 assert spawned_events[-1]["use_id"] in chat._active_talents 1379 1380 1381def test_support_dispatch_after_offer_spawns_app_talent_but_keeps_bare_event_name( 1382 tmp_path, monkeypatch 1383): 1384 import solstone.convey.chat as chat 1385 1386 _setup_journal(tmp_path, monkeypatch) 1387 _reset_chat_state(chat) 1388 _install_fake_timers(monkeypatch) 1389 1390 spawn_calls: list[dict[str, object]] = [] 1391 monkeypatch.setattr( 1392 "solstone.convey.chat._emit_cortex_event", lambda *args, **kwargs: None 1393 ) 1394 1395 def fake_spawn_agent(prompt, name, provider=None, config=None, use_id=None): 1396 spawn_calls.append( 1397 { 1398 "prompt": prompt, 1399 "name": name, 1400 "provider": provider, 1401 "config": config, 1402 "use_id": use_id, 1403 } 1404 ) 1405 return use_id 1406 1407 monkeypatch.setattr("solstone.convey.utils.spawn_agent", fake_spawn_agent) 1408 append_chat_event( 1409 "sol_message", 1410 use_id="seed-offer", 1411 text="offer support", 1412 notes="offer", 1413 requested_target=None, 1414 requested_task=None, 1415 offer={"kind": "support"}, 1416 ) 1417 1418 with chat._state_lock: 1419 start_info = chat._activate_current_locked( 1420 "1713625600000", 1421 {"type": "owner_message", "message": "help"}, 1422 {"app": "sol", "path": "/app/sol", "facet": "work"}, 1423 ) 1424 1425 raw_use_id = start_info["raw_use_id"] 1426 chat._on_cortex_finish( 1427 { 1428 "use_id": raw_use_id, 1429 "result": json.dumps( 1430 { 1431 "message": "I'll get support on that.", 1432 "notes": "need support", 1433 "talent_request": { 1434 "target": "support", 1435 "task": "File a bug report", 1436 "context": json.dumps({"severity": "medium"}), 1437 }, 1438 } 1439 ), 1440 } 1441 ) 1442 1443 events = read_chat_events(chat._today_day()) 1444 sol_messages = [event for event in events if event["kind"] == "sol_message"] 1445 spawned_events = [event for event in events if event["kind"] == "talent_spawned"] 1446 1447 assert sol_messages[-1]["requested_target"] == "support" 1448 assert spawned_events[-1]["name"] == "support" 1449 assert spawned_events[-1]["task"] == "File a bug report" 1450 assert len(spawn_calls) == 1 1451 spawn_call = spawn_calls[0] 1452 assert spawn_call["name"] == "support:support" 1453 assert spawn_call["use_id"] == spawned_events[-1]["use_id"] 1454 assert spawn_call["config"] == { 1455 "app": "sol", 1456 "path": "/app/sol", 1457 "facet": "work", 1458 "chat_parent_use_id": "1713625600000", 1459 } 1460 assert "File a bug report" in str(spawn_call["prompt"]) 1461 assert "Target: support" in str(spawn_call["prompt"]) 1462 with chat._state_lock: 1463 active = chat._active_talents[spawned_events[-1]["use_id"]] 1464 assert active["target"] == "support" 1465 1466 1467def test_watchdog_refreshed_by_progress_event(tmp_path, monkeypatch): 1468 import solstone.convey.chat as chat 1469 1470 _setup_journal(tmp_path, monkeypatch) 1471 _reset_chat_state(chat) 1472 timers = _install_fake_timers(monkeypatch) 1473 1474 monkeypatch.setattr( 1475 "solstone.convey.chat._emit_cortex_event", lambda *args, **kwargs: None 1476 ) 1477 1478 with chat._state_lock: 1479 start_info = chat._activate_current_locked( 1480 "1713627800000", 1481 {"type": "owner_message", "message": "help"}, 1482 {"app": "sol", "path": "/app/sol", "facet": "work"}, 1483 ) 1484 1485 raw_use_id = start_info["raw_use_id"] 1486 assert len(timers) == 1 1487 1488 chat._proxy_progress( 1489 { 1490 "tract": "cortex", 1491 "event": "thinking", 1492 "use_id": raw_use_id, 1493 } 1494 ) 1495 1496 assert len(timers) == 2 1497 assert timers[0].cancelled is True 1498 assert timers[-1].interval == chat._WATCHDOG_TIMEOUTS["chat"] 1499 1500 1501def test_arm_watchdog_uses_per_kind_timeout(tmp_path, monkeypatch): 1502 import solstone.convey.chat as chat 1503 1504 _setup_journal(tmp_path, monkeypatch) 1505 _reset_chat_state(chat) 1506 timers = _install_fake_timers(monkeypatch) 1507 1508 with chat._state_lock: 1509 chat._arm_watchdog_locked("u-chat-1", "chat", "u-chat-1") 1510 chat._arm_watchdog_locked("u-talent-1", "talent", "u-chat-1") 1511 chat._arm_watchdog_locked("u-unknown-1", "weirdkind", "u-chat-1") 1512 1513 assert timers[0].interval == 30 1514 assert timers[1].interval == 180 1515 assert timers[2].interval == 180 1516 1517 1518def test_proxy_progress_does_not_re_emit_request_event(tmp_path, monkeypatch): 1519 import solstone.convey.chat as chat 1520 1521 _setup_journal(tmp_path, monkeypatch) 1522 _reset_chat_state(chat) 1523 timers = _install_fake_timers(monkeypatch) 1524 1525 emitted: list[tuple[str, dict]] = [] 1526 monkeypatch.setattr( 1527 "solstone.convey.chat._emit_cortex_event", 1528 lambda event, **fields: emitted.append((event, fields)), 1529 ) 1530 1531 with chat._state_lock: 1532 start_info = chat._activate_current_locked( 1533 "1713627820000", 1534 {"type": "owner_message", "message": "help"}, 1535 {"app": "sol", "path": "/app/sol", "facet": "work"}, 1536 ) 1537 1538 chat._proxy_progress( 1539 { 1540 "tract": "cortex", 1541 "event": "request", 1542 "use_id": start_info["raw_use_id"], 1543 } 1544 ) 1545 1546 assert emitted == [] 1547 assert len(timers) == 1 1548 1549 1550@pytest.mark.parametrize("event_type", ["thinking", "progress", "start"]) 1551def test_proxy_progress_still_emits_other_event_types( 1552 tmp_path, monkeypatch, event_type 1553): 1554 import solstone.convey.chat as chat 1555 1556 _setup_journal(tmp_path, monkeypatch) 1557 _reset_chat_state(chat) 1558 _install_fake_timers(monkeypatch) 1559 1560 emitted: list[tuple[str, dict]] = [] 1561 monkeypatch.setattr( 1562 "solstone.convey.chat._emit_cortex_event", 1563 lambda event, **fields: emitted.append((event, fields)), 1564 ) 1565 1566 with chat._state_lock: 1567 start_info = chat._activate_current_locked( 1568 "1713627830000", 1569 {"type": "owner_message", "message": "help"}, 1570 {"app": "sol", "path": "/app/sol", "facet": "work"}, 1571 ) 1572 1573 chat._proxy_progress( 1574 { 1575 "tract": "cortex", 1576 "event": event_type, 1577 "use_id": start_info["raw_use_id"], 1578 } 1579 ) 1580 1581 assert emitted == [ 1582 ( 1583 event_type, 1584 { 1585 "use_id": "1713627830000", 1586 "chat_proxy": True, 1587 }, 1588 ) 1589 ] 1590 1591 1592def test_watchdog_refresh_is_no_op_when_no_timer_registered(tmp_path, monkeypatch): 1593 import solstone.convey.chat as chat 1594 1595 _setup_journal(tmp_path, monkeypatch) 1596 _reset_chat_state(chat) 1597 timers = _install_fake_timers(monkeypatch) 1598 1599 monkeypatch.setattr( 1600 "solstone.convey.chat._emit_cortex_event", lambda *args, **kwargs: None 1601 ) 1602 1603 with chat._state_lock: 1604 chat._current_chat_use_id = "1713627850000" 1605 chat._current_chat_state = { 1606 "raw_use_id": "1713627850001", 1607 "raw_use_ids_seen": {"1713627850001"}, 1608 "trigger": {"type": "owner_message", "message": "help"}, 1609 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1610 "retry_count": 0, 1611 } 1612 1613 chat._proxy_progress( 1614 { 1615 "tract": "cortex", 1616 "event": "thinking", 1617 "use_id": "1713627850002", 1618 } 1619 ) 1620 1621 assert len(timers) == 0 1622 1623 1624def test_watchdog_refreshed_by_talent_progress(tmp_path, monkeypatch): 1625 import solstone.convey.chat as chat 1626 1627 _setup_journal(tmp_path, monkeypatch) 1628 _reset_chat_state(chat) 1629 timers = _install_fake_timers(monkeypatch) 1630 1631 monkeypatch.setattr( 1632 "solstone.convey.chat._emit_cortex_event", lambda *args, **kwargs: None 1633 ) 1634 1635 with chat._state_lock: 1636 chat._current_chat_use_id = "1713627900000" 1637 chat._current_chat_state = { 1638 "raw_use_id": None, 1639 "raw_use_ids_seen": set(), 1640 "trigger": {"type": "owner_message", "message": "help"}, 1641 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1642 "retry_count": 0, 1643 } 1644 chat._active_talents["1713627900001"] = { 1645 "chat_use_id": "1713627900000", 1646 "target": "exec", 1647 "task": "summarize", 1648 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1649 } 1650 chat._arm_watchdog_locked("1713627900001", "talent", "1713627900000") 1651 1652 assert len(timers) == 1 1653 1654 chat._proxy_progress( 1655 { 1656 "tract": "cortex", 1657 "event": "thinking", 1658 "use_id": "1713627900001", 1659 } 1660 ) 1661 1662 assert len(timers) == 2 1663 assert timers[0].cancelled is True 1664 assert timers[-1].interval == chat._WATCHDOG_TIMEOUTS["talent"] 1665 1666 1667def test_stalled_run_still_times_out_after_inactivity(tmp_path, monkeypatch): 1668 import solstone.convey.chat as chat 1669 1670 _setup_journal(tmp_path, monkeypatch) 1671 _reset_chat_state(chat) 1672 timers = _install_fake_timers(monkeypatch) 1673 1674 emitted_errors: list[tuple[str, str]] = [] 1675 run_actions: list[dict | None] = [] 1676 monkeypatch.setattr( 1677 "solstone.convey.chat._emit_cortex_event", lambda *args, **kwargs: None 1678 ) 1679 monkeypatch.setattr( 1680 "solstone.convey.chat._emit_error", 1681 lambda use_id, reason: emitted_errors.append((use_id, reason)), 1682 ) 1683 monkeypatch.setattr( 1684 "solstone.convey.chat._run_next_action", 1685 lambda action: run_actions.append(action), 1686 ) 1687 1688 with chat._state_lock: 1689 start_info = chat._activate_current_locked( 1690 "1713627950000", 1691 {"type": "owner_message", "message": "help"}, 1692 {"app": "sol", "path": "/app/sol", "facet": "work"}, 1693 ) 1694 1695 raw_use_id = start_info["raw_use_id"] 1696 assert len(timers) == 1 1697 1698 for _ in range(3): 1699 chat._proxy_progress( 1700 { 1701 "tract": "cortex", 1702 "event": "thinking", 1703 "use_id": raw_use_id, 1704 } 1705 ) 1706 1707 assert len(timers) == 4 1708 timers[-1].fire() 1709 1710 errors = [ 1711 event 1712 for event in read_chat_events(chat._today_day()) 1713 if event["kind"] == "chat_error" 1714 ] 1715 assert emitted_errors == [("1713627950000", "chat_timeout")] 1716 assert run_actions == [None] 1717 assert errors[-1]["use_id"] == "1713627950000" 1718 assert errors[-1]["reason"] == "chat_timeout" 1719 with chat._state_lock: 1720 assert chat._current_chat_use_id is None 1721 assert chat._current_chat_state is None 1722 assert raw_use_id not in chat._watchdog_timers 1723 1724 1725def test_chat_watchdog_times_out_current_chat_generate(tmp_path, monkeypatch): 1726 import solstone.convey.chat as chat 1727 1728 _setup_journal(tmp_path, monkeypatch) 1729 _reset_chat_state(chat) 1730 timers = _install_fake_timers(monkeypatch) 1731 1732 emitted_errors: list[tuple[str, str]] = [] 1733 run_actions: list[dict | None] = [] 1734 monkeypatch.setattr( 1735 "solstone.convey.chat._emit_error", 1736 lambda use_id, reason: emitted_errors.append((use_id, reason)), 1737 ) 1738 monkeypatch.setattr( 1739 "solstone.convey.chat._run_next_action", 1740 lambda action: run_actions.append(action), 1741 ) 1742 1743 with chat._state_lock: 1744 start_info = chat._activate_current_locked( 1745 "1713628000000", 1746 {"type": "owner_message", "message": "help"}, 1747 {"app": "sol", "path": "/app/sol", "facet": "work"}, 1748 ) 1749 1750 raw_use_id = start_info["raw_use_id"] 1751 assert raw_use_id in chat._watchdog_timers 1752 1753 timers[-1].fire() 1754 1755 errors = [ 1756 event 1757 for event in read_chat_events(chat._today_day()) 1758 if event["kind"] == "chat_error" 1759 ] 1760 assert emitted_errors == [("1713628000000", "chat_timeout")] 1761 assert run_actions == [None] 1762 assert errors[-1]["use_id"] == "1713628000000" 1763 assert errors[-1]["reason"] == "chat_timeout" 1764 with chat._state_lock: 1765 assert chat._current_chat_use_id is None 1766 assert chat._current_chat_state is None 1767 assert raw_use_id not in chat._watchdog_timers 1768 1769 1770def test_late_chat_finish_after_watchdog_timeout_is_abandoned_raw( 1771 tmp_path, monkeypatch, caplog 1772): 1773 import solstone.convey.chat as chat 1774 1775 _setup_journal(tmp_path, monkeypatch) 1776 _reset_chat_state(chat) 1777 timers = _install_fake_timers(monkeypatch) 1778 1779 emitted_errors: list[tuple[str, str, dict]] = [] 1780 emitted_finishes: list[tuple[str, str]] = [] 1781 run_actions: list[dict | None] = [] 1782 monkeypatch.setattr( 1783 "solstone.convey.chat._emit_error", 1784 lambda use_id, reason, **kwargs: emitted_errors.append( 1785 (use_id, reason, kwargs) 1786 ), 1787 ) 1788 monkeypatch.setattr( 1789 "solstone.convey.chat._emit_finish", 1790 lambda use_id, message: emitted_finishes.append((use_id, message)), 1791 ) 1792 monkeypatch.setattr( 1793 "solstone.convey.chat._run_next_action", 1794 lambda action: run_actions.append(action), 1795 ) 1796 1797 logical_use_id = "1713628050000" 1798 with chat._state_lock: 1799 start_info = chat._activate_current_locked( 1800 logical_use_id, 1801 {"type": "owner_message", "message": "first"}, 1802 {"app": "sol", "path": "/app/sol", "facet": "work"}, 1803 ) 1804 queued_use_id, queued, start_action = chat._activate_or_enqueue_trigger_locked( 1805 {"type": "owner_message", "message": "second"}, 1806 {"app": "sol", "path": "/app/sol", "facet": "work"}, 1807 ) 1808 1809 assert queued is True 1810 assert start_action is None 1811 raw_use_id = start_info["raw_use_id"] 1812 1813 timers[-1].fire() 1814 1815 assert emitted_errors == [(logical_use_id, "chat_timeout", {})] 1816 assert run_actions and run_actions[-1]["logical_use_id"] == queued_use_id 1817 with chat._state_lock: 1818 assert chat._current_chat_use_id == queued_use_id 1819 assert chat._current_chat_state is not None 1820 assert chat._current_chat_state["raw_use_id"] != raw_use_id 1821 1822 events_before_late = read_chat_events(chat._today_day()) 1823 caplog.clear() 1824 caplog.set_level(logging.DEBUG, logger="solstone.convey.chat") 1825 chat._on_cortex_finish( 1826 { 1827 "use_id": raw_use_id, 1828 "result": json.dumps( 1829 {"message": "late answer", "notes": "ok", "talent_request": None} 1830 ), 1831 } 1832 ) 1833 1834 assert read_chat_events(chat._today_day()) == events_before_late 1835 assert emitted_finishes == [] 1836 assert ( 1837 f"superseded raw cortex event use_id={raw_use_id} " 1838 "event=finish reason=raw rotated" 1839 ) in caplog.text 1840 warning_messages = [ 1841 record.getMessage() 1842 for record in caplog.records 1843 if record.name == "solstone.convey.chat" and record.levelno >= logging.WARNING 1844 ] 1845 assert all("unrouteable cortex event" not in msg for msg in warning_messages) 1846 1847 1848def test_chat_watchdog_times_out_active_talent_and_queues_fold_when_chat_busy( 1849 tmp_path, monkeypatch 1850): 1851 import solstone.convey.chat as chat 1852 1853 _setup_journal(tmp_path, monkeypatch) 1854 _reset_chat_state(chat) 1855 timers = _install_fake_timers(monkeypatch) 1856 1857 monkeypatch.setattr( 1858 "solstone.convey.chat._emit_cortex_event", lambda *args, **kwargs: None 1859 ) 1860 monkeypatch.setattr("solstone.convey.chat._emit_error", lambda *args: None) 1861 monkeypatch.setattr( 1862 "solstone.convey.utils.spawn_agent", lambda *args, **kwargs: kwargs["use_id"] 1863 ) 1864 1865 with chat._state_lock: 1866 chat._current_chat_use_id = "1713629000000" 1867 chat._current_chat_state = { 1868 "raw_use_id": None, 1869 "raw_use_ids_seen": set(), 1870 "trigger": {"type": "owner_message", "message": "help"}, 1871 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1872 "retry_count": 0, 1873 } 1874 chat._active_talents["1713629000001"] = { 1875 "chat_use_id": "1713629000000", 1876 "target": "exec", 1877 "task": "summarize", 1878 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1879 "ask": "help", 1880 } 1881 1882 chat._run_next_action( 1883 { 1884 "kind": "talent", 1885 "logical_use_id": "1713629000000", 1886 "target": "exec", 1887 "use_id": "1713629000001", 1888 "task": "summarize", 1889 "context": {}, 1890 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1891 } 1892 ) 1893 1894 assert "1713629000001" in chat._watchdog_timers 1895 timers[-1].fire() 1896 1897 chat_errors = [ 1898 event 1899 for event in read_chat_events(chat._today_day()) 1900 if event["kind"] == "chat_error" 1901 ] 1902 talent_errors = [ 1903 event 1904 for event in read_chat_events(chat._today_day()) 1905 if event["kind"] == "talent_errored" 1906 ] 1907 assert chat_errors == [] 1908 assert talent_errors[-1]["use_id"] == "1713629000001" 1909 assert talent_errors[-1]["reason"] == "talent took too long" 1910 with chat._state_lock: 1911 assert "1713629000001" not in chat._active_talents 1912 assert chat._current_chat_use_id == "1713629000000" 1913 assert chat._queued_triggers[-1]["trigger"]["type"] == "talent_errored" 1914 assert chat._queued_triggers[-1]["trigger"]["origin"] == { 1915 "logical_use_id": "1713629000000", 1916 "ask": "help", 1917 } 1918 assert "1713629000001" not in chat._watchdog_timers 1919 1920 1921def test_chat_watchdog_timeout_folds_talent_error_with_origin(tmp_path, monkeypatch): 1922 import solstone.convey.chat as chat 1923 1924 _setup_journal(tmp_path, monkeypatch) 1925 _reset_chat_state(chat) 1926 timers = _install_fake_timers(monkeypatch) 1927 1928 actions: list[dict] = [] 1929 monkeypatch.setattr( 1930 "solstone.convey.chat._emit_cortex_event", lambda *args, **kwargs: None 1931 ) 1932 monkeypatch.setattr( 1933 "solstone.convey.chat._emit_error", lambda *args, **kwargs: None 1934 ) 1935 monkeypatch.setattr( 1936 "solstone.convey.chat._run_next_action", 1937 lambda action: actions.append(action) if action is not None else None, 1938 ) 1939 1940 with chat._state_lock: 1941 logical_use_id = chat._reserve_use_id_locked() 1942 talent_use_id = chat._reserve_use_id_locked() 1943 1944 append_chat_event( 1945 "talent_spawned", 1946 use_id=talent_use_id, 1947 name="exec", 1948 task="summarize", 1949 started_at=int(talent_use_id), 1950 ) 1951 1952 with chat._state_lock: 1953 chat._active_talents[talent_use_id] = { 1954 "chat_use_id": logical_use_id, 1955 "target": "exec", 1956 "task": "summarize", 1957 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 1958 "ask": "why did it hang?", 1959 } 1960 chat._arm_watchdog_locked(talent_use_id, "talent", logical_use_id) 1961 1962 timers[-1].fire() 1963 1964 chat_errors = [ 1965 event 1966 for event in read_chat_events(chat._today_day()) 1967 if event["kind"] == "chat_error" 1968 ] 1969 talent_errors = [ 1970 event 1971 for event in read_chat_events(chat._today_day()) 1972 if event["kind"] == "talent_errored" 1973 ] 1974 assert chat_errors == [] 1975 assert talent_errors[-1]["use_id"] == talent_use_id 1976 assert talent_errors[-1]["reason"] == "talent took too long" 1977 assert actions and actions[-1]["kind"] == "chat" 1978 assert actions[-1]["logical_use_id"] != logical_use_id 1979 assert actions[-1]["trigger"]["origin"] == { 1980 "logical_use_id": logical_use_id, 1981 "ask": "why did it hang?", 1982 } 1983 1984 chat._on_cortex_finish( 1985 { 1986 "use_id": actions[-1]["raw_use_id"], 1987 "result": {"message": "ignored", "notes": "ok", "talent_request": None}, 1988 } 1989 ) 1990 1991 folded = [ 1992 event 1993 for event in read_chat_events(chat._today_day()) 1994 if event["kind"] == "sol_message" 1995 ][-1] 1996 assert folded["requested_target"] is None 1997 assert folded["origin"] == { 1998 "logical_use_id": logical_use_id, 1999 "ask": "why did it hang?", 2000 } 2001 assert folded["text"].startswith("I couldn't finish that lookup") 2002 2003 2004def test_cortex_finish_logs_warning_for_unrouteable_use_id( 2005 tmp_path, monkeypatch, caplog 2006): 2007 import solstone.convey.chat as chat 2008 2009 _setup_journal(tmp_path, monkeypatch) 2010 _reset_chat_state(chat) 2011 2012 with chat._state_lock: 2013 use_id = chat._reserve_use_id_locked() 2014 2015 caplog.set_level(logging.WARNING, logger="solstone.convey.chat") 2016 chat._on_cortex_finish({"use_id": use_id, "result": "done"}) 2017 2018 chat_records = [ 2019 record 2020 for record in caplog.records 2021 if record.name == "solstone.convey.chat" and record.levelno == logging.WARNING 2022 ] 2023 assert len(chat_records) == 1 2024 assert ( 2025 f"unrouteable cortex event use_id={use_id} event=finish " 2026 "reason=no matching active chat-generate or talent" 2027 ) == chat_records[0].getMessage() 2028 2029 2030@pytest.mark.parametrize( 2031 ("error_text", "expected_detail"), 2032 [ 2033 ("short msg", "short msg"), 2034 ( 2035 "something went very long with newlines\n\nand whitespace collapsed " 2036 + ("0123456789 " * 30), 2037 None, 2038 ), 2039 ], 2040) 2041def test_on_cortex_error_stores_normalized_detail( 2042 tmp_path, monkeypatch, error_text, expected_detail 2043): 2044 import solstone.convey.chat as chat 2045 2046 _setup_journal(tmp_path, monkeypatch) 2047 _reset_chat_state(chat) 2048 emitted_errors: list[tuple[str, str, dict]] = [] 2049 monkeypatch.setattr("solstone.convey.chat._run_next_action", lambda action: None) 2050 monkeypatch.setattr( 2051 "solstone.convey.chat._emit_error", 2052 lambda use_id, reason, **kwargs: emitted_errors.append( 2053 (use_id, reason, kwargs) 2054 ), 2055 ) 2056 2057 logical_use_id = "1713632500000" 2058 raw_use_id = "1713632500001" 2059 with chat._state_lock: 2060 chat._current_chat_use_id = logical_use_id 2061 chat._current_chat_state = { 2062 "raw_use_id": raw_use_id, 2063 "raw_use_ids_seen": {raw_use_id}, 2064 "trigger": {"type": "owner_message", "message": "help"}, 2065 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 2066 "retry_count": 0, 2067 } 2068 2069 chat._on_cortex_error( 2070 { 2071 "use_id": raw_use_id, 2072 "reason_code": "unknown", 2073 "provider": "google", 2074 "error": error_text, 2075 } 2076 ) 2077 2078 errors = [ 2079 event 2080 for event in read_chat_events(chat._today_day()) 2081 if event["kind"] == "chat_error" 2082 ] 2083 assert len(errors) == 1 2084 assert errors[0]["reason"] == "unknown" 2085 assert errors[0]["provider"] == "google" 2086 detail = errors[0]["detail"] 2087 if expected_detail is not None: 2088 assert detail == expected_detail 2089 else: 2090 assert "\n" not in detail 2091 assert " " not in detail 2092 assert len(detail) <= 240 2093 assert detail[-1] == "" 2094 assert emitted_errors == [ 2095 ( 2096 logical_use_id, 2097 "unknown", 2098 {"provider": "google", "detail": detail}, 2099 ) 2100 ] 2101 2102 2103def test_cortex_error_logs_warning_for_unrouteable_use_id( 2104 tmp_path, monkeypatch, caplog 2105): 2106 import solstone.convey.chat as chat 2107 2108 _setup_journal(tmp_path, monkeypatch) 2109 _reset_chat_state(chat) 2110 2111 with chat._state_lock: 2112 use_id = chat._reserve_use_id_locked() 2113 2114 caplog.set_level(logging.WARNING, logger="solstone.convey.chat") 2115 chat._on_cortex_error({"use_id": use_id, "error": "boom"}) 2116 2117 chat_records = [ 2118 record 2119 for record in caplog.records 2120 if record.name == "solstone.convey.chat" and record.levelno == logging.WARNING 2121 ] 2122 assert len(chat_records) == 1 2123 assert ( 2124 f"unrouteable cortex event use_id={use_id} event=error " 2125 "reason=no matching active chat-generate or talent" 2126 ) == chat_records[0].getMessage() 2127 2128 2129def test_on_cortex_finish_unreserved_silent(tmp_path, monkeypatch, caplog): 2130 import solstone.convey.chat as chat 2131 2132 _setup_journal(tmp_path, monkeypatch) 2133 _reset_chat_state(chat) 2134 2135 caplog.set_level(logging.WARNING, logger="solstone.convey.chat") 2136 chat._on_cortex_finish({"use_id": "1713632000000", "result": "done"}) 2137 2138 chat_records = [ 2139 record 2140 for record in caplog.records 2141 if record.name == "solstone.convey.chat" and record.levelno == logging.WARNING 2142 ] 2143 assert chat_records == [] 2144 with chat._state_lock: 2145 assert chat._current_chat_use_id is None 2146 assert chat._current_chat_state is None 2147 assert chat._active_talents == {} 2148 assert chat._reserved_use_ids == {} 2149 2150 2151def test_on_cortex_error_unreserved_silent(tmp_path, monkeypatch, caplog): 2152 import solstone.convey.chat as chat 2153 2154 _setup_journal(tmp_path, monkeypatch) 2155 _reset_chat_state(chat) 2156 2157 caplog.set_level(logging.WARNING, logger="solstone.convey.chat") 2158 chat._on_cortex_error({"use_id": "1713633000000", "error": "boom"}) 2159 2160 chat_records = [ 2161 record 2162 for record in caplog.records 2163 if record.name == "solstone.convey.chat" and record.levelno == logging.WARNING 2164 ] 2165 assert chat_records == [] 2166 with chat._state_lock: 2167 assert chat._current_chat_use_id is None 2168 assert chat._current_chat_state is None 2169 assert chat._active_talents == {} 2170 assert chat._reserved_use_ids == {} 2171 2172 2173@pytest.mark.parametrize("target", ("read", "exec", "support")) 2174def test_parse_chat_result_accepts_dispatch_targets(target): 2175 import solstone.convey.chat as chat 2176 2177 parsed = chat._parse_chat_result( 2178 { 2179 "message": "Let me think about that.", 2180 "notes": f"dispatch {target}", 2181 "talent_request": { 2182 "target": target, 2183 "task": "Handle the request", 2184 "context": {"facet": "work"}, 2185 }, 2186 } 2187 ) 2188 2189 assert parsed["talent_request"] == { 2190 "target": target, 2191 "task": "Handle the request", 2192 "context": {"facet": "work"}, 2193 } 2194 # Exercises the scope-mandated defensive dict shim. 2195 assert parsed["talent_request"]["context"] == {"facet": "work"} 2196 2197 2198def test_parse_chat_result_decodes_json_string_context(): 2199 import solstone.convey.chat as chat 2200 2201 parsed = chat._parse_chat_result( 2202 { 2203 "message": "I am looking into that.", 2204 "notes": "need exec", 2205 "talent_request": { 2206 "target": "exec", 2207 "task": "Research the last two weeks", 2208 "context": '{"window":"14d"}', 2209 }, 2210 } 2211 ) 2212 2213 assert parsed["talent_request"]["context"] == {"window": "14d"} 2214 2215 2216@pytest.mark.parametrize( 2217 ("raw_context", "expected"), 2218 [ 2219 (None, {}), 2220 ("", {}), 2221 (" ", {}), 2222 ('{"foo": 1}', {"foo": 1}), 2223 ("123", {"_raw": "123"}), 2224 ("true", {"_raw": "true"}), 2225 ("[1, 2]", {"_raw": "[1, 2]"}), 2226 ('"hi"', {"_raw": '"hi"'}), 2227 ("not json at all{", {"_raw": "not json at all{"}), 2228 ({"foo": 1}, {"foo": 1}), 2229 (42, ValueError), 2230 ], 2231) 2232def test_parse_chat_result_context_absorbs_non_dict_shapes(raw_context, expected): 2233 import solstone.convey.chat as chat 2234 2235 payload = { 2236 "message": "I am looking into that.", 2237 "notes": "need exec", 2238 "talent_request": { 2239 "target": "exec", 2240 "task": "Research it", 2241 "context": raw_context, 2242 }, 2243 } 2244 2245 if expected is ValueError: 2246 with pytest.raises(ValueError): 2247 chat._parse_chat_result(payload) 2248 return 2249 2250 parsed = chat._parse_chat_result(payload) 2251 assert parsed["talent_request"]["context"] == expected 2252 2253 2254def test_parse_chat_result_accepts_null_talent_request(): 2255 import solstone.convey.chat as chat 2256 2257 parsed = chat._parse_chat_result( 2258 {"message": "hi", "notes": "n", "talent_request": None} 2259 ) 2260 2261 assert parsed == { 2262 "message": "hi", 2263 "notes": "n", 2264 "talent_request": None, 2265 } 2266 2267 2268def test_parse_chat_result_rejects_unknown_target(): 2269 import solstone.convey.chat as chat 2270 2271 with pytest.raises(ValueError, match="unknown talent target: foo"): 2272 chat._parse_chat_result( 2273 { 2274 "message": "Let me think about that.", 2275 "notes": "dispatch unknown", 2276 "talent_request": {"target": "foo", "task": "Reflect on the week"}, 2277 } 2278 ) 2279 2280 2281def test_parse_chat_result_rejects_missing_target(): 2282 import solstone.convey.chat as chat 2283 2284 with pytest.raises(ValueError, match="chat talent_request.target must be a string"): 2285 chat._parse_chat_result( 2286 { 2287 "message": "I am looking into that.", 2288 "notes": "need exec", 2289 "talent_request": {"task": "Research it", "context": {"k": "v"}}, 2290 } 2291 ) 2292 2293 2294def _target_alias_cases(): 2295 import solstone.convey.chat as chat 2296 2297 return list(chat.TARGET_ALIASES.items()) 2298 2299 2300@pytest.mark.parametrize(("raw_target", "canonical_target"), _target_alias_cases()) 2301def test_parse_chat_result_coerces_target_case_variants(raw_target, canonical_target): 2302 import solstone.convey.chat as chat 2303 2304 parsed = chat._parse_chat_result( 2305 { 2306 "message": "I am looking into that.", 2307 "notes": "need talent", 2308 "talent_request": { 2309 "target": raw_target, 2310 "task": "Research it", 2311 "context": None, 2312 }, 2313 } 2314 ) 2315 2316 assert parsed["talent_request"]["target"] == canonical_target 2317 2318 2319@pytest.mark.parametrize("raw_target", ("reflection", "reflect")) 2320def test_parse_chat_result_coerces_reflection_backcompat_to_read(raw_target): 2321 import solstone.convey.chat as chat 2322 2323 parsed = chat._parse_chat_result( 2324 { 2325 "message": "I am looking into that.", 2326 "notes": "need read", 2327 "talent_request": { 2328 "target": raw_target, 2329 "task": "Reflect on the week", 2330 "context": None, 2331 }, 2332 } 2333 ) 2334 2335 assert parsed["talent_request"]["target"] == "read" 2336 2337 2338def test_parse_chat_result_rejects_unknown_target_after_alias_lookup(): 2339 import solstone.convey.chat as chat 2340 2341 with pytest.raises(ValueError, match="unknown talent target: mystery"): 2342 chat._parse_chat_result( 2343 { 2344 "message": "Let me think about that.", 2345 "notes": "dispatch unknown", 2346 "talent_request": { 2347 "target": "mystery", 2348 "task": "Reflect on the week", 2349 "context": None, 2350 }, 2351 } 2352 ) 2353 2354 2355def test_parse_chat_result_trims_task_whitespace(): 2356 import solstone.convey.chat as chat 2357 2358 parsed = chat._parse_chat_result( 2359 { 2360 "message": "I am looking into that.", 2361 "notes": "need exec", 2362 "talent_request": { 2363 "target": "exec", 2364 "task": " do thing ", 2365 "context": None, 2366 }, 2367 } 2368 ) 2369 2370 assert parsed["talent_request"]["task"] == "do thing" 2371 2372 2373def test_parse_chat_result_rejects_whitespace_only_task(): 2374 import solstone.convey.chat as chat 2375 2376 with pytest.raises( 2377 ValueError, match="chat talent_request.task must be a non-empty string" 2378 ): 2379 chat._parse_chat_result( 2380 { 2381 "message": "I am looking into that.", 2382 "notes": "need exec", 2383 "talent_request": { 2384 "target": "exec", 2385 "task": " ", 2386 "context": None, 2387 }, 2388 } 2389 ) 2390 2391 2392def test_parse_chat_result_emits_debug_on_target_coercion(caplog): 2393 import solstone.convey.chat as chat 2394 2395 with caplog.at_level(logging.DEBUG, logger="solstone.convey.chat"): 2396 chat._parse_chat_result( 2397 { 2398 "message": "I am looking into that.", 2399 "notes": "need exec", 2400 "talent_request": { 2401 "target": "Exec", 2402 "task": "Research it", 2403 "context": None, 2404 }, 2405 }, 2406 use_id="test-use-id", 2407 ) 2408 2409 assert any( 2410 "chat parser coerced target=Exec -> exec (use_id=test-use-id)" 2411 == record.getMessage() 2412 for record in caplog.records 2413 ) 2414 2415 2416def test_parse_chat_result_emits_debug_on_task_coercion(caplog): 2417 import solstone.convey.chat as chat 2418 2419 with caplog.at_level(logging.DEBUG, logger="solstone.convey.chat"): 2420 chat._parse_chat_result( 2421 { 2422 "message": "I am looking into that.", 2423 "notes": "need exec", 2424 "talent_request": { 2425 "target": "exec", 2426 "task": " run ", 2427 "context": None, 2428 }, 2429 }, 2430 use_id="test-use-id", 2431 ) 2432 2433 assert any( 2434 "chat parser coerced task whitespace raw=' run ' -> 'run' " 2435 "(use_id=test-use-id)" == record.getMessage() 2436 for record in caplog.records 2437 ) 2438 2439 2440def test_parse_chat_result_keeps_strict_on_non_dict_talent_request(): 2441 import solstone.convey.chat as chat 2442 2443 with pytest.raises( 2444 ValueError, match="chat talent_request must be an object or null" 2445 ): 2446 chat._parse_chat_result( 2447 { 2448 "message": "I am looking into that.", 2449 "notes": "need exec", 2450 "talent_request": [1, 2, 3], 2451 } 2452 ) 2453 2454 2455def test_parse_chat_result_keeps_strict_on_non_string_message(): 2456 import solstone.convey.chat as chat 2457 2458 with pytest.raises( 2459 ValueError, match="chat result message must be a string or null" 2460 ): 2461 chat._parse_chat_result( 2462 { 2463 "message": 123, 2464 "notes": "need exec", 2465 "talent_request": None, 2466 } 2467 ) 2468 2469 2470def test_parse_chat_result_keeps_strict_on_non_string_notes(): 2471 import solstone.convey.chat as chat 2472 2473 with pytest.raises(ValueError, match="chat result notes must be a string"): 2474 chat._parse_chat_result( 2475 { 2476 "message": "I am looking into that.", 2477 "notes": ["a", "b"], 2478 "talent_request": None, 2479 } 2480 ) 2481 2482 2483def test_read_dispatch_spawns_read_talent(tmp_path, monkeypatch): 2484 import solstone.convey.chat as chat 2485 2486 _setup_journal(tmp_path, monkeypatch) 2487 _reset_chat_state(chat) 2488 2489 actions: list[dict | None] = [] 2490 monkeypatch.setattr( 2491 "solstone.convey.chat._run_next_action", 2492 lambda action: actions.append(action) if action is not None else None, 2493 ) 2494 monkeypatch.setattr( 2495 "solstone.convey.chat._emit_finish", lambda *args, **kwargs: None 2496 ) 2497 monkeypatch.setattr( 2498 "solstone.convey.chat._emit_error", lambda *args, **kwargs: None 2499 ) 2500 2501 with chat._state_lock: 2502 chat._current_chat_use_id = "1713626000000" 2503 chat._current_chat_state = { 2504 "raw_use_id": "1713626000001", 2505 "raw_use_ids_seen": {"1713626000001"}, 2506 "trigger": {"type": "owner_message", "message": "help"}, 2507 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 2508 "retry_count": 0, 2509 } 2510 2511 chat._on_cortex_finish( 2512 { 2513 "use_id": "1713626000001", 2514 "result": json.dumps( 2515 { 2516 "message": "I want to sit with that.", 2517 "notes": "need read", 2518 "talent_request": { 2519 "target": "read", 2520 "task": "Reflect on the week", 2521 "context": json.dumps({"facet": "work"}), 2522 }, 2523 } 2524 ), 2525 } 2526 ) 2527 2528 assert actions[-1]["kind"] == "talent" 2529 assert actions[-1]["target"] == "read" 2530 2531 events = read_chat_events(chat._today_day()) 2532 sol_message = next(event for event in events if event["kind"] == "sol_message") 2533 spawned = next(event for event in events if event["kind"] == "talent_spawned") 2534 assert sol_message["requested_target"] == "read" 2535 assert spawned["name"] == "read" 2536 2537 2538def test_read_finish_retriggers_chat_like_exec(tmp_path, monkeypatch): 2539 import solstone.convey.chat as chat 2540 2541 _setup_journal(tmp_path, monkeypatch) 2542 _reset_chat_state(chat) 2543 2544 actions: list[dict | None] = [] 2545 monkeypatch.setattr( 2546 "solstone.convey.chat._run_next_action", 2547 lambda action: actions.append(action) if action is not None else None, 2548 ) 2549 monkeypatch.setattr( 2550 "solstone.convey.chat._emit_finish", lambda *args, **kwargs: None 2551 ) 2552 monkeypatch.setattr( 2553 "solstone.convey.chat._emit_error", lambda *args, **kwargs: None 2554 ) 2555 2556 with chat._state_lock: 2557 chat._current_chat_use_id = "1713627000000" 2558 chat._current_chat_state = { 2559 "raw_use_id": None, 2560 "raw_use_ids_seen": set(), 2561 "trigger": {"type": "owner_message", "message": "help"}, 2562 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 2563 "retry_count": 0, 2564 } 2565 chat._active_talents["1713627000001"] = { 2566 "chat_use_id": "1713627000000", 2567 "target": "read", 2568 "task": "Reflect on the week", 2569 "location": {"app": "sol", "path": "/app/sol", "facet": "work"}, 2570 "ask": "help", 2571 } 2572 2573 chat._on_cortex_finish({"use_id": "1713627000001", "result": "A reflective note"}) 2574 2575 finished_events = [ 2576 e for e in read_chat_events(chat._today_day()) if e["kind"] == "talent_finished" 2577 ] 2578 assert finished_events[-1]["name"] == "read" 2579 assert actions == [] 2580 with chat._state_lock: 2581 queued = chat._queued_triggers[-1] 2582 assert queued["trigger"]["type"] == "talent_finished" 2583 assert queued["trigger"]["name"] == "read" 2584 assert queued["trigger"]["origin"] == { 2585 "logical_use_id": "1713627000000", 2586 "ask": "help", 2587 }