diff --git a/client/DESIGN.md b/client/DESIGN.md index f69a9602..72b40241 100644 --- a/client/DESIGN.md +++ b/client/DESIGN.md @@ -92,6 +92,7 @@ client/ ├── main.py # 顶层 argv -> subcommand dispatch(含 chat/run/script 主路径) ├── README.md # 使用说明与退出码约定 ├── session.py # CLISessionState / ExecutionMode / SessionStatus +├── session_store.py # workspace 级 session snapshot 持久化与恢复 ├── parser/ │ ├── command.py # 交互命令解析(/mode /approve ...) │ └── kv.py # key=value 参数解析 @@ -114,9 +115,10 @@ client/ ### 5.1 顶层命令树 ```text -dare chat [options] -dare run --task "..." -dare script --file demo.txt +dare chat [--resume [session-id|latest]] [options] +dare run --task "..." [--resume [session-id|latest]] +dare script --file demo.txt [--resume [session-id|latest]] +dare sessions list dare approvals list dare approvals poll [--timeout-ms 30000] @@ -147,6 +149,7 @@ dare doctor 6. `/tools list`、`/skills list`、`/config show`、`/model show` 7. `/interrupt` 8. `/help`、`/quit` +9. `/sessions list` 普通文本行视为任务输入。 @@ -177,6 +180,32 @@ dare doctor 2. `/approvals poll|grant|deny|revoke` 3. `/interrupt` +### 6.4 Session Snapshot And Resume + +`client/` 需要把“单进程内 STM 连续性”提升为“跨进程可恢复”的 CLI contract。 + +第一版设计: + +1. session snapshot 固定写到 `/.dare/sessions/.json` +2. snapshot 至少包含: + - `schema_version` + - `session_id` + - `mode` + - `created_at` + - `updated_at` + - `workspace_dir` + - `messages` +3. `chat/run/script` 都支持 `--resume [session-id|latest]` +4. `--resume` 不带值时默认解析为 `latest` + +恢复边界: + +1. 会恢复:STM/history、`session_id`、`mode` +2. 不恢复:`pending_plan`、`pending_task_description`、`pending_runtime_approvals`、后台 task +3. 恢复后 `CLISessionState.status` 统一回到 `idle` + +这样可以对齐 Claude/Codex CLI 的基础“继续上一次对话”体验,同时避免把 runtime checkpoint 语义混进 CLI session restore。 + ## 7. 配置模型与优先级 ### 7.1 来源 @@ -215,6 +244,12 @@ CLI 层不自行定义“平行配置模型”,只对 `Config` 做覆盖合并 4. `3`:`doctor` 检查失败(环境或配置探测失败) 5. `130`:用户中断(Ctrl+C) +resume 相关错误保持落在退出码 `2`: + +1. `--resume latest` 但没有任何 snapshot +2. `--resume ` 找不到目标文件 +3. snapshot JSON 损坏或 `schema_version` 不兼容 + ### 8.3 宿主编排协议基线(planned) > 本节记录 Issue #135 宿主编排协议的当前设计基线。 @@ -344,7 +379,8 @@ v1 设计选择:优先支持 `--control-stdin`,即 stdin 一行一个 JSON 2. 配置覆盖优先级。 3. action/control 响应解析。 4. session 状态机(plan/approve/reject/background)。 -5. 输出渲染(human/json)。 +5. session snapshot / `--resume` 选择与错误语义。 +6. 输出渲染(human/json)。 ### 10.2 集成测试 diff --git a/client/README.md b/client/README.md index 1a98b0c8..336d736b 100644 --- a/client/README.md +++ b/client/README.md @@ -24,9 +24,17 @@ ```bash # 交互模式 .venv/bin/python -m client chat +# 恢复最近一次会话 +.venv/bin/python -m client chat --resume +# 恢复指定会话 +.venv/bin/python -m client chat --resume +# 列出当前 workspace 可恢复会话 +.venv/bin/python -m client sessions list # 一次性执行 .venv/bin/python -m client run --task "读取 README 并总结" +# 在已有会话历史上继续执行一次任务 +.venv/bin/python -m client run --resume latest --task "继续上一轮,补充测试计划" # 一次性执行(审批等待超时,默认 120s) .venv/bin/python -m client run --task "读取 README 并总结" --approval-timeout-seconds 120 # 一次性执行(自动审批指定工具,例如 run_command) @@ -36,6 +44,8 @@ .venv/bin/python -m client script --file /abs/path/to/demo.txt # 仓库内示例脚本 .venv/bin/python -m client chat --script client/examples/basic.script.txt +# 在已有会话上继续跑脚本 +.venv/bin/python -m client script --resume latest --file /abs/path/to/demo.txt # 审批控制 .venv/bin/python -m client approvals list @@ -51,6 +61,29 @@ .venv/bin/python -m client doctor ``` +## 会话持久化与 Resume + +`client/` 现在支持基础的跨进程会话恢复: + +1. 每个 workspace 会把 CLI session snapshot 写到 `/.dare/sessions/.json`。 +2. `chat/run/script` 都支持 `--resume [session-id|latest]`。 +3. `--resume` 不带值时等价于 `--resume latest`。 +4. 恢复后会继续同一条对话历史,并复用原 `session_id`。 +5. 可以通过 `sessions list` 查看当前 workspace 里有哪些 session 可恢复。 + +第一版明确 **只恢复可安全恢复的 CLI 状态**: + +- 会恢复:消息历史(STM)、执行模式(`plan|execute`)、session id +- 不恢复:运行中的任务、待审批请求、pending plan preview + +因此它对齐的是 Claude/Codex CLI 那类“继续上一条对话”的基础能力,而不是 runtime checkpoint 断点续跑。 + +常见错误语义: + +- `--resume latest` 但当前 workspace 没有任何 session:退出码 `2` +- `--resume ` 找不到对应文件:退出码 `2` +- snapshot 文件损坏或 schema 不兼容:退出码 `2` + ## 配置 ### 配置文件位置与覆盖顺序 diff --git a/client/main.py b/client/main.py index 5a1d9a38..ea5f496f 100644 --- a/client/main.py +++ b/client/main.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import Any, Awaitable, Callable, Iterable +from client.session_store import ClientSessionStore, SessionSnapshot, SessionStoreError from client.commands.approvals import approvals_usage_lines, handle_approvals_tokens from client.commands.info import ( build_doctor_report, @@ -297,6 +298,13 @@ async def _read_control_stdin_line() -> str | None: ) +def _list_session_payload(*, session_store: ClientSessionStore) -> dict[str, Any]: + """Return structured resumable-session summaries for the current workspace.""" + return { + "sessions": [item.to_dict() for item in session_store.list_sessions()], + } + + def _control_surface_actions() -> list[str]: """Return the current CLI host-protocol action surface.""" actions = {action.value for action in _HOST_CONTROL_ACTIONS} @@ -544,6 +552,83 @@ def _normalize_mode(value: str) -> ExecutionMode: return ExecutionMode.PLAN if value == "plan" else ExecutionMode.EXECUTE +@dataclasses.dataclass(frozen=True) +class _ResumeMetadata: + """Normalized resume details emitted to logs/headless envelopes.""" + + requested: str + session_id: str + restored_messages: int + + +def _runtime_session_context(runtime: Any) -> Any | None: + agent = getattr(runtime, "agent", None) + return getattr(agent, "context", None) + + +def _restore_session_snapshot(*, runtime: Any, snapshot: SessionSnapshot) -> int: + """Restore persisted STM history into a freshly bootstrapped runtime.""" + context = _runtime_session_context(runtime) + if context is None: + raise RuntimeError("runtime agent context is unavailable for session resume") + clear = getattr(context, "stm_clear", None) + add = getattr(context, "stm_add", None) + if not callable(clear) or not callable(add): + raise RuntimeError("runtime agent context does not support session resume") + clear() + for message in snapshot.messages: + add(message) + return len(snapshot.messages) + + +def _snapshot_messages_for_persistence(runtime: Any) -> list[Any] | None: + """Best-effort access to runtime STM for session snapshot writes.""" + context = _runtime_session_context(runtime) + get_messages = getattr(context, "stm_get", None) if context is not None else None + if not callable(get_messages): + return None + messages = get_messages() + return list(messages) if isinstance(messages, list) else list(messages) + + +def _build_session_state( + *, + mode: str, + resume: str | None, + runtime: Any, + session_store: ClientSessionStore | None, +) -> tuple[CLISessionState, _ResumeMetadata | None]: + """Create a fresh CLI state or restore one from a persisted snapshot.""" + if session_store is None or not isinstance(resume, str): + return CLISessionState(mode=_normalize_mode(mode)), None + snapshot = session_store.load(resume) + restored_messages = _restore_session_snapshot(runtime=runtime, snapshot=snapshot) + state = CLISessionState( + mode=snapshot.mode, + conversation_id=snapshot.session_id, + ) + return state, _ResumeMetadata( + requested=resume.strip() or "latest", + session_id=snapshot.session_id, + restored_messages=restored_messages, + ) + + +def _persist_session_snapshot( + *, + runtime: Any, + state: CLISessionState, + session_store: ClientSessionStore | None, +) -> None: + """Persist current runtime STM into the workspace session store.""" + if session_store is None: + return + messages = _snapshot_messages_for_persistence(runtime) + if messages is None: + return + session_store.save(state=state, messages=messages) + + @dataclasses.dataclass class _ApprovalWatchState: """Track the currently pending approval request for timeout enforcement.""" @@ -825,6 +910,7 @@ async def _handle_shell_command( action_client: TransportActionClient, output: OutputFacade, background_execute: bool, + session_store: ClientSessionStore | None = None, approval_watch: _ApprovalWatchState | None = None, approval_timeout_seconds: float | None = None, ) -> bool: @@ -840,7 +926,7 @@ async def _handle_shell_command( if command.type == CommandType.HELP: output.display( "/mode [plan|execute], /approve, /reject, /status, " - "/approvals [...], /mcp [...], /tools list, /skills list, " + "/approvals [...], /mcp [...], /tools list, /sessions list, /skills list, " "/config show, /model show, /interrupt, /quit" ) return False @@ -947,6 +1033,16 @@ async def _handle_shell_command( output.emit_data(_serialize(payload)) return False + if command.type == CommandType.SESSIONS: + if command.args[:1] not in ([], ["list"]): + output.display("/sessions list", level="warn") + return False + if session_store is None: + output.display("session store unavailable", level="error") + return False + output.emit_data(_serialize(_list_session_payload(session_store=session_store))) + return False + if command.type == CommandType.SKILLS: payload = await list_skills(action_client=action_client) output.emit_data(_serialize(payload)) @@ -983,6 +1079,7 @@ async def _run_cli_loop( action_client: TransportActionClient, output: OutputFacade, background_execute: bool, + session_store: ClientSessionStore | None = None, approval_watch: _ApprovalWatchState | None = None, approval_timeout_seconds: float | None = None, ) -> bool: @@ -1000,6 +1097,7 @@ async def _run_cli_loop( action_client=action_client, output=output, background_execute=background_execute, + session_store=session_store, approval_watch=approval_watch, approval_timeout_seconds=approval_timeout_seconds, ) @@ -1190,11 +1288,22 @@ async def _run_chat( script_lines: list[str] | None, approval_timeout_seconds: float | None = None, control_stdin: bool = False, + initial_state: CLISessionState | None = None, + session_store: ClientSessionStore | None = None, + resume_metadata: _ResumeMetadata | None = None, ) -> int: - state = CLISessionState(mode=_normalize_mode(mode)) + state = initial_state or CLISessionState(mode=_normalize_mode(mode)) if output.is_headless: output.set_protocol_context(session_id=state.conversation_id, run_id=state.conversation_id) - output.emit_event("session.started", {"mode": state.mode.value, "entrypoint": "script"}) + payload: dict[str, Any] = { + "mode": state.mode.value, + "entrypoint": "script", + } + if resume_metadata is not None: + payload["resumed"] = True + payload["resume_requested"] = resume_metadata.requested + payload["restored_messages"] = resume_metadata.restored_messages + output.emit_event("session.started", payload) inline_chat_approvals = script_lines is None and output.mode == "human" approval_watch = _ApprovalWatchState() if script_lines is not None and approval_timeout_seconds is not None else None @@ -1252,7 +1361,13 @@ def _handle_chat_approval_resolved(request_id: str) -> None: background_execute=False, approval_watch=approval_watch, approval_timeout_seconds=approval_timeout_seconds, + session_store=session_store, ) + try: + _persist_session_snapshot(runtime=runtime, state=state, session_store=session_store) + except (OSError, SessionStoreError, RuntimeError) as exc: + output.display(f"failed to persist session snapshot: {exc}", level="error") + return 1 if _is_execution_running(state): output.display("waiting for last background execution", level="warn") await _wait_for_background_task(state, output=output) @@ -1276,14 +1391,21 @@ def _handle_chat_approval_resolved(request_id: str) -> None: action_client=action_client, output=output, background_execute=True, + session_store=session_store, ) - if quit_requested: - break await _wait_until_prompt_allowed( state, output=output, release_on_pending=not inline_chat_approvals, ) + if not _is_execution_running(state): + try: + _persist_session_snapshot(runtime=runtime, state=state, session_store=session_store) + except (OSError, SessionStoreError, RuntimeError) as exc: + output.display(f"failed to persist session snapshot: {exc}", level="error") + return 1 + if quit_requested: + break if _is_execution_running(state): output.display("waiting for running execution", level="warn") await _wait_for_background_task(state, output=output) @@ -1314,10 +1436,24 @@ def _build_parser() -> argparse.ArgumentParser: chat = sub.add_parser("chat", help="interactive chat mode") chat.add_argument("--mode", choices=["plan", "execute"], default="execute") chat.add_argument("--script", default=None, help="optional script file") + chat.add_argument( + "--resume", + nargs="?", + const="latest", + default=None, + help="resume latest or specified CLI session", + ) run = sub.add_parser("run", help="run one task and exit") run.add_argument("--task", required=True) run.add_argument("--mode", choices=["plan", "execute"], default="execute") + run.add_argument( + "--resume", + nargs="?", + const="latest", + default=None, + help="resume latest or specified CLI session", + ) run.add_argument("--approve", action="store_true", help="execute after plan preview when mode=plan") run.add_argument( "--approval-timeout-seconds", @@ -1350,6 +1486,13 @@ def _build_parser() -> argparse.ArgumentParser: script = sub.add_parser("script", help="run script and exit") script.add_argument("--file", required=True) script.add_argument("--mode", choices=["plan", "execute"], default="execute") + script.add_argument( + "--resume", + nargs="?", + const="latest", + default=None, + help="resume latest or specified CLI session", + ) script.add_argument( "--headless", action="store_true", @@ -1401,6 +1544,10 @@ def _build_parser() -> argparse.ArgumentParser: tools_sub = tools.add_subparsers(dest="tools_cmd", required=True) tools_sub.add_parser("list") + sessions = sub.add_parser("sessions", help="list resumable sessions") + sessions_sub = sessions.add_subparsers(dest="sessions_cmd", required=True) + sessions_sub.add_parser("list") + skills = sub.add_parser("skills", help="list skills") skills_sub = skills.add_subparsers(dest="skills_cmd", required=True) skills_sub.add_parser("list") @@ -1481,6 +1628,7 @@ async def main(argv: list[str] | None = None) -> int: return 2 _ = provider configure_cli_logging(resolve_cli_log_path(config)) + session_store = ClientSessionStore(config.workspace_dir) if not output.is_headless: output.header("DARE CLIENT CLI") @@ -1502,6 +1650,13 @@ async def main(argv: list[str] | None = None) -> int: output.emit_data(_serialize(payload)) return 0 if payload.get("ok") else 3 + if command == "sessions": + if args.sessions_cmd != "list": + output.display(f"unknown sessions command: {args.sessions_cmd}", level="error") + return 2 + output.emit_data(_serialize(_list_session_payload(session_store=session_store))) + return 0 + try: runtime = await bootstrap_runtime(options) except Exception as exc: # noqa: BLE001 @@ -1515,6 +1670,16 @@ async def main(argv: list[str] | None = None) -> int: lines = _load_script_lines_with_handling(Path(args.script), output=output) if lines is None: return 2 + try: + state, resume_metadata = _build_session_state( + mode=args.mode, + resume=getattr(args, "resume", None), + runtime=runtime, + session_store=session_store, + ) + except (SessionStoreError, RuntimeError) as exc: + output.display(f"resume failed: {exc}", level="error") + return 2 return await _run_chat( runtime=runtime, action_client=action_client, @@ -1523,23 +1688,37 @@ async def main(argv: list[str] | None = None) -> int: script_lines=lines, approval_timeout_seconds=None, control_stdin=False, + initial_state=state, + session_store=session_store, + resume_metadata=resume_metadata, ) if command == "run": - state = CLISessionState(mode=_normalize_mode(args.mode)) + try: + state, resume_metadata = _build_session_state( + mode=args.mode, + resume=getattr(args, "resume", None), + runtime=runtime, + session_store=session_store, + ) + except (SessionStoreError, RuntimeError) as exc: + output.display(f"resume failed: {exc}", level="error") + return 2 if output.is_headless: output.set_protocol_context(session_id=state.conversation_id, run_id=state.conversation_id) - output.emit_event( - "session.started", - { - "mode": state.mode.value, - "entrypoint": "run", - "task": args.task, - "workspace": config.workspace_dir, - "adapter": config.llm.adapter or "openai", - "model": config.llm.model, - }, - ) + payload = { + "mode": state.mode.value, + "entrypoint": "run", + "task": args.task, + "workspace": config.workspace_dir, + "adapter": config.llm.adapter or "openai", + "model": config.llm.model, + } + if resume_metadata is not None: + payload["resumed"] = True + payload["resume_requested"] = resume_metadata.requested + payload["restored_messages"] = resume_metadata.restored_messages + output.emit_event("session.started", payload) if state.mode == ExecutionMode.PLAN: try: plan = await preview_plan( @@ -1624,6 +1803,11 @@ def _handle_run_approval_resolved(request_id: str) -> None: with contextlib.suppress(asyncio.CancelledError): await control_task await pump.stop() + try: + _persist_session_snapshot(runtime=runtime, state=state, session_store=session_store) + except (OSError, SessionStoreError, RuntimeError) as exc: + output.display(f"failed to persist session snapshot: {exc}", level="error") + return 1 return 0 if success else 1 if command == "script": @@ -1633,6 +1817,16 @@ def _handle_run_approval_resolved(request_id: str) -> None: script_approval_timeout_seconds = args.approval_timeout_seconds if script_approval_timeout_seconds is None and output.is_headless: script_approval_timeout_seconds = 120.0 + try: + state, resume_metadata = _build_session_state( + mode=args.mode, + resume=getattr(args, "resume", None), + runtime=runtime, + session_store=session_store, + ) + except (SessionStoreError, RuntimeError) as exc: + output.display(f"resume failed: {exc}", level="error") + return 2 return await _run_chat( runtime=runtime, action_client=action_client, @@ -1641,6 +1835,9 @@ def _handle_run_approval_resolved(request_id: str) -> None: script_lines=lines, approval_timeout_seconds=script_approval_timeout_seconds, control_stdin=args.control_stdin, + initial_state=state, + session_store=session_store, + resume_metadata=resume_metadata, ) if command == "approvals": diff --git a/client/parser/command.py b/client/parser/command.py index 8aad0128..fc475ba6 100644 --- a/client/parser/command.py +++ b/client/parser/command.py @@ -18,6 +18,7 @@ class CommandType(Enum): APPROVALS = "approvals" MCP = "mcp" TOOLS = "tools" + SESSIONS = "sessions" SKILLS = "skills" CONFIG = "config" MODEL = "model" @@ -60,6 +61,7 @@ def parse_command(user_input: str) -> Command | tuple[None, str]: "approvals": CommandType.APPROVALS, "mcp": CommandType.MCP, "tools": CommandType.TOOLS, + "sessions": CommandType.SESSIONS, "skills": CommandType.SKILLS, "config": CommandType.CONFIG, "model": CommandType.MODEL, diff --git a/client/session_store.py b/client/session_store.py new file mode 100644 index 00000000..e76394b1 --- /dev/null +++ b/client/session_store.py @@ -0,0 +1,265 @@ +"""Persistent CLI session snapshot storage.""" + +from __future__ import annotations + +import json +import re +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from client.session import CLISessionState, ExecutionMode +from dare_framework.context import Message, MessageMark + +SESSION_SNAPSHOT_SCHEMA_VERSION = "client-session.v1" +LATEST_SESSION_ALIAS = "latest" +SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + + +class SessionStoreError(ValueError): + """Raised when CLI session snapshots cannot be loaded or validated.""" + + +@dataclass(frozen=True) +class SessionSnapshot: + """Serialized session state that can be restored into a fresh runtime.""" + + session_id: str + mode: ExecutionMode + created_at: float + updated_at: float + workspace_dir: str + messages: list[Message] + + +@dataclass(frozen=True) +class SessionListing: + """Summary row returned by session discovery APIs.""" + + session_id: str + mode: ExecutionMode + created_at: float + updated_at: float + workspace_dir: str + messages_count: int + path: str + + def to_dict(self) -> dict[str, Any]: + return { + "session_id": self.session_id, + "mode": self.mode.value, + "created_at": self.created_at, + "updated_at": self.updated_at, + "workspace_dir": self.workspace_dir, + "messages_count": self.messages_count, + "path": self.path, + } + + +def _json_safe(value: Any, *, _seen: set[int] | None = None) -> Any: + """Best-effort conversion for JSON persistence.""" + if value is None or isinstance(value, (bool, int, float, str)): + return value + if _seen is None: + _seen = set() + if isinstance(value, (dict, list, tuple, set)): + marker = id(value) + if marker in _seen: + return "" + _seen.add(marker) + if isinstance(value, dict): + try: + return {str(key): _json_safe(item, _seen=_seen) for key, item in value.items()} + finally: + _seen.remove(marker) + if isinstance(value, set): + try: + normalized = [_json_safe(item, _seen=_seen) for item in value] + return sorted(normalized, key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":"))) + finally: + _seen.remove(marker) + if isinstance(value, (list, tuple)): + try: + return [_json_safe(item, _seen=_seen) for item in value] + finally: + _seen.remove(marker) + return str(value) + + +class ClientSessionStore: + """Workspace-scoped file-backed session snapshot store.""" + + def __init__(self, workspace_dir: str | Path) -> None: + self._workspace_dir = Path(workspace_dir).expanduser().resolve() + self._session_dir = self._workspace_dir / ".dare" / "sessions" + self._session_dir.mkdir(parents=True, exist_ok=True) + + @property + def session_dir(self) -> Path: + return self._session_dir + + def path_for(self, session_id: str) -> Path: + normalized = self._normalize_session_id(session_id) + path = (self._session_dir / f"{normalized}.json").resolve() + session_root = self._session_dir.resolve() + if not path.is_relative_to(session_root): + raise SessionStoreError(f"invalid session_id path traversal: {normalized}") + return path + + def save(self, *, state: CLISessionState, messages: list[Message]) -> Path: + """Persist the current CLI session snapshot.""" + session_id = self._normalize_session_id(state.conversation_id) + path = self.path_for(session_id) + created_at = time.time() + if path.exists(): + try: + created_at = self._load_path(path).created_at + except SessionStoreError: + # Prefer forward progress: overwrite an unreadable older snapshot. + created_at = time.time() + payload = { + "schema_version": SESSION_SNAPSHOT_SCHEMA_VERSION, + "session_id": session_id, + "mode": state.mode.value, + "created_at": created_at, + "updated_at": time.time(), + "workspace_dir": str(self._workspace_dir), + "messages": [self._message_to_dict(message) for message in messages], + } + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + def load(self, resume_target: str) -> SessionSnapshot: + """Load a persisted session snapshot by explicit id or ``latest``.""" + normalized = (resume_target or "").strip() or LATEST_SESSION_ALIAS + if normalized == LATEST_SESSION_ALIAS: + return self._load_latest() + path = self.path_for(normalized) + if not path.exists(): + raise SessionStoreError(f"resume target not found: {normalized}") + return self._load_path(path) + + def _load_latest(self) -> SessionSnapshot: + candidates: list[SessionSnapshot] = [] + first_error: SessionStoreError | None = None + for path in sorted(self._session_dir.glob("*.json")): + try: + candidates.append(self._load_path(path)) + except SessionStoreError as exc: + if first_error is None: + first_error = exc + if not candidates: + if first_error is not None: + raise first_error + raise SessionStoreError(f"resume target not found: {LATEST_SESSION_ALIAS}") + return max(candidates, key=lambda snapshot: snapshot.updated_at) + + def list_sessions(self) -> list[SessionListing]: + """Return resumable sessions ordered by most-recent update first.""" + listings: list[SessionListing] = [] + for path in sorted(self._session_dir.glob("*.json")): + try: + snapshot = self._load_path(path) + except SessionStoreError: + continue + listings.append( + SessionListing( + session_id=snapshot.session_id, + mode=snapshot.mode, + created_at=snapshot.created_at, + updated_at=snapshot.updated_at, + workspace_dir=snapshot.workspace_dir, + messages_count=len(snapshot.messages), + path=str(path), + ) + ) + return sorted(listings, key=lambda item: item.updated_at, reverse=True) + + def _load_path(self, path: Path) -> SessionSnapshot: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise SessionStoreError(f"failed to read session snapshot: {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise SessionStoreError(f"invalid session snapshot JSON: {path}: {exc}") from exc + if not isinstance(raw, dict): + raise SessionStoreError(f"invalid session snapshot payload: {path}") + schema_version = str(raw.get("schema_version", "")).strip() + if schema_version != SESSION_SNAPSHOT_SCHEMA_VERSION: + raise SessionStoreError( + "unsupported session snapshot schema_version: " + f"{schema_version or ''}" + ) + + session_id = self._normalize_session_id(raw.get("session_id")) + mode_raw = str(raw.get("mode", "")).strip() + try: + mode = ExecutionMode(mode_raw) + except ValueError as exc: + raise SessionStoreError(f"invalid session mode in snapshot: {path}: {mode_raw}") from exc + + created_at = self._coerce_timestamp(raw.get("created_at"), field_name="created_at", path=path) + updated_at = self._coerce_timestamp(raw.get("updated_at"), field_name="updated_at", path=path) + workspace_dir = str(raw.get("workspace_dir", "")).strip() or str(self._workspace_dir) + messages_raw = raw.get("messages", []) + if not isinstance(messages_raw, list): + raise SessionStoreError(f"invalid messages payload in snapshot: {path}") + messages = [self._message_from_dict(item, path=path) for item in messages_raw] + return SessionSnapshot( + session_id=session_id, + mode=mode, + created_at=created_at, + updated_at=updated_at, + workspace_dir=workspace_dir, + messages=messages, + ) + + def _message_to_dict(self, message: Message) -> dict[str, Any]: + return { + "role": message.role, + "content": message.content, + "name": message.name, + "metadata": _json_safe(dict(message.metadata)), + "mark": message.mark.value if hasattr(message.mark, "value") else str(message.mark), + "id": message.id, + } + + def _message_from_dict(self, raw: Any, *, path: Path) -> Message: + if not isinstance(raw, dict): + raise SessionStoreError(f"invalid message entry in snapshot: {path}") + mark_raw = str(raw.get("mark", MessageMark.TEMPORARY.value)).strip() + try: + mark = MessageMark(mark_raw) + except ValueError: + mark = MessageMark.TEMPORARY + metadata_raw = raw.get("metadata", {}) + metadata = ( + {str(key): value for key, value in metadata_raw.items()} + if isinstance(metadata_raw, dict) + else {} + ) + return Message( + role=str(raw.get("role", "user")), + content=str(raw.get("content", "")), + name=str(raw.get("name")) if raw.get("name") is not None else None, + metadata=metadata, + mark=mark, + id=str(raw.get("id")) if raw.get("id") is not None else None, + ) + + def _normalize_session_id(self, raw: Any) -> str: + normalized = str(raw).strip() if raw is not None else "" + if not normalized: + raise SessionStoreError("session_id is required") + if not SESSION_ID_PATTERN.fullmatch(normalized): + raise SessionStoreError(f"invalid session_id: {normalized}") + if ".." in normalized: + raise SessionStoreError(f"invalid session_id: {normalized}") + return normalized + + def _coerce_timestamp(self, raw: Any, *, field_name: str, path: Path) -> float: + try: + return float(raw) + except (TypeError, ValueError) as exc: + raise SessionStoreError(f"invalid {field_name} in snapshot: {path}") from exc diff --git a/docs/features/README.md b/docs/features/README.md index cb33119a..e39e12c7 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -41,6 +41,7 @@ - `docs/features/agentscope-d5-safe-compression.md` - `docs/features/agentscope-d7-plan-state-tools.md` - `docs/features/enhance-doc-governance-traceability.md` +- `docs/features/client-session-resume.md` - `docs/features/p0-conformance-gate.md` - `docs/features/p0-default-eventlog.md` - `docs/features/p0-step-driven-execution.md` diff --git a/docs/features/client-session-resume.md b/docs/features/client-session-resume.md new file mode 100644 index 00000000..151957f1 --- /dev/null +++ b/docs/features/client-session-resume.md @@ -0,0 +1,71 @@ +--- +change_ids: ["client-session-resume"] +doc_kind: feature +topics: ["client-cli", "session-resume", "conversation-history", "t5-1"] +todo_ids: ["CRES-001", "CRES-002", "CRES-003", "CRES-004"] +created: 2026-03-04 +updated: 2026-03-04 +status: draft +mode: openspec +--- + +# Feature: client-session-resume + +## Scope + +为 `client/` 补齐跨进程会话恢复能力:把当前仅存在于进程内的 STM/history 持久化到 workspace session snapshot,并通过 `--resume [session-id|latest]` 在后续 `chat/run/script` 启动时恢复同一条对话;同时补 `sessions list` 让用户能枚举可恢复 session。 + +## OpenSpec Artifacts + +- Proposal: `openspec/changes/client-session-resume/proposal.md` +- Design: `openspec/changes/client-session-resume/design.md` +- Specs: + - `openspec/changes/client-session-resume/specs/client-host-orchestration/spec.md` +- Tasks: `openspec/changes/client-session-resume/tasks.md` + +## Governance Anchors + +- `docs/guides/Development_Constraints.md` +- `docs/guides/Documentation_First_Development_SOP.md` +- `docs/todos/2026-03-04_client_cli_session_resume_gap_analysis.md` +- `docs/todos/2026-03-04_client_cli_session_resume_master_todo.md` +- `client/DESIGN.md` +- `client/README.md` + +## Evidence + +### Commands + +- `.venv/bin/python -m pytest -q tests/unit/test_client_cli.py -k resume` +- `.venv/bin/python -m pytest -q tests/integration/test_client_cli_flow.py -k resume` +- `.venv/bin/python -m pytest -q tests/unit/test_client_cli.py` +- `.venv/bin/python -m pytest -q tests/integration/test_client_cli_flow.py` +- `openspec validate client-session-resume` +- `./scripts/ci/check_governance_traceability.sh` +- `./scripts/ci/check_governance_evidence_truth.sh` + +### Results + +- `tests/unit/test_client_cli.py -k resume`: passed (`2` tests),确认 parser 能识别 `--resume`,且缺失 session 时返回确定性错误。 +- `tests/integration/test_client_cli_flow.py -k resume`: passed (`2` tests),确认 first run 会写 snapshot,second run 能用 `latest` 或显式 `session-id` 恢复历史和原 session id。 +- `tests/unit/test_client_cli.py`: passed (`50` tests),新增覆盖 `sessions list` 顶层命令、slash command parser 和按更新时间排序的 listing 输出。 +- `tests/integration/test_client_cli_flow.py`: passed (`28` tests),新增覆盖脚本态 `/sessions list` 输出已保存 session。 +- `openspec validate client-session-resume`: passed。 +- `./scripts/ci/check_governance_traceability.sh`: passed。 +- `./scripts/ci/check_governance_evidence_truth.sh`: active 模式下会因缺少 intent/implementation PR 与 review link 失败;本地实现阶段将本文档保持为 `draft`,待真正进入 review/merge gate 时再补齐链接并切到 `active` / `in_review`。 + +### Behavior Verification + +- Happy path: `run --task "first task"` 结束后会在 workspace `.dare/sessions/` 落 snapshot;随后 `run --resume latest --task "follow up"` 或 `run --resume --task "follow up"` 会先恢复历史 STM,再继续执行,并复用原 session id。`sessions list` / `/sessions list` 会按最近更新时间列出当前 workspace 可恢复 session。 +- Error/fallback path: `--resume latest` 在没有 snapshot 的 workspace 中会以参数错误退出,而不是静默开启一条新空会话。 + +### Risks and Rollback + +- Risk: first version只恢复历史与 mode,不恢复 pending plan / approvals / running task,这与 runtime checkpoint resume 语义不同。 +- Rollback: 回退 `client` 的 session store 与 `--resume` 入口,恢复到“每次启动都是新会话”的基线。 + +### Review and Merge Gate Links + +- Intent PR: `pending` +- Implementation PR: `pending` +- Review thread: `pending` diff --git a/docs/plans/2026-03-04-client-session-resume-implementation.md b/docs/plans/2026-03-04-client-session-resume-implementation.md new file mode 100644 index 00000000..6a514e61 --- /dev/null +++ b/docs/plans/2026-03-04-client-session-resume-implementation.md @@ -0,0 +1,99 @@ +# Client Session Resume Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Claude/Codex-style basic resume support to `client/` by persisting CLI session history and restoring it across process restarts. + +**Architecture:** Keep the runtime model unchanged and persist only CLI-owned session state. Use a file-backed session snapshot store under workspace `.dare/sessions/`, restore STM/history into the freshly bootstrapped agent context, and expose resume through explicit CLI flags instead of implicit auto-loading. + +**Tech Stack:** Python `argparse`, JSON file persistence, existing `Context` STM APIs, pytest unit/integration coverage, OpenSpec docs. + +--- + +### Task 1: Freeze docs-first resume semantics + +**Files:** +- Create: `docs/todos/2026-03-04_client_cli_session_resume_gap_analysis.md` +- Create: `docs/todos/2026-03-04_client_cli_session_resume_master_todo.md` +- Create: `docs/features/client-session-resume.md` +- Create: `openspec/changes/client-session-resume/proposal.md` +- Create: `openspec/changes/client-session-resume/design.md` +- Create: `openspec/changes/client-session-resume/tasks.md` +- Create: `openspec/changes/client-session-resume/specs/client-host-orchestration/spec.md` +- Modify: `client/DESIGN.md` +- Modify: `client/README.md` + +**Step 1: Write the failing test** + +No code test in this task. This is the docs-first prerequisite. + +**Step 2: Run verification** + +Run: `openspec validate client-session-resume --strict` +Expected: PASS after artifacts are created. + +**Step 3: Write minimal implementation** + +Document the scope as “cross-process conversation resume”, not runtime checkpoint resume. Lock the CLI surface to `--resume [session-id|latest]` and the restore boundary to STM/history + mode only. + +### Task 2: Add failing tests for session persistence and restore + +**Files:** +- Modify: `tests/unit/test_client_cli.py` +- Modify: `tests/integration/test_client_cli_flow.py` + +**Step 1: Write the failing test** + +Add tests that expect: +- session snapshots to be written under workspace `.dare/sessions/` +- `chat/run/script --resume` to restore the previous session id and STM history +- `--resume` without an existing snapshot to fail deterministically + +**Step 2: Run test to verify it fails** + +Run: +- `.venv/bin/python -m pytest -q tests/unit/test_client_cli.py -k resume` +- `.venv/bin/python -m pytest -q tests/integration/test_client_cli_flow.py -k resume` + +Expected: FAIL because no session store or resume parser path exists yet. + +### Task 3: Implement the minimal session store and resume path + +**Files:** +- Create: `client/session_store.py` +- Modify: `client/main.py` +- Modify: `client/session.py` + +**Step 1: Write minimal implementation** + +Implement a file-backed store that serializes STM messages plus minimal session metadata, restore it into the bootstrapped runtime context before the next task executes, and save snapshots after each completed CLI turn. + +**Step 2: Run test to verify it passes** + +Run: +- `.venv/bin/python -m pytest -q tests/unit/test_client_cli.py -k resume` +- `.venv/bin/python -m pytest -q tests/integration/test_client_cli_flow.py -k resume` + +Expected: PASS + +### Task 4: Sync docs and run focused verification + +**Files:** +- Modify: `docs/features/client-session-resume.md` +- Modify: `openspec/changes/client-session-resume/tasks.md` +- Modify: `client/DESIGN.md` +- Modify: `client/README.md` + +**Step 1: Run verification** + +Run: +- `.venv/bin/python -m pytest -q tests/unit/test_client_cli.py` +- `.venv/bin/python -m pytest -q tests/integration/test_client_cli_flow.py` +- `openspec validate client-session-resume --strict` +- `./scripts/ci/check_governance_evidence_truth.sh` + +Expected: PASS + +**Step 2: Write minimal implementation** + +Mark only completed tasks, replace placeholders in the feature evidence section with concrete commands/results, and document residual limitations around non-restored ephemeral runtime state. diff --git a/docs/todos/2026-03-04_client_cli_session_resume_gap_analysis.md b/docs/todos/2026-03-04_client_cli_session_resume_gap_analysis.md new file mode 100644 index 00000000..44d5046c --- /dev/null +++ b/docs/todos/2026-03-04_client_cli_session_resume_gap_analysis.md @@ -0,0 +1,108 @@ +--- +change_ids: ["client-session-resume"] +doc_kind: analysis +topics: ["client-cli", "session-resume", "conversation-history", "t5-1"] +created: 2026-03-04 +updated: 2026-03-04 +status: active +mode: openspec +--- + +# 2026-03-04 DARE Client CLI Session Resume Gap Analysis + +> 类型:专题 gap 分析 +> 范围:`client/` 的跨进程会话恢复能力,目标对齐 Claude Code / Codex CLI 的基础“退出后继续同一会话”体验 +> 上游治理项:`docs/todos/project_overall_todos.md` 中 `T5-1 session 管理下 context 持久化与跨会话交接闭环` +> 评审基线:`client/main.py`、`client/session.py`、`client/DESIGN.md`、`client/README.md`、`tests/unit/test_client_cli.py`、`tests/integration/test_client_cli_flow.py` + +--- + +## 1. 先纠偏 + +当前 `client/` 里已经有 `conversation_id` / `session_id` 概念,但它只解决“同一进程内的关联标识”,不等于真正的 `resume`。 + +当前实现的真实语义是: + +1. `client/main.py` 在每次 `chat/run/script` 启动时都会创建新的 `CLISessionState`。 +2. `run_task(...)` 只把 `conversation_id` 放进 `Task.metadata`,并不会把历史会话从磁盘恢复回来。 +3. `DareAgent` 的 STM 会在同一进程中跨轮保留,因此单次 `chat` 里多轮对话有上下文。 +4. 进程一退出,CLI 没有任何 session snapshot、history manifest 或 `--resume` 入口,跨进程连续性完全丢失。 + +因此,对用户问题的直接回答是: + +- 目前没有真正可用的 CLI `resume` 能力。 +- 现有能力只是“运行中的内存会话连续”,不是“退出后恢复历史继续聊”。 + +--- + +## 2. 当前结论 + +- 本次应实现的 `resume` 语义应收敛为:恢复历史消息与 session identity,继续同一条 CLI 对话。 +- 本次不扩成 runtime checkpoint / paused execution 断点续跑;那是 `IExecutionControl.resume()` 的另一条能力线。 +- 最小可用闭环需要同时补: + - session snapshot 持久化 + - `chat/run/script` 的 resume 入口 + - 缺失 / 损坏 snapshot 的确定性错误语义 + - 文档与回归测试 + +--- + +## 3. Gap 明细 + +| Gap ID | 设计声明(Design Claim) | 代码现状(Code Evidence) | 影响评估(Impact) | 建议动作(Action) | 优先级 | +|---|---|---|---|---|---| +| CRES-GAP-001 | CLI session 应能把可恢复的对话状态持久化到确定路径,而不是仅在内存里维持。 | `client/session.py` 只有 `CLISessionState` 内存结构;`client/main.py` 在每次入口都新建 state;仓库内没有 `client` 专用 session store。 | 用户关闭 CLI 后无法继续上一次任务上下文,体验与 Claude/Codex CLI 有明显差距。 | 为 `client/` 增加文件型 session snapshot store,至少持久化 `session_id`、`mode`、更新时间和 STM 消息列表。 | P0 | +| CRES-GAP-002 | CLI 应提供显式 resume 入口,支持恢复最近一次或指定 session。 | `client/_build_parser()` 只有 `chat/run/script`,没有 `--resume` 或等价入口。 | 即使后续补了持久化,用户仍没有稳定命令面去恢复会话。 | 为 `chat/run/script` 增加 `--resume [session-id|latest]`,空参数时默认 `latest`。 | P0 | +| CRES-GAP-003 | Resume 必须定义“恢复什么、不恢复什么”的边界,避免把进程内瞬态状态误当作可恢复状态。 | 当前没有任何 resume 语义;`pending_plan`、后台 task、待审批 request 都只存在内存。 | 若不声明边界,后续实现容易错误恢复 pending approvals / running task,造成状态不一致。 | 在设计与实现中明确:恢复 STM/history 和 mode;不恢复运行中任务、待审批队列、pending plan 预览;恢复后 session 状态重置为 `idle`。 | P1 | +| CRES-GAP-004 | Resume 需要设计级与测试级冻结,保证后续 CLI 演进不会回退。 | 现有 `tests/unit/test_client_cli.py` 与 `tests/integration/test_client_cli_flow.py` 没有 session snapshot / resume 覆盖。 | 没有回归面时,后续改命令行或 runtime bootstrapping 时极易再次丢失恢复能力。 | 增加 unit + integration 测试,覆盖 snapshot 持久化、latest 选择、特定 session 恢复、缺失 session 错误。 | P0 | +| CRES-GAP-005 | 用户应能枚举当前 workspace 的可恢复 session,而不是手动遍历 `.dare/sessions/`。 | 当前虽然已有 snapshot 文件,但 CLI 没有 `sessions list` 或等价入口。 | `resume` 已可用,但 discoverability 仍然差,用户不知道有哪些 session id 可恢复。 | 增加 `dare sessions list` 和交互态 `/sessions list`,返回按更新时间排序的 resumable session 摘要。 | P1 | + +--- + +## 4. 影响范围 + +### 4.1 设计与文档 + +- `client/DESIGN.md` +- `client/README.md` +- `docs/features/client-session-resume.md` +- `openspec/changes/client-session-resume/**` + +### 4.2 实现 + +- `client/main.py` +- `client/session.py` +- `client/runtime/task_runner.py` +- 新增 `client/session_store.py`(或等价持久化模块) + +### 4.3 测试 + +- `tests/unit/test_client_cli.py` +- `tests/integration/test_client_cli_flow.py` +- 可选新增独立 session store 单元测试文件 + +--- + +## 5. 建议切片 + +本轮收敛为单一切片: + +1. Slice A: `client-session-resume` + - 目标:补齐跨进程 session snapshot + resume 命令面 + 回归测试 + - 不包含:checkpoint resume、审批等待恢复、startup handshake replay + +--- + +## 6. 风险提示 + +1. 若直接恢复内存态字段(如 pending approval / running task),会制造“磁盘状态与真实 runtime 状态不一致”的假恢复。 +2. 若不把 snapshot 路径固定到 workspace `.dare/sessions/`,用户将很难判断 resume 的作用域。 +3. 若不覆盖 `latest` 选择和损坏文件错误路径,`resume` 体验会不稳定且难排查。 + +--- + +## 7. 本轮结论 + +- 用户要求的 `resume` 能力当前不存在。 +- 最合理的第一版实现是:`chat/run/script` 共享一套 session snapshot store,并通过 `--resume` 恢复历史消息与 `session_id`。 +- 下一步进入 master TODO、OpenSpec artifacts 和 failing tests。 diff --git a/docs/todos/2026-03-04_client_cli_session_resume_master_todo.md b/docs/todos/2026-03-04_client_cli_session_resume_master_todo.md new file mode 100644 index 00000000..db8d483e --- /dev/null +++ b/docs/todos/2026-03-04_client_cli_session_resume_master_todo.md @@ -0,0 +1,51 @@ +--- +change_ids: ["client-session-resume"] +doc_kind: todo +topics: ["client-cli", "session-resume", "conversation-history", "t5-1"] +created: 2026-03-04 +updated: 2026-03-04 +status: active +mode: openspec +--- + +# 2026-03-04 Client CLI Session Resume Master TODO + +> 来源:`docs/todos/2026-03-04_client_cli_session_resume_gap_analysis.md` +> 执行模型:docs baseline -> OpenSpec slice -> docs-only intent PR -> implementation -> evidence -> archive +> 范围:仅覆盖 `client/` 的跨进程会话恢复闭环 + +## 认领声明(Claim Ledger) + +| Claim ID | TODO Scope | Owner | Status | Declared At | Expires At | OpenSpec Change | Notes | +|---|---|---|---|---|---|---|---| +| CLM-20260304-CRES-A | CRES-001~CRES-005 | codex | done | 2026-03-04 | 2026-03-07 | `client-session-resume` | 本地实现已补齐 resume + sessions list,后续仅剩真实 PR/review/archive 流程。 | + +## 切片规划 + +| Slice | 目标 | 建议 OpenSpec Change | 主要覆盖 TODO | +|---|---|---|---| +| Slice A | 持久化 CLI session snapshot,补齐 `--resume` 与回归测试 | `client-session-resume` | CRES-001, CRES-002, CRES-003, CRES-004 | + +## TODO 清单 + +| ID | Priority | Status | Gap ID | Planned OpenSpec Change | Task | Owner | Evidence | Last Updated | +|---|---|---|---|---|---|---|---|---| +| CRES-001 | P0 | done | CRES-GAP-001 | `client-session-resume` | 为 `client/` 增加 workspace 级 session snapshot store,持久化 `session_id`、`mode`、时间戳和 STM 消息。 | codex | `client/session_store.py`;`client/main.py`;`docs/features/client-session-resume.md` | 2026-03-04 | +| CRES-002 | P0 | done | CRES-GAP-002 | `client-session-resume` | 为 `chat/run/script` 增加 `--resume [session-id|latest]`,并在启动时输出/暴露当前 session id。 | codex | `client/main.py`;`client/README.md`;`client/DESIGN.md` | 2026-03-04 | +| CRES-003 | P1 | done | CRES-GAP-003 | `client-session-resume` | 明确恢复边界:恢复历史与 mode,不恢复 running task / pending approvals / pending plan,并将恢复后状态归一到 `idle`。 | codex | `client/DESIGN.md`;`openspec/changes/client-session-resume/design.md` | 2026-03-04 | +| CRES-004 | P0 | done | CRES-GAP-004 | `client-session-resume` | 新增 unit/integration 测试,覆盖 snapshot 持久化、latest 选择、指定 session 恢复、缺失 session 错误。 | codex | `tests/unit/test_client_cli.py`;`tests/integration/test_client_cli_flow.py` | 2026-03-04 | +| CRES-005 | P1 | done | CRES-GAP-005 | `client-session-resume` | 增加 `dare sessions list` 与交互态 `/sessions list`,输出当前 workspace 可恢复 session 摘要。 | codex | `client/session_store.py`;`client/main.py`;`tests/unit/test_client_cli.py`;`tests/integration/test_client_cli_flow.py`;`client/README.md` | 2026-03-04 | + +## 执行规则 + +1. 先更新 `client/DESIGN.md` / `client/README.md` 与 OpenSpec artifacts,再开始代码实现。 +2. `resume` 仅代表“恢复历史对话”,不代表 runtime checkpoint 断点续跑。 +3. 任何恢复后的 CLI state 都必须从 `idle` 开始,不允许伪造“仍在运行”的状态。 +4. `docs/features/client-session-resume.md` 必须作为该切片的单一状态与证据真相源。 + +## 建议验收边界 + +- `chat --resume` 能恢复最近一次 session 的历史消息并继续多轮对话。 +- `run/script --resume ` 能在已有历史上下文上追加执行。 +- `--resume` 找不到 session 时返回确定性参数错误。 +- snapshot 文件损坏时返回清晰错误,而不是静默新建空会话。 diff --git a/openspec/changes/client-session-resume/design.md b/openspec/changes/client-session-resume/design.md new file mode 100644 index 00000000..55500485 --- /dev/null +++ b/openspec/changes/client-session-resume/design.md @@ -0,0 +1,162 @@ +## Context + +`client/` 当前已经有三块与 resume 相关但未闭环的基础设施: + +- `CLISessionState` 维护 `mode/status/conversation_id` 等 CLI 级状态; +- `DareAgent.context` 的 STM 在单进程多轮里会持续保留用户/assistant/tool 消息; +- `run_task(...)` 会把 `conversation_id` 透传到 `Task.metadata`,用于审计与关联。 + +缺口在于:CLI 没有把这三部分变成可恢复的持久化资产。每次入口都会新建 state 和 runtime,历史上下文没有从磁盘回灌,因此 `conversation_id` 只是一层标签,而不是 resume contract。 + +## Goals / Non-Goals + +**Goals:** + +- 为 `client/` 定义并实现跨进程 session snapshot。 +- 支持 `chat/run/script --resume [session-id|latest]`。 +- 支持显式列出当前 workspace 下可恢复的 sessions。 +- 恢复历史 STM 消息与 CLI mode,并复用原 session id。 +- 对缺失 / 损坏 snapshot 提供确定性错误。 + +**Non-Goals:** + +- 不实现 runtime checkpoint 或 paused execution 断点续跑。 +- 不恢复 pending approvals、running task、pending plan preview。 +- 不改 headless event envelope / control-stdin schema。 +- 不引入远程 session store 或多 workspace 聚合索引。 + +## Decisions + +### Decision 1: session snapshot 固定写入 workspace `.dare/sessions/` + +- snapshot 目录固定为 `/.dare/sessions/`。 +- 每个 session 一个 JSON 文件,文件名使用 `session_id`。 +- `latest` 通过扫描 snapshot 的 `updated_at` 选择最近一次写入。 + +这样可以保持作用域清晰:resume 只作用于当前 workspace,而不是跨仓库混用历史。 + +### Decision 2: 第一版只持久化“可安全恢复”的 CLI state + +持久化内容: + +- `session_id` +- `mode` +- `created_at` +- `updated_at` +- `workspace_dir` +- `messages`(STM 序列化结果) + +不持久化内容: + +- `status` +- `active_execution_task` +- `pending_runtime_approvals` +- `pending_plan` +- `pending_task_description` + +原因是这些字段都依赖活跃进程或一次性 planner 结果,跨进程恢复会制造虚假状态。 + +### Decision 3: resume 后一律从 `idle` 重新开始 + +- 恢复成功后,CLI 会加载历史消息、恢复 `mode`,并把 `status` 归一到 `idle`。 +- 若用户此前在 `plan` 模式下退出,恢复后仍保留 `plan` 模式,但不会恢复上一条 `pending_plan`。 +- 后续新的用户输入会在恢复后的 STM 上继续执行。 + +### Decision 4: `--resume` 是显式入口,不做隐式自动恢复 + +- 不因为 workspace 里存在 snapshot 就自动 resume。 +- 只有显式传入 `--resume` 才加载历史。 +- `--resume` 不带值时默认选择 `latest`。 + +这样可以避免“本想开新会话却被自动加载旧历史”的歧义。 + +### Decision 5: session discovery 通过 `sessions list` 暴露 + +- 顶层命令增加 `dare sessions list`。 +- 交互态增加 `/sessions list`。 +- 返回结果按 `updated_at` 倒序,至少包含 `session_id`、`mode`、`updated_at`、`messages_count`、`path`。 + +这样用户不需要手动遍历 `.dare/sessions/`,也不需要猜测 session id。 + +## Data Structures + +### SessionSnapshot + +```json +{ + "schema_version": "client-session.v1", + "session_id": "abc123", + "mode": "execute", + "created_at": 1772592000.0, + "updated_at": 1772592030.0, + "workspace_dir": "/abs/workspace", + "messages": [ + { + "role": "user", + "content": "summarize README", + "name": null, + "metadata": {}, + "mark": "temporary", + "id": null + } + ] +} +``` + +### RestoreResult + +恢复内部结果至少需要提供: + +- 解析后的 snapshot +- `restored_messages_count` +- 解析出的 `session_id` + +供 CLI 启动日志/headless event 填充 resume metadata。 + +## Core Workflow + +1. CLI 解析到 `--resume` 后,在 runtime bootstrapping 完成后加载 snapshot。 +2. 将 snapshot 中的消息写入 `runtime.agent.context` 的 STM。 +3. 用 snapshot 的 `session_id` 与 `mode` 构造 `CLISessionState`。 +4. 运行新的 `chat/run/script` 输入。 +5. 每次执行完成或关键状态变化后,把当前 STM 与最小 session metadata 写回 snapshot 文件。 + +## Key Interfaces + +### `client/session_store.py` + +- `load(resume_target: str) -> SessionSnapshot` +- `save(state: CLISessionState, messages: list[Message]) -> Path` +- `resolve_latest() -> str` +- `list_sessions() -> list[SessionListing]` + +### `client/main.py` + +- 解析 `--resume` +- 在 `chat/run/script` 入口决定是新建 session 还是恢复 session +- 执行后触发 snapshot 写回 + +## Error Handling + +- `--resume` 目标不存在:返回参数错误(exit code `2`),提示 session id 或 latest 不可用。 +- snapshot JSON 损坏:返回参数错误(exit code `2`),提示文件路径和解析失败原因。 +- snapshot 版本不兼容:返回参数错误(exit code `2`),提示 schema version 不支持。 +- snapshot 写回失败:返回业务错误(exit code `1`),因为执行结果可能已产生,但状态未能持久化。 + +## Risks / Trade-offs + +- [Risk] 把 snapshot 放在 workspace 下意味着同一用户跨 workspace 不能共享 recent sessions。 + -> Mitigation: 这与当前 CLI 的 workspace 作用域一致,先保守隔离。 + +- [Risk] 恢复 STM 但不恢复 pending plan,可能让部分用户觉得“没有完全接着上次继续”。 + -> Mitigation: 文档里明确第一版语义是对话 history resume,不是 planner/runtime snapshot resume。 + +- [Risk] 长会话 snapshot 体积会增长。 + -> Mitigation: 第一版保持最小实现;后续可与 `T5-1` 的压缩/summary 管线对接。 + +## Migration Plan + +1. 先补 docs 与 OpenSpec artifacts,锁定 resume 边界。 +2. 写 failing tests。 +3. 实现 session store 与 parser/runtime resume。 +4. 跑回归并回写 feature evidence。 diff --git a/openspec/changes/client-session-resume/proposal.md b/openspec/changes/client-session-resume/proposal.md new file mode 100644 index 00000000..d8d0bc33 --- /dev/null +++ b/openspec/changes/client-session-resume/proposal.md @@ -0,0 +1,43 @@ +## Why + +当前 `client/` 的多轮上下文只在同一进程里成立。`chat` 里继续追问是有效的,但 CLI 一退出,历史 STM、`conversation_id` 与执行模式就全部丢失,用户无法像 Claude Code / Codex CLI 那样“第二次打开继续上一次会话”。 + +当前基线存在三个实际缺口: + +1. 没有 workspace 级 session snapshot,`conversation_id` 只是 metadata 标签,不是可恢复状态。 +2. 没有 `--resume` 命令面,用户无法恢复最近一次或指定 session。 +3. 没有明确恢复边界,后续实现容易把 pending approvals / running task / pending plan 等进程内瞬态误当作可恢复状态。 + +因此本切片收敛到一件事:为 `client/` 增加“跨进程恢复同一条对话”的基础能力,并用文档与回归测试把它冻结下来。 + +## What Changes + +- 新增 workspace 级 session snapshot store,持久化最小可恢复 CLI state。 +- 为 `chat/run/script` 增加 `--resume [session-id|latest]`。 +- 增加 `sessions list` / `/sessions list`,让用户发现当前 workspace 的 resumable sessions。 +- 恢复时回灌 STM/history 与 `mode`,并重置 CLI status 到 `idle`。 +- 增加 unit/integration 测试,覆盖 latest 选择、指定 session 恢复、缺失 session 错误与 resume 后继续执行。 +- 回写 `client/DESIGN.md`、`client/README.md`、feature evidence 与 TODO/OpenSpec artifacts。 + +## Capabilities + +### Modified Capabilities + +- `client-host-orchestration`: CLI invocation gains deterministic cross-process session restore semantics without changing the headless event/control contract. + +## Impact + +- 影响文件: + - `client/main.py` + - `client/session.py` + - `client/session_store.py` + - `client/DESIGN.md` + - `client/README.md` + - `tests/unit/test_client_cli.py` + - `tests/integration/test_client_cli_flow.py` + - `docs/features/client-session-resume.md` + - `openspec/changes/client-session-resume/**` +- 不包含: + - runtime checkpoint resume + - paused execution / approval wait 恢复 + - startup handshake replay 或 event log replay diff --git a/openspec/changes/client-session-resume/specs/client-host-orchestration/spec.md b/openspec/changes/client-session-resume/specs/client-host-orchestration/spec.md new file mode 100644 index 00000000..c6f32523 --- /dev/null +++ b/openspec/changes/client-session-resume/specs/client-host-orchestration/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: Client host orchestration modes are explicitly separated +The system SHALL distinguish between interactive CLI behavior, legacy automation JSON output, and host-orchestrated headless execution. + +- Interactive mode MAY use prompts and inline human approval UX. +- Legacy automation JSON MAY keep the current `log/event/result` line schema for backward compatibility. +- Headless host orchestration MUST be an explicit mode boundary and MUST NOT rely on prompt text or inline human approval interactions. +- `chat` MUST remain interactive and MUST reject headless-only flags. +- Incompatible headless/legacy flag combinations MUST fail with a deterministic parameter error instead of silently falling back. +- Any of `chat`, `run`, or `script` MAY explicitly resume a persisted session snapshot without changing the mode boundary semantics above. + +#### Scenario: Interactive resume restores prior conversation history +- **GIVEN** a workspace contains a persisted CLI session snapshot +- **WHEN** the user starts `dare chat --resume` or `dare chat --resume ` +- **THEN** the client restores the prior session id and message history before accepting the next prompt +- **AND** the resumed session starts from an idle CLI state instead of pretending a previous task is still running + +#### Scenario: Missing resume target fails deterministically +- **GIVEN** the user passes `--resume latest` or `--resume ` +- **WHEN** no matching persisted session snapshot exists +- **THEN** the client exits with a deterministic parameter error +- **AND** it does not silently create a fresh empty session + +#### Scenario: User lists resumable sessions +- **GIVEN** a workspace contains one or more persisted CLI session snapshots +- **WHEN** the user runs `dare sessions list` or `/sessions list` +- **THEN** the client returns a structured list of resumable session summaries ordered by most recent update +- **AND** each entry includes enough data for the user to choose a `--resume ` target diff --git a/openspec/changes/client-session-resume/tasks.md b/openspec/changes/client-session-resume/tasks.md new file mode 100644 index 00000000..88d7fa6e --- /dev/null +++ b/openspec/changes/client-session-resume/tasks.md @@ -0,0 +1,19 @@ +## 1. Session Snapshot Persistence + +- [x] 1.1 为 `client/` 新增 workspace 级 session snapshot store,并定义最小 JSON schema。 +- [x] 1.2 在 `chat/run/script` 执行后写回 snapshot,保证 `session_id`、mode 与 STM 历史可恢复。 + +## 2. Resume Command Surface + +- [x] 2.1 为 `chat/run/script` 增加 `--resume [session-id|latest]`。 +- [x] 2.2 恢复后复用原 `session_id`,并明确恢复边界为 history + mode,status 重置为 `idle`。 + +## 3. Tests And Docs + +- [x] 3.1 增加 unit/integration 测试,覆盖 latest 选择、指定 session 恢复、resume 后继续执行、缺失 session 错误。 +- [x] 3.2 更新 `client/DESIGN.md`、`client/README.md` 与 `docs/features/client-session-resume.md` 证据区。 + +## 4. Session Discovery + +- [x] 4.1 增加 `dare sessions list` 与 `/sessions list` 命令面。 +- [x] 4.2 返回按更新时间排序的 session 摘要,并补对应 unit/integration 测试与 README。 diff --git a/tests/integration/test_client_cli_flow.py b/tests/integration/test_client_cli_flow.py index d0e509ca..50baec5a 100644 --- a/tests/integration/test_client_cli_flow.py +++ b/tests/integration/test_client_cli_flow.py @@ -10,6 +10,7 @@ import pytest from dare_framework.config import Config +from dare_framework.context import Message class _FakeClientChannel: @@ -37,6 +38,26 @@ async def close(self) -> None: self.closed = True +class _FakeContext: + def __init__(self) -> None: + self._messages: list[Message] = [] + + def stm_add(self, message: Message) -> None: + self._messages.append(message) + + def stm_get(self) -> list[Message]: + return list(self._messages) + + def stm_clear(self) -> list[Message]: + self._messages.clear() + return [] + + +class _FakeAgent: + def __init__(self) -> None: + self.context = _FakeContext() + + class _FakeActionClient: calls: list[tuple[str, dict[str, Any]]] = [] @@ -1579,3 +1600,197 @@ async def _slow_run_task(*, agent, task_text, conversation_id=None, transport=No assert "auto-approving request_id=req-auto-1 for tool=run_command" in log_text assert any(action_id == "approvals:grant" for action_id, _ in _FakeActionClient.calls) assert runtime.closed is True + + +@pytest.mark.asyncio +async def test_main_run_resume_latest_restores_history_and_session_id( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + client_main = importlib.import_module("client.main") + config = _config_for_tests(tmp_path) + runtimes: list[_FakeRuntime] = [] + seen_histories: list[list[str]] = [] + seen_session_ids: list[str | None] = [] + + class _ResumeRuntime(_FakeRuntime): + def __init__(self, *, config: Config) -> None: + super().__init__(config=config) + self.agent = _FakeAgent() + + def _fake_load_effective_config(_options): # noqa: ANN001 + return object(), config + + async def _fake_bootstrap_runtime(_options): # noqa: ANN001 + runtime = _ResumeRuntime(config=config) + runtimes.append(runtime) + return runtime + + class _OkResult: + success = True + output = {"content": "assistant says hi"} + errors: list[str] = [] + + async def _fake_run_task(*, agent, task_text, conversation_id=None, transport=None): # noqa: ANN001 + _ = transport + seen_histories.append([message.content for message in agent.context.stm_get()]) + seen_session_ids.append(conversation_id) + agent.context.stm_add(Message(role="user", content=task_text)) + agent.context.stm_add(Message(role="assistant", content=f"done:{task_text}")) + return _OkResult() + + monkeypatch.setattr(client_main, "load_effective_config", _fake_load_effective_config) + monkeypatch.setattr(client_main, "bootstrap_runtime", _fake_bootstrap_runtime) + monkeypatch.setattr(client_main, "run_task", _fake_run_task) + + first_rc = await client_main.main( + [ + "--workspace", + config.workspace_dir, + "--user-dir", + config.user_dir, + "run", + "--task", + "first task", + ] + ) + assert first_rc == 0 + + session_dir = Path(config.workspace_dir) / ".dare" / "sessions" + session_files = list(session_dir.glob("*.json")) + assert len(session_files) == 1 + + second_rc = await client_main.main( + [ + "--workspace", + config.workspace_dir, + "--user-dir", + config.user_dir, + "run", + "--resume", + "latest", + "--task", + "follow up", + ] + ) + + assert second_rc == 0 + assert seen_histories[0] == [] + assert seen_histories[1] == ["first task", "done:first task"] + assert seen_session_ids[0] + assert seen_session_ids[1] == seen_session_ids[0] + assert len(runtimes) == 2 + assert all(runtime.closed is True for runtime in runtimes) + + +@pytest.mark.asyncio +async def test_main_run_resume_specific_session_restores_history( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + client_main = importlib.import_module("client.main") + config = _config_for_tests(tmp_path) + seen_histories: list[list[str]] = [] + seen_session_ids: list[str | None] = [] + + class _ResumeRuntime(_FakeRuntime): + def __init__(self, *, config: Config) -> None: + super().__init__(config=config) + self.agent = _FakeAgent() + + def _fake_load_effective_config(_options): # noqa: ANN001 + return object(), config + + async def _fake_bootstrap_runtime(_options): # noqa: ANN001 + return _ResumeRuntime(config=config) + + class _OkResult: + success = True + output = {"content": "assistant says hi"} + errors: list[str] = [] + + async def _fake_run_task(*, agent, task_text, conversation_id=None, transport=None): # noqa: ANN001 + _ = transport + seen_histories.append([message.content for message in agent.context.stm_get()]) + seen_session_ids.append(conversation_id) + agent.context.stm_add(Message(role="user", content=task_text)) + agent.context.stm_add(Message(role="assistant", content=f"done:{task_text}")) + return _OkResult() + + monkeypatch.setattr(client_main, "load_effective_config", _fake_load_effective_config) + monkeypatch.setattr(client_main, "bootstrap_runtime", _fake_bootstrap_runtime) + monkeypatch.setattr(client_main, "run_task", _fake_run_task) + + first_rc = await client_main.main( + [ + "--workspace", + config.workspace_dir, + "--user-dir", + config.user_dir, + "run", + "--task", + "seed task", + ] + ) + assert first_rc == 0 + + session_dir = Path(config.workspace_dir) / ".dare" / "sessions" + session_files = list(session_dir.glob("*.json")) + assert len(session_files) == 1 + session_id = session_files[0].stem + + second_rc = await client_main.main( + [ + "--workspace", + config.workspace_dir, + "--user-dir", + config.user_dir, + "run", + "--resume", + session_id, + "--task", + "second task", + ] + ) + + assert second_rc == 0 + assert seen_histories[1] == ["seed task", "done:seed task"] + assert seen_session_ids[1] == session_id + + +@pytest.mark.asyncio +async def test_run_chat_script_sessions_list_emits_saved_sessions( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + client_main = importlib.import_module("client.main") + session_store_module = importlib.import_module("client.session_store") + config = _config_for_tests(tmp_path) + runtime = _FakeRuntime(config=config) + runtime.agent = _FakeAgent() + + store = session_store_module.ClientSessionStore(config.workspace_dir) + older_state = client_main.CLISessionState(conversation_id="session-older") + newer_state = client_main.CLISessionState(conversation_id="session-newer") + store.save(state=older_state, messages=[Message(role="user", content="older")]) + store.save(state=newer_state, messages=[Message(role="user", content="newer")]) + + rc = await client_main._run_chat( + runtime=runtime, + action_client=object(), + output=client_main.OutputFacade("json"), + mode="execute", + script_lines=["/sessions list", "/quit"], + session_store=store, + ) + + lines = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + result_payloads = [line["data"] for line in lines if line.get("type") == "result"] + + assert rc == 0 + assert result_payloads + assert [entry["session_id"] for entry in result_payloads[0]["sessions"]] == [ + "session-newer", + "session-older", + ] diff --git a/tests/unit/test_client_cli.py b/tests/unit/test_client_cli.py index 2714c94b..09207c18 100644 --- a/tests/unit/test_client_cli.py +++ b/tests/unit/test_client_cli.py @@ -17,6 +17,7 @@ from client.runtime.action_client import ActionClientError, _parse_action_response from client.runtime.task_runner import format_run_output from dare_framework.config import Config +from dare_framework.context import Message def test_parse_command_mode() -> None: @@ -26,6 +27,13 @@ def test_parse_command_mode() -> None: assert parsed.args == ["plan"] +def test_parse_command_sessions() -> None: + parsed = parse_command("/sessions list") + assert isinstance(parsed, Command) + assert parsed.type == CommandType.SESSIONS + assert parsed.args == ["list"] + + def test_parse_command_plain_text() -> None: parsed = parse_command("build one file") assert isinstance(parsed, tuple) @@ -1501,6 +1509,29 @@ def test_run_and_script_parser_accept_control_stdin_flag() -> None: assert script_args.control_stdin is True +def test_chat_run_and_script_parser_accept_resume_flag() -> None: + client_main = importlib.import_module("client.main") + parser = client_main._build_parser() + + chat_args = parser.parse_args(["chat", "--resume"]) + run_args = parser.parse_args(["run", "--task", "summarize readme", "--resume", "session-42"]) + script_args = parser.parse_args(["script", "--file", "tasks.txt", "--resume"]) + + assert chat_args.resume == "latest" + assert run_args.resume == "session-42" + assert script_args.resume == "latest" + + +def test_sessions_parser_accepts_list_subcommand() -> None: + client_main = importlib.import_module("client.main") + parser = client_main._build_parser() + + args = parser.parse_args(["sessions", "list"]) + + assert args.command == "sessions" + assert args.sessions_cmd == "list" + + def test_chat_parser_rejects_control_stdin_flag() -> None: client_main = importlib.import_module("client.main") parser = client_main._build_parser() @@ -1694,3 +1725,186 @@ async def _fake_bootstrap_runtime(_options): # noqa: ANN001 assert rc == 2 output = capsys.readouterr().out assert "--control-stdin requires --headless" in output + + +@pytest.mark.asyncio +async def test_main_run_resume_missing_session_returns_two(monkeypatch, tmp_path, capsys) -> None: + client_main = importlib.import_module("client.main") + workspace = tmp_path / "workspace" + user_dir = tmp_path / "user" + workspace.mkdir(parents=True, exist_ok=True) + user_dir.mkdir(parents=True, exist_ok=True) + + config = Config.from_dict( + { + "workspace_dir": str(workspace), + "user_dir": str(user_dir), + "llm": { + "adapter": "openai", + "model": "gpt-4o-mini", + "api_key": "dummy", + }, + } + ) + + def _fake_load_effective_config(_options): # noqa: ANN001 + return object(), config + + class _FakeContext: + def stm_get(self): # noqa: ANN201 + return [] + + def stm_add(self, _message): # noqa: ANN001, ANN201 + return None + + def stm_clear(self): # noqa: ANN201 + return [] + + class _FakeRuntime: + def __init__(self) -> None: + self.agent = type("Agent", (), {"context": _FakeContext()})() + self.channel = object() + self.model = object() + self.config = config + self.client_channel = object() + + async def close(self) -> None: + return None + + async def _fake_bootstrap_runtime(_options): # noqa: ANN001 + return _FakeRuntime() + + async def _unexpected_run_task(*, agent, task_text, conversation_id=None, transport=None): # noqa: ANN001 + raise AssertionError("run_task should not execute when resume target is missing") + + monkeypatch.setattr(client_main, "load_effective_config", _fake_load_effective_config) + monkeypatch.setattr(client_main, "bootstrap_runtime", _fake_bootstrap_runtime) + monkeypatch.setattr(client_main, "run_task", _unexpected_run_task) + + rc = await client_main.main( + [ + "--workspace", + str(workspace), + "--user-dir", + str(user_dir), + "--output", + "json", + "run", + "--resume", + "latest", + "--task", + "continue previous task", + ] + ) + + assert rc == 2 + lines = [line for line in capsys.readouterr().out.splitlines() if line.strip()] + assert lines + payload = json.loads(lines[-1]) + assert payload["type"] == "log" + assert payload["level"] == "error" + assert "resume" in payload["message"] + + +def test_client_session_store_rejects_traversal_session_ids(tmp_path) -> None: + store = importlib.import_module("client.session_store") + session_store = store.ClientSessionStore(tmp_path / "workspace") + + with pytest.raises(store.SessionStoreError, match="invalid session_id"): + session_store.path_for("../../escape") + + with pytest.raises(store.SessionStoreError, match="invalid session_id"): + session_store.path_for("session/../../escape") + + +def test_client_session_store_rejects_tampered_snapshot_session_id(tmp_path) -> None: + store = importlib.import_module("client.session_store") + workspace = tmp_path / "workspace" + session_store = store.ClientSessionStore(workspace) + tampered = session_store.session_dir / "tampered.json" + tampered.write_text( + json.dumps( + { + "schema_version": store.SESSION_SNAPSHOT_SCHEMA_VERSION, + "session_id": "../../escape", + "mode": "execute", + "created_at": 1, + "updated_at": 2, + "workspace_dir": str(workspace), + "messages": [], + } + ), + encoding="utf-8", + ) + + with pytest.raises(store.SessionStoreError, match="invalid session_id"): + session_store.load("tampered") + + assert session_store.list_sessions() == [] + + +@pytest.mark.asyncio +async def test_main_sessions_list_returns_sorted_session_summaries(monkeypatch, tmp_path, capsys) -> None: + client_main = importlib.import_module("client.main") + workspace = tmp_path / "workspace" + user_dir = tmp_path / "user" + workspace.mkdir(parents=True, exist_ok=True) + user_dir.mkdir(parents=True, exist_ok=True) + + config = Config.from_dict( + { + "workspace_dir": str(workspace), + "user_dir": str(user_dir), + "llm": { + "adapter": "openai", + "model": "gpt-4o-mini", + "api_key": "dummy", + }, + } + ) + + store = importlib.import_module("client.session_store") + session_store = store.ClientSessionStore(workspace) + state_a = client_main.CLISessionState(conversation_id="session-a") + state_b = client_main.CLISessionState(conversation_id="session-b") + session_store.save( + state=state_a, + messages=[Message(role="user", content="older")], + ) + session_store.save( + state=state_b, + messages=[ + Message(role="user", content="newer"), + Message(role="assistant", content="done"), + ], + ) + + def _fake_load_effective_config(_options): # noqa: ANN001 + return object(), config + + async def _unexpected_bootstrap(_options): # noqa: ANN001 + raise AssertionError("bootstrap_runtime should not run for sessions list") + + monkeypatch.setattr(client_main, "load_effective_config", _fake_load_effective_config) + monkeypatch.setattr(client_main, "bootstrap_runtime", _unexpected_bootstrap) + + rc = await client_main.main( + [ + "--workspace", + str(workspace), + "--user-dir", + str(user_dir), + "--output", + "json", + "sessions", + "list", + ] + ) + + assert rc == 0 + lines = [line for line in capsys.readouterr().out.splitlines() if line.strip()] + assert lines + payload = json.loads(lines[-1]) + assert payload["type"] == "result" + assert [entry["session_id"] for entry in payload["data"]["sessions"]] == ["session-b", "session-a"] + assert payload["data"]["sessions"][0]["messages_count"] == 2