diff --git a/README.ja-JP.md b/README.ja-JP.md index 3699fb84..c2d332e7 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -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/` を使い、既存の 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/` を使い、既存の 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 を再生成します。 diff --git a/README.md b/README.md index fef156db..697162de 100644 --- a/README.md +++ b/README.md @@ -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/`; 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/`; 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 3005bda4..01b96b34 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 bundle;runtime 启动不依赖全局安装、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/`;已有 OpenClaw workspace 可以按原路径复用,ClawX 不移动也不接管它。Provider/model、原生 cron 任务和已启用 skills 会同步到托管的 cc-connect/Codex runtime。 +ClawX 现在也包含 runtime 抽象层。OpenClaw 仍是默认 runtime 和回滚路径,你可以在 **设置 → 网关 → Runtime** 切换到可选的内置 `cc-connect` runtime。打包产物会同时内置 cc-connect 二进制和 OpenAI Codex 原生 CLI bundle;runtime 启动不依赖全局安装、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/`;已有 OpenClaw workspace 可以按原路径复用,ClawX 不移动也不接管它。Provider/model、原生 cron 任务和已启用 skills 会同步到托管的 cc-connect/Codex runtime。 Agent 和频道设置以 `~/.clawx` 为唯一 canonical 数据源。cc-connect 处于启用状态时,保存设置不会改写 `~/.openclaw/openclaw.json`;切回 OpenClaw 后,Gateway 启动前会重新生成这份兼容投影。 diff --git a/electron/runtime/cc-connect-bridge-adapter.ts b/electron/runtime/cc-connect-bridge-adapter.ts index f00b9957..ee7dde7e 100644 --- a/electron/runtime/cc-connect-bridge-adapter.ts +++ b/electron/runtime/cc-connect-bridge-adapter.ts @@ -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 { + 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; diff --git a/electron/runtime/cc-connect-codex-transcript.ts b/electron/runtime/cc-connect-codex-transcript.ts new file mode 100644 index 00000000..91de3dba --- /dev/null +++ b/electron/runtime/cc-connect-codex-transcript.ts @@ -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(); +const transcriptPathBySessionId = new Map(); + +function setBoundedCache(cache: Map, 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 { + 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 { + 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 { + 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 { + 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; + 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 { + const sessionRoot = join(codexHomeDir, 'sessions'); + const directories = new Map(); + 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(); + + for (const line of jsonl.split(/\r?\n/)) { + if (!line.trim()) continue; + let record: Record; + 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, + agentSessionId: string, + turnHints: CcConnectTranscriptTurnHint[] = [], + expectedWorkDir?: string, +): Promise { + 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(); + 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)); +} diff --git a/electron/runtime/cc-connect-provider.ts b/electron/runtime/cc-connect-provider.ts index 41436017..dde95153 100644 --- a/electron/runtime/cc-connect-provider.ts +++ b/electron/runtime/cc-connect-provider.ts @@ -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; }; @@ -550,6 +553,7 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro private currentProviderProfile: CodexProviderProfile | null = null; private currentProjectProfiles: CodexProviderProfile[] = []; private currentProjectProfileByAgent = new Map(); + private currentProjectWorkDirByAgent = new Map(); private currentChannelEnv: Record = {}; private sessionSyncTimer: ReturnType | 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'}`; diff --git a/tests/e2e/cc-connect-channel-session-history.spec.ts b/tests/e2e/cc-connect-channel-session-history.spec.ts index 10bf8b33..c059333a 100644 --- a/tests/e2e/cc-connect-channel-session-history.spec.ts +++ b/tests/e2e/cc-connect-channel-session-history.spec.ts @@ -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); } diff --git a/tests/e2e/cc-connect-codex-runtime.spec.ts b/tests/e2e/cc-connect-codex-runtime.spec.ts index 7a8fac4a..eaea1f34 100644 --- a/tests/e2e/cc-connect-codex-runtime.spec.ts +++ b/tests/e2e/cc-connect-codex-runtime.spec.ts @@ -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); } diff --git a/tests/unit/cc-connect-bridge-adapter.test.ts b/tests/unit/cc-connect-bridge-adapter.test.ts index 92e873d9..aab569a6 100644 --- a/tests/unit/cc-connect-bridge-adapter.test.ts +++ b/tests/unit/cc-connect-bridge-adapter.test.ts @@ -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((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; + 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((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((resolve) => { diff --git a/tests/unit/cc-connect-runtime-provider.test.ts b/tests/unit/cc-connect-runtime-provider.test.ts index 2b8d56a4..e0530c0a 100644 --- a/tests/unit/cc-connect-runtime-provider.test.ts +++ b/tests/unit/cc-connect-runtime-provider.test.ts @@ -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 });