personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4from __future__ import annotations
5
6import json
7import logging
8from 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 }