feat: sync app server thread replies

This commit is contained in:
AI Bot
2026-06-04 14:09:46 +08:00
parent dbdaab8d0f
commit de9f85bd21
10 changed files with 310 additions and 9 deletions

View File

@@ -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 或本地路径。

View File

@@ -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 启动前失败才回退 CLIturn 启动后不重复执行;桌面远程控制默认先走 `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 启动前失败才回退 CLIturn 启动后不重复执行;桌面远程控制默认先走 `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。

View File

@@ -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

View File

@@ -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。

View File

@@ -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",
}),
})),
);

View File

@@ -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,
}),
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 });
}
});