fix(cc-connect): restore channel tool history

This commit is contained in:
ashione
2026-07-27 16:09:15 +08:00
parent 960f6b298d
commit a378c31261
10 changed files with 882 additions and 10 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
開発者モードを有効にし、OpenClaw が active runtime の場合、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
ClawX には runtime 抽象レイヤーもあります。OpenClaw は既定 runtime とロールバック経路のままで、**設定 → Gateway → Runtime** から任意の同梱 `cc-connect` runtime に切り替えられます。パッケージ版は cc-connect バイナリと OpenAI Codex ネイティブ CLI bundle の両方を app resources に含め、runtime 起動はグローバルインストール、PATH 上のバイナリ、起動時ダウンロードに依存しません。ClawX はアップグレード後も共有できる app config、credential、runtime data、skills、workspace を `~/.clawx`(または `CLAWX_DATA_HOME`)に保持し、`~/.cc-connect` を自動変更しません。GUI chat は cc-connect BridgePlatform 経由で Codex project agent に接続し、管理 project は cc-connect の Codex app-server stdio backend を使うため、Codex transcript を読まずに tool progress を共通 Chat execution graph へ反映できます。承認ボタンと cc-connect card の選択肢は実行グラフに表示され、応答はすべて cc-connect の公開 `card_action` プロトコルを通じて返されます。Runtime が生成した画像、ファイル、音声、動画の packet も BridgePlatform 経由で返り、Chat の添付として表示され続けます。各 Agent は既定でフルオートを使用し、Agent のモデル/runtime 設定で「承認を求める」(`suggest`)を個別に選択できます。新しい agent は `~/.clawx/workspaces/agents/<id>` を使い、既存の OpenClaw workspace は移動や所有権変更なしで元のパスを再利用できます。provider/model、native cron、enabled skills は管理された cc-connect/Codex runtime に同期されます。
ClawX には runtime 抽象レイヤーもあります。OpenClaw は既定 runtime とロールバック経路のままで、**設定 → Gateway → Runtime** から任意の同梱 `cc-connect` runtime に切り替えられます。パッケージ版は cc-connect バイナリと OpenAI Codex ネイティブ CLI bundle の両方を app resources に含め、runtime 起動はグローバルインストール、PATH 上のバイナリ、起動時ダウンロードに依存しません。ClawX はアップグレード後も共有できる app config、credential、runtime data、skills、workspace を `~/.clawx`(または `CLAWX_DATA_HOME`)に保持し、`~/.cc-connect` を自動変更しません。GUI chat は cc-connect BridgePlatform 経由で Codex project agent に接続し、管理 project は cc-connect の Codex app-server stdio backend を使うため、リアルタイムの tool progress を共通 Chat execution graph へ直接反映できます。cc-connect の公開 history に channel session の tool packet がない場合、ClawX は所有する Agent の workspace に限定して一致するローカル Codex transcript から history を補完します。承認ボタンと cc-connect card の選択肢は実行グラフに表示され、応答はすべて cc-connect の公開 `card_action` プロトコルを通じて返されます。Runtime が生成した画像、ファイル、音声、動画の packet も BridgePlatform 経由で返り、Chat の添付として表示され続けます。各 Agent は既定でフルオートを使用し、Agent のモデル/runtime 設定で「承認を求める」(`suggest`)を個別に選択できます。新しい agent は `~/.clawx/workspaces/agents/<id>` を使い、既存の OpenClaw workspace は移動や所有権変更なしで元のパスを再利用できます。provider/model、native cron、enabled skills は管理された cc-connect/Codex runtime に同期されます。
Agent と channel の設定は `~/.clawx` を canonical source とします。cc-connect が active の間は保存しても `~/.openclaw/openclaw.json` を書き換えず、OpenClaw に戻すと Gateway 起動前に互換 projection を再生成します。
+1 -1
View File
@@ -95,7 +95,7 @@ We are committed to maintaining strict alignment with the upstream OpenClaw proj
When Developer Mode is enabled and OpenClaw is the active runtime, the sidebar also provides a native Dreams page for OpenClaw memory review, dream diary inspection, and basic maintenance actions. The full upstream OpenClaw Dreams UI remains available from that page when deeper diagnostics are needed.
ClawX also includes a runtime abstraction layer. OpenClaw remains the default runtime and rollback path, while **Settings → Gateway → Runtime** can switch to an optional bundled `cc-connect` runtime. Packaged builds include both the cc-connect binary and the native OpenAI Codex CLI bundle in app resources; runtime startup does not depend on global installs, PATH binaries, or app-time downloads. ClawX keeps upgrade-stable app config, credentials, runtime data, skills, and workspaces under `~/.clawx` (or `CLAWX_DATA_HOME`) instead of modifying `~/.cc-connect`. GUI chat connects through cc-connect BridgePlatform with Codex as the project agent; managed projects use cc-connect's Codex app-server backend over stdio so tool progress can drive the shared Chat execution graph without reading Codex transcripts. Approval buttons and cc-connect card choices are rendered in that graph, and responses return through cc-connect's public `card_action` protocol. Runtime-generated image, file, audio, and video packets also return through BridgePlatform and remain visible as Chat attachments. Each Agent defaults to Full Auto and can independently select Ask for approval (`suggest`) in Agent model/runtime settings. New agents use `~/.clawx/workspaces/agents/<id>`; existing OpenClaw workspaces can be reused by reference without being moved or owned by ClawX. Provider/model selections, native cron tasks, and enabled skills are synchronized into the managed cc-connect/Codex runtime.
ClawX also includes a runtime abstraction layer. OpenClaw remains the default runtime and rollback path, while **Settings → Gateway → Runtime** can switch to an optional bundled `cc-connect` runtime. Packaged builds include both the cc-connect binary and the native OpenAI Codex CLI bundle in app resources; runtime startup does not depend on global installs, PATH binaries, or app-time downloads. ClawX keeps upgrade-stable app config, credentials, runtime data, skills, and workspaces under `~/.clawx` (or `CLAWX_DATA_HOME`) instead of modifying `~/.cc-connect`. GUI chat connects through cc-connect BridgePlatform with Codex as the project agent; managed projects use cc-connect's Codex app-server backend over stdio so live tool progress can drive the shared Chat execution graph directly. When public cc-connect history omits tool packets for a channel-originated session, ClawX supplements that history from matching local Codex transcripts constrained to the owning Agent's workspace. Approval buttons and cc-connect card choices are rendered in that graph, and responses return through cc-connect's public `card_action` protocol. Runtime-generated image, file, audio, and video packets also return through BridgePlatform and remain visible as Chat attachments. Each Agent defaults to Full Auto and can independently select Ask for approval (`suggest`) in Agent model/runtime settings. New agents use `~/.clawx/workspaces/agents/<id>`; existing OpenClaw workspaces can be reused by reference without being moved or owned by ClawX. Provider/model selections, native cron tasks, and enabled skills are synchronized into the managed cc-connect/Codex runtime.
Agent and channel settings are canonical under `~/.clawx`. While cc-connect is active, saving them does not rewrite `~/.openclaw/openclaw.json`; switching back to OpenClaw rebuilds that compatibility projection before the Gateway starts.
+1 -1
View File
@@ -96,7 +96,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
打开开发者模式且当前 runtime 为 OpenClaw 时,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
ClawX 现在也包含 runtime 抽象层。OpenClaw 仍是默认 runtime 和回滚路径,你可以在 **设置 → 网关 → Runtime** 切换到可选的内置 `cc-connect` runtime。打包产物会同时内置 cc-connect 二进制和 OpenAI Codex 原生 CLI bundleruntime 启动不依赖全局安装、PATH 二进制或运行时下载。ClawX 会把可跨升级复用的 app 配置、凭据、runtime 数据、skills 和 workspace 放在 `~/.clawx`(或 `CLAWX_DATA_HOME`),不会自动修改 `~/.cc-connect`。GUI chat 会通过 cc-connect BridgePlatform 连接到 Codex project agent;托管 project 固定使用 cc-connect 的 Codex app-server stdio backend使工具进度可以驱动共用的 Chat execution graph,而不读取 Codex transcript。审批按钮和 cc-connect card 选项都会显示在执行图中,响应统一通过 cc-connect 公共 `card_action` 协议返回。Runtime 生成的图片、文件、音频和视频包也通过 BridgePlatform 返回,并持续显示为 Chat 附件。每个 Agent 默认使用全自动模式,也可以在 Agent 的模型/runtime 设置中独立选择“需要审批”(`suggest`)。新 agent 使用 `~/.clawx/workspaces/agents/<id>`;已有 OpenClaw workspace 可以按原路径复用,ClawX 不移动也不接管它。Provider/model、原生 cron 任务和已启用 skills 会同步到托管的 cc-connect/Codex runtime。
ClawX 现在也包含 runtime 抽象层。OpenClaw 仍是默认 runtime 和回滚路径,你可以在 **设置 → 网关 → Runtime** 切换到可选的内置 `cc-connect` runtime。打包产物会同时内置 cc-connect 二进制和 OpenAI Codex 原生 CLI bundleruntime 启动不依赖全局安装、PATH 二进制或运行时下载。ClawX 会把可跨升级复用的 app 配置、凭据、runtime 数据、skills 和 workspace 放在 `~/.clawx`(或 `CLAWX_DATA_HOME`),不会自动修改 `~/.cc-connect`。GUI chat 会通过 cc-connect BridgePlatform 连接到 Codex project agent;托管 project 固定使用 cc-connect 的 Codex app-server stdio backend让实时工具进度可以直接驱动共用的 Chat execution graph。当 cc-connect 公共历史缺少频道会话的工具数据包时,ClawX 会从匹配的本地 Codex transcript 补全历史,并将匹配范围限制在该会话所属 Agent 的 workspace。审批按钮和 cc-connect card 选项都会显示在执行图中,响应统一通过 cc-connect 公共 `card_action` 协议返回。Runtime 生成的图片、文件、音频和视频包也通过 BridgePlatform 返回,并持续显示为 Chat 附件。每个 Agent 默认使用全自动模式,也可以在 Agent 的模型/runtime 设置中独立选择“需要审批”(`suggest`)。新 agent 使用 `~/.clawx/workspaces/agents/<id>`;已有 OpenClaw workspace 可以按原路径复用,ClawX 不移动也不接管它。Provider/model、原生 cron 任务和已启用 skills 会同步到托管的 cc-connect/Codex runtime。
Agent 和频道设置以 `~/.clawx` 为唯一 canonical 数据源。cc-connect 处于启用状态时,保存设置不会改写 `~/.openclaw/openclaw.json`;切回 OpenClaw 后,Gateway 启动前会重新生成这份兼容投影。
@@ -106,6 +106,7 @@ const BRIDGE_HEARTBEAT_INTERVAL_MS = 25_000;
const BRIDGE_RECONNECT_DELAY_MS = 3_000;
const CLAWX_PROJECT_PREFIX = 'clawx-';
export const CLAWX_BRIDGE_ADMIN_USER_ID = 'clawx-desktop';
export const CC_CONNECT_SESSION_INSTANCE_MARKER = '$session';
const ABORTED_RUN_TTL_MS = 10 * 60_000;
const PROGRESS_CARD_PAYLOAD_PREFIX = '__cc_connect_progress_card_v1__:';
const TOOL_START_TYPES = new Set(['tool_start', 'tool.started', 'tool_call', 'tool_call_start', 'tool_use', 'tool_use_start']);
@@ -404,10 +405,30 @@ export function toCcConnectBridgeSessionKey(sessionKey: string): string {
if (sessionKey.startsWith('clawx:')) return sessionKey;
if (!sessionKey.startsWith('agent:')) return sessionKey;
const [, scope = 'main', ...userParts] = sessionKey.split(':');
const bridgeParts = userParts.at(-2) === CC_CONNECT_SESSION_INSTANCE_MARKER
? userParts.slice(0, -2)
: userParts;
if (CC_CONNECT_CHANNEL_SESSION_PREFIXES.has(bridgeParts[0] || '')) {
return bridgeParts.join(':');
}
const user = userParts.join(':') || 'main';
return `clawx:${scope || 'main'}:${user || 'main'}`;
}
const CC_CONNECT_CHANNEL_SESSION_PREFIXES = new Set([
'dingtalk',
'discord',
'feishu',
'lark',
'line',
'qq',
'qqbot',
'slack',
'telegram',
'wecom',
'weixin',
]);
function normalizeClawXAgentId(value: string | undefined): string {
const normalized = (value || 'main')
.trim()
@@ -423,6 +444,10 @@ export function ccConnectProjectNameForAgent(agentId: string): string {
}
export function ccConnectProjectNameForSessionKey(sessionKey: string): string {
if (sessionKey.startsWith('agent:')) {
const [, agentId] = sessionKey.split(':');
return ccConnectProjectNameForAgent(agentId);
}
const bridgeKey = toCcConnectBridgeSessionKey(sessionKey);
if (!bridgeKey.startsWith('clawx:')) return ccConnectProjectNameForAgent('main');
const [, agentId] = bridgeKey.split(':');
@@ -502,6 +527,7 @@ export class CcConnectBridgeAdapter {
this.shouldReconnect = false;
this.clearHeartbeat();
this.clearReconnectTimer();
await this.failPendingRuns('cc-connect runtime stopped before the run completed');
const sockets = new Set([
...this.connectingSockets,
...(this.socket ? [this.socket] : []),
@@ -1561,6 +1587,23 @@ export class CcConnectBridgeAdapter {
this.terminalCardRuns.delete(pending.runId);
}
private async failPendingRuns(error: string): Promise<void> {
const failedAt = Date.now();
for (const pending of Array.from(this.pendingRuns.values())) {
if (this.pendingRuns.get(pending.runId) !== pending) continue;
this.abortedRuns.set(pending.runId, {
sessionKey: pending.sessionKey,
abortedAt: failedAt,
});
await this.finishPendingRun(pending, {
text: error,
isError: true,
appendMessage: false,
timestamp: failedAt,
});
}
}
private completeOpenProgressTools(runId: string, isError: boolean): void {
for (const state of this.progressByHandle.values()) {
if (state.runId !== runId) continue;
@@ -0,0 +1,406 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import type { RawMessage } from '@shared/chat/types';
const MAX_TRANSCRIPT_SEARCH_DEPTH = 6;
const MAX_TOOL_OUTPUT_CHARS = 16_000;
const TRANSCRIPT_TURN_MATCH_WINDOW_MS = 2 * 60_000;
const MAX_TRANSCRIPT_FILE_CACHE_ENTRIES = 512;
const MAX_TRANSCRIPT_PATH_CACHE_ENTRIES = 2_048;
type CachedTranscriptFile = {
mtimeMs: number;
size: number;
jsonl: string;
turnMetadata?: {
sessionTimestamp?: number;
sessionWorkDir?: string;
userMessages: string[];
};
toolMessages?: RawMessage[];
};
const transcriptFileCache = new Map<string, CachedTranscriptFile>();
const transcriptPathBySessionId = new Map<string, string>();
function setBoundedCache<K, V>(cache: Map<K, V>, key: K, value: V, maxEntries: number): void {
cache.delete(key);
cache.set(key, value);
while (cache.size > maxEntries) {
const oldestKey = cache.keys().next().value;
if (oldestKey === undefined) break;
cache.delete(oldestKey);
}
}
export type CcConnectTranscriptTurnHint = {
content: string;
timestamp: number;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function parseTimestamp(value: unknown): number | undefined {
if (typeof value !== 'string' || !value.trim()) return undefined;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function parseToolArguments(value: unknown): unknown {
if (typeof value !== 'string') return value ?? {};
const trimmed = value.trim();
if (!trimmed) return {};
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
function displayToolName(name: string): string {
switch (name) {
case 'exec_command':
return 'Bash';
case 'apply_patch':
return 'Patch';
case 'web_search':
case 'web_search_call':
return 'Web Search';
default:
return name || 'tool';
}
}
function toolOutputIsError(output: string): boolean {
const exitCode = output.match(/\bProcess exited with code (\d+)\b/i)?.[1];
return exitCode !== undefined && Number(exitCode) !== 0;
}
function toolOutputText(value: unknown): string {
if (typeof value === 'string') return value;
return JSON.stringify(value ?? '');
}
function truncateToolOutput(output: string): string {
return output.length > MAX_TOOL_OUTPUT_CHARS
? `${output.slice(0, MAX_TOOL_OUTPUT_CHARS)}\n… output truncated by ClawX`
: output;
}
async function readTranscriptFile(path: string): Promise<CachedTranscriptFile | null> {
const metadata = await stat(path).catch(() => null);
if (!metadata) return null;
const cached = transcriptFileCache.get(path);
if (cached && cached.mtimeMs === metadata.mtimeMs && cached.size === metadata.size) {
setBoundedCache(transcriptFileCache, path, cached, MAX_TRANSCRIPT_FILE_CACHE_ENTRIES);
return cached;
}
const jsonl = await readFile(path, 'utf8').catch(() => '');
const entry = {
mtimeMs: metadata.mtimeMs,
size: metadata.size,
jsonl,
};
setBoundedCache(transcriptFileCache, path, entry, MAX_TRANSCRIPT_FILE_CACHE_ENTRIES);
return entry;
}
async function findTranscriptFile(
directory: string,
agentSessionId: string,
depth = 0,
): Promise<string | undefined> {
if (depth > MAX_TRANSCRIPT_SEARCH_DEPTH) return undefined;
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name.endsWith('.jsonl') && entry.name.includes(agentSessionId)) {
return join(directory, entry.name);
}
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const match = await findTranscriptFile(join(directory, entry.name), agentSessionId, depth + 1);
if (match) return match;
}
return undefined;
}
function transcriptDateParts(timestamp: number, utc: boolean): [string, string, string] {
const date = new Date(timestamp);
const year = utc ? date.getUTCFullYear() : date.getFullYear();
const month = (utc ? date.getUTCMonth() : date.getMonth()) + 1;
const day = utc ? date.getUTCDate() : date.getDate();
return [String(year), String(month).padStart(2, '0'), String(day).padStart(2, '0')];
}
function transcriptCandidateDateParts(timestamp: number): Array<[string, string, string]> {
const candidates = [
transcriptDateParts(timestamp - 24 * 60 * 60_000, false),
transcriptDateParts(timestamp, false),
transcriptDateParts(timestamp + 24 * 60 * 60_000, false),
transcriptDateParts(timestamp, true),
];
return Array.from(new Map(candidates.map((parts) => [parts.join('/'), parts])).values());
}
function transcriptFilenameTimestamps(fileName: string): number[] {
const match = fileName.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/);
if (!match) return [];
const [, year, month, day, hour, minute, second] = match.map(Number);
return [
new Date(year, month - 1, day, hour, minute, second).getTime(),
Date.UTC(year, month - 1, day, hour, minute, second),
];
}
function transcriptFilenameMayMatch(fileName: string, hints: CcConnectTranscriptTurnHint[]): boolean {
const timestamps = transcriptFilenameTimestamps(fileName);
return timestamps.length === 0 || hints.some((hint) => (
timestamps.some((timestamp) => Math.abs(timestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS)
));
}
function transcriptTurnMetadata(file: CachedTranscriptFile): NonNullable<CachedTranscriptFile['turnMetadata']> {
if (file.turnMetadata) return file.turnMetadata;
let sessionTimestamp: number | undefined;
let sessionWorkDir: string | undefined;
const userMessages: string[] = [];
for (const line of file.jsonl.split(/\r?\n/)) {
if (!line.trim()) continue;
let record: Record<string, unknown>;
try {
const parsed = JSON.parse(line);
if (!isRecord(parsed)) continue;
record = parsed;
} catch {
continue;
}
if (record.type === 'session_meta' && isRecord(record.payload)) {
sessionTimestamp = parseTimestamp(record.payload.timestamp) ?? parseTimestamp(record.timestamp);
sessionWorkDir = typeof record.payload.cwd === 'string' ? record.payload.cwd : undefined;
continue;
}
if (record.type !== 'response_item' || !isRecord(record.payload)) continue;
const payload = record.payload;
if (payload.type !== 'message' || payload.role !== 'user' || !Array.isArray(payload.content)) continue;
for (const item of payload.content) {
if (!isRecord(item) || item.type !== 'input_text' || typeof item.text !== 'string') continue;
userMessages.push(item.text.trim());
}
}
file.turnMetadata = { sessionTimestamp, sessionWorkDir, userMessages };
return file.turnMetadata;
}
function transcriptMatchesTurn(
file: CachedTranscriptFile,
hints: CcConnectTranscriptTurnHint[],
expectedWorkDir?: string,
): boolean {
const { sessionTimestamp, sessionWorkDir, userMessages } = transcriptTurnMetadata(file);
if (sessionTimestamp === undefined || userMessages.length === 0) return false;
if (expectedWorkDir && (!sessionWorkDir || resolve(sessionWorkDir) !== resolve(expectedWorkDir))) {
return false;
}
return hints.some((hint) => (
Math.abs(sessionTimestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS
&& userMessages.includes(hint.content.trim())
));
}
async function findTurnTranscriptFiles(
codexHomeDir: string,
hints: CcConnectTranscriptTurnHint[],
expectedWorkDir?: string,
): Promise<string[]> {
const sessionRoot = join(codexHomeDir, 'sessions');
const directories = new Map<string, string>();
for (const hint of hints) {
for (const parts of transcriptCandidateDateParts(hint.timestamp)) {
const directory = join(sessionRoot, ...parts);
directories.set(directory, directory);
}
}
const matches: string[] = [];
for (const directory of directories.values()) {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
if (!transcriptFilenameMayMatch(entry.name, hints)) continue;
const path = join(directory, entry.name);
const file = await readTranscriptFile(path);
if (file?.jsonl && transcriptMatchesTurn(file, hints, expectedWorkDir)) matches.push(path);
}
}
return matches;
}
export function parseCcConnectCodexTranscriptTools(jsonl: string): RawMessage[] {
const messages: RawMessage[] = [];
const toolNamesByCallId = new Map<string, string>();
for (const line of jsonl.split(/\r?\n/)) {
if (!line.trim()) continue;
let record: Record<string, unknown>;
try {
const parsed = JSON.parse(line);
if (!isRecord(parsed)) continue;
record = parsed;
} catch {
continue;
}
if (record.type !== 'response_item' || !isRecord(record.payload)) continue;
const payload = record.payload;
const payloadType = typeof payload.type === 'string' ? payload.type : '';
const callId = typeof payload.call_id === 'string'
? payload.call_id.trim()
: typeof payload.id === 'string'
? payload.id.trim()
: '';
if (!callId) continue;
const timestamp = parseTimestamp(record.timestamp);
if (payloadType === 'function_call' || payloadType === 'custom_tool_call') {
const rawName = typeof payload.name === 'string' ? payload.name.trim() : '';
const name = displayToolName(rawName);
toolNamesByCallId.set(callId, name);
messages.push({
id: `cc-connect-codex-tool-${callId}`,
role: 'assistant',
content: [{
type: 'toolCall',
id: callId,
name,
arguments: parseToolArguments(payload.arguments ?? payload.input),
}],
...(timestamp !== undefined ? { timestamp } : {}),
stopReason: 'tool_use',
});
continue;
}
if (payloadType === 'function_call_output' || payloadType === 'custom_tool_call_output') {
const rawOutput = toolOutputText(payload.output ?? payload.content);
const output = truncateToolOutput(rawOutput);
const name = toolNamesByCallId.get(callId) || 'tool';
const isError = toolOutputIsError(rawOutput);
messages.push({
id: `cc-connect-codex-tool-result-${callId}`,
role: 'toolresult',
toolCallId: callId,
toolName: name,
content: output,
details: {
status: isError ? 'error' : 'completed',
aggregated: output,
},
...(isError ? { isError: true } : {}),
...(timestamp !== undefined ? { timestamp } : {}),
});
continue;
}
if (payloadType === 'web_search_call') {
const name = 'Web Search';
messages.push({
id: `cc-connect-codex-tool-${callId}`,
role: 'assistant',
content: [{
type: 'toolCall',
id: callId,
name,
arguments: payload.action ?? {},
}],
...(timestamp !== undefined ? { timestamp } : {}),
stopReason: 'tool_use',
});
continue;
}
if (payloadType === 'mcp_tool_call') {
const server = typeof payload.server === 'string' ? payload.server : '';
const tool = typeof payload.tool === 'string'
? payload.tool
: typeof payload.name === 'string'
? payload.name
: 'tool';
const name = server ? `${server}: ${tool}` : tool;
messages.push({
id: `cc-connect-codex-tool-${callId}`,
role: 'assistant',
content: [{
type: 'toolCall',
id: callId,
name,
arguments: parseToolArguments(payload.arguments ?? payload.input),
}],
...(timestamp !== undefined ? { timestamp } : {}),
stopReason: 'tool_use',
});
if (payload.result !== undefined || payload.error !== undefined) {
const isError = payload.error !== undefined;
const output = truncateToolOutput(toolOutputText(payload.error ?? payload.result));
messages.push({
id: `cc-connect-codex-tool-result-${callId}`,
role: 'toolresult',
toolCallId: callId,
toolName: name,
content: output,
details: {
status: isError ? 'error' : 'completed',
aggregated: output,
},
...(isError ? { isError: true } : {}),
...(timestamp !== undefined ? { timestamp } : {}),
});
}
}
}
return messages;
}
export async function loadCcConnectCodexTranscriptTools(
codexHomeDirs: string | Iterable<string>,
agentSessionId: string,
turnHints: CcConnectTranscriptTurnHint[] = [],
expectedWorkDir?: string,
): Promise<RawMessage[]> {
const hasValidAgentSessionId = /^[A-Za-z0-9_-]+$/.test(agentSessionId);
if (!hasValidAgentSessionId && turnHints.length === 0) return [];
const homes = typeof codexHomeDirs === 'string'
? [codexHomeDirs]
: Array.from(codexHomeDirs);
const transcriptPaths = new Set<string>();
const cachedSessionPath = hasValidAgentSessionId
? transcriptPathBySessionId.get(agentSessionId)
: undefined;
if (cachedSessionPath) transcriptPaths.add(cachedSessionPath);
for (const codexHomeDir of new Set(homes.filter(Boolean))) {
if (hasValidAgentSessionId && !transcriptPathBySessionId.has(agentSessionId)) {
const transcriptPath = await findTranscriptFile(join(codexHomeDir, 'sessions'), agentSessionId);
if (transcriptPath) {
setBoundedCache(
transcriptPathBySessionId,
agentSessionId,
transcriptPath,
MAX_TRANSCRIPT_PATH_CACHE_ENTRIES,
);
transcriptPaths.add(transcriptPath);
}
}
for (const turnTranscriptPath of await findTurnTranscriptFiles(codexHomeDir, turnHints, expectedWorkDir)) {
transcriptPaths.add(turnTranscriptPath);
}
}
const messages: RawMessage[] = [];
for (const transcriptPath of transcriptPaths) {
const file = await readTranscriptFile(transcriptPath);
if (!file?.jsonl) continue;
file.toolMessages ??= parseCcConnectCodexTranscriptTools(file.jsonl);
messages.push(...file.toolMessages);
}
return messages.sort((left, right) => (left.timestamp ?? 0) - (right.timestamp ?? 0));
}
+52 -4
View File
@@ -30,6 +30,7 @@ import {
} from './cc-connect-paths';
import { buildCcConnectWebAdminUrl, CC_CONNECT_MANAGEMENT_PORT } from './cc-connect-control-ui';
import {
CC_CONNECT_SESSION_INSTANCE_MARKER,
ccConnectProjectNameForAgent,
ccConnectProjectNameForSessionKey,
CLAWX_BRIDGE_ADMIN_USER_ID,
@@ -53,6 +54,7 @@ import {
FileCcConnectSessionMetadataStore,
type CcConnectSessionMetadataStore,
} from './cc-connect-session-metadata';
import { loadCcConnectCodexTranscriptTools } from './cc-connect-codex-transcript';
import { readOpenClawConfig, type ChannelConfigData, type OpenClawConfig } from '../utils/channel-config';
import { expandPath, getOpenClawConfigDir } from '../utils/paths';
import * as logger from '../utils/logger';
@@ -148,6 +150,7 @@ type CcConnectApiSessionRef = {
active: boolean;
createdAt: number;
updatedAt: number;
agentSessionId?: string;
lastMessage?: Record<string, unknown>;
};
@@ -550,6 +553,7 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
private currentProviderProfile: CodexProviderProfile | null = null;
private currentProjectProfiles: CodexProviderProfile[] = [];
private currentProjectProfileByAgent = new Map<string, CodexProviderProfile>();
private currentProjectWorkDirByAgent = new Map<string, string>();
private currentChannelEnv: Record<string, string> = {};
private sessionSyncTimer: ReturnType<typeof setInterval> | null = null;
private sessionSyncPolling = false;
@@ -836,8 +840,38 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
return { success: false, error: 'Session not found' };
}
const publicMessages = await this.sessionApi.loadHistory(session);
const codexHomeDirs = new Set([
this.currentProjectProfileByAgent.get(session.agentId)?.codexHomeDir,
this.currentProviderProfile?.codexHomeDir,
...this.currentProjectProfiles.map((profile) => profile.codexHomeDir),
getCcConnectCodexHomeDir(),
].filter((value): value is string => Boolean(value)));
const transcriptTurnHints = publicMessages.slice(-limit).flatMap((message) => {
const content = message.role === 'user' ? runtimeMessageText(message.content) : '';
return content && typeof message.timestamp === 'number'
? [{ content, timestamp: message.timestamp }]
: [];
});
const transcriptMessages = session.agentSessionId || transcriptTurnHints.length > 0
? await loadCcConnectCodexTranscriptTools(
codexHomeDirs,
session.agentSessionId ?? '',
transcriptTurnHints,
this.currentProjectWorkDirByAgent.get(session.agentId),
).catch((error) => {
logger.warn('[cc-connect] failed to load Codex tool history', {
sessionKey,
agentSessionId: session.agentSessionId,
error: error instanceof Error ? error.message : String(error),
});
return [];
})
: [];
const bridgeMessages = await this.bridgeAdapter.loadHistory(sessionKey, limit);
const messages = mergeCcConnectHistory(publicMessages, bridgeMessages).slice(-limit);
const messages = mergeCcConnectHistory(
mergeCcConnectHistory(publicMessages, transcriptMessages),
bridgeMessages,
).slice(-limit);
return {
success: true,
messages,
@@ -1256,6 +1290,10 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
this.currentProjectProfileByAgent = new Map(configuredProjects.flatMap((project) => (
project.providerProfile ? [[project.agentId, project.providerProfile] as const] : []
)));
this.currentProjectWorkDirByAgent = new Map(configuredProjects.map((project) => [
project.agentId,
project.workDir,
]));
const channelPlatforms = collectCcConnectChannelPlatforms(openClawConfig).filter((platform) => !platform.error);
this.currentChannelEnv = Object.assign({}, ...channelPlatforms.map((platform) => platform.env));
await writeFile(configPath, defaultConfig({
@@ -1457,6 +1495,8 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
'GET',
`/projects/${encodeURIComponent(session.projectName)}/sessions/${encodeURIComponent(session.id)}?history_limit=1000`,
);
const detail = isRecord(result) ? result : {};
session.agentSessionId = ccString(detail, ['agent_session_id', 'agentSessionId']) || undefined;
return parseCcConnectApiHistory(result);
}
@@ -2371,8 +2411,13 @@ function hasRuntimeAttachments(message: RawMessage): boolean {
return Array.isArray(message._attachedFiles) && message._attachedFiles.length > 0;
}
function hasRuntimeToolResult(message: RawMessage): boolean {
return message.role === 'toolresult' && Boolean(message.toolCallId || message.id);
}
function runtimeSupplementKey(message: RawMessage): string {
if (hasRuntimeToolCall(message)) return `tool:${runtimeToolCallKey(message)}`;
if (hasRuntimeToolResult(message)) return `tool-result:${message.toolCallId || message.id}`;
if (message.id) return `attachment:${message.id}`;
const attachments = (message._attachedFiles ?? []).map((file) => [
file.filePath,
@@ -2386,12 +2431,12 @@ function runtimeSupplementKey(message: RawMessage): string {
function mergeCcConnectHistory(publicMessages: RawMessage[], bridgeMessages: RawMessage[]): RawMessage[] {
const supplementalMessages = bridgeMessages.filter((message) => (
hasRuntimeToolCall(message) || hasRuntimeAttachments(message)
hasRuntimeToolCall(message) || hasRuntimeToolResult(message) || hasRuntimeAttachments(message)
));
if (supplementalMessages.length === 0) return publicMessages;
const seenSupplements = new Set(
publicMessages
.filter((message) => hasRuntimeToolCall(message) || hasRuntimeAttachments(message))
.filter((message) => hasRuntimeToolCall(message) || hasRuntimeToolResult(message) || hasRuntimeAttachments(message))
.map(runtimeSupplementKey),
);
const merged = [...publicMessages];
@@ -2433,7 +2478,10 @@ export function ccConnectSessionLogicalKey(
const baseKey = `agent:${agentId}:cron:scheduled`;
return active ? baseKey : `${baseKey}:${id}`;
}
if (!sessionKey.startsWith('clawx:')) return sessionKey;
if (!sessionKey.startsWith('clawx:')) {
const baseKey = `agent:${agentId}:${sessionKey}`;
return active ? baseKey : `${baseKey}:${CC_CONNECT_SESSION_INSTANCE_MARKER}:${id}`;
}
const [, keyAgentId = 'main', ...keyParts] = sessionKey.split(':');
const scopedAgentId = agentIdFromCcConnectProjectName(projectName) || normalizeAgentId(keyAgentId) || 'main';
const baseKey = `agent:${scopedAgentId}:${keyParts.join(':') || 'main'}`;
@@ -11,10 +11,30 @@ function stableStringify(value: unknown): string {
test.describe('cc-connect channel session history', () => {
test('renders channel sessions in the sidebar and loads their history', async ({ launchElectronApp }) => {
const sessionKey = 'feishu:oc_probe:ou_probe';
const sessionKey = 'agent:coder:feishu:oc_probe:ou_probe';
const sessionUpdatedAt = Date.now();
const seededHistory = [
{ id: 'm1', role: 'user', content: '飞书 hello', timestamp: sessionUpdatedAt - 1_000 },
{
id: 'm-tool-call',
role: 'assistant',
content: [{
type: 'toolCall',
id: 'call-feishu-bash',
name: 'Bash',
arguments: { cmd: 'pwd && ls' },
}],
timestamp: sessionUpdatedAt - 800,
stopReason: 'tool_use',
},
{
id: 'm-tool-result',
role: 'toolresult',
toolCallId: 'call-feishu-bash',
toolName: 'Bash',
content: '/tmp/workspace',
timestamp: sessionUpdatedAt - 700,
},
{ id: 'm2', role: 'assistant', content: '飞书 reply', timestamp: sessionUpdatedAt },
];
const app = await launchElectronApp({ skipSetup: true });
@@ -86,7 +106,10 @@ test.describe('cc-connect channel session history', () => {
await expect(page.getByTestId(`sidebar-session-${sessionKey}`)).toContainText('Coder');
await page.getByTestId(`sidebar-session-${sessionKey}`).click();
await expect(page.getByTestId('chat-message-0').getByText('飞书 hello')).toBeVisible();
await expect(page.getByTestId('chat-message-1').getByText('飞书 reply')).toBeVisible();
await expect(page.getByText('飞书 reply')).toBeVisible();
await expect(page.getByTestId('chat-execution-graph')).toBeVisible();
await page.getByTestId('chat-execution-graph').click();
await expect(page.getByTestId('chat-execution-graph')).toContainText('Bash');
} finally {
await closeElectronApp(app);
}
@@ -309,6 +309,9 @@ wss.on('connection', (ws) => {
}
if (msg.type === 'message') {
writeBridgeMessage(msg);
if (String(msg.content || '').includes('stay pending until runtime stops')) {
return;
}
if (String(msg.content || '').includes('require approval')) {
pendingApprovals.set(msg.reply_ctx, msg);
ws.send(JSON.stringify({
@@ -867,6 +870,23 @@ test.describe('cc-connect + Codex runtime E2E', () => {
session_key: 'clawx:analysis:member-2',
}),
]));
await page.getByTestId('chat-composer-input').fill('stay pending until runtime stops');
await page.getByTestId('chat-composer-send').click();
await expect.poll(async () => {
const content = await readFile(bridgeMessagesPath, 'utf8').catch(() => '');
return content.includes('stay pending until runtime stops');
}, { timeout: 30_000 }).toBe(true);
const stopResult = await page.evaluate(async () => {
return await window.clawx.hostInvoke({
id: 'runtime-stop-with-pending-run',
module: 'gateway',
action: 'stop',
});
});
expect(stopResult).toMatchObject({ ok: true, data: { success: true } });
await expect(page.getByText('cc-connect runtime stopped before the run completed')).toBeVisible();
} finally {
await closeElectronApp(app);
}
@@ -36,10 +36,13 @@ describe('cc-connect BridgePlatform adapter', () => {
expect(toCcConnectBridgeSessionKey('agent:main:main')).toBe('clawx:main:main');
expect(toCcConnectBridgeSessionKey('agent:research:desk')).toBe('clawx:research:desk');
expect(toCcConnectBridgeSessionKey('agent:main:cron:job-123')).toBe('clawx:main:cron:job-123');
expect(toCcConnectBridgeSessionKey('agent:research:feishu:chat-1:user-1')).toBe('feishu:chat-1:user-1');
expect(toCcConnectBridgeSessionKey('agent:research:feishu:chat-1:user-1:$session:s2')).toBe('feishu:chat-1:user-1');
expect(toCcConnectBridgeSessionKey('clawx:main:main')).toBe('clawx:main:main');
expect(toCcConnectBridgeSessionKey('feishu:chat-1:user-1')).toBe('feishu:chat-1:user-1');
expect(ccConnectProjectNameForSessionKey('agent:research:desk')).toBe('clawx-research');
expect(ccConnectProjectNameForSessionKey('agent:research:cron:job-123')).toBe('clawx-research');
expect(ccConnectProjectNameForSessionKey('agent:research:feishu:chat-1:user-1')).toBe('clawx-research');
expect(ccConnectProjectNameForSessionKey('feishu:chat-1:user-1')).toBe('clawx-main');
});
@@ -129,6 +132,11 @@ describe('cc-connect BridgePlatform adapter', () => {
message: 'ping',
idempotencyKey: 'idem-1',
})).resolves.toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) }));
await expect(adapter.send({
sessionKey: 'agent:coder:feishu:chat-1:user-1',
message: 'channel ping',
idempotencyKey: 'idem-2',
})).resolves.toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) }));
await vi.waitFor(() => {
expect(emitted).toEqual(expect.arrayContaining([
@@ -155,6 +163,11 @@ describe('cc-connect BridgePlatform adapter', () => {
session_key: 'clawx:research:desk',
project: 'clawx-research',
}),
expect.objectContaining({
type: 'message',
session_key: 'feishu:chat-1:user-1',
project: 'clawx-coder',
}),
]));
await expect(adapter.listSessions()).resolves.toEqual([
expect.objectContaining({
@@ -163,6 +176,12 @@ describe('cc-connect BridgePlatform adapter', () => {
derivedTitle: 'ping',
lastMessagePreview: 'pong',
}),
expect.objectContaining({
key: 'agent:coder:feishu:chat-1:user-1',
agentId: 'coder',
derivedTitle: 'channel ping',
lastMessagePreview: 'pong',
}),
]);
await adapter.close();
} finally {
@@ -959,6 +978,82 @@ describe('cc-connect BridgePlatform adapter', () => {
}
});
it('ends pending runs on close so they cannot block reply routing after reconnect', async () => {
const server = new WebSocketServer({ port: 0 });
const port = await new Promise<number>((resolve) => {
server.once('listening', () => {
const address = server.address();
resolve(typeof address === 'object' && address ? address.port : 0);
});
});
const emitted: Array<[string, unknown]> = [];
let sentMessages = 0;
server.on('connection', (socket) => {
socket.on('message', (data) => {
const parsed = JSON.parse(String(data)) as Record<string, unknown>;
if (parsed.type === 'register') {
socket.send(JSON.stringify({ type: 'register_ack', ok: true }));
return;
}
if (parsed.type !== 'message') return;
sentMessages += 1;
if (sentMessages === 2) {
socket.send(JSON.stringify({
type: 'reply',
session_key: parsed.session_key,
content: 'reply after reconnect',
}));
}
});
});
const adapter = new CcConnectBridgeAdapter({
port,
token: 'token',
project: 'clawx-main',
emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never,
});
try {
const first = await adapter.send({
sessionKey: 'agent:main:first',
message: 'never answered',
idempotencyKey: 'idem-before-close',
});
await adapter.close();
expect(emitted).toEqual(expect.arrayContaining([
['chat:runtime-event', expect.objectContaining({
type: 'run.ended',
runId: first.runId,
sessionKey: 'agent:main:first',
status: 'error',
error: 'cc-connect runtime stopped before the run completed',
})],
]));
const second = await adapter.send({
sessionKey: 'agent:main:second',
message: 'answer this',
idempotencyKey: 'idem-after-close',
});
await vi.waitFor(() => {
expect(emitted).toEqual(expect.arrayContaining([
['chat:runtime-event', expect.objectContaining({
type: 'run.ended',
runId: second.runId,
sessionKey: 'agent:main:second',
status: 'completed',
})],
]));
});
} finally {
await adapter.close();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('maps bridge replies without reply_ctx back to the pending app run', async () => {
const server = new WebSocketServer({ port: 0 });
const port = await new Promise<number>((resolve) => {
+238 -1
View File
@@ -135,7 +135,19 @@ describe('CcConnectRuntimeProvider', () => {
'feishu:chat-1:user-1',
'channel-session',
true,
)).toBe('feishu:chat-1:user-1');
)).toBe('agent:research:feishu:chat-1:user-1');
expect(ccConnectSessionLogicalKey(
'clawx-main',
'feishu:chat-1:user-1',
'channel-session',
true,
)).toBe('agent:main:feishu:chat-1:user-1');
expect(ccConnectSessionLogicalKey(
'clawx-main',
'feishu:chat-1:user-1',
'archived-channel-session',
false,
)).toBe('agent:main:feishu:chat-1:user-1:$session:archived-channel-session');
});
afterEach(async () => {
@@ -2475,6 +2487,231 @@ describe('CcConnectRuntimeProvider', () => {
}));
});
it('merges Codex transcript tool calls into cc-connect channel history', async () => {
const agentSessionId = '019fa23b-1dad-76b1-9910-c47608ebf367';
const staleAgentSessionId = '019fa000-0000-7000-8000-000000000000';
const transcriptDir = join(
tempDir,
'runtimes',
'cc-connect',
'codex-home',
'sessions',
'2026',
'07',
'27',
);
await mkdir(transcriptDir, { recursive: true });
await writeFile(join(transcriptDir, `rollout-${agentSessionId}.jsonl`), [
JSON.stringify({
timestamp: '2026-07-27T06:20:10.931Z',
type: 'session_meta',
payload: {
id: agentSessionId,
timestamp: '2026-07-27T06:20:10.931Z',
},
}),
JSON.stringify({
timestamp: '2026-07-27T06:20:10.950Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: '你在哪' }],
},
}),
JSON.stringify({
timestamp: '2026-07-27T06:20:17.195Z',
type: 'response_item',
payload: {
type: 'function_call',
name: 'exec_command',
arguments: '{"cmd":"pwd && ls"}',
call_id: 'call-channel-bash',
},
}),
JSON.stringify({
timestamp: '2026-07-27T06:20:17.306Z',
type: 'response_item',
payload: {
type: 'function_call_output',
call_id: 'call-channel-bash',
output: 'Process exited with code 0\\nOutput:\\n/tmp/workspace',
},
}),
].join('\n'), 'utf8');
const { loadCcConnectCodexTranscriptTools } = await import('@electron/runtime/cc-connect-codex-transcript');
await expect(loadCcConnectCodexTranscriptTools([
join(tempDir, 'credentials', 'current-provider', 'codex-home'),
join(tempDir, 'runtimes', 'cc-connect', 'codex-home'),
], agentSessionId)).resolves.toHaveLength(2);
const channelSession = {
projectName: 'clawx-project-manager',
agentId: 'project-manager',
id: 's2',
sessionKey: 'feishu:oc_probe:ou_probe',
logicalKey: 'agent:project-manager:feishu:oc_probe:ou_probe',
active: true,
createdAt: Date.parse('2026-07-27T06:20:10.931Z'),
updatedAt: Date.parse('2026-07-27T06:20:22.818Z'),
agentSessionId: staleAgentSessionId,
};
const sessionApi = createSessionApiMock({
sessions: () => [channelSession],
histories: {
[channelSession.logicalKey]: [{
id: 'channel-user',
role: 'user',
content: '你在哪',
timestamp: channelSession.createdAt,
}, {
id: 'channel-assistant',
role: 'assistant',
content: '我在本机 workspace',
timestamp: channelSession.updatedAt,
}],
},
});
const bridgeAdapter = createBridgeAdapterMock({
loadHistory: vi.fn(async () => []),
});
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
const provider = new CcConnectRuntimeProvider({
bridgeAdapter: bridgeAdapter as never,
sessionApi: sessionApi as never,
skillSyncer: vi.fn(async () => ({ skills: [] })),
providerProfileLoader: vi.fn(async () => createProviderProfile()) as never,
});
await expect(provider.loadHistory({
sessionKey: channelSession.logicalKey,
limit: 20,
})).resolves.toEqual({
success: true,
messages: [
expect.objectContaining({ id: 'channel-user', role: 'user' }),
expect.objectContaining({
role: 'assistant',
content: [expect.objectContaining({
type: 'toolCall',
id: 'call-channel-bash',
name: 'Bash',
arguments: { cmd: 'pwd && ls' },
})],
}),
expect.objectContaining({
role: 'toolresult',
toolCallId: 'call-channel-bash',
toolName: 'Bash',
}),
expect.objectContaining({ id: 'channel-assistant', role: 'assistant' }),
],
});
});
it('scopes transcript turn matching by workspace and parses direct Web Search and MCP tools', async () => {
const transcriptDir = join(
tempDir,
'runtimes',
'cc-connect',
'codex-home',
'sessions',
'2026',
'07',
'27',
);
await mkdir(transcriptDir, { recursive: true });
const timestamp = Date.parse('2026-07-27T06:30:10.000Z');
const sessionMeta = (id: string, cwd: string) => JSON.stringify({
timestamp: '2026-07-27T06:30:10.000Z',
type: 'session_meta',
payload: {
id,
timestamp: '2026-07-27T06:30:10.000Z',
cwd,
},
});
const userMessage = JSON.stringify({
timestamp: '2026-07-27T06:30:10.100Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: '查一下状态' }],
},
});
await writeFile(join(transcriptDir, 'rollout-coder.jsonl'), [
sessionMeta('coder-session', '/workspace/coder'),
userMessage,
JSON.stringify({
timestamp: '2026-07-27T06:30:11.000Z',
type: 'response_item',
payload: {
type: 'web_search_call',
id: 'web-search-1',
action: { type: 'search', query: 'status' },
},
}),
JSON.stringify({
timestamp: '2026-07-27T06:30:12.000Z',
type: 'response_item',
payload: {
type: 'mcp_tool_call',
id: 'mcp-call-1',
server: 'status-server',
tool: 'read_status',
arguments: '{"scope":"current"}',
result: { status: 'ok' },
},
}),
].join('\n'), 'utf8');
await writeFile(join(transcriptDir, 'rollout-main.jsonl'), [
sessionMeta('main-session', '/workspace/main'),
userMessage,
JSON.stringify({
timestamp: '2026-07-27T06:30:11.000Z',
type: 'response_item',
payload: {
type: 'function_call',
call_id: 'wrong-agent-call',
name: 'exec_command',
arguments: '{"cmd":"should-not-leak"}',
},
}),
].join('\n'), 'utf8');
const { loadCcConnectCodexTranscriptTools } = await import('@electron/runtime/cc-connect-codex-transcript');
const messages = await loadCcConnectCodexTranscriptTools(
join(tempDir, 'runtimes', 'cc-connect', 'codex-home'),
'',
[{ content: '查一下状态', timestamp }],
'/workspace/coder',
);
expect(messages).toEqual([
expect.objectContaining({
role: 'assistant',
content: [expect.objectContaining({
id: 'web-search-1',
name: 'Web Search',
})],
}),
expect.objectContaining({
role: 'assistant',
content: [expect.objectContaining({
id: 'mcp-call-1',
name: 'status-server: read_status',
})],
}),
expect.objectContaining({
role: 'toolresult',
toolCallId: 'mcp-call-1',
content: '{"status":"ok"}',
}),
]);
expect(JSON.stringify(messages)).not.toContain('should-not-leak');
});
it('aborts active cc-connect chat runs through Bridge without restarting the runtime', async () => {
const binaryPath = join(tempDir, 'cc-connect');
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });