Compare commits

...
15 changed files with 1305 additions and 39 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 启动前会重新生成这份兼容投影。
+36 -15
View File
@@ -394,8 +394,10 @@ writes sanitized evidence under
`artifacts/cc-connect/real-oauth-approval-request.png`. These screenshots keep
the tool type, approval controls, lifecycle state, generated filename, and
assistant result visible while masking the isolated managed workspace path.
Reading Codex JSONL,
wrapping Codex stdout, or spawning a second Codex bridge remains forbidden.
Reading Codex JSONL as a real-time event source, wrapping Codex stdout, or
spawning a second Codex bridge remains forbidden. Section 8 documents the sole
bounded historical exception for Channel tool packets omitted by public
cc-connect history.
The local-real verifier performs runtime checks with real filesystem paths but
replaces repository, home, and temporary roots with `<repo>`, `<home>`, and
@@ -454,18 +456,35 @@ assistant rendering, and an unchanged cc-connect PID.
## 8. Sessions and history
Session inventory, history, and deletion use only cc-connect's public
Management/Bridge session endpoints. ClawX does not read or mutate cc-connect
session JSON files. User-assigned titles are ClawX UI metadata stored atomically
in `app/cc-connect-session-metadata.json`; deleting a public session deletes its
title in the same Host API operation. On first use, labels from the old
ClawX-owned `.clawx-supplemental-history.json` are imported without copying its
history payload.
Session inventory, ordinary user/assistant history, and deletion use
cc-connect's public Management/Bridge session endpoints. ClawX does not read or
mutate cc-connect session JSON files. User-assigned titles are ClawX UI metadata
stored atomically in `app/cc-connect-session-metadata.json`; deleting a public
session deletes its title in the same Host API operation. On first use, labels
from the old ClawX-owned `.clawx-supplemental-history.json` are imported without
copying its history payload.
The production Bridge adapter contains no parser for cc-connect session JSON or
Codex transcripts. It retains only messages observed on the current public
Bridge connection for immediate event delivery; durable list/history/delete
always come from the provider's public Management session client.
Bridge connection for immediate event delivery; durable list/delete and
authoritative ordinary messages always come from the provider's public
Management session client.
Pinned cc-connect can omit historical tool packets from public history for
Channel-originated sessions even though Codex recorded and executed those
tools. After public history has loaded, the provider may apply one degraded,
best-effort compatibility supplement that contributes only tool calls and
their results. It cannot create or replace user, assistant, system, attachment,
approval, real-time event, or usage records.
Candidate Codex JSONL files must match the owning Agent workspace. A stale or
exact `agent_session_id` does not bypass that check. Fallback discovery is
bounded to recent public user-turn text and the timestamp of that exact user
record, nearby transcript date directories, bounded path/file caches, and
truncated tool output. Missing, stale, cross-workspace, or ambiguous evidence
leaves public history unchanged. This exception does not satisfy replacement
readiness and must be removed when the pinned cc-connect runtime exposes
durable public Channel tool history.
ClawX owns logical session identity and display metadata; cc-connect owns
runtime sessions and message history. Public session responses carry the
@@ -474,10 +493,12 @@ optional display labels and never copies runtime credentials or message
history.
cc-connect Session REST/Management APIs are the only production source for
list, create, history, switch, and delete. Rename uses an official endpoint if
the pinned binary exposes it; otherwise ClawX stores only the display label in
its logical index and does not rewrite cc-connect private JSON. Hard delete is
reported successful only after the runtime API confirms deletion.
list, create, ordinary message history, switch, and delete. The bounded
Channel tool supplement above is the only historical content exception. Rename
uses an official endpoint if the pinned binary exposes it; otherwise ClawX
stores only the display label in its logical index and does not rewrite
cc-connect private JSON. Hard delete is reported successful only after the
runtime API confirms deletion.
Runtime or provider switching preserves visible historical turns and detaches
the old backend binding. The first subsequent message creates a new runtime
+51 -3
View File
@@ -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();
this.failPendingRuns('cc-connect runtime stopped before the run completed');
const sockets = new Set([
...this.connectingSockets,
...(this.socket ? [this.socket] : []),
@@ -815,7 +841,7 @@ export class CcConnectBridgeAdapter {
}
return;
}
this.handleServerMessage(parsed);
this.handleServerMessage(parsed, socket);
});
socket.once('error', (error) => finish(error));
socket.once('close', () => {
@@ -878,7 +904,8 @@ export class CcConnectBridgeAdapter {
}
}
private handleServerMessage(message: Record<string, unknown>): void {
private handleServerMessage(message: Record<string, unknown>, sourceSocket: WebSocket): void {
if (sourceSocket !== this.socket) return;
const progressItems = parseBridgeProgressItems(message.content);
if (progressItems) {
logger.debug(
@@ -1561,6 +1588,24 @@ export class CcConnectBridgeAdapter {
this.terminalCardRuns.delete(pending.runId);
}
private failPendingRuns(error: string): void {
const failedAt = Date.now();
for (const pending of Array.from(this.pendingRuns.values())) {
if (this.pendingRuns.get(pending.runId) !== pending) continue;
this.completeOpenProgressTools(pending.runId, true);
this.abortedRuns.set(pending.runId, {
sessionKey: pending.sessionKey,
abortedAt: failedAt,
});
void 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;
@@ -1598,7 +1643,10 @@ export class CcConnectBridgeAdapter {
pending.sessionKey === sessionKey || toCcConnectBridgeSessionKey(pending.sessionKey) === bridgeSessionKey
));
if (hasPendingForSession) return false;
return Array.from(this.abortedRuns.values()).some((aborted) => aborted.sessionKey === sessionKey);
return Array.from(this.abortedRuns.values()).some((aborted) => (
aborted.sessionKey === sessionKey
|| toCcConnectBridgeSessionKey(aborted.sessionKey) === bridgeSessionKey
));
}
private pruneAbortedRuns(): void {
@@ -0,0 +1,452 @@
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;
const MAX_FALLBACK_TURN_HINTS = 20;
const MAX_FALLBACK_DIRECTORIES = 12;
const MAX_FALLBACK_CANDIDATE_FILES = 64;
const MAX_FALLBACK_FILE_BYTES = 8 * 1024 * 1024;
const MAX_FALLBACK_TOTAL_BYTES = 32 * 1024 * 1024;
type CachedTranscriptFile = {
mtimeMs: number;
size: number;
jsonl: string;
turnMetadata?: {
sessionTimestamp?: number;
sessionWorkDir?: string;
userTurns: Array<{
content: string;
timestamp?: number;
}>;
};
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 transcriptTurnMetadata(file: CachedTranscriptFile): NonNullable<CachedTranscriptFile['turnMetadata']> {
if (file.turnMetadata) return file.turnMetadata;
let sessionTimestamp: number | undefined;
let sessionWorkDir: string | undefined;
const userTurns: NonNullable<CachedTranscriptFile['turnMetadata']>['userTurns'] = [];
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;
const timestamp = parseTimestamp(record.timestamp) ?? sessionTimestamp;
for (const item of payload.content) {
if (!isRecord(item) || item.type !== 'input_text' || typeof item.text !== 'string') continue;
userTurns.push({
content: item.text.trim(),
...(timestamp !== undefined ? { timestamp } : {}),
});
}
}
file.turnMetadata = { sessionTimestamp, sessionWorkDir, userTurns };
return file.turnMetadata;
}
function transcriptMatchesWorkDir(file: CachedTranscriptFile, expectedWorkDir?: string): boolean {
if (!expectedWorkDir) return true;
const { sessionWorkDir } = transcriptTurnMetadata(file);
return sessionWorkDir !== undefined && resolve(sessionWorkDir) === resolve(expectedWorkDir);
}
function transcriptMatchesTurn(
file: CachedTranscriptFile,
hints: CcConnectTranscriptTurnHint[],
expectedWorkDir?: string,
): boolean {
const { userTurns } = transcriptTurnMetadata(file);
if (userTurns.length === 0 || !transcriptMatchesWorkDir(file, expectedWorkDir)) return false;
return hints.some((hint) => userTurns.some((turn) => (
turn.timestamp !== undefined
&& Math.abs(turn.timestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS
&& turn.content === 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>();
const recentHints = [...hints]
.sort((left, right) => right.timestamp - left.timestamp)
.slice(0, MAX_FALLBACK_TURN_HINTS);
for (const hint of recentHints) {
for (const parts of transcriptCandidateDateParts(hint.timestamp)) {
const directory = join(sessionRoot, ...parts);
directories.set(directory, directory);
if (directories.size >= MAX_FALLBACK_DIRECTORIES) break;
}
if (directories.size >= MAX_FALLBACK_DIRECTORIES) break;
}
const matches: string[] = [];
let candidateFiles = 0;
let candidateBytes = 0;
for (const directory of directories.values()) {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
const transcriptEntries = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
.sort((left, right) => right.name.localeCompare(left.name));
for (const entry of transcriptEntries) {
if (candidateFiles >= MAX_FALLBACK_CANDIDATE_FILES) return matches;
candidateFiles += 1;
const path = join(directory, entry.name);
const metadata = await stat(path).catch(() => null);
if (!metadata || metadata.size > MAX_FALLBACK_FILE_BYTES) continue;
if (candidateBytes + metadata.size > MAX_FALLBACK_TOTAL_BYTES) return matches;
candidateBytes += metadata.size;
const file = await readTranscriptFile(path);
if (file?.jsonl && transcriptMatchesTurn(file, recentHints, 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',
});
const status = typeof payload.status === 'string' ? payload.status.toLowerCase() : '';
const isError = ['cancelled', 'error', 'failed'].includes(status);
if (status === 'completed' || isError) {
const output = isError ? `Web search ${status}` : 'Web search completed';
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 === '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 uniqueHomes = Array.from(new Set(homes.filter(Boolean)));
const idMatchedPaths = new Set<string>();
for (const codexHomeDir of uniqueHomes) {
if (hasValidAgentSessionId) {
const sessionPathCacheKey = `${resolve(codexHomeDir)}\0${agentSessionId}`;
let transcriptPath = transcriptPathBySessionId.get(sessionPathCacheKey);
if (!transcriptPath) {
transcriptPath = await findTranscriptFile(join(codexHomeDir, 'sessions'), agentSessionId);
}
if (transcriptPath) {
setBoundedCache(
transcriptPathBySessionId,
sessionPathCacheKey,
transcriptPath,
MAX_TRANSCRIPT_PATH_CACHE_ENTRIES,
);
const file = await readTranscriptFile(transcriptPath);
const matchesPublicTurn = turnHints.length === 0
|| (file !== null && transcriptMatchesTurn(file, turnHints, expectedWorkDir));
if (file?.jsonl && transcriptMatchesWorkDir(file, expectedWorkDir) && matchesPublicTurn) {
idMatchedPaths.add(transcriptPath);
}
}
}
}
let transcriptPaths = new Set<string>();
if (idMatchedPaths.size === 1) {
transcriptPaths = idMatchedPaths;
} else if (idMatchedPaths.size === 0) {
const fallbackPaths = new Set<string>();
for (const codexHomeDir of uniqueHomes) {
for (const path of await findTurnTranscriptFiles(codexHomeDir, turnHints, expectedWorkDir)) {
fallbackPaths.add(path);
}
}
if (fallbackPaths.size === 1) transcriptPaths = fallbackPaths;
}
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));
}
+54 -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,40 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
return { success: false, error: 'Session not found' };
}
const publicMessages = await this.sessionApi.loadHistory(session);
const channel = ccConnectSessionChannel(session.logicalKey);
let transcriptMessages: RawMessage[] = [];
if (channel) {
const owningProfile = this.currentProjectProfileByAgent.get(session.agentId)
?? this.currentProviderProfile;
const codexHomeDir = owningProfile?.codexHomeDir ?? getCcConnectCodexHomeDir();
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 }]
: [];
});
if (session.agentSessionId || transcriptTurnHints.length > 0) {
transcriptMessages = await loadCcConnectCodexTranscriptTools(
codexHomeDir,
session.agentSessionId ?? '',
transcriptTurnHints,
this.currentProjectWorkDirByAgent.get(session.agentId),
).catch((error) => {
logger.warn('[cc-connect] failed to load Codex tool history', {
sessionKey,
channel,
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 +1292,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 +1497,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 +2413,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 +2433,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 +2480,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'}`;
@@ -37,7 +37,7 @@ expectedUserBehavior:
- Real bundled cc-connect validates Management API cron lifecycle and doctor execution through Host API without external model credentials.
- Codex OAuth status/import/logout through the real Electron Host API is covered with isolated synthetic auth state. Status may inspect only redacted user-global auth metadata and account match state; runtime profile construction must not consume it, import must require an explicit Host API action, and no token value may cross the Host API response.
- Real Codex OAuth chat, direct cross-agent session fidelity, and cc-connect-owned token usage can be verified only when a developer explicitly supplies a Codex auth file through `CLAWX_REAL_CODEX_AUTH_JSON` so the import into isolated managed CODEX_HOME is intentional.
- Managed cc-connect Codex projects use the cc-connect-owned app-server backend over stdio so public progress-card payloads can drive the shared Chat execution graph without reading Codex transcripts.
- Managed cc-connect Codex projects use the cc-connect-owned app-server backend over stdio so public progress-card payloads drive the live Chat execution graph without using Codex transcripts as a real-time source. A separate bounded historical compatibility supplement may restore only workspace- and turn-matched Channel tool calls/results omitted by public history.
- Real OpenAI API-key chat and real Feishu/Lark channel lifecycle checks remain opt-in and are not default CI gates.
- Public provider profiles and committed test artifacts never contain OAuth token material.
- Chat preflight validates the provider profile bound to the target Agent project: an invalid binding blocks only that Agent, while a valid explicitly bound Agent remains usable when the default provider profile is invalid.
@@ -161,7 +161,7 @@ acceptance:
- The `bridge-rich-packets-local-diagnostics` row runs deterministic BridgePlatform adapter coverage for card/buttons, preview acknowledgements, first-frame and update-message replacements, text-preview deletion, structured-progress retention, and typing no-op stability. The separate `bridge-rich-progress-real-bundle` row runs the real bundled cc-connect v1.4.1 engine against a deterministic Codex app-server protocol boundary and proves public `preview_start`/`update_message`, normalized thinking/tool events, the GUI execution graph, final assistant delivery, and sanitized `artifacts/cc-connect/real-rich-progress-bridge.{json,png}` evidence. This does not claim a real OpenAI credential or an upstream-triggered `delete_message`.
- The local real-validation report includes `ccConnectCliSurface` JSON and a Markdown `cc-connect Upstream CLI Surface` section from the bundled binary, including command, cron, sessions, providers, Feishu/Lark, channel lifecycle evidence, and missing upstream primitives such as undocumented per-platform channel connect/disconnect.
- The local real-validation report includes a top-level `runtimeMatrixStatus`, a `replacementReadiness` JSON object, and a Markdown `Replacement Readiness` section derived from required replacement rows; skipped or not-run OpenAI API-key and Feishu/Lark rows must keep `runtimeMatrixStatus` `partial`, include the next command to run, and may set the overall report status to `fail` only when `--require-replacement-ready` is used as a hard gate.
- The local real-validation report includes a machine-readable `replacementContract` checklist and Markdown `Replacement Contract Checklist` section that maps the current cc-connect replacement decisions to evidence: Developer Mode gating remains unchanged, Doctor Fix non-parity is explicit, BridgePlatform-only runtime ownership forbids direct ClawX-to-Codex chat/session/history/tool execution, Codex OAuth/OpenAI API-key verification is tracked separately, provider/model matrix limitations are not implied parity, Feishu/Lark local projection is not live tenant delivery, cron lifecycle/scheduled exec/scheduled prompt BridgePlatform delivery is not live tenant-channel delivery parity, session/history rename/delete/title/cross-agent contracts and token usage contracts are tied to runtime-owned evidence, real validation remains opt-in, and all-platform packaging smoke remains a release-validation item.
- The local real-validation report includes a machine-readable `replacementContract` checklist and Markdown `Replacement Contract Checklist` section that maps the current cc-connect replacement decisions to evidence: Developer Mode gating remains unchanged, Doctor Fix non-parity is explicit, BridgePlatform-only runtime ownership forbids direct ClawX-to-Codex execution and Codex transcripts as a real-time or usage source, the bounded Channel tool-history supplement remains degraded and cannot claim replacement readiness, Codex OAuth/OpenAI API-key verification is tracked separately, provider/model matrix limitations are not implied parity, Feishu/Lark local projection is not live tenant delivery, cron lifecycle/scheduled exec/scheduled prompt BridgePlatform delivery is not live tenant-channel delivery parity, session/history rename/delete/title/cross-agent contracts and token usage contracts are tied to runtime-owned evidence, real validation remains opt-in, and all-platform packaging smoke remains a release-validation item.
- The local real-validation check table always includes a `replacement-readiness` row. It must be `PARTIAL` for informational partial reports and `FAIL` only when `--require-replacement-ready` is used as the hard gate, so `required-coverage` success for a selected subset cannot be mistaken for full replacement readiness.
- `--no-write` must preserve the last JSON/Markdown report artifacts while still returning the same hard-gate exit status and printing a sanitized console summary, allowing non-destructive replacement-readiness checks after a full local-real run.
- The local real-validation report includes `validationGaps` JSON and a Markdown `Validation Gaps` table that distinguishes required local replacement-gate gaps from follow-up full-parity evidence gaps. The required replacement gate includes public cc-connect token usage, real OpenAI API-key chat, real Feishu/Lark lifecycle, and real Feishu/Lark inbound marker delivery; follow-up full-parity evidence gaps include real scheduled prompt/channel cron delivery as a separate gap from scheduled exec delivery, non-approval standalone buttons, upstream-triggered delete-message delivery, and notarized macOS dmg/zip smoke. Native target release-smoke evidence is recorded from workflow run `29176833065`.
@@ -179,7 +179,7 @@ acceptance:
- Deterministic Electron E2E renders a cc-connect Bridge `buttons` approval in the Chat execution graph, clicks an offered action, verifies `chat.approval.respond` reaches the runtime provider, captures the exact public `card_action` packet, and verifies assistant delivery resumes. It also changes the Main Agent permission mode in GUI and proves the managed cc-connect project config changes to `mode = "suggest"`.
- Real bundled cc-connect Electron E2E sends `/lang` from the GUI Chat box, renders the public Bridge card select options as a capability-aware runtime choice, clicks `act:/lang ja`, verifies the public `card_action -> card` loop, confirms live `language: ja` through the public Management project API, preserves the runtime PID, closes the Chat run, and writes sanitized before/after screenshots plus structured evidence. This row must not claim that cc-connect v1.4.1 persists manual language changes to `config.toml`; upstream registers `SaveLanguage` only for auto-detection.
- The real comprehensive OAuth test verifies chat box delivery, direct cross-agent research chat/session summary, prompt cron paths, a real Codex file-writing tool turn with run-correlated cc-connect Bridge tool events, and an `apply_patch` generated-file card rendered in GUI chat through cc-connect and Codex using `auth_mode: chatgpt`. Token usage remains a separate upstream-blocked replacement row and is not inferred from Codex transcripts.
- Bridge-adapter production code contains no cc-connect private session-store or Codex-transcript parser. Deterministic adapter coverage is limited to the public Bridge protocol and real-time in-memory delivery; provider unit/E2E coverage proves named, cross-agent, channel, title, history, and delete parity exclusively through public Management session APIs plus ClawX-owned label metadata.
- Bridge-adapter production code contains no cc-connect private session-store or Codex-transcript parser. Deterministic adapter coverage is limited to the public Bridge protocol and real-time in-memory delivery; provider unit/E2E coverage proves named, cross-agent, channel, title, ordinary history, and delete parity through public Management session APIs plus ClawX-owned label metadata, and separately proves the bounded historical Channel tool supplement rejects stale cross-workspace evidence and never becomes a real-time or usage source.
- `tests/e2e/cc-connect-real-openai-api-key.spec.ts` includes a default local OpenAI-compatible API-key smoke and also validates real OpenAI API-key chat, secret redaction, and managed runtime process cleanup when explicitly enabled.
- `tests/e2e/cc-connect-real-feishu-channel.spec.ts` remains skipped by default and validates real Feishu/Lark config projection, runtime status, lifecycle reload, delete cleanup, domain alias mapping, managed runtime process cleanup, and canonical configuration ownership: an existing OpenClaw compatibility file is imported read-only, `runtime-config.json` retains non-secret metadata, channel secrets exist only in the encrypted vault without plaintext bytes, cc-connect-mode import/delete never changes the compatibility source, and sanitized `artifacts/cc-connect/real-feishu-lifecycle.json` records only boolean lifecycle/ownership evidence. When `CLAWX_REAL_FEISHU_INBOUND_E2E=1` is enabled, the same spec writes a sanitized marker handoff artifact then verifies the manual inbound tenant-message marker is stored by cc-connect; it still does not prove undocumented per-platform connect/disconnect primitives.
- Packaged smoke supports macOS, Windows, and Linux unpacked layouts; `--real-oauth=1` validates packaged GUI chat through the platform-specific managed Codex OAuth launcher while asserting public provider-profile output excludes token material, and the sanitized evidence `checks` list must include `real-oauth-chat-through-managed-launcher` only when that real turn completed.
@@ -37,7 +37,7 @@ expectedUserBehavior:
- Existing OpenClaw workspaces are reused by reference; new Agents receive managed ~/.clawx workspaces.
- Different Agents can bind different OAuth or API-key accounts without credential, session, workspace, or usage crossover.
- Each Agent can independently select cc-connect `full-auto` or approval-required `suggest` mode; the latter has real OAuth GUI approval evidence.
- Sessions and history use cc-connect public APIs; tools and approval responses use public Bridge events/`card_action`; per-run cancellation and usage remain explicit replacement blockers until cc-connect exposes public APIs/events.
- Sessions and ordinary history use cc-connect public APIs; tools and approval responses use public Bridge events/`card_action`. When a Channel session's public history omits tool packets, a bounded historical compatibility supplement may restore only workspace- and turn-matched tool calls/results from the owning Codex transcript. Per-run cancellation and usage remain explicit replacement blockers until cc-connect exposes public APIs/events.
- Feishu/Lark messages reach the bound Agent through cc-connect and replies return through cc-connect.
- GUI and Channel /cron manage the same native cron-expression jobs.
- Skills are shared across runtimes and a real skill can be invoked in cc-connect chat.
@@ -71,7 +71,7 @@ acceptance:
- The dependency and bundled binary are pinned to the same verified stable cc-connect version.
- electron-builder `afterPack` verifies copied cc-connect and Codex resources for the target architecture; final macOS x64/arm64, Windows x64, and Linux x64/arm64 unpacked resources pass the packaged-resource verifier, including signed Mach-O section and code-signature validation where whole-file SHA changes.
- Release publishing depends on native packaged smoke for macOS x64/arm64, Windows x64, and Linux x64/arm64. Each smoke launches the packaged Electron app, starts cc-connect through Host API, checks managed runtime state plus Cron and Doctor, rolls back to OpenClaw, and proves PID/ports/runtime-directory processes are cleaned.
- No cc-connect runtime code launches Codex for chat or uses Codex transcript polling as a production event/history/usage path.
- No cc-connect runtime code launches Codex for chat or uses Codex transcripts as a real-time event source, an ordinary message-history authority, or a usage source. A bounded historical Channel compatibility supplement may read matching Codex JSONL only after public cc-connect history loads and omits tool packets; it must not create user/assistant turns, cross Agent workspaces, or claim replacement readiness.
- No cc-connect runtime code writes OpenClaw config or cc-connect private session stores.
- Canonical Agent/channel saves in cc-connect mode update only the ClawX runtime config and encrypted vault; OpenClaw start/restart explicitly rebuilds the compatibility projection before Gateway startup, and a newer projection never overrides existing canonical state by mtime.
- The shared-root writer lock is acquired before layout initialization or migration and fails closed on acquisition errors. A real two-Electron E2E proves the duplicate exits before runtime/scheduler construction, cannot replace the live owner, and a successor acquires the lock after clean shutdown.
@@ -83,6 +83,7 @@ acceptance:
- cc-connect project work_dir always resolves from the Agent workspace registry and never from process.cwd or the source checkout.
- Native cron-expression jobs are shared between GUI and Channel; a real bundled cc-connect Bridge channel proves `/cron` add/list/disable/enable/delete and GUI/Channel bidirectional visibility for one Feishu target without runtime restart. Manual run, at, and every remain capability-aware and non-mutating because cc-connect v1.4.1 does not expose equivalent Host API schedule operations.
- Pinned cc-connect Bridge capabilities match its public protocol; ClawX opts into progress-card payloads and maps only events emitted by cc-connect, with an explicitly marked terminal inference when a final reply closes a tool lacking a result entry.
- The degraded Channel tool-history supplement is Main-owned, best-effort, bounded by recent public user-turn hints, exact Agent workspace, transcript date, cache limits, and output truncation. Missing or ambiguous evidence leaves public history unchanged, and the exception must be removed when pinned cc-connect exposes durable public tool history.
- Token usage maps only a published, versioned runtime payload with project, session/turn, provider/model, counters, and reconnect/replay or durable-history semantics; absent cc-connect counters produce explicit `missing` turn records and never footer- or transcript-derived estimates.
- The unmerged cc-connect usage-observer proposal in upstream PR #1428 is tracked as design evidence, not treated as a supported API, because it lacks release provenance, project/provider/model attribution, and durable replay semantics.
- Real OAuth, real external API-key, Feishu inbound/reply, native Channel Cron, Doctor, workspace, and packaged evidence paths are recorded in a sanitized report.
+1
View File
@@ -1991,6 +1991,7 @@ function buildCoverage(report) {
'Host API sessions.rename updates cc-connect-owned session labels and titles',
'Host API sessions.delete removes cc-connect-owned session state and history',
'channel session display names preserve cc-connect chat/user metadata',
'bounded historical Channel tool supplement accepts only workspace- and turn-matched Codex evidence omitted by public history',
],
evidence: [runtimeProviderUnit.command, bridgeAdapterUnit.command, mockBridgeE2e.command].filter(Boolean).join(' && ')
|| runtimeProviderUnit.reason
@@ -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);
}
+17 -2
View File
@@ -12,7 +12,15 @@ test.describe('ClawX canonical data layout migration', () => {
const legacySettingsPath = join(legacyDir, 'settings.json');
const legacyProvidersPath = join(legacyDir, 'clawx-providers.json');
const legacyRendererStatePath = join(legacyDir, 'Local Storage', 'leveldb', 'legacy-state.log');
const legacyBrowserStatePath = join(legacyDir, 'Partitions', 'clawx-web-browser', 'Cookies');
// Avoid Chromium opening or replacing its live Cookies database while this test
// verifies recursive partition migration, especially on Windows.
const legacyBrowserStateFile = 'legacy-browser-state.txt';
const legacyBrowserStatePath = join(
legacyDir,
'Partitions',
'clawx-web-browser',
legacyBrowserStateFile,
);
await mkdir(join(legacyDir, 'Local Storage', 'leveldb'), { recursive: true });
await mkdir(join(legacyDir, 'Partitions', 'clawx-web-browser'), { recursive: true });
await writeFile(legacySettingsPath, JSON.stringify({
@@ -62,7 +70,14 @@ test.describe('ClawX canonical data layout migration', () => {
readFile(join(dataRoot, 'state', 'data-version.json'), 'utf8'),
readFile(join(dataRoot, 'state', 'migration-journal.jsonl'), 'utf8'),
readFile(join(dataRoot, 'system', 'electron', 'Local Storage', 'leveldb', 'legacy-state.log'), 'utf8'),
readFile(join(dataRoot, 'system', 'electron', 'Partitions', 'clawx-web-browser', 'Cookies'), 'utf8'),
readFile(join(
dataRoot,
'system',
'electron',
'Partitions',
'clawx-web-browser',
legacyBrowserStateFile,
), 'utf8'),
]);
expect(JSON.parse(settings)).toMatchObject({ runtimeKind: 'cc-connect', gatewayAutoStart: false });
expect(JSON.parse(providers)).toMatchObject({
+176 -4
View File
@@ -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');
});
@@ -115,8 +118,9 @@ describe('cc-connect BridgePlatform adapter', () => {
});
});
let adapter: CcConnectBridgeAdapter | undefined;
try {
const adapter = new CcConnectBridgeAdapter({
adapter = new CcConnectBridgeAdapter({
port,
token: 'token',
project: 'clawx-main',
@@ -129,6 +133,12 @@ describe('cc-connect BridgePlatform adapter', () => {
message: 'ping',
idempotencyKey: 'idem-1',
})).resolves.toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) }));
const channelRun = await adapter.send({
sessionKey: 'agent:coder:feishu:chat-1:user-1',
message: 'channel ping',
idempotencyKey: 'idem-2',
});
expect(channelRun).toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) }));
await vi.waitFor(() => {
expect(emitted).toEqual(expect.arrayContaining([
@@ -146,6 +156,12 @@ describe('cc-connect BridgePlatform adapter', () => {
sessionKey: 'agent:research:desk',
status: 'completed',
})],
['chat:runtime-event', expect.objectContaining({
type: 'run.ended',
runId: channelRun.runId,
sessionKey: 'agent:coder:feishu:chat-1:user-1',
status: 'completed',
})],
]));
});
@@ -155,17 +171,31 @@ 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([
const sessions = await adapter.listSessions();
expect(sessions).toHaveLength(2);
expect(sessions).toEqual(expect.arrayContaining([
expect.objectContaining({
key: 'agent:research:desk',
agentId: 'research',
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 {
await adapter?.close();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
@@ -913,6 +943,9 @@ describe('cc-connect BridgePlatform adapter', () => {
});
const sockets: WebSocket[] = [];
const received: Record<string, unknown>[] = [];
const emitted: Array<[string, unknown]> = [];
const progressPrefix = '__cc_connect_progress_card_v1__:';
let droppedMessage: Record<string, unknown> | undefined;
server.on('connection', (socket) => {
sockets.push(socket);
@@ -921,7 +954,31 @@ describe('cc-connect BridgePlatform adapter', () => {
received.push(parsed);
if (parsed.type === 'register') {
socket.send(JSON.stringify({ type: 'register_ack', ok: true }));
if (sockets.indexOf(socket) === 1 && droppedMessage) {
setTimeout(() => {
socket.send(JSON.stringify({
type: 'reply',
session_key: droppedMessage?.session_key,
reply_ctx: droppedMessage?.reply_ctx,
content: 'original answer after reconnect',
}));
}, 10);
}
return;
}
if (parsed.type !== 'message' || sockets.indexOf(socket) !== 0) return;
droppedMessage = parsed;
socket.send(JSON.stringify({
type: 'preview_start',
ref_id: 'dropped-progress',
session_key: parsed.session_key,
reply_ctx: parsed.reply_ctx,
content: `${progressPrefix}${JSON.stringify({
version: 2,
state: 'running',
items: [{ kind: 'tool_use', tool: 'Bash', text: 'sleep 30' }],
})}`,
}));
});
});
@@ -929,7 +986,7 @@ describe('cc-connect BridgePlatform adapter', () => {
port,
token: 'token',
project: 'clawx-main',
emit: vi.fn(),
emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never,
heartbeatIntervalMs: 20,
reconnectDelayMs: 20,
});
@@ -942,11 +999,50 @@ describe('cc-connect BridgePlatform adapter', () => {
]));
});
const droppedRun = await adapter.send({
sessionKey: 'agent:coder:feishu:chat-1:user-1',
message: 'drop this run',
idempotencyKey: 'idem-before-drop',
});
await vi.waitFor(() => {
expect(emitted).toEqual(expect.arrayContaining([
['chat:runtime-event', expect.objectContaining({
type: 'tool.started',
runId: droppedRun.runId,
toolCallId: `${droppedRun.runId}:progress:0`,
})],
]));
});
sockets[0]?.close();
await vi.waitFor(() => {
expect(sockets).toHaveLength(2);
expect(adapter.isConnected()).toBe(true);
expect(emitted).toEqual(expect.arrayContaining([
['chat:runtime-event', expect.objectContaining({
type: 'tool.completed',
runId: droppedRun.runId,
toolCallId: `${droppedRun.runId}:progress:0`,
meta: expect.objectContaining({
status: 'completed',
success: true,
inferredFromRunCompletion: true,
}),
})],
['chat:runtime-event', expect.objectContaining({
type: 'run.ended',
runId: droppedRun.runId,
sessionKey: 'agent:coder:feishu:chat-1:user-1',
status: 'completed',
})],
]));
});
expect(emitted).not.toEqual(expect.arrayContaining([
['chat:runtime-event', expect.objectContaining({
type: 'run.ended',
runId: droppedRun.runId,
status: 'error',
})],
]));
await adapter.close();
const connectionCountAfterClose = sockets.length;
@@ -959,6 +1055,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:coder:feishu:chat-1:user-1',
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:coder:feishu:chat-1:user-1',
status: 'error',
error: 'cc-connect runtime stopped before the run completed',
})],
]));
const second = await adapter.send({
sessionKey: 'agent:coder:feishu:chat-1:user-1',
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:coder:feishu:chat-1:user-1',
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) => {
+464 -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,457 @@ 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 ownerCodexHome = join(tempDir, 'runtimes', 'cc-connect', 'codex-home');
const otherAgentCodexHome = join(tempDir, 'credentials', 'other-agent', 'codex-home');
const transcriptDir = join(
ownerCodexHome,
'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',
cwd: '/tmp/workspace',
},
}),
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 otherTranscriptDir = join(otherAgentCodexHome, 'sessions', '2026', '07', '27');
await mkdir(otherTranscriptDir, { recursive: true });
await writeFile(join(otherTranscriptDir, `rollout-${staleAgentSessionId}.jsonl`), [
JSON.stringify({
timestamp: '2026-07-27T06:20:10.931Z',
type: 'session_meta',
payload: {
id: staleAgentSessionId,
timestamp: '2026-07-27T06:20:10.931Z',
cwd: '/tmp/workspace',
},
}),
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":"must-not-cross-agent"}',
call_id: 'call-other-agent',
},
}),
].join('\n'), 'utf8');
const { loadCcConnectCodexTranscriptTools } = await import('@electron/runtime/cc-connect-codex-transcript');
await expect(loadCcConnectCodexTranscriptTools([
join(tempDir, 'credentials', 'current-provider', 'codex-home'),
ownerCodexHome,
], 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 guiSession = {
projectName: 'clawx-main',
agentId: 'main',
id: 's1',
sessionKey: 'main',
logicalKey: 'agent:main:main',
active: true,
createdAt: channelSession.createdAt,
updatedAt: channelSession.updatedAt,
agentSessionId,
};
const sessionApi = createSessionApiMock({
sessions: () => [channelSession, guiSession],
histories: {
[channelSession.logicalKey]: [{
id: 'channel-user',
role: 'user',
content: '你在哪',
timestamp: channelSession.createdAt,
}, {
id: 'channel-assistant',
role: 'assistant',
content: '我在本机 workspace',
timestamp: channelSession.updatedAt,
}],
[guiSession.logicalKey]: [{
id: 'gui-user',
role: 'user',
content: '你在哪',
timestamp: guiSession.createdAt,
}, {
id: 'gui-assistant',
role: 'assistant',
content: 'GUI public history remains authoritative',
timestamp: guiSession.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,
});
Reflect.set(provider, 'currentProjectProfileByAgent', new Map([
['project-manager', createProviderProfile({ codexHomeDir: ownerCodexHome })],
]));
Reflect.set(provider, 'currentProjectProfiles', [
createProviderProfile({ codexHomeDir: ownerCodexHome }),
createProviderProfile({ codexHomeDir: otherAgentCodexHome }),
]);
Reflect.set(provider, 'currentProviderProfile', createProviderProfile({
codexHomeDir: otherAgentCodexHome,
}));
Reflect.set(provider, 'currentProjectWorkDirByAgent', new Map([
['project-manager', '/tmp/workspace'],
]));
await expect(provider.loadHistory({
sessionKey: guiSession.logicalKey,
limit: 20,
})).resolves.toEqual({
success: true,
messages: [
expect.objectContaining({ id: 'gui-user', role: 'user' }),
expect.objectContaining({ id: 'gui-assistant', role: 'assistant' }),
],
});
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' }),
],
});
expect(JSON.stringify(await provider.loadHistory({
sessionKey: channelSession.logicalKey,
limit: 20,
}))).not.toContain('must-not-cross-agent');
});
it('rejects stale cross-workspace session IDs and matches later Web Search and MCP turns', 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:00:10.000Z',
type: 'session_meta',
payload: {
id,
timestamp: '2026-07-27T06:00: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-2026-07-27T06-00-10-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',
status: 'completed',
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-session.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'),
'main-session',
[{ content: '查一下状态', timestamp }],
'/workspace/coder',
);
expect(messages).toEqual([
expect.objectContaining({
role: 'assistant',
content: [expect.objectContaining({
id: 'web-search-1',
name: 'Web Search',
})],
}),
expect.objectContaining({
role: 'toolresult',
toolCallId: 'web-search-1',
toolName: 'Web Search',
content: 'Web search completed',
}),
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('rejects ambiguous turn-matched transcript fallback candidates', async () => {
const transcriptDir = join(
tempDir,
'runtimes',
'cc-connect',
'codex-home',
'sessions',
'2026',
'07',
'27',
);
await mkdir(transcriptDir, { recursive: true });
const turnTimestamp = Date.parse('2026-07-27T06:30:10.100Z');
const transcript = (id: string, callId: string) => [
JSON.stringify({
timestamp: '2026-07-27T06:00:10.000Z',
type: 'session_meta',
payload: {
id,
timestamp: '2026-07-27T06:00:10.000Z',
cwd: '/workspace/coder',
},
}),
JSON.stringify({
timestamp: '2026-07-27T06:30:10.100Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'yes' }],
},
}),
JSON.stringify({
timestamp: '2026-07-27T06:30:11.000Z',
type: 'response_item',
payload: {
type: 'function_call',
call_id: callId,
name: 'exec_command',
arguments: `{"cmd":"${id}"}`,
},
}),
].join('\n');
await Promise.all([
writeFile(
join(transcriptDir, 'rollout-2026-07-27T06-00-10-first-session.jsonl'),
transcript('first-session', 'first-call'),
'utf8',
),
writeFile(
join(transcriptDir, 'rollout-2026-07-27T06-05-10-second-session.jsonl'),
transcript('second-session', 'second-call'),
'utf8',
),
]);
const { loadCcConnectCodexTranscriptTools } = await import('@electron/runtime/cc-connect-codex-transcript');
await expect(loadCcConnectCodexTranscriptTools(
join(tempDir, 'runtimes', 'cc-connect', 'codex-home'),
'',
[{ content: 'yes', timestamp: turnTimestamp }],
'/workspace/coder',
)).resolves.toEqual([]);
const idMatchedMessages = await loadCcConnectCodexTranscriptTools(
join(tempDir, 'runtimes', 'cc-connect', 'codex-home'),
'first-session',
[{ content: 'yes', timestamp: turnTimestamp }],
'/workspace/coder',
);
expect(JSON.stringify(idMatchedMessages)).toContain('first-call');
expect(JSON.stringify(idMatchedMessages)).not.toContain('second-call');
});
it('bounds fallback transcript scanning before reading older candidates', async () => {
const transcriptDir = join(
tempDir,
'runtimes',
'cc-connect',
'codex-home',
'sessions',
'2026',
'07',
'27',
);
await mkdir(transcriptDir, { recursive: true });
const turnTimestamp = Date.parse('2026-07-27T06:30:10.100Z');
const transcript = (content: string, callId: string) => [
JSON.stringify({
timestamp: '2026-07-27T06:00:10.000Z',
type: 'session_meta',
payload: {
timestamp: '2026-07-27T06:00:10.000Z',
cwd: '/workspace/coder',
},
}),
JSON.stringify({
timestamp: '2026-07-27T06:30:10.100Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: content }],
},
}),
JSON.stringify({
timestamp: '2026-07-27T06:30:11.000Z',
type: 'response_item',
payload: {
type: 'function_call',
call_id: callId,
name: 'exec_command',
arguments: '{"cmd":"bounded"}',
},
}),
].join('\n');
await Promise.all([
writeFile(
join(transcriptDir, 'rollout-2026-07-27T00-00-00-target.jsonl'),
transcript('bounded fallback target', 'target-call'),
'utf8',
),
...Array.from({ length: 64 }, (_, index) => writeFile(
join(transcriptDir, `rollout-2026-07-27T12-${String(index).padStart(2, '0')}-00-decoy.jsonl`),
transcript('different prompt', `decoy-${index}`),
'utf8',
)),
]);
const { loadCcConnectCodexTranscriptTools } = await import('@electron/runtime/cc-connect-codex-transcript');
await expect(loadCcConnectCodexTranscriptTools(
join(tempDir, 'runtimes', 'cc-connect', 'codex-home'),
'',
[{ content: 'bounded fallback target', timestamp: turnTimestamp }],
'/workspace/coder',
)).resolves.toEqual([]);
});
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 });