comate-cli 0.2.0__tar.gz → 0.2.2__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.
- {comate_cli-0.2.0 → comate_cli-0.2.2}/.gitignore +5 -1
- {comate_cli-0.2.0 → comate_cli-0.2.2}/PKG-INFO +1 -1
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/mcp_cli.py +22 -15
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/app.py +10 -6
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/logging_adapter.py +94 -15
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/rpc_protocol.py +12 -5
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/rpc_stdio.py +104 -9
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui.py +9 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/pyproject.toml +1 -1
- comate_cli-0.2.2/tests/test_app_mcp_preload.py +48 -0
- comate_cli-0.2.2/tests/test_logging_adapter.py +68 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_mcp_cli.py +40 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_rpc_protocol.py +7 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_rpc_stdio_bridge.py +43 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/uv.lock +74 -91
- {comate_cli-0.2.0 → comate_cli-0.2.2}/README.md +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/__init__.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/__main__.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/main.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/__init__.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/animations.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/assistant_render.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/env_utils.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/error_display.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/event_renderer.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/fragment_utils.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/history_printer.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/input_geometry.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/logo.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/markdown_render.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/mention_completer.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/message_style.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/models.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/preflight.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/question_view.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/resume_selector.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/rewind_store.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/selection_menu.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/session_view.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/slash_commands.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/startup.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/status_bar.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/text_effects.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tips.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tool_view.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/commands.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/history_sync.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/input_behavior.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/key_bindings.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/render_panels.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/test_memory.md +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/conftest.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_app_preflight_gate.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_app_shutdown.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_app_usage_line.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_cli_project_root.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_compact_command_semantics.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_completion_context_activation.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_completion_status_panel.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_context_command.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_custom_slash_commands.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_event_renderer.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_history_sync.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_input_behavior.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_interrupt_exit_semantics.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_layout_coordinator.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_logo.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_main_args.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_mcp_slash_command.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_mention_completer.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_preflight.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_preflight_copilot.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_question_key_bindings.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_question_view.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_resume_selector.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_rewind_command_semantics.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_rewind_store.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_selection_menu.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_skills_slash_command.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_slash_argument_hint.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_slash_completer.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_slash_registry.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_status_bar.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_task_panel_key_bindings.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_tool_view.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_tui_elapsed_status.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_tui_mcp_init_gate.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_tui_paste_placeholder.py +0 -0
- {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_tui_split_invariance.py +0 -0
|
@@ -23,8 +23,13 @@ class McpCliError(ValueError):
|
|
|
23
23
|
self.exit_code = int(exit_code)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
class _McpArgumentParser(argparse.ArgumentParser):
|
|
27
|
+
def error(self, message: str) -> None:
|
|
28
|
+
raise McpCliError(message)
|
|
29
|
+
|
|
30
|
+
|
|
26
31
|
def _build_parser() -> argparse.ArgumentParser:
|
|
27
|
-
parser =
|
|
32
|
+
parser = _McpArgumentParser(
|
|
28
33
|
prog="comate mcp",
|
|
29
34
|
description="Configure and manage MCP servers",
|
|
30
35
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
@@ -143,11 +148,11 @@ def _parse_env_entries(entries: list[str]) -> dict[str, str]:
|
|
|
143
148
|
return env
|
|
144
149
|
|
|
145
150
|
|
|
146
|
-
def
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return
|
|
151
|
+
def _split_trailing_command_args(argv: list[str]) -> tuple[list[str], list[str]]:
|
|
152
|
+
if "--" not in argv:
|
|
153
|
+
return list(argv), []
|
|
154
|
+
separator_index = argv.index("--")
|
|
155
|
+
return list(argv[:separator_index]), list(argv[separator_index + 1 :])
|
|
151
156
|
|
|
152
157
|
|
|
153
158
|
def _build_add_server_config(args: argparse.Namespace) -> McpServerConfig:
|
|
@@ -155,9 +160,7 @@ def _build_add_server_config(args: argparse.Namespace) -> McpServerConfig:
|
|
|
155
160
|
header_entries = [str(item) for item in list(args.header or [])]
|
|
156
161
|
env_entries = [str(item) for item in list(args.env or [])]
|
|
157
162
|
command_or_url = str(args.command_or_url or "").strip()
|
|
158
|
-
remainder =
|
|
159
|
-
[str(item) for item in list(getattr(args, "extra_args", []) or [])]
|
|
160
|
-
)
|
|
163
|
+
remainder = [str(item) for item in list(getattr(args, "extra_args", []) or [])]
|
|
161
164
|
|
|
162
165
|
if transport == "http":
|
|
163
166
|
if env_entries:
|
|
@@ -177,6 +180,9 @@ def _build_add_server_config(args: argparse.Namespace) -> McpServerConfig:
|
|
|
177
180
|
|
|
178
181
|
if header_entries:
|
|
179
182
|
raise McpCliError("--header is only supported for http transport")
|
|
183
|
+
stdio_args = list(remainder)
|
|
184
|
+
if not command_or_url and stdio_args:
|
|
185
|
+
command_or_url = stdio_args.pop(0)
|
|
180
186
|
if not command_or_url:
|
|
181
187
|
raise McpCliError("stdio transport requires command after <name>")
|
|
182
188
|
|
|
@@ -184,8 +190,8 @@ def _build_add_server_config(args: argparse.Namespace) -> McpServerConfig:
|
|
|
184
190
|
cfg = {
|
|
185
191
|
"command": command_or_url,
|
|
186
192
|
}
|
|
187
|
-
if
|
|
188
|
-
cfg["args"] =
|
|
193
|
+
if stdio_args:
|
|
194
|
+
cfg["args"] = stdio_args
|
|
189
195
|
if env:
|
|
190
196
|
cfg["env"] = env
|
|
191
197
|
return cfg # type: ignore[return-value]
|
|
@@ -365,16 +371,17 @@ def _cmd_get(args: argparse.Namespace, *, project_root: PathInput | None) -> Non
|
|
|
365
371
|
|
|
366
372
|
def run_mcp_command(argv: list[str], *, project_root: PathInput | None = None) -> None:
|
|
367
373
|
parser = _build_parser()
|
|
368
|
-
|
|
374
|
+
parser_argv, trailing_args = _split_trailing_command_args(argv)
|
|
375
|
+
parsed = parser.parse_args(parser_argv)
|
|
369
376
|
command = str(getattr(parsed, "command", "") or "").strip()
|
|
370
377
|
if not command:
|
|
371
378
|
parser.print_help(sys.stdout)
|
|
372
379
|
return
|
|
373
|
-
if command != "add" and
|
|
374
|
-
joined = " ".join(
|
|
380
|
+
if command != "add" and trailing_args:
|
|
381
|
+
joined = " ".join(["--", *trailing_args])
|
|
375
382
|
raise McpCliError(f"Unrecognized arguments: {joined}")
|
|
376
383
|
if command == "add":
|
|
377
|
-
setattr(parsed, "extra_args", list(
|
|
384
|
+
setattr(parsed, "extra_args", list(trailing_args))
|
|
378
385
|
|
|
379
386
|
handlers = {
|
|
380
387
|
"add": _cmd_add,
|
|
@@ -15,8 +15,7 @@ from pathlib import Path
|
|
|
15
15
|
|
|
16
16
|
from rich.console import Console
|
|
17
17
|
|
|
18
|
-
from comate_agent_sdk import Agent
|
|
19
|
-
from comate_agent_sdk.agent import AgentConfig, ChatSession
|
|
18
|
+
from comate_agent_sdk.agent import Agent, AgentConfig, ChatSession
|
|
20
19
|
from comate_agent_sdk.context import EnvOptions
|
|
21
20
|
from comate_agent_sdk.tools import tool
|
|
22
21
|
|
|
@@ -230,16 +229,20 @@ def _format_resume_hint(session_id: str | None) -> str | None:
|
|
|
230
229
|
|
|
231
230
|
async def _preload_mcp_in_tui(session: ChatSession) -> None:
|
|
232
231
|
"""在 TUI 内异步加载 MCP,初始化阶段不输出 scrollback 文案。"""
|
|
233
|
-
|
|
232
|
+
runtime = session.runtime
|
|
233
|
+
if not bool(runtime.options.mcp_enabled):
|
|
234
234
|
return
|
|
235
235
|
|
|
236
236
|
try:
|
|
237
|
-
|
|
237
|
+
preload_task = runtime.start_mcp_preload()
|
|
238
|
+
if preload_task is None:
|
|
239
|
+
return
|
|
240
|
+
await preload_task
|
|
238
241
|
except Exception as e:
|
|
239
242
|
logger.warning(f"MCP init failed: {e}", exc_info=True)
|
|
240
243
|
return
|
|
241
244
|
|
|
242
|
-
mgr =
|
|
245
|
+
mgr = runtime._mcp_manager
|
|
243
246
|
if mgr is None:
|
|
244
247
|
return
|
|
245
248
|
|
|
@@ -311,7 +314,7 @@ async def run(
|
|
|
311
314
|
|
|
312
315
|
# 配置 TUI logging handler(将 SDK 日志输出到 TUI)
|
|
313
316
|
from comate_cli.terminal_agent.logging_adapter import setup_tui_logging
|
|
314
|
-
setup_tui_logging(renderer)
|
|
317
|
+
logging_session = setup_tui_logging(renderer)
|
|
315
318
|
|
|
316
319
|
tui = TerminalAgentTUI(session, status_bar, renderer)
|
|
317
320
|
tui.add_resume_history(mode)
|
|
@@ -333,6 +336,7 @@ async def run(
|
|
|
333
336
|
exc_info=True,
|
|
334
337
|
)
|
|
335
338
|
finally:
|
|
339
|
+
logging_session.close()
|
|
336
340
|
if active_session is session:
|
|
337
341
|
await _graceful_shutdown(active_session)
|
|
338
342
|
else:
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
5
7
|
from typing import TYPE_CHECKING
|
|
6
8
|
|
|
7
9
|
from prompt_toolkit.application import run_in_terminal
|
|
@@ -106,42 +108,119 @@ class TUILoggingHandler(logging.Handler):
|
|
|
106
108
|
# DummyApplication,直接调用
|
|
107
109
|
_append()
|
|
108
110
|
else:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
app.create_background_task(
|
|
112
|
+
run_in_terminal(_append, in_executor=False)
|
|
113
|
+
)
|
|
111
114
|
except Exception:
|
|
112
115
|
# 没有 app 或导入失败,直接调用
|
|
113
116
|
_append()
|
|
114
117
|
|
|
115
118
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
class _DropTerminalStreamFilter(logging.Filter):
|
|
120
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
121
|
+
del record
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TUILoggingSession:
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
root_logger: logging.Logger,
|
|
130
|
+
added_handlers: list[logging.Handler],
|
|
131
|
+
muted_handlers: list[tuple[logging.Handler, logging.Filter]],
|
|
132
|
+
previous_level: int,
|
|
133
|
+
) -> None:
|
|
134
|
+
self._root_logger = root_logger
|
|
135
|
+
self._added_handlers = list(added_handlers)
|
|
136
|
+
self._muted_handlers = list(muted_handlers)
|
|
137
|
+
self._previous_level = previous_level
|
|
138
|
+
self._closed = False
|
|
139
|
+
|
|
140
|
+
def close(self) -> None:
|
|
141
|
+
if self._closed:
|
|
142
|
+
return
|
|
143
|
+
self._closed = True
|
|
144
|
+
|
|
145
|
+
for handler, log_filter in self._muted_handlers:
|
|
146
|
+
try:
|
|
147
|
+
handler.removeFilter(log_filter)
|
|
148
|
+
except Exception:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
for handler in self._added_handlers:
|
|
152
|
+
try:
|
|
153
|
+
self._root_logger.removeHandler(handler)
|
|
154
|
+
except Exception:
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
handler.close()
|
|
158
|
+
except Exception:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
self._root_logger.setLevel(self._previous_level)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _build_file_handler() -> logging.Handler:
|
|
124
165
|
from logging.handlers import RotatingFileHandler
|
|
125
166
|
|
|
126
|
-
# 1. 日志文件 handler(完整调试信息,含 traceback)
|
|
127
167
|
log_dir = os.path.join(os.path.expanduser("~"), ".comate", "logs")
|
|
128
168
|
os.makedirs(log_dir, exist_ok=True)
|
|
129
169
|
log_path = os.path.join(log_dir, "agent.log")
|
|
130
170
|
file_handler = RotatingFileHandler(
|
|
131
|
-
log_path,
|
|
171
|
+
log_path,
|
|
172
|
+
maxBytes=10 * 1024 * 1024,
|
|
173
|
+
backupCount=3,
|
|
174
|
+
encoding="utf-8",
|
|
132
175
|
)
|
|
133
176
|
file_handler.setLevel(logging.DEBUG)
|
|
134
177
|
file_handler.setFormatter(
|
|
135
178
|
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
136
179
|
)
|
|
180
|
+
return file_handler
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _should_mute_terminal_stream(handler: logging.Handler) -> bool:
|
|
184
|
+
if not isinstance(handler, logging.StreamHandler):
|
|
185
|
+
return False
|
|
186
|
+
stream = getattr(handler, "stream", None)
|
|
187
|
+
return stream in {sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__}
|
|
188
|
+
|
|
137
189
|
|
|
138
|
-
|
|
190
|
+
def setup_tui_logging(renderer: EventRenderer) -> TUILoggingSession:
|
|
191
|
+
"""统一日志初始化:文件 + TUI 双通道。
|
|
192
|
+
|
|
193
|
+
- 所有日志(含 traceback)写入 ~/.comate/logs/agent.log(RotatingFileHandler)
|
|
194
|
+
- WARNING/ERROR 以用户友好格式显示在 TUI scrollback
|
|
195
|
+
- 临时静默 root logger 上写往 stdout/stderr 的 stream handler,避免污染 TUI
|
|
196
|
+
"""
|
|
139
197
|
root = logging.getLogger()
|
|
140
|
-
root.
|
|
198
|
+
previous_level = root.level
|
|
199
|
+
|
|
200
|
+
# 1. 日志文件 handler(完整调试信息,含 traceback)
|
|
201
|
+
file_handler = _build_file_handler()
|
|
141
202
|
root.addHandler(file_handler)
|
|
142
203
|
root.setLevel(logging.DEBUG)
|
|
143
204
|
|
|
144
|
-
#
|
|
205
|
+
# 2. TUI handler 挂到 root(覆盖所有命名空间的 WARNING/ERROR)
|
|
145
206
|
tui_handler = TUILoggingHandler(renderer)
|
|
146
207
|
tui_handler.setLevel(logging.WARNING)
|
|
147
208
|
root.addHandler(tui_handler)
|
|
209
|
+
|
|
210
|
+
# 3. 临时静默直写终端的 handler,避免污染 prompt_toolkit UI
|
|
211
|
+
muted_handlers: list[tuple[logging.Handler, logging.Filter]] = []
|
|
212
|
+
for handler in list(root.handlers):
|
|
213
|
+
if handler in {file_handler, tui_handler}:
|
|
214
|
+
continue
|
|
215
|
+
if not _should_mute_terminal_stream(handler):
|
|
216
|
+
continue
|
|
217
|
+
log_filter = _DropTerminalStreamFilter()
|
|
218
|
+
handler.addFilter(log_filter)
|
|
219
|
+
muted_handlers.append((handler, log_filter))
|
|
220
|
+
|
|
221
|
+
return TUILoggingSession(
|
|
222
|
+
root_logger=root,
|
|
223
|
+
added_handlers=[file_handler, tui_handler],
|
|
224
|
+
muted_handlers=muted_handlers,
|
|
225
|
+
previous_level=previous_level,
|
|
226
|
+
)
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from dataclasses import asdict, is_dataclass
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any, TypeGuard
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class ErrorCodes:
|
|
@@ -30,6 +30,13 @@ class JSONRPCProtocolError(Exception):
|
|
|
30
30
|
self.data = data
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
JSONRPCRequestId = str | int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_jsonrpc_request_id(raw_value: Any) -> TypeGuard[JSONRPCRequestId]:
|
|
37
|
+
return isinstance(raw_value, (str, int)) and not isinstance(raw_value, bool)
|
|
38
|
+
|
|
39
|
+
|
|
33
40
|
def parse_jsonrpc_message(raw_line: str) -> dict[str, Any]:
|
|
34
41
|
try:
|
|
35
42
|
parsed = json.loads(raw_line)
|
|
@@ -52,13 +59,13 @@ def parse_jsonrpc_message(raw_line: str) -> dict[str, Any]:
|
|
|
52
59
|
return parsed
|
|
53
60
|
|
|
54
61
|
|
|
55
|
-
def build_success_response(request_id:
|
|
62
|
+
def build_success_response(request_id: JSONRPCRequestId, result: Any) -> dict[str, Any]:
|
|
56
63
|
return {"jsonrpc": "2.0", "id": request_id, "result": _to_jsonable(result)}
|
|
57
64
|
|
|
58
65
|
|
|
59
66
|
def build_error_response(
|
|
60
67
|
*,
|
|
61
|
-
request_id:
|
|
68
|
+
request_id: JSONRPCRequestId | None,
|
|
62
69
|
code: int,
|
|
63
70
|
message: str,
|
|
64
71
|
data: Any = None,
|
|
@@ -82,8 +89,8 @@ def build_event_notification(event: Any) -> dict[str, Any]:
|
|
|
82
89
|
return {"jsonrpc": "2.0", "method": "event", "params": params}
|
|
83
90
|
|
|
84
91
|
|
|
85
|
-
def _coerce_request_id(raw_value: Any) ->
|
|
86
|
-
if
|
|
92
|
+
def _coerce_request_id(raw_value: Any) -> JSONRPCRequestId | None:
|
|
93
|
+
if is_jsonrpc_request_id(raw_value):
|
|
87
94
|
return raw_value
|
|
88
95
|
return None
|
|
89
96
|
|
|
@@ -14,6 +14,7 @@ from comate_cli.terminal_agent.rpc_protocol import (
|
|
|
14
14
|
build_error_response,
|
|
15
15
|
build_event_notification,
|
|
16
16
|
build_success_response,
|
|
17
|
+
is_jsonrpc_request_id,
|
|
17
18
|
parse_jsonrpc_message,
|
|
18
19
|
)
|
|
19
20
|
|
|
@@ -33,8 +34,13 @@ class StdioRPCBridge:
|
|
|
33
34
|
self._init_error: Exception | None = None
|
|
34
35
|
self._active_prompt_request_id: str | int | None = None
|
|
35
36
|
self._active_prompt_result: asyncio.Future[dict[str, Any]] | None = None
|
|
37
|
+
self._finalize_prompt_task: asyncio.Task[None] | None = None
|
|
38
|
+
self._stdin_queue: asyncio.Queue[str | None] | None = None
|
|
39
|
+
self._stdin_reader_fd: int | None = None
|
|
40
|
+
self._stdin_error: Exception | None = None
|
|
36
41
|
|
|
37
42
|
async def run(self) -> None:
|
|
43
|
+
self._install_stdin_reader()
|
|
38
44
|
self._event_pump_task = asyncio.create_task(
|
|
39
45
|
self._run_event_pump(),
|
|
40
46
|
name="rpc-event-pump",
|
|
@@ -42,10 +48,29 @@ class StdioRPCBridge:
|
|
|
42
48
|
try:
|
|
43
49
|
while not self._closing:
|
|
44
50
|
pump_task = self._event_pump_task
|
|
45
|
-
|
|
51
|
+
input_task = asyncio.create_task(
|
|
52
|
+
self._read_next_input_line(),
|
|
53
|
+
name="rpc-stdin-read",
|
|
54
|
+
)
|
|
55
|
+
wait_tasks: set[asyncio.Task[Any]] = {input_task}
|
|
56
|
+
if pump_task is not None:
|
|
57
|
+
wait_tasks.add(pump_task)
|
|
58
|
+
|
|
59
|
+
done, _pending = await asyncio.wait(
|
|
60
|
+
wait_tasks,
|
|
61
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if pump_task is not None and pump_task in done:
|
|
65
|
+
input_task.cancel()
|
|
66
|
+
try:
|
|
67
|
+
await input_task
|
|
68
|
+
except asyncio.CancelledError:
|
|
69
|
+
pass
|
|
46
70
|
await pump_task
|
|
71
|
+
break
|
|
47
72
|
|
|
48
|
-
raw_line =
|
|
73
|
+
raw_line = input_task.result()
|
|
49
74
|
if raw_line == "":
|
|
50
75
|
break
|
|
51
76
|
|
|
@@ -56,12 +81,14 @@ class StdioRPCBridge:
|
|
|
56
81
|
await self._handle_incoming_line(line)
|
|
57
82
|
finally:
|
|
58
83
|
self._closing = True
|
|
84
|
+
self._remove_stdin_reader()
|
|
59
85
|
await self._cancel_active_prompt()
|
|
60
86
|
active_future = self._active_prompt_result
|
|
61
87
|
if active_future is not None and not active_future.done():
|
|
62
88
|
active_future.set_result(
|
|
63
89
|
{"status": "cancelled", "stop_reason": "cancelled"}
|
|
64
90
|
)
|
|
91
|
+
await self._await_finalize_prompt_task()
|
|
65
92
|
pump_task = self._event_pump_task
|
|
66
93
|
if pump_task is not None:
|
|
67
94
|
pump_task.cancel()
|
|
@@ -92,7 +119,7 @@ class StdioRPCBridge:
|
|
|
92
119
|
if method is None:
|
|
93
120
|
await self._send(
|
|
94
121
|
build_error_response(
|
|
95
|
-
request_id=request_id if
|
|
122
|
+
request_id=request_id if is_jsonrpc_request_id(request_id) else None,
|
|
96
123
|
code=ErrorCodes.INVALID_REQUEST,
|
|
97
124
|
message="response payload is not supported on this endpoint",
|
|
98
125
|
)
|
|
@@ -114,14 +141,14 @@ class StdioRPCBridge:
|
|
|
114
141
|
|
|
115
142
|
await self._send(
|
|
116
143
|
build_error_response(
|
|
117
|
-
request_id=request_id if
|
|
144
|
+
request_id=request_id if is_jsonrpc_request_id(request_id) else None,
|
|
118
145
|
code=ErrorCodes.METHOD_NOT_FOUND,
|
|
119
146
|
message=f"method not found: {method}",
|
|
120
147
|
)
|
|
121
148
|
)
|
|
122
149
|
|
|
123
150
|
async def _handle_initialize(self, request_id: Any) -> None:
|
|
124
|
-
if not
|
|
151
|
+
if not is_jsonrpc_request_id(request_id):
|
|
125
152
|
await self._send(
|
|
126
153
|
build_error_response(
|
|
127
154
|
request_id=None,
|
|
@@ -155,7 +182,7 @@ class StdioRPCBridge:
|
|
|
155
182
|
)
|
|
156
183
|
|
|
157
184
|
async def _handle_prompt(self, request_id: Any, params: Any) -> None:
|
|
158
|
-
if not
|
|
185
|
+
if not is_jsonrpc_request_id(request_id):
|
|
159
186
|
await self._send(
|
|
160
187
|
build_error_response(
|
|
161
188
|
request_id=None,
|
|
@@ -213,13 +240,13 @@ class StdioRPCBridge:
|
|
|
213
240
|
)
|
|
214
241
|
return
|
|
215
242
|
|
|
216
|
-
asyncio.create_task(
|
|
243
|
+
self._finalize_prompt_task = asyncio.create_task(
|
|
217
244
|
self._finalize_prompt_result(result_future=result_future, request_id=request_id),
|
|
218
245
|
name=f"rpc-prompt-finalize-{request_id}",
|
|
219
246
|
)
|
|
220
247
|
|
|
221
248
|
async def _handle_cancel(self, request_id: Any) -> None:
|
|
222
|
-
if not
|
|
249
|
+
if not is_jsonrpc_request_id(request_id):
|
|
223
250
|
await self._send(
|
|
224
251
|
build_error_response(
|
|
225
252
|
request_id=None,
|
|
@@ -233,7 +260,7 @@ class StdioRPCBridge:
|
|
|
233
260
|
await self._send(build_success_response(request_id, {"cancelled": cancelled}))
|
|
234
261
|
|
|
235
262
|
async def _handle_replay(self, request_id: Any) -> None:
|
|
236
|
-
if not
|
|
263
|
+
if not is_jsonrpc_request_id(request_id):
|
|
237
264
|
await self._send(
|
|
238
265
|
build_error_response(
|
|
239
266
|
request_id=None,
|
|
@@ -313,6 +340,8 @@ class StdioRPCBridge:
|
|
|
313
340
|
)
|
|
314
341
|
finally:
|
|
315
342
|
self._clear_active_prompt(result_future)
|
|
343
|
+
if self._finalize_prompt_task is asyncio.current_task():
|
|
344
|
+
self._finalize_prompt_task = None
|
|
316
345
|
|
|
317
346
|
async def _cancel_active_prompt(self) -> bool:
|
|
318
347
|
if not self._has_active_prompt():
|
|
@@ -347,6 +376,72 @@ class StdioRPCBridge:
|
|
|
347
376
|
self._active_prompt_result = None
|
|
348
377
|
self._active_prompt_request_id = None
|
|
349
378
|
|
|
379
|
+
def _install_stdin_reader(self) -> None:
|
|
380
|
+
if self._stdin_queue is not None:
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
loop = asyncio.get_running_loop()
|
|
384
|
+
self._stdin_queue = asyncio.Queue()
|
|
385
|
+
try:
|
|
386
|
+
stdin_fd = sys.stdin.fileno()
|
|
387
|
+
loop.add_reader(stdin_fd, self._on_stdin_ready)
|
|
388
|
+
except (AttributeError, NotImplementedError, OSError, ValueError) as exc:
|
|
389
|
+
self._stdin_queue = None
|
|
390
|
+
raise RuntimeError(f"failed to install stdin reader: {exc}") from exc
|
|
391
|
+
|
|
392
|
+
self._stdin_reader_fd = stdin_fd
|
|
393
|
+
self._stdin_error = None
|
|
394
|
+
|
|
395
|
+
def _remove_stdin_reader(self) -> None:
|
|
396
|
+
stdin_fd = self._stdin_reader_fd
|
|
397
|
+
if stdin_fd is None:
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
asyncio.get_running_loop().remove_reader(stdin_fd)
|
|
402
|
+
except RuntimeError:
|
|
403
|
+
pass
|
|
404
|
+
self._stdin_reader_fd = None
|
|
405
|
+
self._stdin_queue = None
|
|
406
|
+
|
|
407
|
+
def _on_stdin_ready(self) -> None:
|
|
408
|
+
queue = self._stdin_queue
|
|
409
|
+
if queue is None:
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
raw_line = sys.stdin.readline()
|
|
414
|
+
except Exception as exc:
|
|
415
|
+
logger.exception("rpc stdin read failed")
|
|
416
|
+
self._stdin_error = RuntimeError(f"stdin read failed: {exc}")
|
|
417
|
+
self._remove_stdin_reader()
|
|
418
|
+
queue.put_nowait(None)
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
if raw_line == "":
|
|
422
|
+
self._remove_stdin_reader()
|
|
423
|
+
queue.put_nowait(None)
|
|
424
|
+
return
|
|
425
|
+
queue.put_nowait(raw_line)
|
|
426
|
+
|
|
427
|
+
async def _read_next_input_line(self) -> str:
|
|
428
|
+
queue = self._stdin_queue
|
|
429
|
+
if queue is None:
|
|
430
|
+
raise RuntimeError("stdin reader is not installed")
|
|
431
|
+
|
|
432
|
+
raw_line = await queue.get()
|
|
433
|
+
if raw_line is None:
|
|
434
|
+
if self._stdin_error is not None:
|
|
435
|
+
raise self._stdin_error
|
|
436
|
+
return ""
|
|
437
|
+
return raw_line
|
|
438
|
+
|
|
439
|
+
async def _await_finalize_prompt_task(self) -> None:
|
|
440
|
+
finalize_task = self._finalize_prompt_task
|
|
441
|
+
if finalize_task is None:
|
|
442
|
+
return
|
|
443
|
+
await finalize_task
|
|
444
|
+
|
|
350
445
|
async def _send(self, payload: dict[str, Any]) -> None:
|
|
351
446
|
encoded = f"{self._dump_json(payload)}\n"
|
|
352
447
|
async with self._write_lock:
|
|
@@ -741,6 +741,15 @@ class TerminalAgentTUI(
|
|
|
741
741
|
}
|
|
742
742
|
if isinstance(event, StopEvent):
|
|
743
743
|
stop_reason = str(event.reason or "")
|
|
744
|
+
if event.reason == "shutdown_request":
|
|
745
|
+
shutdown_recipients = event.metadata.get("shutdown_recipients", [])
|
|
746
|
+
if shutdown_recipients:
|
|
747
|
+
from comate_agent_sdk.agent.runner_engine.query_stream import (
|
|
748
|
+
_send_team_shutdown_response,
|
|
749
|
+
)
|
|
750
|
+
_send_team_shutdown_response(
|
|
751
|
+
self._session.runtime, recipients=shutdown_recipients
|
|
752
|
+
)
|
|
744
753
|
is_waiting, new_questions = self._renderer.handle_event(event)
|
|
745
754
|
self._maybe_flash_tool_result(event)
|
|
746
755
|
if is_waiting:
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import types
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from comate_cli.terminal_agent.app import _preload_mcp_in_tui
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _FakeRuntime:
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self.options = types.SimpleNamespace(mcp_enabled=True)
|
|
13
|
+
self._mcp_manager = types.SimpleNamespace(
|
|
14
|
+
failed_servers=[],
|
|
15
|
+
tool_infos=[types.SimpleNamespace(server_alias="ctx7")],
|
|
16
|
+
)
|
|
17
|
+
self.start_calls = 0
|
|
18
|
+
self.awaited = asyncio.Event()
|
|
19
|
+
|
|
20
|
+
def start_mcp_preload(self) -> asyncio.Task[None]:
|
|
21
|
+
self.start_calls += 1
|
|
22
|
+
|
|
23
|
+
async def _worker() -> None:
|
|
24
|
+
await asyncio.sleep(0)
|
|
25
|
+
self.awaited.set()
|
|
26
|
+
|
|
27
|
+
return asyncio.create_task(_worker())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _FakeSession:
|
|
31
|
+
def __init__(self, runtime: _FakeRuntime) -> None:
|
|
32
|
+
self.runtime = runtime
|
|
33
|
+
self._agent = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestAppMcpPreload(unittest.IsolatedAsyncioTestCase):
|
|
37
|
+
async def test_preload_mcp_in_tui_uses_runtime_public_entry(self) -> None:
|
|
38
|
+
runtime = _FakeRuntime()
|
|
39
|
+
session = _FakeSession(runtime)
|
|
40
|
+
|
|
41
|
+
await _preload_mcp_in_tui(session) # type: ignore[arg-type]
|
|
42
|
+
|
|
43
|
+
self.assertEqual(runtime.start_calls, 1)
|
|
44
|
+
self.assertTrue(runtime.awaited.is_set())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
unittest.main(verbosity=2)
|