远程会话记录契约(#2047,Phase A)
本文档固定远程会话的会话记录 / content_blocks / 消息计数 / 回放契约,并记录
Phase A 的产品决策。依赖 #2046(命令/测试证据分离)与 #2022(规范化 provider
生命周期事件)的 Phase B 跟进事项,见
docs/dev-notes/2046-transcript-phase-b-followups.md。
范围
RemoteSessionManager 通过一条共享的 stdout 摄取路径
(process_session_output → _accumulate_assistant_text →
_flush_assistant_buffer)服务两类远程会话:
- 普通交互式远程会话 —— 由人通过 web UI 驱动的远程工作区 / 远程终端
(
session_type为chat/agent/terminal)。 - 自主工作流会话 —— 由自主 runner 驱动(
session_type == 'workflow'且带context.workflow_id,由is_autonomous_workflow_session判定)。
Phase A 决策:统一 schema + 自主会话增量式证 据策略
我们采用统一的 session_messages schema(不设单独的会话记录 profile,
也不新增 is_autonomous 列)。自主会话的结构化证据策略是增量式的:它门控在
已有的派生标志 is_autonomous_workflow_session 上,因此共享路径的交互行为保持
不变。
理由(见 #2047 的范围说明,2026-07-26):
- 本阶段不得改变普通远程会话的语义。
session_messages已经携带content_blocks,不需要第二个 profile 来表达 差异。- 是否需要第二个 profile 推迟到 Phase B 再评估——待 #2046 分离权威的命令/测试 证据、#2022 规范化 provider 事件之后。
#1939 回归(本阶段修复)
PR #1939 拓宽了共享路径,使得仅含 tool/thinking 的轮次也写入一条空的
content="" assistant 行,并把 Claude user tool_result 块折叠进 assistant
轮次。动机是自主会话(保留真实的测试执行证据),但实现没有加守卫,同样作用于
普通交互式会话——产生空的 assistant 气泡并虚增 message_count。
#2047 把该证据策略限定在自主会话:
_accumulate_assistant_text只有在轮次同时产生可见 文本时,才为普通会话累积 结构化块;自主会话还会累积仅含块的轮次。_flush_assistant_buffer只有在存在可见文本时,才为普通会话写行;自主会话 还会把仅含块的轮次作为content_blocks证据持久化。usertool_result折叠分支只对自主会话运行。
轮次契约
一个轮次(turn)是两次 flush 触发(type == "result" 或进程
is_complete)之间累积的 assistant 输出。持久化的行存放在
session_messages。
| 会话类型 | 有可见文本 | 仅 tool/thinking | user tool_result |
|---|---|---|---|
| 普通交互式 | 写入一行 role=assistant,content=text,source=remote_live;随附的 tool_use/thinking 块保存在 metadata.content_blocks | 不写行、不计数 | 忽略(不折叠) |
| 自主工作流 | 与交互式相同 | 写入一行 role=assistant,content="",块保存在 metadata.content_blocks | 折叠进当前轮次的 metadata.content_blocks |
role 为 assistant;source 为 remote_live;结构化块随行保存在
metadata.content_blocks(并同时解码进 content_blocks 列)。系统流结束时
写入一条 role=system 行。
计数契约
agent_sessions.message_count每新插入一条 assistant/system 轮次加 1 (increment_session_usage(message_delta=1),以所存行的_was_inserted标志为门控)。不产生行的纯 tool 轮次(普通会话)不计数。request_count、total_tokens、total_input_tokens、total_output_tokens由process_usage_report独立驱动,与会话记录写入无关。- 目前没有单独的
conversation_turn_count或visible_message_count列; 两者都是 #2046/#2022 落地后的 Phase B 候选项。
幂等性
_flush_assistant_buffer只在getattr(stored, "_was_inserted", False)时才为轮次计数;对已有行的元数据合并更新不会增加message_count。result会 flush 当前轮次;随后数据为空的进程is_complete只会 flush 一个 空缓冲,不会额外写入(见test_result_then_process_complete_does_not_double_flush)。- 重复的相同轮次只有在确实是不同轮次时才产生不同的行;重放同一路流不会复制 行。
回放契约(重连)
- 会话记录回放:
GET /api/remote/sessions/<id>→RemoteSessionManager.get_session_status()["messages"]→SessionManager.get_messages,按timestamp ASC(写入顺序)排序。 - 实时事件回放:SSE
stream_session_output路由按event_index ASC重放remote_runtime_outputs,通过set_last_delivered做游标跟踪,中途断开 不会重复事件。 - 这两个来源相互独立:
remote_runtime_outputs是实时 stdout 环形缓冲;session_messages是持久化的会话记录。
目标形态:规范化的轮次标识(TranscriptTurn)
Issue #2047 提出一种规范化的轮次形状,让持久化/展示策略可以被统一决策。 目标定义如下(实现推迟到 #2022,由它负责 provider 事件规范化):
@dataclass
class TranscriptTurn:
turn_id: str
role: str
visible_text: str
content_blocks: list[dict]
tool_call_ids: list[str]
evidence_ids: list[str]
started_at: datetime | None
completed_at: datetime | None
terminal_reason: str | None
本阶段不引入 TranscriptTurn;只是把目标记录在案,让 #2022 可以产出它,
持久化/展示层可以直接消费,而无需重新解析每种 CLI 协议。
锁定该契约的测试
tests/integration/test_remote_session_transcript_e2e.py—— 真实 SQLite 数据库行:文本 / 文本+tool_use / 纯 tool / 纯 thinking / user tool_result (交互式 vs 自主)、双 flush 幂等、回放顺序、OpenAI 消息形状、系统流。tests/integration/test_remote_session_api_e2e.py——get_session_status载荷(messages[]、message_count)与持久化行一致; 纯 tool 轮次之后没有空气泡。tests/unit/test_remote_assistant_message.py—— 累积调用契约,包括test_empty_text_not_stored(普通会话)与仅自主会话的证据测试。frontend/src/components/common/MessageContent.test.tsx—— 块渲染 (text / thinking / tool_use / tool_result / reasoning)与纯内容回退。