Session View Contract
本文档是 feature/session-contract-ui-backend 分支上 Web UI session 优化工作的共享契约(contract)。
该分支的目标是改进 Web UI 的 session 发现、分组、标注和可读性。后端的改动是为了配合这项 UI 工作,提供一套稳定的 session 视图契约。这并不是一次纯后端的架构重写。
Problem
当前的 Web UI 经常把原始的 session key 同时当作地址和语义数据源。这迫使前端代码必须从诸如 agent:*:webchat:*、:cli:、:subagent:、:cron: 或 :thread: 这样的字符串中推断概念。
这种做法很脆弱,因为一个 session key 可能编码了多个相互独立的概念:
- 路由地址
- agent/workspace 归属
- 入口 surface
- 外部 channel 身份
- direct/group/channel 的会话拓扑
- thread/topic 修饰符
- subagent/task 来源
- cron 来源或投递
- 遗留兼容形态
UI 不应该拥有这些解释逻辑。后端必须提供一个 UI-ready 的 session 视图,前端则应基于该视图进行渲染。
Product Model
Web UI 有两个不同的 surface,它们绝不能彼此混为一谈:
Conversations:日常导航 surface,从以用户为中心的视角呈现用户希望打开、阅读并继续的条目。Sessions:更底层的账本/调试 surface,涵盖所有已持久化的运行时记录,包括 WebChat、CLI、channel thread、cron 运行、subagent、系统任务,以及删除、过滤和原始 key 检查。
Conversations 并不是一个更小的 Sessions 表。它应按用户入口点对条目进行分组:
- Chats:
sessionKind: "chat" - Channels:
sessionKind: "channel" - Automations:
sessionKind: "cron"
Task 和 system session 通常应留在 Sessions 账本中,除非某个专门的后台工作 UI 明确选择纳入它们。
New chat 只是一个 WebChat 创建流程:
New chat -> choose agent -> create/open WebChat
它绝不能创建 cron job、channel session、subagent task 或 system/task session。cron 和 channel 的创建/配置属于它们各自的 Automations/Channels surface。
API
首选的 RPC:
rpc.call("sessions.list", { limit: 200, view: "session-list-v1" })
如果使用 REST 端点,那么在聊天 session 选择器依赖它获取更大列表之前,/api/sessions 应支持等价的 limit 和 view 参数。当前默认的 session 列表大小对于选择器 UI 来说可能太小。
响应形态保持向后兼容:
interface SessionsListResponseV1 {
sessions: SessionListItemV1[];
count: number;
ts: number;
}
只发送 { limit } 的现有调用方必须继续正常工作。
Contract Fields
interface SessionListItemV1 {
key: string;
sessionId?: string;
// Legacy/stored agent id. Kept for compatibility with existing callers.
agentId?: string;
// Effective routing/workspace owner. New UI should prefer this.
effectiveAgentId: string;
sessionKind: "chat" | "channel" | "task" | "cron" | "system" | "unknown";
surface:
| "webchat"
| "cli"
| "tui"
| "mcp"
| "slack"
| "feishu"
| "wecom"
| "telegram"
| "discord"
| "dingtalk"
| "matrix"
| "qq"
| "cron"
| "subagent"
| "unknown";
conversationKind: "main" | "direct" | "group" | "channel" | "unknown";
thread?: {
id: string;
kind: "thread" | "topic";
} | null;
title: string;
subtitle?: string;
groupLabel: string;
updatedAt: number;
messageCount: number;
status: string;
runStatus:
| "idle"
| "queued"
| "running"
| "interrupted"
| "failed"
| "timeout"
| "cancelled";
// Whether the current Web UI should enable its standard chat composer.
interactive: boolean;
channelContext?: {
name?: string;
id?: string;
accountId?: string;
peerId?: string;
threadId?: string;
};
parent?: {
key: string;
taskId?: string;
spawnDepth?: number;
} | null;
cron?: {
jobId?: string;
sessionTarget?: "main" | "isolated" | "current" | "session";
originSessionKey?: string;
targetSessionKey?: string;
} | null;
}
Field Semantics
key
公开的 session 路由地址。UI 可以用它来打开、恢复、复制、删除和调试一个 session。UI 绝不能解析它来进行语义分类。
sessionId
可用时的后端 transcript/存储标识。它不是主要的 UI 路由地址。
agentId
遗留或已存储的 agent id。它仍然保留,以兼容现有的 CLI/TUI/MCP 以及较旧的 UI 路径。
effectiveAgentId
应当用于 UI 归属、workspace/路由显示以及 agent 标记的 agent id。它处理了那些已存储 agentId 可能为 main、但路由实际应跟随另一个 agent 的遗留记录。
sessionKind
session 的生命周期分类:
chat:面向人的交互式聊天 session,包括 WebChat、CLI、TUI、MCP 以及 main-agent 的聊天 session。channel:外部平台会话 session。task:运行时/后台 task session,尤其是 subagent 工作。cron:由 cron 拥有的隔离运行 session。system:当后端暴露内部/系统 session 时使用。unknown:后端无法对该记录进行分类。
该字段是 Conversations surface 的主要分组依据。如果某个设计草图把这个概念称为 conversationKind: "chat" | "channel" | "cron" | "task",那么该概念在契约中的名称是 sessionKind。
surface
产生该 session 视图的入口 surface 或平台。例如:webchat、cli、tui、mcp、feishu、slack、telegram、cron、subagent。
当前的终端 TUI session 是 CLI 兼容的。它们通常应报告 surface: "cli",因为现有的 TUI gateway 路径创建的是 CLI session 并使用 CLI gateway 客户端契约。只有当未来的 TUI 路径明确把 session 标记为 TUI 所有时,才使用 surface: "tui"。
已知的公开 channel surface 与 channel 适配器契约保持一致:slack、discord、feishu、dingtalk、wecom、qq、matrix 和 telegram。未知或尚未公开的适配器应降级为 surface: "unknown",同时在 channelContext 中保留显示用的元数据。
conversationKind
会话拓扑:
main:某个 agent 的 main/私有 session。direct:一对一的 direct/私有会话。group:group、room 或多人会话。channel:平台 channel/广播式会话。unknown:后端无法对拓扑进行分类。
thread 和 topic 信息必须通过 thread 表达,而不是新增 conversationKind: "thread"。
thread
用于平台 thread/topic 上下文的可选修饰符。它不替代 conversationKind。
title
主要的面向用户的标签。UI 应优先使用它而非原始 key。
subtitle
次要上下文,例如来源、agent、channel、parent 或近期上下文。
groupLabel
UI 分组标签。session 选择器和 Sessions 页面的分组应使用此字段,而不是从 key 推导分组。
updatedAt
用于按时间排序和相对时间显示的 Epoch 毫秒数。
messageCount
用于显示的 transcript/消息数量。
status
已持久化的 session 生命周期状态。当前后端取值包括 running、done、failed、killed 和 timeout。前端应将其视为一个后端生命周期字符串,并使用 runStatus 来显示 idle/running 的 turn 标记。
runStatus
用于显示当前/最近一次 turn 的运行时 task 状态。这与已持久化的 session 生命周期状态是分开的。
interactive
当前 Web UI 是否应为该记录启用其标准的聊天 composer。这与可见性不是一回事。一条非交互式记录仍可以作为可读条目出现在 Conversations 或 Sessions 中。
默认规则:
- WebChat 记录应是交互式的。
- CLI/TUI/MCP 记录是兼容性账本记录;除非后端明确将其标记为交互式,否则不要启用 Web UI composer。
- channel 记录可从 Web UI 阅读,但除非存在安全的 channel 回复流程,否则标准的 WebChat composer 应保持禁用。
- cron、subagent、task 和 system 记录通常应为非交互式。
如果 UI 之后需要更细的区分,应新增一个结构化字段(例如 openMode),而不是从 key 推断行为。
channelContext
可选的外部 channel 身份和投递元数据。除非某个功能明确需要路由细节,否则在 UI 中它应仅用于显示。
不要将其与 sessions.list 中已存在的遗留 channel 字段混淆。遗留字段仍保留给 CLI/TUI/较旧的调用方,且可能是一个字符串。新 UI 应使用 surface 和 channelContext。
parent
可选的 subagent/task parent 关系。
cron
可选的 cron 元数据。cron 元数据不会自动把一个已有的 webchat/channel session 变成 cron session。
error_id
session.event.error 负载上可选的简短引用 id(并会以 (ref: <error_id>) 的形式渲染进面向用户的错误文本中)。它把该错误与其持久化的 turn_errors 记录关联起来,用于诊断。该字段是增量添加的;在较旧的 gateway 上或未写入记录时可能不存在。
Backend Rules
后端负责 session 的分类和显示规范化。
后端在生成时应优先使用显式的结构化数据,再退回到遗留的 key fallback:
- session 记录字段,包括 agent id、显示名、channel 字段、投递上下文、parent session key 和来源元数据。
- 路由/来源元数据,例如 source kind、channel kind、channel id、thread id 和交互模式。
- task 运行时记录,用于
runStatus、subagent/task 状态和 parent 关系。 - cron job/session 元数据,用于 cron 拥有的隔离运行以及 cron 投递上下文。
- 仅在后端规范化内部作为兼容性 fallback 时才解析遗留 key。
前端绝不能重复后端的 key 解析逻辑。
后端兼容性要求:
- 保持
sessions.list { limit }正常工作。 - 保持
sessions.create -> { key, sessionId }正常工作。 - 保持
sessions.resolve({ key })正常工作。 - 保持
chat.history({ sessionKey })正常工作。 - 保持现有记录字段,例如
agent_id、agentId、updated_at、updatedAt、message_count、entry_count、sourceKind和channelKind。 - 新增契约字段,但不删除或更改旧的形态。
- 把现有字段视为兼容性输出,而不是新 Web UI 的权威语义来源。
- 保持现有 CLI 和当前终端 TUI 的 gateway 契约精简:
sessions.create({ kind: "cli" })、sessions.list({ limit })、sessions.resolve({ key })和chat.history({ sessionKey })不得新增必填的新参数。
Frontend Rules
前端应基于契约字段进行渲染:
- 使用
sessionKind作为 Conversations 的主要分区:Chats、Channels 和 Automations。 - 使用
groupLabel进行分区内的二级分组。 - 使用
title作为主要文本。 - 使用
subtitle作为次要文本。 - 使用
effectiveAgentId进行 agent 标记和 agent 归属显示。 - 使用
messageCount显示消息数量。 - 使用
updatedAt显示相对时间。 - 使用
runStatus显示运行时标记。 - 使用
sessionKind、surface和conversationKind决定图标、颜色和高层视觉处理。 - 使用
interactive决定是否启用标准的 Web UI composer。 - 把
thread或topic视为修饰符,而非独立的会话类型。 - 仅在 open/resume/copy/delete/RPC/debug 操作中使用
key。
如果 UI 需要某个缺失的语义字段,后端应将其加入本契约,而不是由前端从 key 推导。
Sessions 页面规则:
- 显示
sessions.list返回的所有记录,包括 WebChat、CLI、channel、channel thread/topic、cron、subagent、task、system 和 unknown 记录。 - 保持原始 key 可见或便于检查。
- 相较于日常导航分组,更侧重过滤、删除、状态检查、调试以及 resume/open 操作。
New chat 规则:
- 只创建 WebChat session。
- 询问或推断目标 agent,然后调用 WebChat 创建/打开流程。
- 不创建 cron、channel、subagent、task 或 system 记录。
Forbidden Frontend Behavior
前端绝不能:
- 解析
session.key来判断 session kind - 解析
session.key来判断 agent 归属 - 按
:webchat:、:cli:、:subagent:、:cron:、:thread:或:topic:等 key token 进行分组 - 从 key 片段推断外部 channel 类型
- 在显式的 debug/copy 上下文之外,把原始 key 当作主要的面向用户的标签
- 仅因为 cron 投递进了某个普通的 webchat/channel session,就把它渲染成 cron
- 把
New chat用作创建 cron、channel、subagent、task 或 system session 的通用入口
临时性的 fallback 逻辑应被隔离、清晰标注,且不应成为主要的 UI 路径。
Display Examples
WebChat
{
"key": "agent:main:webchat:default",
"sessionId": "0d2d6f3e-8a41-40de-a3d4-5f05a3c4557a",
"agentId": "main",
"effectiveAgentId": "main",
"sessionKind": "chat",
"surface": "webchat",
"conversationKind": "direct",
"thread": null,
"title": "Web chat",
"subtitle": "main",
"groupLabel": "Web chat",
"updatedAt": 1760000000000,
"messageCount": 42,
"status": "done",
"runStatus": "idle",
"interactive": true,
"parent": null,
"cron": null
}
CLI
{
"key": "agent:main:cli:a1b2c3d4",
"agentId": "main",
"effectiveAgentId": "main",
"sessionKind": "chat",
"surface": "cli",
"conversationKind": "main",
"title": "CLI session",
"subtitle": "main",
"groupLabel": "CLI",
"updatedAt": 1760000000000,
"messageCount": 12,
"status": "done",
"runStatus": "idle",
"interactive": false
}
Subagent Task
{
"key": "agent:main:subagent:760b927a",
"agentId": "main",
"effectiveAgentId": "main",
"sessionKind": "task",
"surface": "subagent",
"conversationKind": "unknown",
"title": "Subagent task",
"subtitle": "Spawned from Web chat",
"groupLabel": "Subagents",
"updatedAt": 1760000000000,
"messageCount": 8,
"status": "running",
"runStatus": "running",
"interactive": false,
"parent": {
"key": "agent:main:webchat:default",
"taskId": "task-123",
"spawnDepth": 1
},
"cron": null
}
Cron-Owned Isolated Run
{
"key": "cron:daily-summary:run:abc123",
"effectiveAgentId": "main",
"sessionKind": "cron",
"surface": "cron",
"conversationKind": "unknown",
"title": "Daily summary",
"subtitle": "Cron isolated run",
"groupLabel": "Cron",
"updatedAt": 1760000000000,
"messageCount": 4,
"status": "done",
"runStatus": "idle",
"interactive": false,
"cron": {
"jobId": "daily-summary",
"sessionTarget": "isolated"
}
}
Cron Delivery Into Existing Channel Session
cron 投递元数据可能存在,但已有的 channel session 仍保留其原本的视觉身份。
{
"key": "agent:main:feishu:group:oc_123",
"agentId": "main",
"effectiveAgentId": "main",
"sessionKind": "channel",
"surface": "feishu",
"conversationKind": "group",
"title": "Launch room",
"subtitle": "Feishu group",
"groupLabel": "Feishu",
"updatedAt": 1760000000000,
"messageCount": 31,
"status": "done",
"runStatus": "idle",
"interactive": false,
"channelContext": {
"name": "feishu",
"id": "oc_123"
},
"cron": {
"jobId": "launch-check",
"sessionTarget": "session",
"targetSessionKey": "agent:main:feishu:group:oc_123"
}
}
External Channel Thread
{
"key": "agent:main:slack:group:C123:thread:1717000000.000100",
"agentId": "main",
"effectiveAgentId": "main",
"sessionKind": "channel",
"surface": "slack",
"conversationKind": "group",
"thread": {
"id": "1717000000.000100",
"kind": "thread"
},
"title": "C123 thread",
"subtitle": "Slack thread",
"groupLabel": "Slack",
"updatedAt": 1760000000000,
"messageCount": 16,
"status": "done",
"runStatus": "idle",
"interactive": false,
"channelContext": {
"name": "slack",
"id": "C123",
"threadId": "1717000000.000100"
}
}
Legacy Agent Mismatch
如果已存储的 agentId 是 main,但实际的路由/workspace 所有者是另一个 agent,UI 必须显示 effectiveAgentId。
{
"key": "agent:kid-project:webchat:test",
"agentId": "main",
"effectiveAgentId": "kid-project",
"sessionKind": "chat",
"surface": "webchat",
"conversationKind": "direct",
"title": "Kid project",
"subtitle": "Web chat",
"groupLabel": "Web chat",
"updatedAt": 1760000000000,
"messageCount": 5,
"status": "done",
"runStatus": "idle",
"interactive": true
}
Backend Test Expectations
后端契约测试至少应覆盖:
- WebChat session
- CLI session
- subagent task session
- cron 拥有的隔离 session
- cron 投递进已有 session
- 外部 channel session,例如 Feishu、Slack 或 Telegram
- thread/topic 修饰符
- 已存储
agentId与effectiveAgentId不同的遗留记录 - unknown/fallback 记录,但仍能产生可用的
title、groupLabel和runStatus - 当前终端 TUI 记录保持 CLI 兼容,除非未来的 TUI 路径明确将其标记为 TUI 所有
- WebChat、CLI/TUI、channel、cron、subagent、task 和 system 记录的
interactive默认值
Frontend Acceptance Criteria
- Conversations 侧边栏先按
sessionKind分组,再按groupLabel分组。 - 聊天 session 选择器根据契约显示
title、subtitle、groupLabel、effectiveAgentId以及interactive行为。 - Sessions 页面显示
title、subtitle、effectiveAgentId、messageCount、updatedAt、runStatus以及原始 key/debug 操作入口。 - 没有任何新的前端逻辑为了语义分类而解析
session.key。 - 现有的 open/resume/copy/delete 行为仍使用
key。 - WebChat、CLI、subagent、cron 和外部 channel session 均基于契约字段渲染。
New chat只创建 WebChat session。- unknown 或缺失的值能够优雅降级。
- fallback 逻辑不会重新把 key 解析变成主要路径。