soothe-cli 0.5.28__tar.gz → 0.5.29__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/PKG-INFO +1 -1
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/commands/autopilot_cmd.py +1 -1
- soothe_cli-0.5.29/src/soothe_cli/runtime/parse/_utils.py +14 -0
- soothe_cli-0.5.29/src/soothe_cli/runtime/parse/message_processing.py +51 -0
- soothe_cli-0.5.29/src/soothe_cli/runtime/parse/tool_message_format.py +17 -0
- soothe_cli-0.5.29/src/soothe_cli/runtime/parse/tool_result.py +15 -0
- soothe_cli-0.5.29/src/soothe_cli/runtime/state/transcript.py +24 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/transport/session.py +51 -0
- soothe_cli-0.5.29/src/soothe_cli/runtime/wire/display_text.py +15 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/wire/message_text.py +3 -3
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_app.py +3 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_execution.py +14 -3
- soothe_cli-0.5.29/src/soothe_cli/tui/app/_history.py +335 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_model.py +7 -2
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_startup.py +1 -1
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/app.tcss +26 -6
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/command_registry.py +2 -2
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/model_config.py +10 -4
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/sessions.py +74 -27
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/textual_adapter.py +2 -2
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/tips.py +1 -1
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +2 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/clipboard.py +30 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/file_change_preview.py +64 -60
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/loop_selector.py +64 -9
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/messages.py +12 -17
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/status.py +6 -6
- soothe_cli-0.5.28/src/soothe_cli/runtime/parse/_utils.py +0 -30
- soothe_cli-0.5.28/src/soothe_cli/runtime/parse/message_processing.py +0 -551
- soothe_cli-0.5.28/src/soothe_cli/runtime/parse/tool_message_format.py +0 -115
- soothe_cli-0.5.28/src/soothe_cli/runtime/parse/tool_result.py +0 -141
- soothe_cli-0.5.28/src/soothe_cli/runtime/state/transcript.py +0 -208
- soothe_cli-0.5.28/src/soothe_cli/runtime/wire/display_text.py +0 -64
- soothe_cli-0.5.28/src/soothe_cli/tui/app/_history.py +0 -969
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/.gitignore +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/README.md +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/pyproject.toml +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/__init__.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/daemon.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/headless.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/main.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/config/cli_config.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/config/loader.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/config/logging_setup.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/__init__.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/headless/processor.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/async_renderer_protocol.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/engine.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/state/session_stats.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/state/step_router.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/task_scope.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/turn/prepare.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/wire/messages.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/_cli_context.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/_env_vars.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/_version.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/__init__.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_commands.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_messages_mixin.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_module_init.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_ui.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/binding.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/commands/__init__.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/commands/command_router.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/config.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/file_change_notify.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/file_change_renderers.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/hooks.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/input.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/media_utils.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/path_utils.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/preview_limits.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/project_utils.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/theme.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/tool_display.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/unicode_security.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/update_check.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/loading.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
- {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/welcome.py +0 -0
|
@@ -91,7 +91,7 @@ def run(
|
|
|
91
91
|
detail = client.get_goal(goal_id)
|
|
92
92
|
goal = detail.get("goal") or {}
|
|
93
93
|
status = goal.get("status", "unknown")
|
|
94
|
-
if status in ("completed", "failed", "suspended"):
|
|
94
|
+
if status in ("completed", "failed", "cancelled", "suspended"):
|
|
95
95
|
typer.echo(f"Goal {goal_id[:8]}: {status}")
|
|
96
96
|
if status == "failed":
|
|
97
97
|
sys.exit(1)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Re-export shim for ``soothe_sdk.display._text_utils`` (RFC-413).
|
|
2
|
+
|
|
3
|
+
These utilities live in the SDK so the daemon-resident ``CardBinder`` can
|
|
4
|
+
reuse them. This module preserves the original CLI import path.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from soothe_sdk.display._text_utils import (
|
|
10
|
+
normalize_tool_name,
|
|
11
|
+
text_looks_like_error,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = ["normalize_tool_name", "text_looks_like_error"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Re-export shim for ``soothe_sdk.display.message_processing`` (RFC-413).
|
|
2
|
+
|
|
3
|
+
These helpers live in the SDK so the daemon-resident ``CardBinder`` can
|
|
4
|
+
reuse them. This module preserves the original CLI import path used
|
|
5
|
+
across the runtime, TUI, and tests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
# Underscore-prefixed names below are re-exported intentionally — they are
|
|
11
|
+
# imported by other CLI modules (tool_call_resolution, widgets/messages, etc.)
|
|
12
|
+
# and CLI tests. Keep them in __all__ to keep `ruff` from stripping them.
|
|
13
|
+
from soothe_sdk.display.message_processing import (
|
|
14
|
+
_normalize_tool_name_for_arg_map,
|
|
15
|
+
_pending_or_overlay_id_matches_lookup,
|
|
16
|
+
_resolve_pending_lookup_tool_name,
|
|
17
|
+
accumulate_tool_call_chunks,
|
|
18
|
+
coerce_tool_call_args_to_dict,
|
|
19
|
+
coerce_tool_call_entry_to_dict,
|
|
20
|
+
extract_tool_args_dict,
|
|
21
|
+
extract_tool_brief,
|
|
22
|
+
finalize_pending_tool_call,
|
|
23
|
+
ingest_tool_call_stream_state,
|
|
24
|
+
normalize_tool_calls_list,
|
|
25
|
+
richest_pending_args_for_lookup,
|
|
26
|
+
seed_pending_tool_calls_from_message,
|
|
27
|
+
tool_calls_have_any_arg_dict,
|
|
28
|
+
tool_ids_touched_by_stream_message,
|
|
29
|
+
tool_lookup_step_id,
|
|
30
|
+
try_parse_pending_tool_call_args,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"_normalize_tool_name_for_arg_map",
|
|
35
|
+
"_pending_or_overlay_id_matches_lookup",
|
|
36
|
+
"_resolve_pending_lookup_tool_name",
|
|
37
|
+
"accumulate_tool_call_chunks",
|
|
38
|
+
"coerce_tool_call_args_to_dict",
|
|
39
|
+
"coerce_tool_call_entry_to_dict",
|
|
40
|
+
"extract_tool_args_dict",
|
|
41
|
+
"extract_tool_brief",
|
|
42
|
+
"finalize_pending_tool_call",
|
|
43
|
+
"ingest_tool_call_stream_state",
|
|
44
|
+
"normalize_tool_calls_list",
|
|
45
|
+
"richest_pending_args_for_lookup",
|
|
46
|
+
"seed_pending_tool_calls_from_message",
|
|
47
|
+
"tool_calls_have_any_arg_dict",
|
|
48
|
+
"tool_ids_touched_by_stream_message",
|
|
49
|
+
"tool_lookup_step_id",
|
|
50
|
+
"try_parse_pending_tool_call_args",
|
|
51
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Re-export shim for ``soothe_sdk.display.tool_message_format`` (RFC-413)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from soothe_sdk.display.tool_message_format import (
|
|
6
|
+
format_content_block_for_tool_display,
|
|
7
|
+
format_tool_message_content,
|
|
8
|
+
run_python_envelope_indicates_failure,
|
|
9
|
+
try_parse_run_python_result_envelope,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"format_content_block_for_tool_display",
|
|
14
|
+
"format_tool_message_content",
|
|
15
|
+
"run_python_envelope_indicates_failure",
|
|
16
|
+
"try_parse_run_python_result_envelope",
|
|
17
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Re-export shim for ``soothe_sdk.display.tool_result`` (RFC-413)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from soothe_sdk.display.tool_result import (
|
|
6
|
+
ToolResultPayload,
|
|
7
|
+
extract_tool_result_payload,
|
|
8
|
+
infer_tool_output_suggests_error,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ToolResultPayload",
|
|
13
|
+
"extract_tool_result_payload",
|
|
14
|
+
"infer_tool_output_suggests_error",
|
|
15
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Transcript message models for TUI display.
|
|
2
|
+
|
|
3
|
+
These types live in ``soothe_sdk.display.transcript_types`` so they can be
|
|
4
|
+
shared with the daemon-resident ``CardBinder`` (RFC-413). This module
|
|
5
|
+
re-exports them to preserve the CLI's existing import paths.
|
|
6
|
+
|
|
7
|
+
DOM virtualization lives in ``soothe_cli.tui.widgets.message_store``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from soothe_sdk.display.transcript_types import (
|
|
13
|
+
UPDATABLE_FIELDS,
|
|
14
|
+
MessageData,
|
|
15
|
+
MessageType,
|
|
16
|
+
ToolStatus,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"UPDATABLE_FIELDS",
|
|
21
|
+
"MessageData",
|
|
22
|
+
"MessageType",
|
|
23
|
+
"ToolStatus",
|
|
24
|
+
]
|
|
@@ -381,6 +381,57 @@ class TuiDaemonSession:
|
|
|
381
381
|
await connect_websocket_with_retries(self._rpc_client)
|
|
382
382
|
self._rpc_connected = True
|
|
383
383
|
|
|
384
|
+
async def fetch_loop_cards(self, loop_id: str) -> SimpleNamespace:
|
|
385
|
+
"""Fetch the daemon's bound display-card snapshot for a loop.
|
|
386
|
+
|
|
387
|
+
RFC-413: returns a populated ledger (eagerly backfilled if the loop
|
|
388
|
+
has no ``cards.jsonl`` yet) so resume can render through the same
|
|
389
|
+
binder that produced the original cards.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
loop_id: AgentLoop id.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
``SimpleNamespace`` with ``cards: list[dict]``, ``seq: int``,
|
|
396
|
+
``success: bool``. On error, ``cards=[]`` and ``success=False`` so
|
|
397
|
+
the caller can fall back to the legacy resume path.
|
|
398
|
+
"""
|
|
399
|
+
lid = str(loop_id or "").strip()
|
|
400
|
+
if not lid:
|
|
401
|
+
return SimpleNamespace(cards=[], seq=0, success=False)
|
|
402
|
+
|
|
403
|
+
async with self._rpc_lock:
|
|
404
|
+
await self._ensure_rpc_connected()
|
|
405
|
+
try:
|
|
406
|
+
resp = await self._rpc_client.request_response(
|
|
407
|
+
{"type": "loop_cards_fetch", "loop_id": lid},
|
|
408
|
+
response_type="loop_cards_fetch_response",
|
|
409
|
+
timeout=30.0,
|
|
410
|
+
)
|
|
411
|
+
except Exception:
|
|
412
|
+
logger.warning(
|
|
413
|
+
"loop_cards_fetch failed for loop %s",
|
|
414
|
+
lid[:16],
|
|
415
|
+
exc_info=True,
|
|
416
|
+
)
|
|
417
|
+
return SimpleNamespace(cards=[], seq=0, success=False)
|
|
418
|
+
|
|
419
|
+
raw_cards = resp.get("cards")
|
|
420
|
+
cards = list(raw_cards) if isinstance(raw_cards, list) else []
|
|
421
|
+
seq = int(resp.get("seq") or 0)
|
|
422
|
+
context_tokens_raw = resp.get("context_tokens")
|
|
423
|
+
context_tokens = (
|
|
424
|
+
context_tokens_raw
|
|
425
|
+
if isinstance(context_tokens_raw, int) and context_tokens_raw >= 0
|
|
426
|
+
else 0
|
|
427
|
+
)
|
|
428
|
+
return SimpleNamespace(
|
|
429
|
+
cards=cards,
|
|
430
|
+
seq=seq,
|
|
431
|
+
context_tokens=context_tokens,
|
|
432
|
+
success=True,
|
|
433
|
+
)
|
|
434
|
+
|
|
384
435
|
async def aget_loop_state(self, loop_id: str) -> Any:
|
|
385
436
|
"""Load agent-loop state channels from the daemon (``loop_state_get`` RPC).
|
|
386
437
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Re-export shim for ``soothe_sdk.display.text_extract`` (RFC-413)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from soothe_sdk.display.text_extract import (
|
|
6
|
+
extract_ai_text_for_display,
|
|
7
|
+
extract_user_text_for_display,
|
|
8
|
+
normalize_stream_message,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"extract_ai_text_for_display",
|
|
13
|
+
"extract_user_text_for_display",
|
|
14
|
+
"normalize_stream_message",
|
|
15
|
+
]
|
|
@@ -25,7 +25,7 @@ def extract_text_from_message_content(content: Any) -> str:
|
|
|
25
25
|
parts.append(block)
|
|
26
26
|
elif isinstance(block, dict) and "text" in block:
|
|
27
27
|
parts.append(str(block["text"]))
|
|
28
|
-
return "".join(parts)
|
|
28
|
+
return "\n".join(parts)
|
|
29
29
|
return ""
|
|
30
30
|
|
|
31
31
|
|
|
@@ -41,7 +41,7 @@ def extract_plain_text_from_stream_message(msg: Any) -> str:
|
|
|
41
41
|
if text:
|
|
42
42
|
texts.append(str(text))
|
|
43
43
|
if texts:
|
|
44
|
-
return "".join(texts)
|
|
44
|
+
return "\n".join(texts)
|
|
45
45
|
if hasattr(msg, "content"):
|
|
46
46
|
return extract_text_from_message_content(getattr(msg, "content", None))
|
|
47
47
|
if isinstance(msg, dict):
|
|
@@ -54,7 +54,7 @@ def extract_plain_text_from_stream_message(msg: Any) -> str:
|
|
|
54
54
|
text = block.get("text", "")
|
|
55
55
|
if text:
|
|
56
56
|
texts.append(str(text))
|
|
57
|
-
return "".join(texts)
|
|
57
|
+
return "\n".join(texts)
|
|
58
58
|
content = body.get("content", "")
|
|
59
59
|
if isinstance(content, str):
|
|
60
60
|
return content
|
|
@@ -224,6 +224,9 @@ class SootheApp(
|
|
|
224
224
|
|
|
225
225
|
self._agent_running = False
|
|
226
226
|
|
|
227
|
+
self._bg_event_worker: Worker[None] | None = None
|
|
228
|
+
"""Background daemon event consumer worker (cancelled on active turn start)."""
|
|
229
|
+
|
|
227
230
|
self._server_startup_error: str | None = None
|
|
228
231
|
"""Set when daemon bootstrap fails; persists for the session lifetime."""
|
|
229
232
|
|
|
@@ -246,7 +246,12 @@ class _ExecutionMixin:
|
|
|
246
246
|
# the wire ``clarification_answer`` flag plus the ``clarification_answers``
|
|
247
247
|
# list, then clears the persisted flag so a follow-up turn is treated
|
|
248
248
|
# as a new goal.
|
|
249
|
-
|
|
249
|
+
#
|
|
250
|
+
# Use ``_send_to_agent`` (not a direct ``await _run_agent_task``) so the
|
|
251
|
+
# resumed turn runs in a Textual worker. Awaiting the task inline blocks
|
|
252
|
+
# the message handler — and therefore the event loop — until the loop
|
|
253
|
+
# next pauses, which freezes scrolling and chat-input focus.
|
|
254
|
+
await self._send_to_agent(payload_text)
|
|
250
255
|
|
|
251
256
|
async def _handle_shell_command(self, command: str) -> None:
|
|
252
257
|
"""Handle a shell command (! prefix).
|
|
@@ -488,7 +493,7 @@ class _ExecutionMixin:
|
|
|
488
493
|
"Commands: /quit, /clear, /editor, /autopilot, /mcp, "
|
|
489
494
|
"/model [--model-params JSON] [--default], /notifications, "
|
|
490
495
|
"/reload, /skill:<name>, /theme, "
|
|
491
|
-
"/tokens, /
|
|
496
|
+
"/tokens, /resume, "
|
|
492
497
|
"/research, /explore, /plan, /«subagent» (when configured), "
|
|
493
498
|
"/update, /auto-update, /changelog, /docs, /feedback, /help\n\n"
|
|
494
499
|
"Interactive Features:\n"
|
|
@@ -571,7 +576,7 @@ class _ExecutionMixin:
|
|
|
571
576
|
await self._mount_message(AppMessage(f"Started new loop: {new_loop_id}"))
|
|
572
577
|
elif cmd == "/editor":
|
|
573
578
|
await self.action_open_editor()
|
|
574
|
-
elif cmd == "/
|
|
579
|
+
elif cmd == "/resume":
|
|
575
580
|
await self._show_loop_selector()
|
|
576
581
|
elif cmd == "/update":
|
|
577
582
|
await self._handle_update_command()
|
|
@@ -813,6 +818,12 @@ class _ExecutionMixin:
|
|
|
813
818
|
return
|
|
814
819
|
self._agent_running = True
|
|
815
820
|
|
|
821
|
+
# Cancel background event consumer so it doesn't compete for
|
|
822
|
+
# WebSocket reads with the active turn's iter_turn_chunks().
|
|
823
|
+
if self._bg_event_worker is not None:
|
|
824
|
+
self._bg_event_worker.cancel()
|
|
825
|
+
self._bg_event_worker = None
|
|
826
|
+
|
|
816
827
|
if self._chat_input:
|
|
817
828
|
self._chat_input.set_cursor_active(active=False)
|
|
818
829
|
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Conversation history loading, daemon event consumption, and thin binder delegation.
|
|
2
|
+
|
|
3
|
+
Pure event → card binding logic lives in ``soothe_sdk.display.card_binder``
|
|
4
|
+
(RFC-413). The static methods on ``_HistoryMixin`` are kept as thin
|
|
5
|
+
wrappers so the existing ``SootheApp._convert_messages_to_data(...)`` API
|
|
6
|
+
(used by tests and other mixins) continues to work.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import uuid
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
from textual.content import Content
|
|
21
|
+
|
|
22
|
+
from soothe_sdk.display import card_binder as _binder
|
|
23
|
+
from soothe_sdk.display.text_extract import (
|
|
24
|
+
extract_ai_text_for_display,
|
|
25
|
+
extract_user_text_for_display,
|
|
26
|
+
normalize_stream_message,
|
|
27
|
+
)
|
|
28
|
+
from soothe_sdk.display.transcript_types import MessageData
|
|
29
|
+
from textual.content import Content
|
|
30
|
+
|
|
31
|
+
from soothe_cli.tui.app._module_init import _LoopHistoryPayload
|
|
32
|
+
from soothe_cli.tui.widgets.messages import (
|
|
33
|
+
AppMessage,
|
|
34
|
+
AssistantMessage,
|
|
35
|
+
UserMessage,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _HistoryMixin:
|
|
42
|
+
"""History conversion, loading, and daemon WebSocket event consumption."""
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Binder delegation (RFC-413).
|
|
46
|
+
# Pure logic lives in `soothe_sdk.display.card_binder`; these wrappers
|
|
47
|
+
# preserve the existing SootheApp API so tests and callers stay unchanged.
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _is_loop_internal_checkpoint_message(msg: Any) -> bool:
|
|
52
|
+
"""Delegate to ``soothe_sdk.display.card_binder.is_loop_internal_checkpoint_message``."""
|
|
53
|
+
return _binder.is_loop_internal_checkpoint_message(msg)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _merge_visible_messages_with_cognition_cards(
|
|
57
|
+
visible: list[MessageData],
|
|
58
|
+
cognition: list[MessageData],
|
|
59
|
+
) -> list[MessageData]:
|
|
60
|
+
"""Delegate to ``soothe_sdk.display.card_binder.merge_visible_messages_with_cognition_cards``."""
|
|
61
|
+
return _binder.merge_visible_messages_with_cognition_cards(visible, cognition)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _convert_messages_to_data(
|
|
65
|
+
messages: list[Any],
|
|
66
|
+
*,
|
|
67
|
+
cognition_card_replay: list[MessageData] | None = None,
|
|
68
|
+
) -> list[MessageData]:
|
|
69
|
+
"""Delegate to ``soothe_sdk.display.card_binder.convert_messages_to_data``."""
|
|
70
|
+
return _binder.convert_messages_to_data(
|
|
71
|
+
messages,
|
|
72
|
+
cognition_card_replay=cognition_card_replay,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _conversation_rows_to_langchain_messages(rows: list[dict[str, Any]]) -> list[Any]:
|
|
77
|
+
"""Delegate to ``soothe_sdk.display.card_binder.conversation_rows_to_langchain_messages``."""
|
|
78
|
+
return _binder.conversation_rows_to_langchain_messages(rows)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _parse_loop_event_timestamp(timestamp: Any) -> datetime | None:
|
|
82
|
+
"""Delegate to ``soothe_sdk.display.card_binder.parse_loop_event_timestamp``."""
|
|
83
|
+
return _binder.parse_loop_event_timestamp(timestamp)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _convert_event_to_message_data(event: dict[str, Any]) -> MessageData | None:
|
|
87
|
+
"""Delegate to ``soothe_sdk.display.card_binder.convert_event_to_message_data``."""
|
|
88
|
+
return _binder.convert_event_to_message_data(event)
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _collect_cognition_card_replay(events: list[dict[str, Any]]) -> list[MessageData]:
|
|
92
|
+
"""Delegate to ``soothe_sdk.display.card_binder.collect_cognition_card_replay``."""
|
|
93
|
+
return _binder.collect_cognition_card_replay(events)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _merge_step_progress(prior: MessageData, later: MessageData) -> MessageData:
|
|
97
|
+
"""Delegate to ``soothe_sdk.display.card_binder.merge_step_progress``."""
|
|
98
|
+
return _binder.merge_step_progress(prior, later)
|
|
99
|
+
|
|
100
|
+
def _convert_loop_events_to_data(self, events: list[dict[str, Any]]) -> list[MessageData]:
|
|
101
|
+
"""Delegate to ``soothe_sdk.display.card_binder.convert_loop_events_to_data``."""
|
|
102
|
+
return _binder.convert_loop_events_to_data(events)
|
|
103
|
+
|
|
104
|
+
def _merge_history_sources(
|
|
105
|
+
self,
|
|
106
|
+
checkpoint_messages: list[Any],
|
|
107
|
+
activity_events: list[dict[str, Any]],
|
|
108
|
+
) -> list[tuple[str, Any]]:
|
|
109
|
+
"""Delegate to ``soothe_sdk.display.card_binder.merge_history_sources``."""
|
|
110
|
+
return _binder.merge_history_sources(checkpoint_messages, activity_events)
|
|
111
|
+
|
|
112
|
+
def _convert_combined_to_data(self, combined: list[tuple[str, Any]]) -> list[MessageData]:
|
|
113
|
+
"""Delegate to ``soothe_sdk.display.card_binder.convert_combined_to_data``."""
|
|
114
|
+
return _binder.convert_combined_to_data(combined)
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# I/O: resume reads from the daemon's bound card ledger (RFC-413).
|
|
118
|
+
# Legacy checkpoint + activity-log readers were removed when RFC-411
|
|
119
|
+
# was superseded — the daemon now owns derivation and exposes a single
|
|
120
|
+
# ``loop_cards_fetch`` RPC.
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
async def _fetch_loop_history_data(self, loop_id: str) -> _LoopHistoryPayload:
|
|
124
|
+
"""Fetch conversation history from the daemon's bound card ledger.
|
|
125
|
+
|
|
126
|
+
RFC-413: this is the only resume path. The daemon's
|
|
127
|
+
``loop_cards_fetch`` RPC returns the cards plus the persisted
|
|
128
|
+
context-token count in one round-trip; the daemon eagerly
|
|
129
|
+
backfills the ledger for legacy loops on first access.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
loop_id: Loop id.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Payload containing converted message data and the persisted
|
|
136
|
+
context-token count. Empty payload on error or when the daemon
|
|
137
|
+
session is unavailable; the caller mounts an "Could not load
|
|
138
|
+
history" message via the surrounding error path.
|
|
139
|
+
"""
|
|
140
|
+
if self._daemon_session is None:
|
|
141
|
+
return _LoopHistoryPayload([], 0)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
response = await self._daemon_session.fetch_loop_cards(loop_id)
|
|
145
|
+
except Exception:
|
|
146
|
+
logger.warning("loop_cards_fetch failed for %s", loop_id, exc_info=True)
|
|
147
|
+
return _LoopHistoryPayload([], 0)
|
|
148
|
+
|
|
149
|
+
if not getattr(response, "success", False):
|
|
150
|
+
return _LoopHistoryPayload([], 0)
|
|
151
|
+
|
|
152
|
+
raw_cards = list(getattr(response, "cards", []) or [])
|
|
153
|
+
context_tokens = int(getattr(response, "context_tokens", 0) or 0)
|
|
154
|
+
if not raw_cards:
|
|
155
|
+
return _LoopHistoryPayload([], context_tokens)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
from soothe_sdk.display.card_ledger import card_from_wire_dict
|
|
159
|
+
|
|
160
|
+
data = [card_from_wire_dict(c) for c in raw_cards]
|
|
161
|
+
except Exception:
|
|
162
|
+
logger.warning(
|
|
163
|
+
"Failed to deserialize loop_cards_fetch payload for %s",
|
|
164
|
+
loop_id,
|
|
165
|
+
exc_info=True,
|
|
166
|
+
)
|
|
167
|
+
return _LoopHistoryPayload([], context_tokens)
|
|
168
|
+
|
|
169
|
+
return _LoopHistoryPayload(data, context_tokens)
|
|
170
|
+
|
|
171
|
+
async def _upgrade_loop_message_link(
|
|
172
|
+
self,
|
|
173
|
+
widget: AppMessage,
|
|
174
|
+
*,
|
|
175
|
+
prefix: str,
|
|
176
|
+
loop_id: str,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Upgrade a plain status message to a linked one when URL resolves.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
widget: The already-mounted app message.
|
|
182
|
+
prefix: Text prefix before the loop id.
|
|
183
|
+
loop_id: Loop id.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
loop_msg = await self._build_loop_status_line(prefix, loop_id)
|
|
187
|
+
if not isinstance(loop_msg, Content):
|
|
188
|
+
logger.debug(
|
|
189
|
+
"Skipping loop link upgrade for %s: URL did not resolve",
|
|
190
|
+
loop_id,
|
|
191
|
+
)
|
|
192
|
+
return
|
|
193
|
+
if widget.parent is None:
|
|
194
|
+
logger.debug(
|
|
195
|
+
"Skipping loop link upgrade for %s: widget no longer mounted",
|
|
196
|
+
loop_id,
|
|
197
|
+
)
|
|
198
|
+
return
|
|
199
|
+
# Keep serialized content in sync with the rendered content.
|
|
200
|
+
widget._content = loop_msg
|
|
201
|
+
widget.update(loop_msg)
|
|
202
|
+
except Exception:
|
|
203
|
+
logger.warning(
|
|
204
|
+
"Failed to upgrade loop message link for %s",
|
|
205
|
+
loop_id,
|
|
206
|
+
exc_info=True,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _schedule_loop_message_link(
|
|
210
|
+
self,
|
|
211
|
+
widget: AppMessage,
|
|
212
|
+
*,
|
|
213
|
+
prefix: str,
|
|
214
|
+
loop_id: str,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Schedule loop URL link resolution and apply updates in the background.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
widget: The message widget to update.
|
|
220
|
+
prefix: Text prefix before the loop id.
|
|
221
|
+
loop_id: Loop id.
|
|
222
|
+
"""
|
|
223
|
+
self.run_worker(
|
|
224
|
+
self._upgrade_loop_message_link(
|
|
225
|
+
widget,
|
|
226
|
+
prefix=prefix,
|
|
227
|
+
loop_id=loop_id,
|
|
228
|
+
),
|
|
229
|
+
exclusive=False,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
async def _consume_daemon_events_background(self) -> None:
|
|
233
|
+
"""Consume daemon websocket events for an already-running loop subscription.
|
|
234
|
+
|
|
235
|
+
IG-228: Reads passively when the loop is running without an active local turn,
|
|
236
|
+
using the same processing pipeline as streaming queries.
|
|
237
|
+
"""
|
|
238
|
+
if not self._daemon_session:
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
logger.info("Starting background event consumer for subscribed loop")
|
|
242
|
+
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
|
|
243
|
+
|
|
244
|
+
assistant_cards_by_ns: dict[tuple[Any, ...], AssistantMessage] = {}
|
|
245
|
+
last_user_text_by_ns: dict[tuple[Any, ...], str] = {}
|
|
246
|
+
last_ai_chunk_by_ns: dict[tuple[Any, ...], str] = {}
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
# Use iter_turn_chunks to read events (same as active turn execution)
|
|
250
|
+
chunk_source = self._daemon_session.iter_turn_chunks()
|
|
251
|
+
async for chunk in chunk_source:
|
|
252
|
+
if not isinstance(chunk, (list, tuple)) or len(chunk) != 3:
|
|
253
|
+
logger.debug("Skipping invalid stream chunk: %s", type(chunk).__name__)
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
namespace, mode, data = chunk
|
|
257
|
+
ns_key = tuple(namespace) if namespace else ()
|
|
258
|
+
|
|
259
|
+
async def _flush_assistant_ns(key: tuple[Any, ...]) -> None:
|
|
260
|
+
card = assistant_cards_by_ns.pop(key, None)
|
|
261
|
+
if card is not None:
|
|
262
|
+
await card.stop_stream()
|
|
263
|
+
|
|
264
|
+
if mode == "status":
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
if mode == "messages":
|
|
268
|
+
if not isinstance(data, (list, tuple)) or len(data) != 2:
|
|
269
|
+
continue
|
|
270
|
+
message, _metadata = data
|
|
271
|
+
message = normalize_stream_message(message)
|
|
272
|
+
|
|
273
|
+
user_text = extract_user_text_for_display(message)
|
|
274
|
+
if user_text is not None:
|
|
275
|
+
# Deduplicate immediate replayed user rows after reconnect/resubscribe.
|
|
276
|
+
if last_user_text_by_ns.get(ns_key) == user_text:
|
|
277
|
+
continue
|
|
278
|
+
await _flush_assistant_ns(ns_key)
|
|
279
|
+
await self._mount_message(UserMessage(user_text))
|
|
280
|
+
last_user_text_by_ns[ns_key] = user_text
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
if isinstance(message, ToolMessage):
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
if isinstance(message, (AIMessage, AIMessageChunk)):
|
|
287
|
+
extracted = extract_ai_text_for_display(message)
|
|
288
|
+
if extracted:
|
|
289
|
+
# Deduplicate immediate replayed AI chunks after reconnect/resubscribe.
|
|
290
|
+
if last_ai_chunk_by_ns.get(ns_key) == extracted:
|
|
291
|
+
if getattr(message, "chunk_position", None) == "last":
|
|
292
|
+
await _flush_assistant_ns(ns_key)
|
|
293
|
+
continue
|
|
294
|
+
asst = assistant_cards_by_ns.get(ns_key)
|
|
295
|
+
if asst is None:
|
|
296
|
+
asst = AssistantMessage(id=f"asst-{uuid.uuid4().hex[:8]}")
|
|
297
|
+
await self._mount_message(asst)
|
|
298
|
+
assistant_cards_by_ns[ns_key] = asst
|
|
299
|
+
await asst.append_content(extracted)
|
|
300
|
+
last_ai_chunk_by_ns[ns_key] = extracted
|
|
301
|
+
|
|
302
|
+
if getattr(message, "chunk_position", None) == "last":
|
|
303
|
+
await _flush_assistant_ns(ns_key)
|
|
304
|
+
last_ai_chunk_by_ns.pop(ns_key, None)
|
|
305
|
+
continue
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
if mode != "updates" or not isinstance(data, dict):
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
await _flush_assistant_ns(ns_key)
|
|
312
|
+
payloads: list[dict[str, Any]] = []
|
|
313
|
+
if isinstance(data.get("type"), str):
|
|
314
|
+
payloads.append(data)
|
|
315
|
+
for value in data.values():
|
|
316
|
+
if isinstance(value, dict) and isinstance(value.get("type"), str):
|
|
317
|
+
payloads.append(value)
|
|
318
|
+
|
|
319
|
+
except asyncio.CancelledError:
|
|
320
|
+
logger.info("Background event consumer cancelled")
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
logger.warning("Background event consumer error: %s", exc)
|
|
323
|
+
finally:
|
|
324
|
+
for card in assistant_cards_by_ns.values():
|
|
325
|
+
with suppress(Exception):
|
|
326
|
+
await card.stop_stream()
|
|
327
|
+
self._bg_event_worker = None
|
|
328
|
+
# If an agent turn was active (e.g. the loop completed while the
|
|
329
|
+
# background consumer was reading), perform the same cleanup that
|
|
330
|
+
# _run_agent_task's finally block would: re-enable input, clear
|
|
331
|
+
# spinner, drain deferred actions, and process queued messages.
|
|
332
|
+
if self._agent_running:
|
|
333
|
+
with suppress(Exception):
|
|
334
|
+
await self._cleanup_agent_task()
|
|
335
|
+
logger.info("Background event consumer stopped")
|
|
@@ -369,8 +369,13 @@ class _ModelMixin:
|
|
|
369
369
|
warn_if_missing=False,
|
|
370
370
|
)
|
|
371
371
|
|
|
372
|
+
# Render historical transcript before live events start arriving on the
|
|
373
|
+
# new subscription (RFC-413). Awaiting (rather than scheduling) guarantees
|
|
374
|
+
# painting order: prior history first, then live frames.
|
|
375
|
+
await self._load_loop_history(loop_id=loop_id)
|
|
376
|
+
|
|
372
377
|
# Start consuming daemon events for this loop
|
|
373
|
-
self.run_worker(
|
|
378
|
+
self._bg_event_worker = self.run_worker(
|
|
374
379
|
self._consume_daemon_events_background(),
|
|
375
380
|
exclusive=False,
|
|
376
381
|
group="daemon-event-reader",
|
|
@@ -389,7 +394,7 @@ class _ModelMixin:
|
|
|
389
394
|
warn_if_missing=True,
|
|
390
395
|
)
|
|
391
396
|
await self._mount_message(
|
|
392
|
-
AppMessage(f"Failed to attach to loop {loop_id}: {exc}. Use /
|
|
397
|
+
AppMessage(f"Failed to attach to loop {loop_id}: {exc}. Use /resume to try again.")
|
|
393
398
|
)
|
|
394
399
|
finally:
|
|
395
400
|
self._loop_switching = False
|
|
@@ -488,7 +488,7 @@ class _StartupMixin:
|
|
|
488
488
|
"Loop %s is running, starting background event reader",
|
|
489
489
|
status_loop_id[:8] if status_loop_id else "?",
|
|
490
490
|
)
|
|
491
|
-
self.run_worker(
|
|
491
|
+
self._bg_event_worker = self.run_worker(
|
|
492
492
|
self._consume_daemon_events_background(),
|
|
493
493
|
exclusive=False,
|
|
494
494
|
group="daemon-event-reader",
|