AgentSessionPreviewDto 详解:Macro Agent 会话预览的三态判别联合类型

发布时间:2026/11/1 1:32:03
AgentSessionPreviewDto 详解:Macro Agent 会话预览的三态判别联合类型
  • 后端
  • 前端
  • 协同办公
  • 即时通讯
  • 企业应用
  • CRM
  • AI Agent
  • 人工智能

【免费下载链接】macro

Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

在 Macro 的 agent-harness 服务中,客户端经常拿到一个会话 id,却并不知道自己是否有权打开它——它可能来自一条 @ 提及、一段聊天消息或一个文档链接。AgentSessionPreviewDto正是为这种“渲染前先探一眼”的场景设计的线上传输类型:它把每个被请求的 id 解析为access、no_access、does_not_exist三种结果之一,让前端用一个switch分支就能画出会话芯片(chip)。本文以生成的类型文档为骨架,结合 Rust 服务端实现、SQL 查询与前端调用链,完整讲清这个类型的结构、语义、访问判定顺序与实战用法。

类型签名:一个带type标签的三态联合

关联文档给出的定义如下(原始定义见 AgentSessionPreviewDto.md):

AgentSessionPreviewDto = AgentSessionPreviewData & { type: "access" } | WithAgentSessionId & { type: "no_access" } | WithAgentSessionId & { type: "does_not_exist" }

它的两个组成类型分别是:

  • AgentSessionPreviewData:调用者有查看权限时返回的完整字段集,用于渲染芯片内容;
  • WithAgentSessionId:只有一个id: string,用于另外两种“只告诉你这个 id 是什么状态、不泄露内容”的分支。

对应的 Rust 定义位于 axum_router.rs,关键是 serde 的标签序列化策略:

#[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum AgentSessionPreviewDto { /// 调用者可以查看该会话 Access(Box<AgentSessionPreviewData>), /// 会话存在,但调用者没有任何授权 NoAccess(WithAgentSessionId), /// 该 id 下不存在任何会话 DoesNotExist(WithAgentSessionId), }

有三点值得注意:

  1. 内部标签(internally tagged):标签字段名是type,值与变体名一起走snake_case,因此线上取值就是文档写的access、no_access、does_not_exist。
  2. Access变体装箱:Box<AgentSessionPreviewData>让枚举体积保持稳定,避免“一个大结构体撑大所有分支”。这一点与领域层的 AgentSessionPreview 一致(那里同样用Box并附带了clippy::large_enum_variant的说明注释)。
  3. 两个方向都能反序列化:源码注释明确写着 “Clients deserialize this, so both derives are used”,因此Serialize与Deserialize都派生,服务端序列化、客户端(乃至测试夹具)反序列化共用同一套形状。

为什么用三态而不是错误码

文档的核心动机写在注释里:AgentSessionPreviewDto“Tagged the same way the chat and document preview endpoints tag theirs”,也就是与聊天、文档的预览端点保持同一套标签语义,客户端既然已经有渲染那些芯片的分支,就能用同样的方式渲染会话芯片。

更实际的原因在领域层注释中讲得更透(model.rs):

  • 预览存在的意义,是客户端在还不知道自己能否打开会话时,就先为一个引用(提及、链接)画出一个芯片;
  • 因此每个 id 必须被回答为“三种事实之一”,而不是一个错误;
  • 只有第一种分支携带数据,所以无权限的调用者除了“这个会话存在”之外学不到任何东西——这与GET /agent-sessions/{id}返回403已经泄露的信息量相同,不构成额外泄漏。

这也解释了一个容易被误读的设计:预览端点没有按 id 的访问提取器(no per-id access extractor)。路由注释写得很直接(axum_router.rs):“a chip has to render for a session the caller cannot open, so access is answered per id in the body rather than enforced on the request”。如果像单条读取那样在请求层直接 403,整个批量预览就会因为其中一条无权访问而失败。

对比之下,GET /agent-sessions/{session_id}是严格鉴权的:源码注释指出该端点已把“无授权”(401)与“根本不存在”(404)区分开,以便客户端分辨“创建尚未落库、应当重试的竞态”和“该拒绝的拒绝”(axum_router.rs)。预览端点把这个区分下沉到响应体里,一次请求返回整批结果。

