CHANGELOG.md
15-
16-- 新增 `/clear` slash command：清空当前 leader session 的 conversation，
17:  保留 session_id、team membership、inbox 与 TaskStore。成功后会清屏、
18-  重打 logo、重置 TUI transcript view，并刷新 status bar；busy 或
19-  compacting 状态会拒绝执行。
docs/cli-rpc-stdio-guide.md
35-```
36-
37:### 2.2 恢复历史会话（必须带 session_id）
38-
39-```bash
38-
39-```bash
40:comate resume <session_id> --rpc-stdio
41-```
42-
42-
43-约束：
44:- `resume` **不带** `<session_id>` 时**不支持** `--rpc-stdio`，会直接报错。
45-- `-p/--print` 与 `--rpc-stdio` **互斥**。
46-
111-## 4. 方法清单（共 4 个）
112-
113:### 4.1 `initialize` — 握手，拿 session_id
114-
115-请求：
125-  "status": "ok",
126-  "protocol_version": "1.0",
127:  "session_id": "<uuid>"
128-}}
129-```
129-```
130-
131:- `session_id` 来自 SDK 的 `SessionInitEvent`，**不是**进程自动生成的占位。
132-- 如果事件泵在握手前就出错，会返回 `INTERNAL_ERROR`。
133-- **建议**：每个进程会话先发 `initialize`，把 `session_id` 存下来，便于后续 `comate resume <id> --rpc-stdio` 续接。
131-- `session_id` 来自 SDK 的 `SessionInitEvent`，**不是**进程自动生成的占位。
132-- 如果事件泵在握手前就出错，会返回 `INTERNAL_ERROR`。
133:- **建议**：每个进程会话先发 `initialize`，把 `session_id` 存下来，便于后续 `comate resume <id> --rpc-stdio` 续接。
134-
135-### 4.2 `prompt` — 发起一轮推理
147-
148-行为：
149:1. 校验通过后调用 `session.send(user_input)` **入队**。注意 SDK 的 `send()` 只负责入队，不阻塞等待回复。
150-2. bridge 的事件泵持续消费 `session.events()`，把每个 `AgentEvent` 包装成 `event` 通知输出。
151-3. 当收到 `StopEvent` 时，解析 stop reason，resolve 对应 future。
148-行为：
149-1. 校验通过后调用 `session.send(user_input)` **入队**。注意 SDK 的 `send()` 只负责入队，不阻塞等待回复。
150:2. bridge 的事件泵持续消费 `session.events()`，把每个 `AgentEvent` 包装成 `event` 通知输出。
151-3. 当收到 `StopEvent` 时，解析 stop reason，resolve 对应 future。
152-4. 最终向原始 `id` 回一个 `result`。
158-| `waiting_for_plan_approval` | `waiting_for_plan_approval` | 同名 |
159-| `waiting_for_input` | `waiting_for_input` | 同名 |
160:| `interrupted` | **cancelled** | `interrupted` |
161-| `shutdown_request` | **cancelled** | `shutdown_request` |
162-| 其他（`completed` / `max_iterations` / `yielded`） | `completed` | 原始 reason |
179-
180-行为：
181:1. 调用 `session.run_controller.interrupt("rpc_cancel")`；
182-2. `cancelled=true/false` 仅表示**是否找到了活跃任务并触发了中断**；
183-3. 真正的 prompt 结果（`status: "cancelled"`）会随后由 `StopEvent(reason="interrupted")` 触发，**通过原始 prompt `id` 的 result 响应返回**，而不是在 cancel 响应里。
181-1. 调用 `session.run_controller.interrupt("rpc_cancel")`；
182-2. `cancelled=true/false` 仅表示**是否找到了活跃任务并触发了中断**；
183:3. 真正的 prompt 结果（`status: "cancelled"`）会随后由 `StopEvent(reason="interrupted")` 触发，**通过原始 prompt `id` 的 result 响应返回**，而不是在 cancel 响应里。
184-
185-**坑点提醒**：cancel 响应里 `cancelled: true` 不等于"prompt 已结束"，必须继续等到那个 prompt `id` 的 result 才算真的收尾。
202-```
203-
204:stdio bridge 明确不承担历史重放职责。需要历史请走 `comate resume <session_id> --rpc-stdio` 重新拉起进程。
205-
206----
212-| `event_type` | 含义 | 关键字段 |
213-|---|---|---|
214:| `SessionInitEvent` | 会话初始化完成 | `session_id` |
215-| `MessageStartEvent` / `MessageCompleteEvent` | 一条消息的边界 | — |
216-| `TextDeltaEvent` | 流式文本增量（**transient，不入历史**） | `delta`, `message_id` |
247- │──────────────────────────────────────────────────▶│
248- │                                                    │ (等 SessionInitEvent)
249: │ ◀───────── {"id":1,"result":{"status":"ok","session_id":"..."}}
250- │
251- │  {"id":10,"method":"prompt","params":{"user_input":"..."}}
251- │  {"id":10,"method":"prompt","params":{"user_input":"..."}}
252- │──────────────────────────────────────────────────▶│
253: │                                                    │ session.send() 入队
254- │ ◀───────── {"method":"event","params":{"event_type":"MessageStartEvent",...}}
255- │ ◀───────── {"method":"event","params":{"event_type":"TextDeltaEvent",...}}
342-    try:
343-        init = await client.initialize()
344:        print("session_id:", init["result"]["session_id"])
345-
346-        result = await client.prompt("用一句话解释 RPC 是什么")
373-7. **stdin EOF 即退出**：关 stdin 等同于结束会话，bridge 会进入 `_graceful_shutdown`。
374-8. **不要发"响应型 payload"**：bridge 对没有 `method` 字段的 payload 会返回 `INVALID_REQUEST`。
375:9. **`StopEvent.reason` 决定语义**：`interrupted` / `shutdown_request` 在 RPC 侧统一表现为 `status: "cancelled"`，要区分得看 `stop_reason`。
376-10. **resume 必须带 id**：`comate resume --rpc-stdio`（不带 id）会直接报错退出。
377-
tests/test_tui_builtin_slash_chain.py
44-
45-class _FakeSession:
46:    session_id = "session-1"
47-    _cwd = Path.cwd()
48-
tests/test_slash_clear.py
112-        ),
113-    )
114:    return ChatSession(agent, session_id="leader-session", cwd=root)
115-
116-
tests/test_completion_status_panel.py
425-
426-        self.assertIn(
427:            "Thinking  (3m 40s • ctrl+c to interrupt • ultra thinking)",
428-            text,
429-        )
442-        )
443-
444:    def test_loading_text_appends_tool_args_progress_before_interrupt_hint(self) -> None:
445-        tui = _FakeTUI(None, width=120)
446-        tui._busy = True
457-
458-        self.assertIn(
459:            "Thinking  (3m 40s • ↓ 1.2k tokens • ctrl+c to interrupt • ultra thinking)",
460-            text,
461-        )
516-
517-        self.assertIn(
518:            "Generating...  (3m 40s • ctrl+c to interrupt • deep reasoning)",
519-            text,
520-        )
654-        self.assertIn("发布流程", text)
655-        self.assertIn(
656:            "(3m 40s • ctrl+c to interrupt • extended thinking)",
657-            text,
658-        )
tests/test_task_panel_key_bindings.py
27-
28-class _StubRunController:
29:    def interrupt(self, reason: str) -> None:  # noqa: ARG002
30-        return None
31-
33-class _StubSession:
34-    def __init__(self) -> None:
35:        self.run_controller = _StubRunController()
36-
37-    def request_exit(self) -> None:
92-        self._app = None
93-        self._stream_task = None
94:        self._interrupt_requested_at = None
95-        self._interrupt_force_window_seconds = 1.5
96-        self._session = _StubSession()
93-        self._stream_task = None
94-        self._interrupt_requested_at = None
95:        self._interrupt_force_window_seconds = 1.5
96-        self._session = _StubSession()
97-        self._renderer = _StubRenderer()
tests/test_app_print_mode.py
17-
18-        mock_session = MagicMock()
19:        mock_session.session_id = "session-test"
20-        mock_session.query_stream = MagicMock(return_value=_async_iter([
21-            SessionInitEvent(session_id="session-test"),
19-        mock_session.session_id = "session-test"
20-        mock_session.query_stream = MagicMock(return_value=_async_iter([
21:            SessionInitEvent(session_id="session-test"),
22-            TextEvent(content="first answer"),
23-            TextEvent(content="final answer"),
47-
48-        mock_session = MagicMock()
49:        mock_session.session_id = "session-test"
50-        mock_session.query_stream = MagicMock(return_value=_async_iter([
51-            SessionInitEvent(session_id="session-test"),
49-        mock_session.session_id = "session-test"
50-        mock_session.query_stream = MagicMock(return_value=_async_iter([
51:            SessionInitEvent(session_id="session-test"),
52-            StopEvent(reason="completed"),
53-        ]))
75-
76-        mock_session = MagicMock()
77:        mock_session.session_id = "session-test"
78-        mock_session.query_stream = MagicMock(return_value=_async_iter([
79-            SessionInitEvent(session_id="session-test"),
77-        mock_session.session_id = "session-test"
78-        mock_session.query_stream = MagicMock(return_value=_async_iter([
79:            SessionInitEvent(session_id="session-test"),
80-            TextEvent(content="no newline"),
81-            StopEvent(reason="completed"),
104-
105-        mock_session = MagicMock()
106:        mock_session.session_id = "session-before-init"
107-        mock_session.query_stream = MagicMock(return_value=_async_iter([
108-            SessionInitEvent(session_id="session-json"),
106-        mock_session.session_id = "session-before-init"
107-        mock_session.query_stream = MagicMock(return_value=_async_iter([
108:            SessionInitEvent(session_id="session-json"),
109-            TextEvent(content="final answer", structured_output={"ok": True}),
110-            StopEvent(reason="completed"),
135-        self.assertEqual(payload["result"], "final answer")
136-        self.assertEqual(payload["structured_output"], {"ok": True})
137:        self.assertEqual(payload["session_id"], "session-json")
138-        self.assertEqual(payload["total_cost_usd"], 0.123)
139-        self.assertEqual(payload["usage"]["total_cost"], 0.123)
146-
147-        mock_session = MagicMock()
148:        mock_session.session_id = "session-max"
149-        mock_session.query_stream = MagicMock(return_value=_async_iter([
150-            SessionInitEvent(session_id="session-max"),
148-        mock_session.session_id = "session-max"
149-        mock_session.query_stream = MagicMock(return_value=_async_iter([
150:            SessionInitEvent(session_id="session-max"),
151-            StopEvent(reason="max_iterations"),
152-        ]))
180-
181-        async def _broken_stream():
182:            yield SessionInitEvent(session_id="session-error")
183-            raise RuntimeError("provider exploded")
184-
184-
185-        mock_session = MagicMock()
186:        mock_session.session_id = "session-error"
187-        mock_session.query_stream = MagicMock(return_value=_broken_stream())
188-        mock_session.get_usage = AsyncMock(return_value=_usage_summary())
217-
218-        async def _broken_stream():
219:            yield SessionInitEvent(session_id="session-error")
220-            raise RuntimeError("provider exploded")
221-
221-
222-        mock_session = MagicMock()
223:        mock_session.session_id = "session-error"
224-        mock_session.query_stream = MagicMock(return_value=_broken_stream())
225-        mock_session.get_usage = AsyncMock(return_value=_usage_summary())
251-
252-        mock_session = MagicMock()
253:        mock_session.session_id = "session-hook"
254-        mock_session.query_stream = MagicMock(return_value=_async_iter([
255-            SessionInitEvent(session_id="session-hook"),
253-        mock_session.session_id = "session-hook"
254-        mock_session.query_stream = MagicMock(return_value=_async_iter([
255:            SessionInitEvent(session_id="session-hook"),
256-            HookStartedEvent(
257-                event_name="UserPromptSubmit",
295-
296-        mock_session = MagicMock()
297:        mock_session.session_id = "session-shutdown"
298-        mock_session.query_stream = MagicMock(return_value=_async_iter([
299-            SessionInitEvent(session_id="session-shutdown"),
297-        mock_session.session_id = "session-shutdown"
298-        mock_session.query_stream = MagicMock(return_value=_async_iter([
299:            SessionInitEvent(session_id="session-shutdown"),
300-            StopEvent(reason="completed"),
301-        ]))
324-
325-        async def _broken_stream():
326:            yield SessionInitEvent(session_id="session-error")
327-            raise RuntimeError("provider exploded")
328-
tests/test_tui_thinking_display.py
37-
38-class _FakeSession:
39:    session_id = "session-1"
40-    _cwd = Path.cwd()
41-
tests/test_rewind_command_semantics.py
78-            mode=mode,
79-            target_item_id=target_item_id,
80:            new_session_id="new-session" if mode is not RewindMode.RESTORE_FILES_ONLY else None,
81-            prefill_text="turn2",
82-            conversation_rewound=mode is not RewindMode.RESTORE_FILES_ONLY,
96-        self._refresh_count = 0
97-        self._ui_mode = None
98:        self._replaced_session_ids: list[str] = []
99-
100-    def _set_busy(self, busy: bool) -> None:
113-        self._prefilled_text = text
114-
115:    async def _replace_active_session(self, new_session_id: str) -> None:
116-        self._replaced_session_ids.append(new_session_id)
117-        registry = getattr(self, "_tool_result_registry", None)
114-
115-    async def _replace_active_session(self, new_session_id: str) -> None:
116:        self._replaced_session_ids.append(new_session_id)
117-        registry = getattr(self, "_tool_result_registry", None)
118-        if registry is not None:
170-
171-        self.assertEqual(commands._session.rewind_calls, [("u2", RewindMode.FORK_ONLY)])
172:        self.assertEqual(commands._replaced_session_ids, ["new-session"])
173-        self.assertEqual(commands._prefilled_text, "turn2")
174-        self.assertFalse(commands._busy)
194-        asyncio.run(commands._execute_rewind_mode(target_item_id="u2", mode="fork_only"))
195-
196:        self.assertEqual(commands._replaced_session_ids, ["new-session"])
197-        self.assertEqual(commands._tool_result_registry.snapshot(), ())
198-
208-
209-        self.assertEqual(commands._session.rewind_calls, [("u2", RewindMode.RESTORE_FILES_ONLY)])
210:        self.assertEqual(commands._replaced_session_ids, [])
211-        self.assertEqual(commands._prefilled_text, "turn2")
212-
236-            mode=RewindMode.RESTORE_FILES_ONLY,
237-            target_item_id="u2",
238:            new_session_id=None,
239-            prefill_text="turn2",
240-            conversation_rewound=False,
tests/test_resume_selector.py
15-from comate_cli.terminal_agent.resume_selector import (
16-    list_resume_sessions_via_sdk,
17:    select_resume_session_id,
18-)
19-
22-    cwd: Path,
23-    *,
24:    session_id: str,
25-    with_header: bool = True,
26-    archived: bool = False,
26-    archived: bool = False,
27-) -> None:
28:    root = default_session_root(session_id, cwd=cwd)
29-    write_session_metadata(
30-        root,
29-    write_session_metadata(
30-        root,
31:        session_id=session_id,
32-        task_namespace="default",
33-        cwd=str(cwd.resolve()),
34-        archived=archived,
35-    )
36:    transcript_path = get_transcript_path(session_id, cwd=cwd)
37-    writer = TranscriptWriter(transcript_path, session_id=session_id, cwd=cwd, version="test")
38-    if with_header:
35-    )
36-    transcript_path = get_transcript_path(session_id, cwd=cwd)
37:    writer = TranscriptWriter(transcript_path, session_id=session_id, cwd=cwd, version="test")
38-    if with_header:
39-        writer.write_header_snapshot_once({"header_items": []})
41-        [
42-            ContextItem(
43:                id=f"{session_id}-u1",
44-                item_type=ItemType.USER_MESSAGE,
45-                message=UserMessage(content="hello"),
62-        cwd = tmp_path / "project"
63-        cwd.mkdir()
64:        _write_minimal_session(cwd, session_id="s1", with_header=True)
65-        _write_minimal_session(cwd, session_id="missing-header", with_header=False)
66-        _write_minimal_session(cwd, session_id="archived", with_header=True, archived=True)
63-        cwd.mkdir()
64-        _write_minimal_session(cwd, session_id="s1", with_header=True)
65:        _write_minimal_session(cwd, session_id="missing-header", with_header=False)
66-        _write_minimal_session(cwd, session_id="archived", with_header=True, archived=True)
67-
64-        _write_minimal_session(cwd, session_id="s1", with_header=True)
65-        _write_minimal_session(cwd, session_id="missing-header", with_header=False)
66:        _write_minimal_session(cwd, session_id="archived", with_header=True, archived=True)
67-
68-        items = list_resume_sessions_via_sdk(cwd=cwd)
68-        items = list_resume_sessions_via_sdk(cwd=cwd)
69-
70:        assert [item.session_id for item in items] == ["s1"]
71-        assert items[0].primary_text == "hello"
72-
73-
74-@pytest.mark.asyncio
75:async def test_select_resume_session_id_returns_picker_result(monkeypatch: pytest.MonkeyPatch) -> None:
76-    raw = resume_selector.SessionInfo(
77-        session_id="s1",
75-async def test_select_resume_session_id_returns_picker_result(monkeypatch: pytest.MonkeyPatch) -> None:
76-    raw = resume_selector.SessionInfo(
77:        session_id="s1",
78-        cwd="/tmp/project",
79-        last_modified=1.0,
104-    console = Console(file=io.StringIO(), force_terminal=False)
105-
106:    result = await select_resume_session_id(console, cwd=Path("/tmp/project"))
107-
108-    assert result == "s1"
tests/test_app_shutdown.py
12-
13-class _FakeSession:
14:    def __init__(self, session_id: str, *, fail: bool = False) -> None:
15-        self.session_id = session_id
16-        self._fail = fail
13-class _FakeSession:
14-    def __init__(self, session_id: str, *, fail: bool = False) -> None:
15:        self.session_id = session_id
16-        self._fail = fail
17-        self.shutdown_calls = 0
20-        self.shutdown_calls += 1
21-        if self._fail:
22:            raise RuntimeError(f"boom-{self.session_id}")
23-
24-
39-
40-        fake_session = MagicMock()
41:        fake_session.session_id = "session-1"
42-        fake_session.runtime = SimpleNamespace(_mcp_manager=None)
43-        fake_session.get_mode.return_value = "default"
58-
59-        class _FakeTUI:
60:            initialized_session_id = None
61-
62-            def __init__(self, session, status_bar, renderer) -> None:
114-
115-        fake_session = MagicMock()
116:        fake_session.session_id = "session-1"
117-        fake_session.runtime = SimpleNamespace(_mcp_manager=None)
118-        fake_session.get_mode.return_value = "default"
132-
133-        class _RaisingTUI:
134:            initialized_session_id = None
135-            exit_source = "keyboard_interrupt"
136-
133-        class _RaisingTUI:
134-            initialized_session_id = None
135:            exit_source = "keyboard_interrupt"
136-
137-            def __init__(self, session, status_bar, renderer) -> None:
183-        self.assertEqual(call_log, ["tui_run", "graceful_shutdown", "logging_close"])
184-        log_text = "\n".join(logs.output)
185:        self.assertIn("TUI branch leaving: exit_source=keyboard_interrupt", log_text)
186-        self.assertIn("session_id=session-1", log_text)
187-
184-        log_text = "\n".join(logs.output)
185-        self.assertIn("TUI branch leaving: exit_source=keyboard_interrupt", log_text)
186:        self.assertIn("session_id=session-1", log_text)
187-
188-    async def test_loop_exception_handler_suppresses_memory_prefetch_task_exception(self) -> None:
tests/test_tui_tool_result_registry_lifecycle.py
18-        self._template = object()
19-        self._cwd = None
20:        self.run_controller = _RunController()
21-        self.closed = False
22-
73-            tui._pending_plan_approval = None
74-            tui._task_poll_last_list_id = "old"
75:            tui._allow_latest_recall_on_interrupt = True
76-            tui._last_turn_user_preview = "old"
77-            tui._initialized_session_id = "old"
75-            tui._allow_latest_recall_on_interrupt = True
76-            tui._last_turn_user_preview = "old"
77:            tui._initialized_session_id = "old"
78-            tui._sync_skill_slash_commands = lambda: None
79-            tui._bind_team_message_callback = lambda _session: None
tests/test_app_usage_line.py
64-        fake_logging_session = MagicMock()
65-        fake_session = MagicMock()
66:        fake_session.session_id = "sess-123"
67-        fake_session.runtime = SimpleNamespace(_mcp_manager=None)
68-        fake_session.get_mode.return_value = "default"
97-
98-        class _FakeTUI:
99:            initialized_session_id = "sess-123"
100-
101-            def __init__(self, session, status_bar, renderer) -> None:
tests/test_handle_error.py
18-    tui._last_turn_user_preview = "some preview"
19-    tui._run_start_time = 12345.0
20:    tui._interrupt_requested_at = 67890.0
21-    tui._allow_latest_recall_on_interrupt = True
22-    tui._set_busy = MagicMock()
19-    tui._run_start_time = 12345.0
20-    tui._interrupt_requested_at = 67890.0
21:    tui._allow_latest_recall_on_interrupt = True
22-    tui._set_busy = MagicMock()
23-    tui._append_run_elapsed_to_history = MagicMock()
83-        assert tui._last_turn_user_preview is None
84-        assert tui._run_start_time is None
85:        assert tui._interrupt_requested_at is None
86-
87-    def test_handle_error_calls_interrupt_turn(self):
85-        assert tui._interrupt_requested_at is None
86-
87:    def test_handle_error_calls_interrupt_turn(self):
88-        from comate_cli.terminal_agent.tui import TerminalAgentTUI
89-
93-        asyncio.run(TerminalAgentTUI._handle_error(tui, exc))
94-
95:        tui._renderer.interrupt_turn.assert_called_once()
96-
97-    def test_handle_error_clears_auto_compacting_flag(self):
tests/test_question_key_bindings.py
32-
33-class _StubRunController:
34:    def interrupt(self, reason: str) -> None:  # noqa: ARG002
35-        return None
36-
38-class _StubSession:
39-    def __init__(self) -> None:
40:        self.run_controller = _StubRunController()
41-
42-    def request_exit(self) -> None:
45-
46-class _StubRenderer:
47:    def interrupt_turn(self) -> None:
48-        return None
49-
80-        self._app = None
81-        self._stream_task = None
82:        self._interrupt_requested_at = None
83-        self._interrupt_force_window_seconds = 1.5
84-        self._session = _StubSession()
81-        self._stream_task = None
82-        self._interrupt_requested_at = None
83:        self._interrupt_force_window_seconds = 1.5
84-        self._session = _StubSession()
85-        self._renderer = _StubRenderer()
tests/test_main_args.py
10-class TestMainArgs(unittest.TestCase):
11-    def test_parse_args_defaults_to_new_session(self) -> None:
12:        rpc_stdio, resume_session_id, resume_select, print_prompt, output_format = (
13-            main_module._parse_args([])
14-        )
14-        )
15-        self.assertFalse(rpc_stdio)
16:        self.assertIsNone(resume_session_id)
17-        self.assertFalse(resume_select)
18-        self.assertIsNone(print_prompt)
20-
21-    def test_parse_args_resume_subcommand(self) -> None:
22:        rpc_stdio, resume_session_id, resume_select, print_prompt, output_format = (
23-            main_module._parse_args(["resume", "sid-001", "--rpc-stdio"])
24-        )
24-        )
25-        self.assertTrue(rpc_stdio)
26:        self.assertEqual(resume_session_id, "sid-001")
27-        self.assertFalse(resume_select)
28-        self.assertIsNone(print_prompt)
29-        self.assertEqual(output_format, "text")
30-
31:    def test_parse_args_resume_without_session_id_enters_selection_mode(self) -> None:
32-        rpc_stdio, resume_session_id, resume_select, print_prompt, output_format = (
33-            main_module._parse_args(["resume"])
30-
31-    def test_parse_args_resume_without_session_id_enters_selection_mode(self) -> None:
32:        rpc_stdio, resume_session_id, resume_select, print_prompt, output_format = (
33-            main_module._parse_args(["resume"])
34-        )
34-        )
35-        self.assertFalse(rpc_stdio)
36:        self.assertIsNone(resume_session_id)
37-        self.assertTrue(resume_select)
38-        self.assertIsNone(print_prompt)
39-        self.assertEqual(output_format, "text")
40-
41:    def test_parse_args_rejects_legacy_implicit_session_id(self) -> None:
42-        with self.assertRaises(main_module._ArgumentError):
43-            main_module._parse_args(["sid-legacy"])
43-            main_module._parse_args(["sid-legacy"])
44-
45:    def test_parse_args_rejects_resume_without_session_id_with_rpc_stdio(self) -> None:
46-        with self.assertRaises(main_module._ArgumentError):
47-            main_module._parse_args(["resume", "--rpc-stdio"])
52-
53-    def test_parse_args_print_with_prompt(self) -> None:
54:        rpc_stdio, resume_session_id, resume_select, print_prompt, output_format = (
55-            main_module._parse_args(["-p", "explain this code"])
56-        )
56-        )
57-        self.assertFalse(rpc_stdio)
58:        self.assertIsNone(resume_session_id)
59-        self.assertFalse(resume_select)
60-        self.assertEqual(print_prompt, "explain this code")
90-
91-    def test_parse_args_print_defaults_output_format_to_text(self) -> None:
92:        rpc_stdio, resume_session_id, resume_select, print_prompt, output_format = (
93-            main_module._parse_args(["-p", "hello"])
94-        )
94-        )
95-        self.assertFalse(rpc_stdio)
96:        self.assertIsNone(resume_session_id)
97-        self.assertFalse(resume_select)
98-        self.assertEqual(print_prompt, "hello")
tests/test_mcp_slash_command.py
161-        self._closing = False
162-        self._session = SimpleNamespace(
163:            session_id="test-session",
164-            run_controller=_FakeRunController(),
165-        )
162-        self._session = SimpleNamespace(
163-            session_id="test-session",
164:            run_controller=_FakeRunController(),
165-        )
166-        self._event_pump_task = None
tests/test_resume_picker.py
22-def _info(**kw) -> SessionInfo:
23-    defaults = dict(
24:        session_id="s1",
25-        cwd="/home/u/proj",
26-        last_modified=time.time(),
72-    def _items(self) -> list[ResumeSessionItem]:
73-        return [
74:            _session_info_to_item(_info(session_id="a", first_prompt="build resume picker")),
75-            _session_info_to_item(_info(session_id="b", first_prompt="refactor task store")),
76-            _session_info_to_item(_info(session_id="c", first_prompt="wire team_name", team_name="agent_team")),
73-        return [
74-            _session_info_to_item(_info(session_id="a", first_prompt="build resume picker")),
75:            _session_info_to_item(_info(session_id="b", first_prompt="refactor task store")),
76-            _session_info_to_item(_info(session_id="c", first_prompt="wire team_name", team_name="agent_team")),
77-        ]
74-            _session_info_to_item(_info(session_id="a", first_prompt="build resume picker")),
75-            _session_info_to_item(_info(session_id="b", first_prompt="refactor task store")),
76:            _session_info_to_item(_info(session_id="c", first_prompt="wire team_name", team_name="agent_team")),
77-        ]
78-
84-        items = self._items()
85-        out = _filter_items(items, "resume")
86:        assert [item.session_id for item in out] == ["a"]
87-
88-    def test_case_insensitive(self) -> None:
89-        items = self._items()
90-        out = _filter_items(items, "REFACTOR")
91:        assert [item.session_id for item in out] == ["b"]
92-
93-    def test_match_in_team_name(self) -> None:
94-        items = self._items()
95-        out = _filter_items(items, "agent_team")
96:        assert [item.session_id for item in out] == ["c"]
97-
98-
188-
189-def _make_items(n: int = 3) -> list[ResumeSessionItem]:
190:    return [_session_info_to_item(_info(session_id=f"s{i}", first_prompt=f"prompt {i}")) for i in range(n)]
191-
192-
218-async def test_picker_search_filters_and_selects() -> None:
219-    items = [
220:        _session_info_to_item(_info(session_id="a", first_prompt="alpha")),
221-        _session_info_to_item(_info(session_id="b", first_prompt="beta")),
222-    ]
219-    items = [
220-        _session_info_to_item(_info(session_id="a", first_prompt="alpha")),
221:        _session_info_to_item(_info(session_id="b", first_prompt="beta")),
222-    ]
223-    with create_pipe_input() as pipe_input:
250-        encoding="utf-8",
251-    )
252:    items = [_session_info_to_item(_info(session_id="p1", first_prompt="hi"))]
253-
254-    with create_pipe_input() as pipe_input:
254-    with create_pipe_input() as pipe_input:
255-        pipe_input.send_text("\x16\x1b\r")
256:        app = ResumePickerApp(items, transcript_path_resolver=lambda _session_id: jsonl)
257-        app._app.input = pipe_input
258-        app._app.output = DummyOutput()
263-@pytest.mark.asyncio
264-async def test_ctrl_a_toggles_scope_and_reloads() -> None:
265:    cwd_items = [_session_info_to_item(_info(session_id="cwd1"))]
266-    all_items = cwd_items + [_session_info_to_item(_info(session_id="other1", cwd="/x"))]
267-    calls: list[str] = []
264-async def test_ctrl_a_toggles_scope_and_reloads() -> None:
265-    cwd_items = [_session_info_to_item(_info(session_id="cwd1"))]
266:    all_items = cwd_items + [_session_info_to_item(_info(session_id="other1", cwd="/x"))]
267-    calls: list[str] = []
268-
318-class TestRenderHeader:
319-    def test_list_mode_header_shows_session_count_and_cwd_label(self) -> None:
320:        items = [_session_info_to_item(_info(session_id=f"s{i}")) for i in range(3)]
321-        app = ResumePickerApp(items)
322-        rendered = app._render_header()
328-
329-    def test_search_mode_header_shows_match_count_and_all_label(self) -> None:
330:        items = [_session_info_to_item(_info(session_id=f"s{i}", first_prompt=f"alpha {i}")) for i in range(5)]
331-        app = ResumePickerApp(items)
332-        app.state.scope = "all"
344-    def test_unselected_uses_default_style_no_reverse(self) -> None:
345-        items = [
346:            _session_info_to_item(_info(session_id="s0", first_prompt="hello")),
347-            _session_info_to_item(_info(session_id="s1", first_prompt="world")),
348-        ]
345-        items = [
346-            _session_info_to_item(_info(session_id="s0", first_prompt="hello")),
347:            _session_info_to_item(_info(session_id="s1", first_prompt="world")),
348-        ]
349-        app = ResumePickerApp(items)
358-    def test_selected_uses_cursor_arrow_and_yellow_class(self) -> None:
359-        items = [
360:            _session_info_to_item(_info(session_id="s0", first_prompt="hello")),
361-            _session_info_to_item(_info(session_id="s1", first_prompt="world")),
362-        ]
359-        items = [
360-            _session_info_to_item(_info(session_id="s0", first_prompt="hello")),
361:            _session_info_to_item(_info(session_id="s1", first_prompt="world")),
362-        ]
363-        app = ResumePickerApp(items)
tests/test_tui_startup_latency.py
120-        self.assertIn("Plugin init failed at startup", "\n".join(logs.output))
121-
122:    async def test_run_async_keyboard_interrupt_is_logged_with_exit_source(self) -> None:
123-        app = _KeyboardInterruptApp()
124-        tui = _make_tui(app)
134-        log_text = "\n".join(logs.output)
135-        self.assertIn("TUI run_async raised: exception=KeyboardInterrupt", log_text)
136:        self.assertIn("source=keyboard_interrupt", log_text)
137-        self.assertEqual(tui.exit_source, "keyboard_interrupt")
138-        self.assertTrue(tui._renderer.closed)
135-        self.assertIn("TUI run_async raised: exception=KeyboardInterrupt", log_text)
136-        self.assertIn("source=keyboard_interrupt", log_text)
137:        self.assertEqual(tui.exit_source, "keyboard_interrupt")
138-        self.assertTrue(tui._renderer.closed)
139-
comate_cli/main.py
211-        "  comate [--rpc-stdio]\n"
212-        "  comate -p [--output-format text|json] <prompt>\n"
213:        "  comate resume [<session_id>] [--rpc-stdio]\n"
214-        "  comate mcp <subcommand> [options]"
215-    )
280-        if rpc_stdio:
281-            raise _ArgumentError(
282:                "resume without <session_id> does not support --rpc-stdio"
283-            )
284-        return rpc_stdio, None, True, None, output_format
287-        return rpc_stdio, positionals[1], False, None, output_format
288-
289:    raise _ArgumentError("resume accepts at most one <session_id>")
290-
291-
380-
381-    try:
382:        rpc_stdio, resume_session_id, resume_select, print_prompt, print_output_format = (
383-            _parse_args(run_argv)
384-        )
401-            run(
402-                rpc_stdio=rpc_stdio,
403:                resume_session_id=resume_session_id,
404-                resume_select=resume_select,
405-                print_message=print_message,
tests/test_tui_send_undo_window.py
136-    tui._staging_recallable = False
137-    tui._staging_submitted = False
138:    tui._pending_latest_recall_on_interrupt = False
139-    tui._render_dirty = False
140-    tui._ctrl_c_press_count = 0
140-    tui._ctrl_c_press_count = 0
141-    tui._ctrl_c_last_pressed_at = 0.0
142:    tui._interrupt_requested_at = None
143-    tui._active_paste_token = None
144-    tui._paste_placeholder_text = None
269-        _close_all(scheduled)
270-
271:    def test_recall_latest_turn_after_interrupt_restores_only_when_sdk_recalls(self) -> None:
272-        tui, session = _build_tui()
273-        session.recall_result = SimpleNamespace(item_id="u1", prefill_text="hello")
274-        tui._input_area = _FakeInputArea("")
275-        tui._staging_payload = SimpleNamespace(raw_text="hello")
276:        tui._pending_latest_recall_on_interrupt = True
277-
278-        recalled = tui._recall_latest_turn_after_interrupt()
276-        tui._pending_latest_recall_on_interrupt = True
277-
278:        recalled = tui._recall_latest_turn_after_interrupt()
279-
280-        self.assertTrue(recalled)
280-        self.assertTrue(recalled)
281-        self.assertEqual(session.recall_calls, 1)
282:        self.assertFalse(tui._pending_latest_recall_on_interrupt)
283-        self.assertIsNone(tui._staging_payload)
284-        self.assertEqual(tui._input_area.buffer.text, "hello")
285-        self.assertEqual(tui._input_area.buffer.cursor_position, len("hello"))
286-
287:    def test_recall_latest_turn_after_interrupt_keeps_state_when_sdk_declines(self) -> None:
288-        tui, session = _build_tui()
289-        tui._input_area = _FakeInputArea("")
289-        tui._input_area = _FakeInputArea("")
290-        tui._staging_payload = SimpleNamespace(raw_text="hello")
291:        tui._pending_latest_recall_on_interrupt = True
292-
293-        recalled = tui._recall_latest_turn_after_interrupt()
291-        tui._pending_latest_recall_on_interrupt = True
292-
293:        recalled = tui._recall_latest_turn_after_interrupt()
294-
295-        self.assertFalse(recalled)
295-        self.assertFalse(recalled)
296-        self.assertEqual(session.recall_calls, 1)
297:        self.assertFalse(tui._pending_latest_recall_on_interrupt)
298-        self.assertIsNotNone(tui._staging_payload)
299-        self.assertEqual(tui._input_area.buffer.text, "")
524-
525-        tui._submit_from_input()
526:        tui._pending_latest_recall_on_interrupt = True
527-        recalled = tui._recall_latest_turn_after_interrupt()
528-
525-        tui._submit_from_input()
526-        tui._pending_latest_recall_on_interrupt = True
527:        recalled = tui._recall_latest_turn_after_interrupt()
528-
529-        self.assertTrue(recalled)
tests/test_event_renderer_tool_args_progress.py
158-            "start_turn",
159-            "finalize_turn",
160:            "interrupt_turn",
161-            "reset_history_view",
162-        ):
tests/test_interrupt_exit_semantics.py
41-class _StubRunController:
42-    def __init__(self) -> None:
43:        self.interrupt_calls: list[str] = []
44-        self.clear_calls = 0
45-
44-        self.clear_calls = 0
45-
46:    def interrupt(self, reason: str) -> None:
47-        self.interrupt_calls.append(reason)
48-
45-
46-    def interrupt(self, reason: str) -> None:
47:        self.interrupt_calls.append(reason)
48-
49-    def clear(self) -> None:
53-class _StubSession:
54-    def __init__(self, events: list[object] | None = None) -> None:
55:        self.session_id = "stub-session"
56-        self.run_controller = _StubRunController()
57-        self._events = list(events or [])
54-    def __init__(self, events: list[object] | None = None) -> None:
55-        self.session_id = "stub-session"
56:        self.run_controller = _StubRunController()
57-        self._events = list(events or [])
58-
72-        return False
73-
74:    def interrupt_turn(self) -> None:
75-        return None
76-
179-        self._app = None
180-        self._stream_task = None
181:        self._interrupt_requested_at = None
182-        self._interrupt_force_window_seconds = 1.5
183-        self._session = _StubSession()
180-        self._stream_task = None
181-        self._interrupt_requested_at = None
182:        self._interrupt_force_window_seconds = 1.5
183-        self._session = _StubSession()
184-        self._renderer = _StubRenderer()
185-        self._waiting_for_input = False
186-        self._pending_questions = None
187:        self._allow_latest_recall_on_interrupt = False
188-        self._esc_press_count = 0
189-        self._esc_last_pressed_at = 0.0
195-        self._todo_panel_expanded = False
196-        self._staging_payload = None
197:        self._pending_latest_recall_on_interrupt = False
198-        self.recall_staging_calls = 0
199-        self.exit_requests = 0
281-
282-class TestInterruptExitSemantics(unittest.IsolatedAsyncioTestCase):
283:    def test_busy_second_ctrl_c_requests_exit_instead_of_force_interrupt(self) -> None:
284-        host = _StubKeyBindingHost()
285-        binding = _find_binding(host, "c-c")
290-        binding.handler(None)
291-
292:        self.assertEqual(host._session.run_controller.interrupt_calls, ["user"])
293-        self.assertEqual(host.exit_requests, 1)
294-        self.assertEqual(host.exit_sources, ["ctrl_c_busy_second"])
307-
308-        self.assertEqual(host.recall_staging_calls, 1)
309:        self.assertEqual(host._session.run_controller.interrupt_calls, [])
310-        self.assertEqual(host.exit_requests, 0)
311-        self.assertEqual(host._renderer.messages, [])
314-        binding_host = _StubKeyBindingHost()
315-        binding_host._busy = True
316:        binding_host._allow_latest_recall_on_interrupt = False
317-        binding = _find_binding(binding_host, "c-c")
318-
319-        binding.handler(None)
320-
321:        self.assertEqual(binding_host._session.run_controller.interrupt_calls, ["user"])
322-        self.assertFalse(binding_host._pending_latest_recall_on_interrupt)
323-        self.assertEqual(binding_host.exit_requests, 0)
320-
321-        self.assertEqual(binding_host._session.run_controller.interrupt_calls, ["user"])
322:        self.assertFalse(binding_host._pending_latest_recall_on_interrupt)
323-        self.assertEqual(binding_host.exit_requests, 0)
324-
325-        recallable_host = _StubKeyBindingHost()
326-        recallable_host._busy = True
327:        recallable_host._allow_latest_recall_on_interrupt = True
328-        binding = _find_binding(recallable_host, "c-c")
329-
330-        binding.handler(None)
331-
332:        self.assertEqual(recallable_host._session.run_controller.interrupt_calls, ["user"])
333-        self.assertTrue(recallable_host._pending_latest_recall_on_interrupt)
334-        self.assertEqual(recallable_host.exit_requests, 0)
331-
332-        self.assertEqual(recallable_host._session.run_controller.interrupt_calls, ["user"])
333:        self.assertTrue(recallable_host._pending_latest_recall_on_interrupt)
334-        self.assertEqual(recallable_host.exit_requests, 0)
335-
334-        self.assertEqual(recallable_host.exit_requests, 0)
335-
336:    async def test_interrupted_stop_without_recall_shows_second_ctrl_c_hint(self) -> None:
337-        tui = TerminalAgentTUI.__new__(TerminalAgentTUI)
338-        tui._session = _StubSession(events=[StopEvent(reason="interrupted")])
tests/test_event_renderer_log_boundary.py
102-
103-
104:def test_interrupt_turn_does_not_drop_pending_logs() -> None:
105-    """用户中断不丢已入队 log，等下个 boundary 再落地。"""
106-    renderer = EventRenderer()
106-    renderer = EventRenderer()
107-    renderer.enqueue_log(severity="warning", message="will survive")
108:    renderer.interrupt_turn()
109-    assert len(renderer._pending_logs) == 1
110-    renderer.start_turn()
tests/test_event_renderer_e2e.py
126-        self.assertLess(idx_checking, idx_result)
127-
128:    # ── T-J6：interrupt 丢弃未闭合 fence ──
129-
130-    def test_j6_interrupt_drops_unfinished_fence(self):
128-    # ── T-J6：interrupt 丢弃未闭合 fence ──
129-
130:    def test_j6_interrupt_drops_unfinished_fence(self):
131-        renderer = EventRenderer()
132-        renderer.start_turn()
135-        before_history_len = len(renderer._history)
136-        # 中断
137:        renderer.interrupt_turn()
138-        # 容器内未 commit 内容应被丢弃 → scrollback 无新增 fence entry
139-        new_entries = renderer._history[before_history_len:]
tests/test_session_query_token_summary.py
17-    cwd: Path,
18-    *,
19:    session_id: str,
20-    with_total_usage: bool,
21-) -> Path:
20-    with_total_usage: bool,
21-) -> Path:
22:    root = default_session_root(session_id, cwd=cwd)
23-    write_session_metadata(
24-        root,
23-    write_session_metadata(
24-        root,
25:        session_id=session_id,
26-        task_namespace="default",
27-        cwd=str(cwd.resolve()),
28-    )
29-
30:    transcript_path = get_transcript_path(session_id, cwd=cwd)
31-    writer = TranscriptWriter(transcript_path, session_id=session_id, cwd=cwd, version="test")
32-    writer.write_header_snapshot_once({"header_items": []})
29-
30-    transcript_path = get_transcript_path(session_id, cwd=cwd)
31:    writer = TranscriptWriter(transcript_path, session_id=session_id, cwd=cwd, version="test")
32-    writer.write_header_snapshot_once({"header_items": []})
33-    writer.sync_conversation_items(
34-        [
35-            ContextItem(
36:                id=f"{session_id}-u1",
37-                item_type=ItemType.USER_MESSAGE,
38-                message=UserMessage(content="hello"),
50-            json.dumps(
51-                {
52:                    "session_id": session_id,
53-                    "by_level": {},
54-                    "compacted_usage": {
82-        cwd = tmp_path / "project"
83-        cwd.mkdir()
84:        _write_session(cwd, session_id="sess-1", with_total_usage=True)
85-
86-        results = list_sessions(cwd=cwd)
104-        cwd = tmp_path / "project"
105-        cwd.mkdir()
106:        _write_session(cwd, session_id="sess-2", with_total_usage=False)
107-
108-        results = list_sessions(cwd=cwd)
119-        cwd = tmp_path / "project"
120-        cwd.mkdir()
121:        root = _write_session(cwd, session_id="sess-3", with_total_usage=True)
122-        (root / "total_usage.json").write_text("{not-json", encoding="utf-8")
123-
tests/test_tool_result_formatters.py
328-            exit_code=0,
329-            timed_out=False,
330:            interrupted=False,
331-            killed=False,
332-            duration_ms=12,
664-            exit_code=0,
665-            timed_out=False,
666:            interrupted=False,
667-            killed=False,
668-            duration_ms=12,
tests/test_event_renderer_streaming.py
42-        self.assertEqual(renderer._assistant_buffer, "legacy hello")
43-
44:    def test_provider_error_stop_does_not_add_interrupted_message(self) -> None:
45-        renderer = EventRenderer()
46-        renderer.start_turn()
46-        renderer.start_turn()
47-        renderer.handle_event(
48:            StopEvent(reason="interrupted", metadata={"error": Exception("Connection refused")})
49-        )
50-
50-
51-        texts = [entry.text for entry in renderer.history_entries()]
52:        self.assertNotIn("Current task interrupted.", texts)
53-
54-    def test_thinking_event_no_op_when_delta_received(self) -> None:
435-        )
436-
437:    def test_interrupt_drops_pending_remainder_only(self):
438-        renderer = EventRenderer()
439-        renderer._show_thinking_cb = lambda: True
439-        renderer._show_thinking_cb = lambda: True
440-        renderer.handle_event(ThinkingDeltaEvent(delta="visible\nresidue", message_id="m1"))
441:        renderer.interrupt_turn()
442-        thinking_entries = [
443-            entry for entry in renderer._history if entry.entry_type == "thinking"
841-
842-class TestDropAllPending(unittest.TestCase):
843:    """spec §6.4：interrupt_turn 调用，语义 = 用户不要剩下的内容，全丢弃不入队。"""
844-
845-    def test_clears_everything(self):
tests/test_tui_team_messages.py
36-class _FakeSession:
37-    def __init__(self) -> None:
38:        self.session_id = "session-for-team-message-test"
39-        self._cwd = "."
40-        self._storage_root = "."
tests/test_update_check.py
836-        tui.run = AsyncMock()
837-        tui.session = session
838:        tui.initialized_session_id = None
839-
840-        order: list[str] = []
1037-        tui.run = AsyncMock()
1038-        tui.session = session
1039:        tui.initialized_session_id = None
1040-
1041-        with (

[Showing results with pagination = limit: 250]

<system-reminder>
Recommended next step:
* Read(file_path="CHANGELOG.md", offset=0, limit=120)
* Grep(pattern="session_id|session\\.events|session\\.send|run_controller|interrupt", path=".", output_mode="content", head_limit=250, offset=200)
* Read(file_path=".agent/artifacts/grep_content/81d4cf35489f009f64aba1f01c0a9c73ac256391d5369dbbae2b3f92c912afb0.txt", offset=1, limit=500)
</system-reminder>