feat: sync app server thread replies
This commit is contained in:
@@ -85,7 +85,8 @@
|
||||
- 当前 App Server heartbeat discovery 已扩展到 `experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list`,设备详情页会展示“治理:实验特性 / 协作模式 / MCP / 权限”摘要;MCP 只保留服务名、工具数量、资源数量和认证状态,permission profile 只保留 id/description,不保存本地路径、resource URI、文件规则、token 或工具参数。
|
||||
- 当前 App Server heartbeat discovery 已继续扩展到 `account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect`,设备详情页会展示账号、套餐、额度、App 配置、托管要求和外部 Agent 迁移候选摘要;该链路只保存计数、开关和状态,不保存邮箱、API key、完整 config、本地路径、迁移描述或外部 Agent 原始内容。
|
||||
- 当前 App Server heartbeat discovery 已新增 `thread/list / thread/loaded/list` 线程可见性摘要,设备详情页会展示线程总数、已加载线程、活跃线程和最新更新时间;metadata 只保留非归档线程的 `id / name / sourceKind / status / updatedAt / loaded` 轻量目录,不保存 cwd、本地路径、turn 内容、用户正文或内部 prompt。
|
||||
- 当前 App Server heartbeat discovery 已新增 `thread/turns/list` turn 运行态摘要,设备详情页会展示总轮次、运行中轮次、完成轮次和最新 turn 更新时间;请求固定使用 `itemsView=notLoaded`,metadata 只保留每个线程的 turn 计数、最近状态和更新时间,不保存 turn id、items、用户输入、模型输出或内部 prompt。
|
||||
- 当前 App Server heartbeat discovery 已新增 `thread/turns/list` turn 运行态摘要,设备详情页会展示总轮次、运行中轮次、完成轮次和最新 turn 更新时间;请求固定使用 `itemsView=summary`,metadata 只保留每个线程的 turn 计数、最近状态、更新时间和最终 `agentMessage` 安全摘要,不保存用户输入、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。
|
||||
- 当前 App Server heartbeat discovery 会把非归档可见线程的最终 `agentMessage` 合并进 `projectCandidates.recentAssistantMessages`;服务端据此把 Codex Desktop 自己产生的新回复反向同步到 Boss APP 会话列表、preview、lastMessageAt 和未读数。已有本地扫描候选优先保留 folder/thread 映射,App Server 只补充最新回复摘要。
|
||||
- 当前 App Server heartbeat discovery 已新增线程操作能力摘要,设备详情页会展示“线程操作”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `thread/archive / thread/compact/start / thread/shellCommand / turn/interrupt` 等写操作。
|
||||
- 当前 App Server heartbeat discovery 已支持 `skills/extraRoots/set` 共享 Skill 根目录下发摘要,设备详情页会展示“共享 Skill 根”;metadata 不保存根目录绝对路径、Skill 文件路径、token 或配置原文。
|
||||
- 当前 App Server heartbeat discovery 已支持 `hooks/list` 钩子治理摘要,设备详情页会展示“Hook”;metadata 只保留 hook 数、启用数、受管 / 可信 / 修改 / 未信任计数、warning / error 计数和事件 / handler 类型,不保存 hook key、command、sourcePath、statusMessage、hash、error message 或本地路径。
|
||||
|
||||
@@ -153,7 +153,9 @@
|
||||
- Web 和原生 Android 当前都已经接上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台页面;已绑定生产设备继续保留 heartbeat 自动导入链路
|
||||
- 原生首页的刷新失败策略当前已改成按当前 tab 独立判错,不会再因为 `设备 / 设置 / OTA` 的旁路请求失败把会话页刷新一并判成失败
|
||||
- 当前量产方向已经明确为“Boss 企业控制面 + 可插拔执行协议”:多租户、权限、审批、审计、备份、回退和 Skill 治理由 Boss 承担,Codex App Server / Codex MCP / Codex CLI / Computer Use / 业务系统 API 都作为 provider 接入;详见 `docs/architecture/enterprise_ai_ops_architecture_cn.md`
|
||||
- 当前 Codex App Server 已完成二十七批接入:boss-agent 默认开启 `local-agent/codex-app-server-runner.mjs` 作为 Codex 绑定入口,优先走 `codex app-server` stdio,也可灰度连接 `ws://127.0.0.1:<port>` 或 `unix://PATH` 同机长驻 App Server;长驻连接支持 `Authorization: Bearer <token>`,配置上优先使用 `codexAppServerAuthTokenFile`。turn 启动前失败才回退 CLI,turn 启动后不重复执行;桌面远程控制默认先走 `codex-computer-use`,失败后回退 `cua-driver-computer-use`。2026-06-03 已按本机 `codex-cli 0.136.0-alpha.2` 生成协议快照 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`,并把 `turn/plan/updated`、`turn/diff/updated`、`item/started|completed`、`thread/started`、`item/*/requestApproval`、`item/autoApprovalReview/*`、`guardianWarning`、`serverRequest/resolved`、`item/fileChange/patchUpdated`、`thread/status/changed`、`thread/realtime/*`、`model/rerouted`、`thread/tokenUsage/updated`、`mcpServer/startupStatus/updated`、`remoteControl/status/changed`、`windowsSandbox/setupCompleted`、`thread/goal/*`、`thread/settings/updated`、`thread/compacted`、`account/updated`、`account/rateLimits/updated`、`model/verification`、`warning`、`configWarning`、`deprecationNotice`、`ThreadItem.collabToolCall`、`ThreadItem.contextCompaction`、`mcpToolCall`、`dynamicToolCall`、`webSearch`、`imageView`、`imageGeneration`、`hook/started|completed`、`enteredReviewMode`、`exitedReviewMode`、`commandExecution`、`ThreadItem.plan`、`ThreadItem.reasoning.summary` 归一到 Boss `execution_progress` 卡片;realtime 只保留状态、文本摘要和计数,运行状态只保留模型切换、上下文用量、MCP 状态、远控连接摘要和 Windows 沙箱准备状态,线程配置只保留目标、模型、审批、沙箱、协作模式和压缩状态,线程协作只保留工具名、状态、目标类型、目标数量和智能体状态集合,工具活动只保留类型、名称、状态和安全摘要,图像生成只保留状态与安全文件名,钩子生命周期只保留事件名、处理器类型、状态、来源、执行模式和耗时,思考摘要只保留官方 summary 文本和状态,账号状态只保留认证方式、套餐、额度窗口、积分余额和模型校验摘要,不保存 SDP、音频原始数据、raw item、remote installationId、cwd、turnId、配置文件路径、collab 源/目标线程 ID、collab receiverThreadIds、collab prompt、agentsStates 私有消息、共享 Skill 根绝对路径、hook key/command/sourcePath/statusMessage/hash/error message、tool arguments/result/contentItems、web URL token、命令正文/输出、raw reasoning content、reasoning item id、imageGeneration revisedPrompt/result、hook sourcePath/statusMessage/entries、Windows sandbox sourcePath/samplePaths/本地绝对路径或未清洗密钥。heartbeat 已能缓存 `model/list / skills/list / skills/extraRoots/set / hooks/list / plugin/list / app/list / modelProvider/capabilities/read / experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list / account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect / thread/list / thread/loaded/list / thread/turns/list` 的能力摘要;同批已补 `turn/steer` 活跃 turn 干预和 `POST /api/v1/projects/[projectId]/thread-collaboration` 服务端线程协作排队入口。MCP、权限、账号、配置、外部 Agent、线程、turn、Hook、Skill extra roots、运行事件、扩展事件、线程生命周期和流式增量 discovery 只保留安全摘要,不保存 resource URI、权限文件规则、工具参数、邮箱、完整 config、本地路径、迁移描述、turn id、turn items、用户正文、模型输出、hook 命令、共享 Skill 根绝对路径、原始增量文本、命令输出、推理正文、文件输出或 token。
|
||||
- 当前 Codex App Server 已完成二十八批接入:boss-agent 默认开启 `local-agent/codex-app-server-runner.mjs` 作为 Codex 绑定入口,优先走 `codex app-server` stdio,也可灰度连接 `ws://127.0.0.1:<port>` 或 `unix://PATH` 同机长驻 App Server;长驻连接支持 `Authorization: Bearer <token>`,配置上优先使用 `codexAppServerAuthTokenFile`。turn 启动前失败才回退 CLI,turn 启动后不重复执行;桌面远程控制默认先走 `codex-computer-use`,失败后回退 `cua-driver-computer-use`。2026-06-03 已按本机 `codex-cli 0.136.0-alpha.2` 生成协议快照 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`。
|
||||
- App Server runner 已把 plan、diff、item、approval、warning、file change、thread status、realtime、model route、token usage、MCP、remote control、thread goal、settings、compaction、account、model verification、collab、tool activity、reasoning summary、image generation、hook、Windows sandbox 和 stream delta 归一到 Boss `execution_progress` 卡片;字段白名单只保留安全摘要,不保存 SDP、音频原始数据、raw item、remote installationId、cwd、turnId、配置文件路径、collab 源/目标线程 ID、receiverThreadIds、collab prompt、agentsStates 私有消息、共享 Skill 根绝对路径、hook key/command/sourcePath/statusMessage/hash/error message、tool arguments/result/contentItems、web URL token、命令正文/输出、raw reasoning content、reasoning item id、imageGeneration revisedPrompt/result、Windows sandbox sourcePath/samplePaths、本地绝对路径或未清洗密钥。
|
||||
- Heartbeat discovery 已能缓存 `model/list / skills/list / skills/extraRoots/set / hooks/list / plugin/list / app/list / modelProvider/capabilities/read / experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list / account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect / thread/list / thread/loaded/list / thread/turns/list` 的能力摘要。`thread/turns/list` 固定使用 `itemsView=summary`,只额外提取最终 `agentMessage` 安全摘要,并合并进 `projectCandidates.recentAssistantMessages` 让 Codex Desktop 自己产生的新回复反向同步到 Boss APP;不保存用户正文、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。同批已补 `turn/steer` 活跃 turn 干预和 `POST /api/v1/projects/[projectId]/thread-collaboration` 服务端线程协作排队入口。
|
||||
- 第十九批另补 `threadActionSummary` 线程操作能力摘要:设备详情页会显示 archive / unarchive / fork / compact / rollback / rename / metadata / steer / interrupt / shell / unsubscribe 等能力分组;该字段只读,不在 heartbeat 中调用任何会改变线程状态的 App Server API。
|
||||
- 第二十批另补 `pluginGovernanceSummary` 插件治理能力摘要:设备详情页会显示 install / uninstall / read / skill-read / share 等能力分组;该字段只读,不在 heartbeat 中调用任何插件安装、卸载或共享写 API。
|
||||
- 第二十一批另补 `accountGovernanceSummary / configGovernanceSummary` 账号与配置治理能力摘要:设备详情页会显示 login / logout / token refresh / add credits nudge / config write / MCP reload / Skill config write 等能力分组;这些字段只读,不在 heartbeat 中调用任何账号或配置写 API。
|
||||
|
||||
@@ -121,7 +121,8 @@
|
||||
- App Server heartbeat discovery 现在还会按 TTL 拉取 `experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list`,写入 `capabilities.codexAppServer.metadata.experimentalFeatures / collaborationModes / permissionProfiles / mcpServers`。这些字段用于 APP/后台治理页展示 Codex 当前可用的实验特性、多 Agent/协作模式、权限 profile 和 MCP 服务健康;MCP 请求固定使用 `detail=toolsAndAuthOnly`,服务端状态里不保存 resource URI、工具参数、permission profile 文件规则、本地路径或密钥。
|
||||
- App Server heartbeat discovery 现在还会按 TTL 拉取 `account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect`,写入 `capabilities.codexAppServer.metadata.accountSummary / rateLimitSummary / appConfigSummary / configRequirements / externalAgentMigration`。这些字段用于 APP/后台展示账号、额度、App 配置、企业托管要求和外部 Agent 迁移候选摘要;当前只做观测,不通过 Boss 远程写 `config.toml` 或执行外部 Agent 导入,且不保存邮箱、完整 config、API key、本地路径或迁移描述。
|
||||
- App Server heartbeat discovery 现在还会按 TTL 拉取 `thread/list / thread/loaded/list`,写入 `capabilities.codexAppServer.metadata.threadSummary`。该字段用于 APP/后台展示 Codex 当前可见线程数量、加载态、活跃态和非归档线程轻量目录;目录只保留 `id / name / sourceKind / status / updatedAt / loaded`,不保存 cwd、本地路径、turn 内容、用户正文或内部 prompt。
|
||||
- App Server heartbeat discovery 现在还会按 TTL 对非归档可见线程拉取 `thread/turns/list`,写入 `capabilities.codexAppServer.metadata.threadTurnSummary`。该字段用于 APP/后台展示 Codex 当前线程 turn 运行态;请求固定 `itemsView=notLoaded`,只保留 turn 计数、运行中 / 完成计数、最近状态和更新时间,不保存 turn id、items、用户正文、模型输出或内部 prompt。
|
||||
- App Server heartbeat discovery 现在还会按 TTL 对非归档可见线程拉取 `thread/turns/list`,写入 `capabilities.codexAppServer.metadata.threadTurnSummary`。该字段用于 APP/后台展示 Codex 当前线程 turn 运行态;请求固定 `itemsView=summary`,只保留 turn 计数、运行中 / 完成计数、最近状态、更新时间和最终 `agentMessage` 安全摘要,不保存用户正文、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。
|
||||
- App Server heartbeat discovery 现在还会把最终 `agentMessage` 安全摘要合并进 `projectCandidates.recentAssistantMessages`。服务端根据 `codexThreadRef` 将 Codex Desktop 自己产生的新回复反向同步到 Boss APP 对应会话、preview、lastMessageAt 和未读数;已有本地扫描候选的 folder/thread 映射优先,App Server 只补充最新回复摘要。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.threadActionSummary`。该字段用于 APP/后台展示当前协议下可接入的线程治理动作数量和分组,覆盖 archive / unarchive / fork / compact / rollback / rename / metadata / steer / interrupt / shell / unsubscribe;它只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用这些写操作。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.pluginGovernanceSummary`。该字段用于 APP/后台展示当前协议下可接入的插件治理动作数量和分组,覆盖 install / uninstall / read / skill-read / share;它只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用插件安装、卸载或共享写操作。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.accountGovernanceSummary / configGovernanceSummary`。这些字段用于 APP/后台展示当前协议下可接入的账号与配置治理动作数量和分组,覆盖 login / logout / token refresh / add credits nudge / config write / MCP reload / Skill config write;它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用账号或配置写操作。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -39,7 +39,8 @@
|
||||
- 当前 App Server 能力发现已新增治理摘要:local-agent 会在 heartbeat discovery 中拉取 `experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list`,并把实验特性、协作模式、权限 Profile 与 MCP 服务状态写入设备 `codexAppServer.metadata`;设备详情页会显示“治理”摘要。该链路只保留安全摘要,不保存 MCP resource URI、permission profile 文件规则、本地路径、token 或工具参数。
|
||||
- 当前 App Server 能力发现已新增账号与配置摘要:local-agent 会在 heartbeat discovery 中拉取 `account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect`,并把账号登录方式、套餐、额度使用率、App 配置计数、托管要求数量和外部 Agent 迁移候选数量写入设备 `codexAppServer.metadata`;设备详情页会显示“账号 / 配置”摘要。该链路只读不写,不保存账号邮箱、完整 config、API key、本地路径或迁移描述。
|
||||
- 当前 App Server 能力发现已新增线程可见性摘要:local-agent 会在 heartbeat discovery 中拉取 `thread/list / thread/loaded/list`,并把线程总数、已加载线程数、活跃线程数、归档线程数、最新更新时间和非归档线程轻量目录写入设备 `codexAppServer.metadata.threadSummary`;设备详情页会显示“线程”摘要。该链路不保存 cwd、本地路径、turn 内容、用户正文或内部 prompt。
|
||||
- 当前 App Server 能力发现已新增 turn 运行态摘要:local-agent 会在 heartbeat discovery 中对非归档可见线程拉取 `thread/turns/list`,请求固定 `itemsView=notLoaded`,并把总轮次、运行中轮次、完成轮次、最新 turn 更新时间和每个线程的最近 turn 状态写入设备 `codexAppServer.metadata.threadTurnSummary`;设备详情页会显示“轮次”摘要。该链路不保存 turn id、turn items、用户正文、模型输出或内部 prompt。
|
||||
- 当前 App Server 能力发现已新增 turn 运行态摘要:local-agent 会在 heartbeat discovery 中对非归档可见线程拉取 `thread/turns/list`,请求固定 `itemsView=summary`,并把总轮次、运行中轮次、完成轮次、最新 turn 更新时间、每个线程的最近 turn 状态和最终 `agentMessage` 安全摘要写入设备 `codexAppServer.metadata.threadTurnSummary`;设备详情页会显示“轮次”摘要。该链路不保存用户正文、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。
|
||||
- 当前 App Server discovery 还会把最终 `agentMessage` 合并进 heartbeat `projectCandidates.recentAssistantMessages`。服务端已有 `codexThreadRef` 匹配时会把 Codex Desktop 自己产生的新回复反向同步到 Boss APP 对应会话,并刷新 preview、lastMessageAt 和未读数;已有本地扫描候选的 folder/thread 映射优先保留,App Server 只补充最新回复摘要。
|
||||
- 当前 App Server 能力发现已新增线程操作能力摘要:local-agent 会把已验证进入当前协议快照的 archive / unarchive / fork / compact / rollback / rename / metadata / steer / interrupt / shell / unsubscribe 写入设备 `codexAppServer.metadata.threadActionSummary`;设备详情页会显示“线程操作”。该字段只读,不在 heartbeat 中调用任何线程写 API。
|
||||
- 当前 App Server 能力发现已新增插件治理能力摘要:local-agent 会把已验证进入当前协议快照的 install / uninstall / read / skill-read / share 写入设备 `codexAppServer.metadata.pluginGovernanceSummary`;设备详情页会显示“插件治理”。该字段只读,不在 heartbeat 中调用任何插件写 API。
|
||||
- 当前 App Server 能力发现已新增账号与配置治理能力摘要:local-agent 会把已验证进入当前协议快照的 login / logout / token refresh / add credits nudge / config write / MCP reload / Skill config write 写入设备 `codexAppServer.metadata.accountGovernanceSummary / configGovernanceSummary`;设备详情页会显示“账号治理 / 配置治理”。这些字段只读,不在 heartbeat 中调用任何账号或配置写 API。
|
||||
|
||||
@@ -1928,6 +1928,68 @@ function extractDiscoveryTurnStatus(turn) {
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_DISCOVERY_RECENT_ASSISTANT_MESSAGES = 6;
|
||||
|
||||
function extractDiscoveryTurnItems(turn) {
|
||||
if (asArray(turn?.items).length > 0) {
|
||||
return asArray(turn.items);
|
||||
}
|
||||
if (asArray(turn?.summary?.items).length > 0) {
|
||||
return asArray(turn.summary.items);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeDiscoveryAssistantMessageText(item) {
|
||||
if (!item || typeof item !== "object" || item.type !== "agentMessage") {
|
||||
return "";
|
||||
}
|
||||
const text = trimToDefined(item.text ?? item.content ?? item.message);
|
||||
return text ? safeProgressText(text, 4000) : "";
|
||||
}
|
||||
|
||||
function normalizeDiscoveryAssistantPhase(item) {
|
||||
const phase = trimToDefined(item?.phase);
|
||||
return phase === "final_answer" || phase === "commentary" ? phase : undefined;
|
||||
}
|
||||
|
||||
function extractDiscoveryRecentAssistantMessages(threadId, turns) {
|
||||
const messages = [];
|
||||
for (const turn of turns) {
|
||||
const turnId = trimToDefined(turn?.id ?? turn?.turnId) || "unknown-turn";
|
||||
const sentAt = trimToDefined(turn?.updatedAt ?? turn?.lastUpdatedAt ?? turn?.createdAt) || "";
|
||||
if (!sentAt) {
|
||||
continue;
|
||||
}
|
||||
for (const item of extractDiscoveryTurnItems(turn)) {
|
||||
const body = normalizeDiscoveryAssistantMessageText(item);
|
||||
if (!body) {
|
||||
continue;
|
||||
}
|
||||
const itemId = trimToDefined(item?.id) || crypto
|
||||
.createHash("sha256")
|
||||
.update(`${threadId}:${turnId}:${body}`)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
messages.push({
|
||||
messageId: `codex-app-server:${threadId}:${turnId}:${itemId}`,
|
||||
body,
|
||||
sentAt,
|
||||
phase: normalizeDiscoveryAssistantPhase(item),
|
||||
});
|
||||
}
|
||||
}
|
||||
const deduped = new Map();
|
||||
for (const message of messages) {
|
||||
if (!deduped.has(message.messageId)) {
|
||||
deduped.set(message.messageId, message);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()]
|
||||
.sort((left, right) => left.sentAt.localeCompare(right.sentAt))
|
||||
.slice(-MAX_DISCOVERY_RECENT_ASSISTANT_MESSAGES);
|
||||
}
|
||||
|
||||
function normalizeDiscoveryThreadTurnSummary(threadTurnResults, limit) {
|
||||
const threadSummaries = threadTurnResults
|
||||
.map(({ threadId, result }) => {
|
||||
@@ -1951,6 +2013,7 @@ function normalizeDiscoveryThreadTurnSummary(threadTurnResults, limit) {
|
||||
completedTurnCount: turns.filter((turn) => /completed|complete|success|succeeded/i.test(turn.status)).length,
|
||||
latestTurnStatus: latestTurn?.status || "unknown",
|
||||
latestTurnUpdatedAt: latestTurn?.updatedAt || "",
|
||||
recentAssistantMessages: extractDiscoveryRecentAssistantMessages(threadId, rawTurns),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => String(right.latestTurnUpdatedAt).localeCompare(String(left.latestTurnUpdatedAt)))
|
||||
@@ -2137,7 +2200,7 @@ export async function discoverCodexAppServerCapabilities(runnerConfig) {
|
||||
threadId: thread.id,
|
||||
limit: turnProbeLimit,
|
||||
sortDirection: "desc",
|
||||
itemsView: "notLoaded",
|
||||
itemsView: "summary",
|
||||
}),
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -122,6 +122,102 @@ async function resolveHeartbeatProjects(config, runtime) {
|
||||
}
|
||||
}
|
||||
|
||||
function trimToDefined(value) {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function mergeRecentAssistantMessages(left = [], right = []) {
|
||||
const messages = new Map();
|
||||
for (const message of [...left, ...right]) {
|
||||
const messageId = trimToDefined(message?.messageId);
|
||||
const body = trimToDefined(message?.body);
|
||||
if (!messageId || !body) {
|
||||
continue;
|
||||
}
|
||||
messages.set(messageId, {
|
||||
messageId,
|
||||
body,
|
||||
sentAt: trimToDefined(message?.sentAt) || new Date().toISOString(),
|
||||
...(trimToDefined(message?.phase) ? { phase: trimToDefined(message.phase) } : {}),
|
||||
});
|
||||
}
|
||||
return [...messages.values()].sort((a, b) => a.sentAt.localeCompare(b.sentAt)).slice(-6);
|
||||
}
|
||||
|
||||
function resolveCandidateKey(candidate) {
|
||||
return trimToDefined(candidate?.codexThreadRef) || trimToDefined(candidate?.threadId);
|
||||
}
|
||||
|
||||
function mergeHeartbeatProjectCandidates(existingCandidates = [], appServerCandidates = []) {
|
||||
const candidateMap = new Map();
|
||||
for (const candidate of [...existingCandidates, ...appServerCandidates]) {
|
||||
const key = resolveCandidateKey(candidate);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const existing = candidateMap.get(key);
|
||||
if (!existing) {
|
||||
candidateMap.set(key, candidate);
|
||||
continue;
|
||||
}
|
||||
const recentAssistantMessages = mergeRecentAssistantMessages(
|
||||
existing.recentAssistantMessages,
|
||||
candidate.recentAssistantMessages,
|
||||
);
|
||||
candidateMap.set(key, {
|
||||
...candidate,
|
||||
...existing,
|
||||
lastActiveAt: [existing.lastActiveAt, candidate.lastActiveAt].filter(Boolean).sort().at(-1),
|
||||
suggestedImport: existing.suggestedImport ?? candidate.suggestedImport ?? true,
|
||||
...(recentAssistantMessages.length > 0 ? { recentAssistantMessages } : {}),
|
||||
});
|
||||
}
|
||||
return [...candidateMap.values()].sort((a, b) =>
|
||||
String(b.lastActiveAt ?? "").localeCompare(String(a.lastActiveAt ?? "")),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCodexAppServerProjectCandidates(metadata) {
|
||||
const visibleThreads = Array.isArray(metadata?.threadSummary?.visibleThreads)
|
||||
? metadata.threadSummary.visibleThreads
|
||||
: [];
|
||||
const turnSummaries = Array.isArray(metadata?.threadTurnSummary?.threads)
|
||||
? metadata.threadTurnSummary.threads
|
||||
: [];
|
||||
const turnSummaryByThreadId = new Map(
|
||||
turnSummaries
|
||||
.map((thread) => [trimToDefined(thread?.threadId), thread])
|
||||
.filter(([threadId]) => Boolean(threadId)),
|
||||
);
|
||||
return visibleThreads
|
||||
.map((thread) => {
|
||||
const threadId = trimToDefined(thread?.id);
|
||||
if (!threadId) {
|
||||
return null;
|
||||
}
|
||||
const turnSummary = turnSummaryByThreadId.get(threadId);
|
||||
const threadName = trimToDefined(thread?.name) || threadId;
|
||||
const recentAssistantMessages = mergeRecentAssistantMessages(
|
||||
[],
|
||||
turnSummary?.recentAssistantMessages,
|
||||
);
|
||||
return {
|
||||
folderName: threadName,
|
||||
threadId,
|
||||
threadDisplayName: threadName,
|
||||
codexThreadRef: threadId,
|
||||
lastActiveAt:
|
||||
trimToDefined(turnSummary?.latestTurnUpdatedAt) ||
|
||||
trimToDefined(thread?.updatedAt) ||
|
||||
new Date().toISOString(),
|
||||
suggestedImport: true,
|
||||
...(recentAssistantMessages.length > 0 ? { recentAssistantMessages } : {}),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function postHeartbeat(config, runtime, heartbeatProjects) {
|
||||
const now = new Date().toISOString();
|
||||
const preferredExecutionMode =
|
||||
@@ -142,6 +238,17 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
|
||||
const guiConnected =
|
||||
config.guiConnected === true ||
|
||||
(config.guiConnected !== false && heartbeatProjects.guiConnected === true);
|
||||
const appServerCandidates = buildCodexAppServerProjectCandidates(codexAppServerMetadata);
|
||||
const mergedProjectCandidates = mergeHeartbeatProjectCandidates(
|
||||
heartbeatProjects.projectCandidates,
|
||||
appServerCandidates,
|
||||
);
|
||||
const mergedProjects = [
|
||||
...new Set([
|
||||
...heartbeatProjects.projects,
|
||||
...mergedProjectCandidates.map((candidate) => candidate.folderName).filter(Boolean),
|
||||
]),
|
||||
];
|
||||
const response = await fetch(`${config.controlPlaneUrl.replace(/\/$/, "")}/api/device-heartbeat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -184,8 +291,8 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
|
||||
},
|
||||
},
|
||||
preferredExecutionMode,
|
||||
projects: heartbeatProjects.projects,
|
||||
projectCandidates: heartbeatProjects.projectCandidates,
|
||||
projects: mergedProjects,
|
||||
projectCandidates: mergedProjectCandidates,
|
||||
endpoint: config.endpoint,
|
||||
}),
|
||||
});
|
||||
|
||||
18
tests/fixtures/codex-app-server-runtime.mjs
vendored
18
tests/fixtures/codex-app-server-runtime.mjs
vendored
@@ -505,6 +505,7 @@ rl.on("line", (line) => {
|
||||
if (message.method === "thread/turns/list") {
|
||||
const threadId = message.params?.threadId;
|
||||
if (threadId === "thr-active") {
|
||||
const includeSummaryItems = message.params?.itemsView === "summary" || !message.params?.itemsView;
|
||||
send({
|
||||
id: message.id,
|
||||
result: {
|
||||
@@ -522,6 +523,23 @@ rl.on("line", (line) => {
|
||||
status: "completed",
|
||||
createdAt: "2026-06-03T08:00:00.000Z",
|
||||
updatedAt: "2026-06-03T08:10:00.000Z",
|
||||
...(includeSummaryItems
|
||||
? {
|
||||
items: [
|
||||
{
|
||||
type: "userMessage",
|
||||
id: "private-user-message-should-not-leak",
|
||||
content: [{ type: "text", text: "private user summary text should not leak" }],
|
||||
},
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "agent-final-app-server-reply",
|
||||
text: "App Server 最终回复已完成同步。",
|
||||
phase: "final_answer",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
|
||||
@@ -113,6 +113,14 @@ test("codex app-server discovery includes governance and MCP summaries without l
|
||||
completedTurnCount: 1,
|
||||
latestTurnStatus: "running",
|
||||
latestTurnUpdatedAt: "2026-06-03T08:21:00.000Z",
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-app-server:thr-active:turn-active-1:agent-final-app-server-reply",
|
||||
body: "App Server 最终回复已完成同步。",
|
||||
sentAt: "2026-06-03T08:10:00.000Z",
|
||||
phase: "final_answer",
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(metadata.threadActionSummary, {
|
||||
actionCount: 11,
|
||||
|
||||
@@ -271,3 +271,101 @@ test("local-agent heartbeat reports Codex App Server discovered models, skills,
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("local-agent heartbeat enriches Codex App Server threads with recent final replies", async () => {
|
||||
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-app-server-recent-replies-"));
|
||||
const skillsDir = path.join(runtimeRoot, "skills");
|
||||
await mkdir(skillsDir, { recursive: true });
|
||||
|
||||
const mockControlPlane = await startMockControlPlane();
|
||||
const exampleConfig = JSON.parse(
|
||||
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
|
||||
);
|
||||
const configPath = path.join(runtimeRoot, "config.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...exampleConfig,
|
||||
bindHost: "127.0.0.1",
|
||||
port: 0,
|
||||
controlPlaneUrl: `http://127.0.0.1:${mockControlPlane.port}`,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
masterAgentPollIntervalMs: 60_000,
|
||||
masterAgentEnabled: false,
|
||||
codexSessionDiscoveryEnabled: false,
|
||||
codexAppServerEnabled: true,
|
||||
codexAppServerCommand: process.execPath,
|
||||
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
|
||||
codexAppServerWorkdir: repoRoot,
|
||||
codexAppServerTimeoutMs: 5000,
|
||||
codexAppServerDiscoveryTtlMs: 60_000,
|
||||
projects: ["boss"],
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "boss",
|
||||
folderRef: repoRoot,
|
||||
threadId: "thr-active",
|
||||
threadDisplayName: "Boss App Server rollout",
|
||||
codexFolderRef: repoRoot,
|
||||
codexThreadRef: "thr-active",
|
||||
lastActiveAt: "2026-06-03T08:20:00.000Z",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
skillsDir,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
try {
|
||||
const heartbeatRequest = await Promise.race([
|
||||
mockControlPlane.heartbeatReceived,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`timed out waiting for heartbeat\n${stderr}`));
|
||||
}, 8000);
|
||||
}),
|
||||
]);
|
||||
|
||||
const payload = JSON.parse(heartbeatRequest.bodyText);
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
const candidate = payload.projectCandidates.find((item) => item.codexThreadRef === "thr-active");
|
||||
|
||||
assert.ok(candidate, "expected heartbeat to keep the imported Codex App Server thread candidate");
|
||||
assert.equal(candidate.folderName, "boss");
|
||||
assert.deepEqual(candidate.recentAssistantMessages, [
|
||||
{
|
||||
messageId: "codex-app-server:thr-active:turn-active-1:agent-final-app-server-reply",
|
||||
body: "App Server 最终回复已完成同步。",
|
||||
sentAt: "2026-06-03T08:10:00.000Z",
|
||||
phase: "final_answer",
|
||||
},
|
||||
]);
|
||||
assert.ok(!serializedPayload.includes("private active turn text should not leak"));
|
||||
assert.ok(!serializedPayload.includes("private item content should not leak"));
|
||||
assert.ok(!serializedPayload.includes("private user summary text should not leak"));
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => {
|
||||
child.once("close", resolve);
|
||||
}).catch(() => null);
|
||||
await new Promise((resolve) => {
|
||||
mockControlPlane.server.close(resolve);
|
||||
});
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user