端点契约:POST /agent-sessions/preview

类型永远依附于端点出现,完整契约如下。

请求体

{ "sessionIds": [ "3f2a9c1e-0000-4000-8000-000000000001", "3f2a9c1e-0000-4000-8000-000000000002" ] }
  • 字段名sessionIds(camelCase,DTO 上标了#[serde(rename_all = "camelCase")]);
  • 服务端会折叠重复 id,最多接受MAX_PREVIEW_SESSION_IDS = 100个去重后的 id(model.rs、请求结构体注释见 axum_router.rs)。这个上限的注释解释得很务实:“a mention menu never renders anywhere near this many chips at once”。

响应体

{ "previews": [ { "type": "access", "id": "3f2a9c1e-0000-4000-8000-000000000001", "name": "Fix flaky tests", "ownerId": "user|a1b2c3", "botId": "9d8c7b6a-0000-4000-8000-000000000010", "bot": { "id": "9d8c7b6a-0000-4000-8000-000000000010", "name": "Test Agent", "handle": "test-agent", "avatarUrl": null }, "status": { "kind": "event", "event": "acp_ready" }, "createdAt": "2026-10-30T09:12:44Z", "modifiedAt": "2026-10-30T09:40:02Z" }, { "type": "no_access", "id": "3f2a9c1e-0000-4000-8000-000000000002" } ] }

响应外壳是PreviewAgentSessionsResponse,只有一个previews数组;文档明确说明它是“one entry per distinct requested id,in no particular order”(PreviewAgentSessionsResponse.md)。顺序不保证、也不与请求顺序对齐,客户端必须按id做映射,不能按下标取值。

(上面的示例值用于说明形状;字段集合与类型与 OpenAPI schema 一致。)

状态码

状态码含义依据
200批量结果,逐 id 回答axum_router.rs
400去重后的 id 数超过 100(TooManyPreviewIds)axum_router.rs
401调用者未通过认证同上注解
500服务端错误同上注解

注意400的语义是“这批请求本身太大”,而不是“其中某个 id 有问题”;单个 id 的问题一律以三态之一回答。

字段语义:AgentSessionPreviewData的九个属性

access分支的字段全部来自 AgentSessionPreviewData.md,服务端结构体见 axum_router.rs:

字段类型必填说明
idstring(uuid)是会话 id
namestring是面向用户的会话名
ownerIdstring是会话所属用户
botIdstring(uuid)是运行该 agent 的 bot,供头像使用
botnull \| SessionBot否bot 的最小身份信息,可用时服务端补全
statusSessionStatusDto是会话最后已知状态,用于在线状态指示
createdAtstring(date-time)是创建时间
modifiedAtstring(date-time)是最后修改时间

bot为什么可选

bot用的是#[serde(default, skip_serializing_if = "Option::is_none")]:字段缺席时反序列化为None,为None时干脆不序列化。这个设计不是随手写的——bot是在权限判定之后才补全的。仓储层返回候选数据时统一把bot置为None(postgres.rs),服务层再对可访问会话按bot_id去重后水合(service.rs)。

SessionBot自身定义在 model.rs:id、name、handle(不含前导@)、可选的avatarUrl。注释提示了一个容易踩坑的细节:bot 发出的消息,其 sender 形如"bot|{id}"。

status的三态

SessionStatusDto同样是内部标签联合(标签字段为kind,见 axum_router.rs):

  • { "kind": "no_messages" }—— 尚未收到任何状态更新(领域模型里的默认值);
  • { "kind": "event", "event": "acp_ready" }—— 运行时上报的最后一条系统事件,event是事件的线上名称;
  • { "kind": "disconnected" }—— 会话在未发送关闭事件的情况下断开。

它的领域对应物是 SessionStatus。前端apps/web里还有一个把字符串归一化的工具函数normalizeAgentSessionStatus(mention-types.ts),说明该状态在既有链路中既可能以对象、也可能以纯字符串形式出现。

服务端解析流程:一次请求内部发生了什么

preview_sessions是这套语义的唯一实现入口(service.rs),顺序如下:

  1. 去重与上限校验:把 id 收进HashSet再展开,长度超过MAX_PREVIEW_SESSION_IDS就返回TooManyPreviewIds;
  2. 空输入短路:ids.is_empty()直接返回空数组,不会打数据库;
  3. 批量取候选:调用仓储preview(viewer, &ids),结果按data.id建HashMap;
  4. 逐 id 裁决:
    • 在 map 里找不到 →DoesNotExist(id);
    • 找到则判断visible = candidate.has_grant || view_access.can_view(viewer, id),可见为Access,否则NoAccess(id);
  5. 回填 bot 身份:只遍历Access变体,按bot_id缓存session_bot()查询结果,失败只记warn日志并降级为None,不影响整批响应。

权限的两条腿:物化授权 + 视图端口

第 4 步的||是全篇最关键的实现细节。has_grant来自数据库里真实存在的授权行;view_access则负责那些“没有行也能继承”的访问权——路由注释点明了来源:“Links and originating discussions can grant access without a materialized row”(service.rs)。

视图端口是 SessionViewAccess,can_view返回一个装箱 future(object-safe,便于服务以擦除类型持有)。默认实现NoInheritedSessionAccess恒返回false,即“没有 entity-access 服务的进程或测试环境下,只认物化授权”。这解释了为什么单测可以在不接完整权限系统的前提下验证行为。

SQL:谁算“有物化授权”

仓储的 SQL(postgres.rs)先构造调用者的授权来源集合,再对entity_access做EXISTS:

WITH viewer_source_ids AS ( SELECT cp.channel_id::text AS source_id FROM comms_channel_participants cp WHERE cp.user_id = $2 AND cp.left_at IS NULL UNION ALL SELECT t.team_id::text FROM team_user t WHERE t.user_id = $2 UNION ALL SELECT $2 ) ... EXISTS ( SELECT 1 FROM entity_access ea WHERE ea.entity_id = s.id AND ea.entity_type = 'agent_session' AND ea.source_id IN (SELECT source_id FROM viewer_source_ids) ) AS "has_access!"

也就是说,物化授权有三种来源:用户本人、其尚未退出的频道、其所属团队。源码注释强调这三个来源“与 Soup 分支和访问提取器解析的是同样三个”,从而保证预览结论与真正去读那条路由时的结论一致,不会出现“芯片显示可点、点进去 403”的错位。

同时查询还会带回thread_parent(从comms_messages里取父实体类型与 id,序列化为MessageParent),这就是第 4 步视图端口需要的“继承线索”原料。

友好降级而非整批失败

两个降级点值得单独记住:

  • 候选解析失败会整体作为错误抛出(failed to preview agent sessions);
  • 但bot 身份补全失败只记 warn(failed to hydrate session preview bot),bot退化为null。芯片仍能画出名称与状态,只是少了头像/句柄。

客户端用法:TypeScript SDK 与分支渲染

生成的 TS 类型与文档一一对应(types.gen.ts):

export type AgentSessionPreviewDto = (AgentSessionPreviewData & { type: 'access'; }) | (WithAgentSessionId & { type: 'no_access'; }) | (WithAgentSessionId & { type: 'does_not_exist'; });

SDK 方法previewAgentSessions(sdk.gen.ts)把POST /agent-sessions/preview与Content-Type: application/json封装好了:

const result = await client.previewAgentSessions({ body: { sessionIds: ids }, }); for (const preview of result.previews) { switch (preview.type) { case 'access': // preview 此时已收窄为 AgentSessionPreviewData & { type: 'access' } renderChip({ id: preview.id, name: preview.name, bot: preview.bot, status: preview.status }); break; case 'no_access': renderChip({ id: preview.id, locked: true }); break; case 'does_not_exist': renderChip({ id: preview.id, missing: true }); break; } }

type字段是判别式(discriminant),TypeScript 会自动收窄类型——这正是文档所说“同一个分支就能渲染”的收益。

真实链路:先查 Soup,再用预览补齐

apps/web的提及解析并没有无条件打这个端点,而是做了两级策略(mention-fetchers.ts):

  1. 先把 id 批量丢给 GraphQL 的 Soup 查询(agentSessionMentionInput会按 id 数量构造or表达式,或退化为include: true);
  2. 命中即为access;
  3. 剩下的(在 Soup 里查不到的)才调用agentHarnessServiceClient.preview。源码注释解释了原因:“Absence in Soup cannot distinguish deletion from revoked access”——Soup 里缺失既可能是被删了也可能是权限被收回,而这恰恰是预览端点三态存在的意义。

底层 HTTP 客户端就是薄封装(client.ts):

preview(sessionIds: string[]) { return fetchWithToken<PreviewAgentSessionsResponse>( `${agentHarnessHost}/agent-sessions/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionIds }) } ); }

再往上还有一层批处理器(mention-batcher.ts):AsyncBatcher以wait: 30(毫秒聚合窗口)、maxSize: 50把零散的单 id 请求合并成一次批量调用,并在 fetch 前用[...new Set(...)]再去重一次。maxSize: 50与后端上限 100 之间留有安全余量。

客户端的归一化

前端把三态映射成自己的AgentSessionMentionPreview(mention-types.ts):

export type AgentSessionMentionPreview = | { access: 'access'; data: AgentSessionMentionData } | { access: 'no_access' | 'does_not_exist' };

并把modifiedAt重命名为updatedAt以对齐既有芯片组件(mention-fetchers.ts中的updatedAt: preview.modifiedAt)。如果你在自建客户端,这种字段重命名要在适配层做,不要直接改 DTO 的形状。

边界与陷阱清单

  • 顺序不可依赖:响应“in no particular order”,且去重后条目数可能小于请求数。永远按id建索引。
  • 重复 id 会被折叠:服务端用HashSet去重,因此“请求 150 个含大量重复的 id”不一定报 400,判定发生在去重之后。
  • 上限是 100 个去重 id,不是 100 个原始元素;超限返回400而非截断。
  • no_access与does_not_exist都只有id:不要期待在其中读到name或status,也不要在前端逻辑里把两者混为“渲染失败”——它们对应不同的 UI 文案(上锁 vs 已删除)。
  • bot可能为null:这是服务端水合失败后的正常降级,芯片要有无头像的兜底样式。
  • 不要用它做权限判决的唯一依据:真正的写操作仍走AgentSessionAccessLevelExtractor(例如重命名需要OwnerAccessLevel,见 axum_router.rs);预览只回答“能不能看个芯片”。

测试如何验证这套语义

服务层测试给了两条可执行的行为契约(service/test.rs):

  • previews_hydrate_bot_identity_only_for_accessible_sessions:传入[session, session]两个相同 id,断言结果长度为1(证明去重),且Access分支的bot.name已被水合为"Test Agent";换一个无关用户查询同一 id,则精确得到vec![AgentSessionPreview::NoAccess(session)]。
  • previews_resolve_discussion_and_link_access_through_the_view_port:对“文档讨论派生”和“Initiative 派生”两种父实体分别验证——默认(无视图端口)时全部为NoAccess;用GrantingViewAccess注入后,对应会话变为Access。这直接证明了物化授权与继承授权是两条独立的通过路径。

领域层还提供了一个便利方法AgentSessionPreview::id()(model.rs),无论三态如何都能取出所答的 id,测试里正是用它做sort_by_key后再比较,避免依赖服务端返回顺序。

小结

AgentSessionPreviewDto看起来只是一个三行 union,但它承载的是 Macro 在“隐私最小暴露”和“UI 可用性”之间的一次明确取舍:批量、逐 id、以事实代替错误。落到实践上记住四件事即可——用type做判别式分支、按id而非下标关联结果、bot允许缺席、100 个去重 id 是硬上限。服务端侧则由“物化授权 SQL +SessionViewAccess视图端口”两条腿共同决定access与no_access的分界,确保预览结论与真正打开会话时的权限判定保持一致。

  • 后端
  • 前端
  • 协同办公
  • 即时通讯
  • 企业应用
  • CRM
  • AI Agent
  • 人工智能

【免费下载链接】macro

Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

相关推荐

上一篇:终极免费文档下载指南:如何用kill-doc脚本一键获取30+平台公开资源
下一篇:Audiveris乐谱识别教程:5步将纸质乐谱转换为数字宝藏

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考