CopilotKit Teams 通道 HITL 按钮动作信封:Adaptive CardAction.Submit的往返协议深度解析
【免费下载链接】CopilotKitThe Frontend Stack for Agents & Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKit
本文以 CopilotKit 仓库中 button-action-envelope.md 为权威规格,深入解析@copilotkit/channels-teams中"人机协作(HITL)按钮动作信封"(HITL button action envelope)的完整生命周期:渲染器出站发出的 Adaptive CardAction.Submit线上形态、Teams 点击回传的 Message activity、以及解码端必须遵守的判定规则。读完本文,你将掌握如何在 CopilotKit 的 Teams 适配器中构建可靠的审批/确认类 HITL 流程(awaitChoice等待用户点击),并能正确编写或校验任何"带外解码"(out-of-band decode)按钮点击的消费者代码。
什么是 HITL 按钮动作信封
在 CopilotKit Channels 体系中,@copilotkit/channels-teams是把 Microsoft Teams 接入平台无关的渠道引擎(channels-core)的适配器,与@copilotkit/channels-slack对 Slack 的角色完全对称。其中"人机协作"(Human-in-the-loop)场景的核心是:Agent 向用户渲染一个带按钮的卡片并暂停等待(引擎的thread.awaitChoice),用户点击按钮后点击事件需要被精确解码回引擎,才能恢复被挂起的运行。
这个往返过程依赖一个权威的线上数据形态(wire shape),即本文标题所说的"按钮动作信封":
- 它是
@copilotkit/channels-teams渲染出的所有按钮的统一出站形态; - 它定义了 Teams 点击回传时,消费者(consumer)必须按何种规则解码;
- 任何"带外"解码者(例如 CopilotKit Intelligence 的 managed-Teams ingress——它深度引入本包的渲染器,但运行自己的入站解码逻辑)都必须逐字节一致地匹配这个形态,否则
awaitChoice等待器将永远无法恢复。
该规格在仓库中由三个文件共同锚定:
- 合同测试:
src/button-action-envelope.contract.test.ts——从双向锁定信封形态; - 出站发射器(Emitter):
src/render/adaptive-card.ts中的renderButton; - 入站解码器(Decoder):
src/interaction.ts中的parseCardAction。
出站方向:渲染器发出什么
当一个<Button>带有onClick处理器(即不是链接按钮)时,它被渲染为Adaptive Card 顶层的Action.Submit。这里有一个刻意为之的设计决策:必须是Action.Submit,而不是Action.Execute——Action.Execute需要verb,而我们的按钮不使用 verb 机制。不透明的动作 id 和可选的 value 全部搭乘在 action 的data字段上:
{ "type": "Action.Submit", "title": "Approve", "data": { "ckActionId": "ck:approve", // 不透明 id;仅当 Button 带 onClick 处理器时才出现 "value": { "decision": "yes" }, // 仅当 Button 带 value prop 时才出现 }, "style": "positive", // 可选:"positive"(primary 主按钮)| "destructive"(danger 危险按钮) }三条关键规则:
- 链接按钮走
Action.OpenUrl:带urlprop 的<Button>渲染为Action.OpenUrl,不携带任何data——它不是交互式提交,永远不会往返(round-trip)。 data的省略规则:如果按钮既没有onClickid 也没有value,data整个被省略。- 顶层动作:
Action.Submit出现在卡片的actions顶层数组中,而非 body 元素中。
源码佐证:renderButton的实现细节
在 adaptive-card.ts 中,renderButton的实现与上述信封一一对应:
function renderButton(node: ChannelNode): CardAction { const props = node.props ?? {}; // Link button → Action.OpenUrl(打开 URL;不携带提交数据) if (typeof props.url === "string" && props.url.length > 0) { return { type: "Action.OpenUrl", title: truncateText(collectText(node), TEAMS_LIMITS.buttonText), url: props.url, }; } const action: CardAction = { type: "Action.Submit", title: truncateText(collectText(node), TEAMS_LIMITS.buttonText), }; const id = idFromHandler(props.onClick); const data: Record<string, unknown> = {}; if (id) data.ckActionId = id; if (props.value !== undefined) data.value = props.value; if (Object.keys(data).length > 0) action.data = data; // style 映射:primary → positive,danger/destructive → destructive ... }几个值得注意的实现事实:
- id 的来源:
onClick处理器在进入渲染器之前,已经由 action registry 预绑定(pre-bound)为{ id }形态——即事件 prop 上被盖上了不透明的注册 id 戳。idFromHandler(adaptive-card.ts)正是从该对象中提取id字符串。这就是ckActionId(如ck:approve)的产生位置。 - style 映射:JSX 层的
primary被映射为 Adaptive Card 的positive,danger/destructive被映射为destructive,对应 Teams 客户端的绿色主按钮与红色危险按钮。 - 文本截断:按钮标题经过
truncateText处理,上限为TEAMS_LIMITS.buttonText(256 字符),保证不超过 Teams 的载荷上限。 - 渲染器是"全量"的(total renderer):未知 intrinsic 节点会被跳过;
actions数组会经clampArray钳制到TEAMS_LIMITS.actions(6 个顶层动作——Teams 大约显示 6 个后就会溢出),body 元素钳制到TEAMS_LIMITS.bodyElements(100 个)。这些上限定义在 budget.ts,源于 Teams 对单张 Adaptive Card 附件约 28 KB JSON 的硬性限制。
入站方向:Teams 点击交付什么
点击一个Action.Submit按钮,Teams 回传的是一个Message activity(activity.type === "message"),而不是invoke/adaptiveCard/action/Action.Executeactivity。动作的data会整体变成activity.value,且消息的text为空——载荷在value里,不在文本里:
{ "type": "message", "text": "", // 空——载荷在 value,而非 text "value": { // === 出站时发出的 action `data`(并与卡片输入合并) "ckActionId": "ck:approve", "value": { "decision": "yes" }, }, "conversation": { "id": "<stable conversation id>" }, }解码规则(Decode rules)
规格对解码者提出四条硬性规则:
- 判定"是否是卡片动作":
typeof activity.value.ckActionId === "string"。若不满足,这就是一条普通聊天消息,应按普通消息处理。 - 字段提取:
id = activity.value.ckActionId,value = activity.value.value。只搬运这两个字段——禁止任何"resume-data 走私"(no resume-data smuggling);持久性(durability)依赖消费者以id为键的动作存储(action store)。 - 卡片输入合并:如果卡片上还带有
<Input>/<Select>字段,Teams 会把它们的值合并进activity.value,与ckActionId/value平级。需要时直接从activity.value中按命名键读取输入值。 - 会话键(conversation key):从
activity.conversation.id推导(见conversationKeyOf)。ingress 与 interaction 解码必须使用同一个键,否则awaitChoice等待器会永久悬空(stranded)。
源码佐证:parseCardAction与conversationKeyOf
interaction.ts 中parseCardAction的实现忠实执行了上述规则:
export function parseCardAction(activity: TeamsActivityLike): | { id: string; value: unknown; values?: Record<string, unknown> } | undefined { const activityValue = activity.value as ... | undefined; const data = activityValue?.action?.data ?? activityValue; if (!data || typeof data !== "object" || typeof data.ckActionId !== "string") { return undefined; // 不是卡片动作 → 普通聊天消息 } const values = Object.fromEntries( Object.entries(activityValue ?? {}).filter( ([name]) => name !== "action" && name !== "ckActionId" && name !== "value", ), ); return { id: data.ckActionId, value: data.value, ...(Object.keys(values).length > 0 ? { values } : {}), }; }补充实现事实:
- 兼容
action.data嵌套:data = activityValue?.action?.data ?? activityValue意味着解码器同时兼容"直接平铺"与"嵌套在action下"两种回传形态,这是对 Teams 不同客户端行为差异的防御性处理。 - 输入值收敛:除
action/ckActionId/value之外的其余键(即卡片<Input>/<Select>合并进来的值)被收敛为values对象返回,对应规则 3 的"按命名键读取输入值"。 conversationKeyOf(interaction.ts)极简且稳定:直接返回activity.conversation?.id ?? ""。测试 interaction.test.ts 专门验证"ingress 消息与其后的卡片动作提交必须推导出同一个键",并断言无会话 id 时返回空串、绝不抛异常。
源码佐证:点击在适配器内的路由
在 adapter.ts 的handleActivity中,入站 message activity 会先经过parseCardAction判定:
- 命中则调用
sink.onInteraction(...),携带action.id、action.value、action.values与会话键,让引擎解析匹配的awaitChoice等待器并执行按钮的onClick(例如原地编辑选择器卡片)。 - 未命中则走普通聊天消息路径(去
<at>bot</at>提及、下载附件、sink.onTurn(...))。 - 在有凭据(真实 Teams)时,入站 HTTP turn 立即 ack,交互在
continueConversation的分离式(detached)proactive context 上运行——因为入站点击 turn 的连接器客户端是匿名身份,原地updateActivity编辑卡片会被 Connector 以 401 拒绝;在匿名本地 Playground(M365 Agents Playground,无 app id)则直接在入站 turn context 上执行。
decodeInteraction(adapter.ts)是同一解码逻辑在PlatformAdapter边界上的公开形态,供外部复用同一套信封。
合同测试:双向锁定的实证
button-action-envelope.contract.test.ts是这份规格的"可执行注释",从两个方向把信封钉死:
- 出站断言:渲染带
onClick: { id: "ck:approve" }与value: { decision: "yes" }的按钮,断言action.type === "Action.Submit"、没有verb属性、且action.data恰好等于{ ckActionId: "ck:approve", value: { decision: "yes" } }。 - 往返断言:把出站的
action.data原样当作 Teams 回传的activity.value(text为空),断言parseCardAction解码出{ id: "ck:approve", value: { decision: "yes" } },且conversationKeyOf返回"conv-1"。 - 负向断言:普通聊天消息(
value: undefined)解码结果为undefined——即"不是卡片动作"。
为什么这些约束如此重要
Action.Submit而非Action.Execute:避免verb语义与 Teams 对adaptiveCard/actioninvoke 的特殊处理,让点击以最朴素的 Message activity 回传,任何基于 message 的 ingress 都能捕获。- 无 resume-data 走私:信封刻意只携带不透明 id 与极小的 value。恢复运行所需的全部状态存放在消费者的动作存储中(以
id为键),而不是塞进线上载荷。这让信封保持最小、可校验、可审计,也让 managed ingress 与自托管 ingress 能够共享同一形态。 - 会话键单一来源:
conversationKeyOf是 ingress(onTurn)与交互解码必须共用的推导函数。任何一侧私自改用不同推导方式,都会让awaitChoice等待器静默悬空——这是规格中反复强调的失败模式。 - HITL 的时间尺度:适配器对
awaitChoice的支持依赖分离式 turn 交接——在真实 Teams 中,点击可能发生在几分钟后(adapter.ts 中ackDeadlineMs = 15000声明了入站 turn 的实际窗口)。注意当前等待器是内存态的(v1),进程重启后不会存活,这是 README 明确标注的后续计划项。
落地实践:如何消费这份信封
无论你走自托管适配器路径(进程持有 Microsoft Teams 凭据,直接跑 ingress),还是走managed Intelligence Channels路径(Intelligence 拥有 provider 边缘,managed-Teams ingress 深度复用本包渲染器但自行解码入站),你的解码代码都应严格对齐如下伪逻辑:
// 出站侧:渲染(由 @copilotkit/channels-teams 完成) // <Button onClick={{ id: "ck:approve" }} value={{ decision: "yes" }} style="primary"> // → Action.Submit,data = { ckActionId: "ck:approve", value: { decision: "yes" } } // 入站侧:解码(消费者必须逐字节一致) function decodeButtonClick(activity) { if (typeof activity.value?.ckActionId !== "string") return null; // 普通聊天消息 const id = activity.value.ckActionId; const value = activity.value.value; // 按钮值(可能缺省) const inputs = { ...activity.value }; // 含 <Input>/<Select> 合并值 delete inputs.ckActionId; delete inputs.value; delete inputs.action; const conversationKey = activity.conversation?.id ?? ""; return { id, value, inputs, conversationKey }; }验证路径同样明确:直接运行本包的合同测试button-action-envelope.contract.test.ts,或参照interaction.test.ts中的解码用例;端到端验证可用 M365 Agents Playground 在匿名本地模式下启动POST /api/messages(默认端口 3978)后点击卡片按钮观察等待器恢复。
小结
CopilotKit 的 Teams HITL 按钮动作信封是一份"小而严"的线上契约:出站用顶层Action.Submit携带data.ckActionId+data.value(链接按钮例外走Action.OpenUrl);入站以 Message activity 的value原样承载并允许卡片输入合并;解码仅凭ckActionId判定、只提取id/value、禁止状态走私、并以conversationKeyOf作为会话键的单一来源。无论是 CopilotKit 自托管适配器还是 managed ingress,只有严格对齐这份信封,HITL 的按钮往返才能精确、可靠、可恢复。
【免费下载链接】CopilotKitThe Frontend Stack for Agents & Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考