mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix(cc-connect): restore channel tool history
This commit is contained in:
+1
-1
@@ -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 を再生成します。
|
||||
|
||||
|
||||
@@ -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
@@ -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/<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 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/<id>`;已有 OpenClaw workspace 可以按原路径复用,ClawX 不移动也不接管它。Provider/model、原生 cron 任务和已启用 skills 会同步到托管的 cc-connect/Codex runtime。
|
||||
|
||||
Agent 和频道设置以 `~/.clawx` 为唯一 canonical 数据源。cc-connect 处于启用状态时,保存设置不会改写 `~/.openclaw/openclaw.json`;切回 OpenClaw 后,Gateway 启动前会重新生成这份兼容投影。
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ const BRIDGE_HEARTBEAT_INTERVAL_MS = 25_000;
|
||||
const BRIDGE_RECONNECT_DELAY_MS = 3_000;
|
||||
const CLAWX_PROJECT_PREFIX = 'clawx-';
|
||||
export const CLAWX_BRIDGE_ADMIN_USER_ID = 'clawx-desktop';
|
||||
export const CC_CONNECT_SESSION_INSTANCE_MARKER = '$session';
|
||||
const ABORTED_RUN_TTL_MS = 10 * 60_000;
|
||||
const PROGRESS_CARD_PAYLOAD_PREFIX = '__cc_connect_progress_card_v1__:';
|
||||
const TOOL_START_TYPES = new Set(['tool_start', 'tool.started', 'tool_call', 'tool_call_start', 'tool_use', 'tool_use_start']);
|
||||
@@ -404,10 +405,30 @@ export function toCcConnectBridgeSessionKey(sessionKey: string): string {
|
||||
if (sessionKey.startsWith('clawx:')) return sessionKey;
|
||||
if (!sessionKey.startsWith('agent:')) return sessionKey;
|
||||
const [, scope = 'main', ...userParts] = sessionKey.split(':');
|
||||
const bridgeParts = userParts.at(-2) === CC_CONNECT_SESSION_INSTANCE_MARKER
|
||||
? userParts.slice(0, -2)
|
||||
: userParts;
|
||||
if (CC_CONNECT_CHANNEL_SESSION_PREFIXES.has(bridgeParts[0] || '')) {
|
||||
return bridgeParts.join(':');
|
||||
}
|
||||
const user = userParts.join(':') || 'main';
|
||||
return `clawx:${scope || 'main'}:${user || 'main'}`;
|
||||
}
|
||||
|
||||
const CC_CONNECT_CHANNEL_SESSION_PREFIXES = new Set([
|
||||
'dingtalk',
|
||||
'discord',
|
||||
'feishu',
|
||||
'lark',
|
||||
'line',
|
||||
'qq',
|
||||
'qqbot',
|
||||
'slack',
|
||||
'telegram',
|
||||
'wecom',
|
||||
'weixin',
|
||||
]);
|
||||
|
||||
function normalizeClawXAgentId(value: string | undefined): string {
|
||||
const normalized = (value || 'main')
|
||||
.trim()
|
||||
@@ -423,6 +444,10 @@ export function ccConnectProjectNameForAgent(agentId: string): string {
|
||||
}
|
||||
|
||||
export function ccConnectProjectNameForSessionKey(sessionKey: string): string {
|
||||
if (sessionKey.startsWith('agent:')) {
|
||||
const [, agentId] = sessionKey.split(':');
|
||||
return ccConnectProjectNameForAgent(agentId);
|
||||
}
|
||||
const bridgeKey = toCcConnectBridgeSessionKey(sessionKey);
|
||||
if (!bridgeKey.startsWith('clawx:')) return ccConnectProjectNameForAgent('main');
|
||||
const [, agentId] = bridgeKey.split(':');
|
||||
@@ -502,6 +527,7 @@ export class CcConnectBridgeAdapter {
|
||||
this.shouldReconnect = false;
|
||||
this.clearHeartbeat();
|
||||
this.clearReconnectTimer();
|
||||
await this.failPendingRuns('cc-connect runtime stopped before the run completed');
|
||||
const sockets = new Set([
|
||||
...this.connectingSockets,
|
||||
...(this.socket ? [this.socket] : []),
|
||||
@@ -1561,6 +1587,23 @@ export class CcConnectBridgeAdapter {
|
||||
this.terminalCardRuns.delete(pending.runId);
|
||||
}
|
||||
|
||||
private async failPendingRuns(error: string): Promise<void> {
|
||||
const failedAt = Date.now();
|
||||
for (const pending of Array.from(this.pendingRuns.values())) {
|
||||
if (this.pendingRuns.get(pending.runId) !== pending) continue;
|
||||
this.abortedRuns.set(pending.runId, {
|
||||
sessionKey: pending.sessionKey,
|
||||
abortedAt: failedAt,
|
||||
});
|
||||
await this.finishPendingRun(pending, {
|
||||
text: error,
|
||||
isError: true,
|
||||
appendMessage: false,
|
||||
timestamp: failedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private completeOpenProgressTools(runId: string, isError: boolean): void {
|
||||
for (const state of this.progressByHandle.values()) {
|
||||
if (state.runId !== runId) continue;
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { RawMessage } from '@shared/chat/types';
|
||||
|
||||
const MAX_TRANSCRIPT_SEARCH_DEPTH = 6;
|
||||
const MAX_TOOL_OUTPUT_CHARS = 16_000;
|
||||
const TRANSCRIPT_TURN_MATCH_WINDOW_MS = 2 * 60_000;
|
||||
const MAX_TRANSCRIPT_FILE_CACHE_ENTRIES = 512;
|
||||
const MAX_TRANSCRIPT_PATH_CACHE_ENTRIES = 2_048;
|
||||
type CachedTranscriptFile = {
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
jsonl: string;
|
||||
turnMetadata?: {
|
||||
sessionTimestamp?: number;
|
||||
sessionWorkDir?: string;
|
||||
userMessages: string[];
|
||||
};
|
||||
toolMessages?: RawMessage[];
|
||||
};
|
||||
|
||||
const transcriptFileCache = new Map<string, CachedTranscriptFile>();
|
||||
const transcriptPathBySessionId = new Map<string, string>();
|
||||
|
||||
function setBoundedCache<K, V>(cache: Map<K, V>, key: K, value: V, maxEntries: number): void {
|
||||
cache.delete(key);
|
||||
cache.set(key, value);
|
||||
while (cache.size > maxEntries) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (oldestKey === undefined) break;
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export type CcConnectTranscriptTurnHint = {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseTimestamp(value: unknown): number | undefined {
|
||||
if (typeof value !== 'string' || !value.trim()) return undefined;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function parseToolArguments(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value ?? {};
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function displayToolName(name: string): string {
|
||||
switch (name) {
|
||||
case 'exec_command':
|
||||
return 'Bash';
|
||||
case 'apply_patch':
|
||||
return 'Patch';
|
||||
case 'web_search':
|
||||
case 'web_search_call':
|
||||
return 'Web Search';
|
||||
default:
|
||||
return name || 'tool';
|
||||
}
|
||||
}
|
||||
|
||||
function toolOutputIsError(output: string): boolean {
|
||||
const exitCode = output.match(/\bProcess exited with code (\d+)\b/i)?.[1];
|
||||
return exitCode !== undefined && Number(exitCode) !== 0;
|
||||
}
|
||||
|
||||
function toolOutputText(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value ?? '');
|
||||
}
|
||||
|
||||
function truncateToolOutput(output: string): string {
|
||||
return output.length > MAX_TOOL_OUTPUT_CHARS
|
||||
? `${output.slice(0, MAX_TOOL_OUTPUT_CHARS)}\n… output truncated by ClawX`
|
||||
: output;
|
||||
}
|
||||
|
||||
async function readTranscriptFile(path: string): Promise<CachedTranscriptFile | null> {
|
||||
const metadata = await stat(path).catch(() => null);
|
||||
if (!metadata) return null;
|
||||
const cached = transcriptFileCache.get(path);
|
||||
if (cached && cached.mtimeMs === metadata.mtimeMs && cached.size === metadata.size) {
|
||||
setBoundedCache(transcriptFileCache, path, cached, MAX_TRANSCRIPT_FILE_CACHE_ENTRIES);
|
||||
return cached;
|
||||
}
|
||||
const jsonl = await readFile(path, 'utf8').catch(() => '');
|
||||
const entry = {
|
||||
mtimeMs: metadata.mtimeMs,
|
||||
size: metadata.size,
|
||||
jsonl,
|
||||
};
|
||||
setBoundedCache(transcriptFileCache, path, entry, MAX_TRANSCRIPT_FILE_CACHE_ENTRIES);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function findTranscriptFile(
|
||||
directory: string,
|
||||
agentSessionId: string,
|
||||
depth = 0,
|
||||
): Promise<string | undefined> {
|
||||
if (depth > MAX_TRANSCRIPT_SEARCH_DEPTH) return undefined;
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (entry.name.endsWith('.jsonl') && entry.name.includes(agentSessionId)) {
|
||||
return join(directory, entry.name);
|
||||
}
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const match = await findTranscriptFile(join(directory, entry.name), agentSessionId, depth + 1);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function transcriptDateParts(timestamp: number, utc: boolean): [string, string, string] {
|
||||
const date = new Date(timestamp);
|
||||
const year = utc ? date.getUTCFullYear() : date.getFullYear();
|
||||
const month = (utc ? date.getUTCMonth() : date.getMonth()) + 1;
|
||||
const day = utc ? date.getUTCDate() : date.getDate();
|
||||
return [String(year), String(month).padStart(2, '0'), String(day).padStart(2, '0')];
|
||||
}
|
||||
|
||||
function transcriptCandidateDateParts(timestamp: number): Array<[string, string, string]> {
|
||||
const candidates = [
|
||||
transcriptDateParts(timestamp - 24 * 60 * 60_000, false),
|
||||
transcriptDateParts(timestamp, false),
|
||||
transcriptDateParts(timestamp + 24 * 60 * 60_000, false),
|
||||
transcriptDateParts(timestamp, true),
|
||||
];
|
||||
return Array.from(new Map(candidates.map((parts) => [parts.join('/'), parts])).values());
|
||||
}
|
||||
|
||||
function transcriptFilenameTimestamps(fileName: string): number[] {
|
||||
const match = fileName.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/);
|
||||
if (!match) return [];
|
||||
const [, year, month, day, hour, minute, second] = match.map(Number);
|
||||
return [
|
||||
new Date(year, month - 1, day, hour, minute, second).getTime(),
|
||||
Date.UTC(year, month - 1, day, hour, minute, second),
|
||||
];
|
||||
}
|
||||
|
||||
function transcriptFilenameMayMatch(fileName: string, hints: CcConnectTranscriptTurnHint[]): boolean {
|
||||
const timestamps = transcriptFilenameTimestamps(fileName);
|
||||
return timestamps.length === 0 || hints.some((hint) => (
|
||||
timestamps.some((timestamp) => Math.abs(timestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS)
|
||||
));
|
||||
}
|
||||
|
||||
function transcriptTurnMetadata(file: CachedTranscriptFile): NonNullable<CachedTranscriptFile['turnMetadata']> {
|
||||
if (file.turnMetadata) return file.turnMetadata;
|
||||
let sessionTimestamp: number | undefined;
|
||||
let sessionWorkDir: string | undefined;
|
||||
const userMessages: string[] = [];
|
||||
for (const line of file.jsonl.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
let record: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (!isRecord(parsed)) continue;
|
||||
record = parsed;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (record.type === 'session_meta' && isRecord(record.payload)) {
|
||||
sessionTimestamp = parseTimestamp(record.payload.timestamp) ?? parseTimestamp(record.timestamp);
|
||||
sessionWorkDir = typeof record.payload.cwd === 'string' ? record.payload.cwd : undefined;
|
||||
continue;
|
||||
}
|
||||
if (record.type !== 'response_item' || !isRecord(record.payload)) continue;
|
||||
const payload = record.payload;
|
||||
if (payload.type !== 'message' || payload.role !== 'user' || !Array.isArray(payload.content)) continue;
|
||||
for (const item of payload.content) {
|
||||
if (!isRecord(item) || item.type !== 'input_text' || typeof item.text !== 'string') continue;
|
||||
userMessages.push(item.text.trim());
|
||||
}
|
||||
}
|
||||
file.turnMetadata = { sessionTimestamp, sessionWorkDir, userMessages };
|
||||
return file.turnMetadata;
|
||||
}
|
||||
|
||||
function transcriptMatchesTurn(
|
||||
file: CachedTranscriptFile,
|
||||
hints: CcConnectTranscriptTurnHint[],
|
||||
expectedWorkDir?: string,
|
||||
): boolean {
|
||||
const { sessionTimestamp, sessionWorkDir, userMessages } = transcriptTurnMetadata(file);
|
||||
if (sessionTimestamp === undefined || userMessages.length === 0) return false;
|
||||
if (expectedWorkDir && (!sessionWorkDir || resolve(sessionWorkDir) !== resolve(expectedWorkDir))) {
|
||||
return false;
|
||||
}
|
||||
return hints.some((hint) => (
|
||||
Math.abs(sessionTimestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS
|
||||
&& userMessages.includes(hint.content.trim())
|
||||
));
|
||||
}
|
||||
|
||||
async function findTurnTranscriptFiles(
|
||||
codexHomeDir: string,
|
||||
hints: CcConnectTranscriptTurnHint[],
|
||||
expectedWorkDir?: string,
|
||||
): Promise<string[]> {
|
||||
const sessionRoot = join(codexHomeDir, 'sessions');
|
||||
const directories = new Map<string, string>();
|
||||
for (const hint of hints) {
|
||||
for (const parts of transcriptCandidateDateParts(hint.timestamp)) {
|
||||
const directory = join(sessionRoot, ...parts);
|
||||
directories.set(directory, directory);
|
||||
}
|
||||
}
|
||||
const matches: string[] = [];
|
||||
for (const directory of directories.values()) {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||
if (!transcriptFilenameMayMatch(entry.name, hints)) continue;
|
||||
const path = join(directory, entry.name);
|
||||
const file = await readTranscriptFile(path);
|
||||
if (file?.jsonl && transcriptMatchesTurn(file, hints, expectedWorkDir)) matches.push(path);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function parseCcConnectCodexTranscriptTools(jsonl: string): RawMessage[] {
|
||||
const messages: RawMessage[] = [];
|
||||
const toolNamesByCallId = new Map<string, string>();
|
||||
|
||||
for (const line of jsonl.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
let record: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (!isRecord(parsed)) continue;
|
||||
record = parsed;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (record.type !== 'response_item' || !isRecord(record.payload)) continue;
|
||||
const payload = record.payload;
|
||||
const payloadType = typeof payload.type === 'string' ? payload.type : '';
|
||||
const callId = typeof payload.call_id === 'string'
|
||||
? payload.call_id.trim()
|
||||
: typeof payload.id === 'string'
|
||||
? payload.id.trim()
|
||||
: '';
|
||||
if (!callId) continue;
|
||||
const timestamp = parseTimestamp(record.timestamp);
|
||||
|
||||
if (payloadType === 'function_call' || payloadType === 'custom_tool_call') {
|
||||
const rawName = typeof payload.name === 'string' ? payload.name.trim() : '';
|
||||
const name = displayToolName(rawName);
|
||||
toolNamesByCallId.set(callId, name);
|
||||
messages.push({
|
||||
id: `cc-connect-codex-tool-${callId}`,
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'toolCall',
|
||||
id: callId,
|
||||
name,
|
||||
arguments: parseToolArguments(payload.arguments ?? payload.input),
|
||||
}],
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
stopReason: 'tool_use',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (payloadType === 'function_call_output' || payloadType === 'custom_tool_call_output') {
|
||||
const rawOutput = toolOutputText(payload.output ?? payload.content);
|
||||
const output = truncateToolOutput(rawOutput);
|
||||
const name = toolNamesByCallId.get(callId) || 'tool';
|
||||
const isError = toolOutputIsError(rawOutput);
|
||||
messages.push({
|
||||
id: `cc-connect-codex-tool-result-${callId}`,
|
||||
role: 'toolresult',
|
||||
toolCallId: callId,
|
||||
toolName: name,
|
||||
content: output,
|
||||
details: {
|
||||
status: isError ? 'error' : 'completed',
|
||||
aggregated: output,
|
||||
},
|
||||
...(isError ? { isError: true } : {}),
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (payloadType === 'web_search_call') {
|
||||
const name = 'Web Search';
|
||||
messages.push({
|
||||
id: `cc-connect-codex-tool-${callId}`,
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'toolCall',
|
||||
id: callId,
|
||||
name,
|
||||
arguments: payload.action ?? {},
|
||||
}],
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
stopReason: 'tool_use',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (payloadType === 'mcp_tool_call') {
|
||||
const server = typeof payload.server === 'string' ? payload.server : '';
|
||||
const tool = typeof payload.tool === 'string'
|
||||
? payload.tool
|
||||
: typeof payload.name === 'string'
|
||||
? payload.name
|
||||
: 'tool';
|
||||
const name = server ? `${server}: ${tool}` : tool;
|
||||
messages.push({
|
||||
id: `cc-connect-codex-tool-${callId}`,
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'toolCall',
|
||||
id: callId,
|
||||
name,
|
||||
arguments: parseToolArguments(payload.arguments ?? payload.input),
|
||||
}],
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
stopReason: 'tool_use',
|
||||
});
|
||||
if (payload.result !== undefined || payload.error !== undefined) {
|
||||
const isError = payload.error !== undefined;
|
||||
const output = truncateToolOutput(toolOutputText(payload.error ?? payload.result));
|
||||
messages.push({
|
||||
id: `cc-connect-codex-tool-result-${callId}`,
|
||||
role: 'toolresult',
|
||||
toolCallId: callId,
|
||||
toolName: name,
|
||||
content: output,
|
||||
details: {
|
||||
status: isError ? 'error' : 'completed',
|
||||
aggregated: output,
|
||||
},
|
||||
...(isError ? { isError: true } : {}),
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export async function loadCcConnectCodexTranscriptTools(
|
||||
codexHomeDirs: string | Iterable<string>,
|
||||
agentSessionId: string,
|
||||
turnHints: CcConnectTranscriptTurnHint[] = [],
|
||||
expectedWorkDir?: string,
|
||||
): Promise<RawMessage[]> {
|
||||
const hasValidAgentSessionId = /^[A-Za-z0-9_-]+$/.test(agentSessionId);
|
||||
if (!hasValidAgentSessionId && turnHints.length === 0) return [];
|
||||
const homes = typeof codexHomeDirs === 'string'
|
||||
? [codexHomeDirs]
|
||||
: Array.from(codexHomeDirs);
|
||||
const transcriptPaths = new Set<string>();
|
||||
const cachedSessionPath = hasValidAgentSessionId
|
||||
? transcriptPathBySessionId.get(agentSessionId)
|
||||
: undefined;
|
||||
if (cachedSessionPath) transcriptPaths.add(cachedSessionPath);
|
||||
for (const codexHomeDir of new Set(homes.filter(Boolean))) {
|
||||
if (hasValidAgentSessionId && !transcriptPathBySessionId.has(agentSessionId)) {
|
||||
const transcriptPath = await findTranscriptFile(join(codexHomeDir, 'sessions'), agentSessionId);
|
||||
if (transcriptPath) {
|
||||
setBoundedCache(
|
||||
transcriptPathBySessionId,
|
||||
agentSessionId,
|
||||
transcriptPath,
|
||||
MAX_TRANSCRIPT_PATH_CACHE_ENTRIES,
|
||||
);
|
||||
transcriptPaths.add(transcriptPath);
|
||||
}
|
||||
}
|
||||
for (const turnTranscriptPath of await findTurnTranscriptFiles(codexHomeDir, turnHints, expectedWorkDir)) {
|
||||
transcriptPaths.add(turnTranscriptPath);
|
||||
}
|
||||
}
|
||||
const messages: RawMessage[] = [];
|
||||
for (const transcriptPath of transcriptPaths) {
|
||||
const file = await readTranscriptFile(transcriptPath);
|
||||
if (!file?.jsonl) continue;
|
||||
file.toolMessages ??= parseCcConnectCodexTranscriptTools(file.jsonl);
|
||||
messages.push(...file.toolMessages);
|
||||
}
|
||||
return messages.sort((left, right) => (left.timestamp ?? 0) - (right.timestamp ?? 0));
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from './cc-connect-paths';
|
||||
import { buildCcConnectWebAdminUrl, CC_CONNECT_MANAGEMENT_PORT } from './cc-connect-control-ui';
|
||||
import {
|
||||
CC_CONNECT_SESSION_INSTANCE_MARKER,
|
||||
ccConnectProjectNameForAgent,
|
||||
ccConnectProjectNameForSessionKey,
|
||||
CLAWX_BRIDGE_ADMIN_USER_ID,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
FileCcConnectSessionMetadataStore,
|
||||
type CcConnectSessionMetadataStore,
|
||||
} from './cc-connect-session-metadata';
|
||||
import { loadCcConnectCodexTranscriptTools } from './cc-connect-codex-transcript';
|
||||
import { readOpenClawConfig, type ChannelConfigData, type OpenClawConfig } from '../utils/channel-config';
|
||||
import { expandPath, getOpenClawConfigDir } from '../utils/paths';
|
||||
import * as logger from '../utils/logger';
|
||||
@@ -148,6 +150,7 @@ type CcConnectApiSessionRef = {
|
||||
active: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
agentSessionId?: string;
|
||||
lastMessage?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -550,6 +553,7 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
|
||||
private currentProviderProfile: CodexProviderProfile | null = null;
|
||||
private currentProjectProfiles: CodexProviderProfile[] = [];
|
||||
private currentProjectProfileByAgent = new Map<string, CodexProviderProfile>();
|
||||
private currentProjectWorkDirByAgent = new Map<string, string>();
|
||||
private currentChannelEnv: Record<string, string> = {};
|
||||
private sessionSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private sessionSyncPolling = false;
|
||||
@@ -836,8 +840,38 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
|
||||
return { success: false, error: 'Session not found' };
|
||||
}
|
||||
const publicMessages = await this.sessionApi.loadHistory(session);
|
||||
const codexHomeDirs = new Set([
|
||||
this.currentProjectProfileByAgent.get(session.agentId)?.codexHomeDir,
|
||||
this.currentProviderProfile?.codexHomeDir,
|
||||
...this.currentProjectProfiles.map((profile) => profile.codexHomeDir),
|
||||
getCcConnectCodexHomeDir(),
|
||||
].filter((value): value is string => Boolean(value)));
|
||||
const transcriptTurnHints = publicMessages.slice(-limit).flatMap((message) => {
|
||||
const content = message.role === 'user' ? runtimeMessageText(message.content) : '';
|
||||
return content && typeof message.timestamp === 'number'
|
||||
? [{ content, timestamp: message.timestamp }]
|
||||
: [];
|
||||
});
|
||||
const transcriptMessages = session.agentSessionId || transcriptTurnHints.length > 0
|
||||
? await loadCcConnectCodexTranscriptTools(
|
||||
codexHomeDirs,
|
||||
session.agentSessionId ?? '',
|
||||
transcriptTurnHints,
|
||||
this.currentProjectWorkDirByAgent.get(session.agentId),
|
||||
).catch((error) => {
|
||||
logger.warn('[cc-connect] failed to load Codex tool history', {
|
||||
sessionKey,
|
||||
agentSessionId: session.agentSessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
})
|
||||
: [];
|
||||
const bridgeMessages = await this.bridgeAdapter.loadHistory(sessionKey, limit);
|
||||
const messages = mergeCcConnectHistory(publicMessages, bridgeMessages).slice(-limit);
|
||||
const messages = mergeCcConnectHistory(
|
||||
mergeCcConnectHistory(publicMessages, transcriptMessages),
|
||||
bridgeMessages,
|
||||
).slice(-limit);
|
||||
return {
|
||||
success: true,
|
||||
messages,
|
||||
@@ -1256,6 +1290,10 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
|
||||
this.currentProjectProfileByAgent = new Map(configuredProjects.flatMap((project) => (
|
||||
project.providerProfile ? [[project.agentId, project.providerProfile] as const] : []
|
||||
)));
|
||||
this.currentProjectWorkDirByAgent = new Map(configuredProjects.map((project) => [
|
||||
project.agentId,
|
||||
project.workDir,
|
||||
]));
|
||||
const channelPlatforms = collectCcConnectChannelPlatforms(openClawConfig).filter((platform) => !platform.error);
|
||||
this.currentChannelEnv = Object.assign({}, ...channelPlatforms.map((platform) => platform.env));
|
||||
await writeFile(configPath, defaultConfig({
|
||||
@@ -1457,6 +1495,8 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
|
||||
'GET',
|
||||
`/projects/${encodeURIComponent(session.projectName)}/sessions/${encodeURIComponent(session.id)}?history_limit=1000`,
|
||||
);
|
||||
const detail = isRecord(result) ? result : {};
|
||||
session.agentSessionId = ccString(detail, ['agent_session_id', 'agentSessionId']) || undefined;
|
||||
return parseCcConnectApiHistory(result);
|
||||
}
|
||||
|
||||
@@ -2371,8 +2411,13 @@ function hasRuntimeAttachments(message: RawMessage): boolean {
|
||||
return Array.isArray(message._attachedFiles) && message._attachedFiles.length > 0;
|
||||
}
|
||||
|
||||
function hasRuntimeToolResult(message: RawMessage): boolean {
|
||||
return message.role === 'toolresult' && Boolean(message.toolCallId || message.id);
|
||||
}
|
||||
|
||||
function runtimeSupplementKey(message: RawMessage): string {
|
||||
if (hasRuntimeToolCall(message)) return `tool:${runtimeToolCallKey(message)}`;
|
||||
if (hasRuntimeToolResult(message)) return `tool-result:${message.toolCallId || message.id}`;
|
||||
if (message.id) return `attachment:${message.id}`;
|
||||
const attachments = (message._attachedFiles ?? []).map((file) => [
|
||||
file.filePath,
|
||||
@@ -2386,12 +2431,12 @@ function runtimeSupplementKey(message: RawMessage): string {
|
||||
|
||||
function mergeCcConnectHistory(publicMessages: RawMessage[], bridgeMessages: RawMessage[]): RawMessage[] {
|
||||
const supplementalMessages = bridgeMessages.filter((message) => (
|
||||
hasRuntimeToolCall(message) || hasRuntimeAttachments(message)
|
||||
hasRuntimeToolCall(message) || hasRuntimeToolResult(message) || hasRuntimeAttachments(message)
|
||||
));
|
||||
if (supplementalMessages.length === 0) return publicMessages;
|
||||
const seenSupplements = new Set(
|
||||
publicMessages
|
||||
.filter((message) => hasRuntimeToolCall(message) || hasRuntimeAttachments(message))
|
||||
.filter((message) => hasRuntimeToolCall(message) || hasRuntimeToolResult(message) || hasRuntimeAttachments(message))
|
||||
.map(runtimeSupplementKey),
|
||||
);
|
||||
const merged = [...publicMessages];
|
||||
@@ -2433,7 +2478,10 @@ export function ccConnectSessionLogicalKey(
|
||||
const baseKey = `agent:${agentId}:cron:scheduled`;
|
||||
return active ? baseKey : `${baseKey}:${id}`;
|
||||
}
|
||||
if (!sessionKey.startsWith('clawx:')) return sessionKey;
|
||||
if (!sessionKey.startsWith('clawx:')) {
|
||||
const baseKey = `agent:${agentId}:${sessionKey}`;
|
||||
return active ? baseKey : `${baseKey}:${CC_CONNECT_SESSION_INSTANCE_MARKER}:${id}`;
|
||||
}
|
||||
const [, keyAgentId = 'main', ...keyParts] = sessionKey.split(':');
|
||||
const scopedAgentId = agentIdFromCcConnectProjectName(projectName) || normalizeAgentId(keyAgentId) || 'main';
|
||||
const baseKey = `agent:${scopedAgentId}:${keyParts.join(':') || 'main'}`;
|
||||
|
||||
@@ -11,10 +11,30 @@ function stableStringify(value: unknown): string {
|
||||
|
||||
test.describe('cc-connect channel session history', () => {
|
||||
test('renders channel sessions in the sidebar and loads their history', async ({ launchElectronApp }) => {
|
||||
const sessionKey = 'feishu:oc_probe:ou_probe';
|
||||
const sessionKey = 'agent:coder:feishu:oc_probe:ou_probe';
|
||||
const sessionUpdatedAt = Date.now();
|
||||
const seededHistory = [
|
||||
{ id: 'm1', role: 'user', content: '飞书 hello', timestamp: sessionUpdatedAt - 1_000 },
|
||||
{
|
||||
id: 'm-tool-call',
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'toolCall',
|
||||
id: 'call-feishu-bash',
|
||||
name: 'Bash',
|
||||
arguments: { cmd: 'pwd && ls' },
|
||||
}],
|
||||
timestamp: sessionUpdatedAt - 800,
|
||||
stopReason: 'tool_use',
|
||||
},
|
||||
{
|
||||
id: 'm-tool-result',
|
||||
role: 'toolresult',
|
||||
toolCallId: 'call-feishu-bash',
|
||||
toolName: 'Bash',
|
||||
content: '/tmp/workspace',
|
||||
timestamp: sessionUpdatedAt - 700,
|
||||
},
|
||||
{ id: 'm2', role: 'assistant', content: '飞书 reply', timestamp: sessionUpdatedAt },
|
||||
];
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
@@ -86,7 +106,10 @@ test.describe('cc-connect channel session history', () => {
|
||||
await expect(page.getByTestId(`sidebar-session-${sessionKey}`)).toContainText('Coder');
|
||||
await page.getByTestId(`sidebar-session-${sessionKey}`).click();
|
||||
await expect(page.getByTestId('chat-message-0').getByText('飞书 hello')).toBeVisible();
|
||||
await expect(page.getByTestId('chat-message-1').getByText('飞书 reply')).toBeVisible();
|
||||
await expect(page.getByText('飞书 reply')).toBeVisible();
|
||||
await expect(page.getByTestId('chat-execution-graph')).toBeVisible();
|
||||
await page.getByTestId('chat-execution-graph').click();
|
||||
await expect(page.getByTestId('chat-execution-graph')).toContainText('Bash');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -309,6 +309,9 @@ wss.on('connection', (ws) => {
|
||||
}
|
||||
if (msg.type === 'message') {
|
||||
writeBridgeMessage(msg);
|
||||
if (String(msg.content || '').includes('stay pending until runtime stops')) {
|
||||
return;
|
||||
}
|
||||
if (String(msg.content || '').includes('require approval')) {
|
||||
pendingApprovals.set(msg.reply_ctx, msg);
|
||||
ws.send(JSON.stringify({
|
||||
@@ -867,6 +870,23 @@ test.describe('cc-connect + Codex runtime E2E', () => {
|
||||
session_key: 'clawx:analysis:member-2',
|
||||
}),
|
||||
]));
|
||||
|
||||
await page.getByTestId('chat-composer-input').fill('stay pending until runtime stops');
|
||||
await page.getByTestId('chat-composer-send').click();
|
||||
await expect.poll(async () => {
|
||||
const content = await readFile(bridgeMessagesPath, 'utf8').catch(() => '');
|
||||
return content.includes('stay pending until runtime stops');
|
||||
}, { timeout: 30_000 }).toBe(true);
|
||||
|
||||
const stopResult = await page.evaluate(async () => {
|
||||
return await window.clawx.hostInvoke({
|
||||
id: 'runtime-stop-with-pending-run',
|
||||
module: 'gateway',
|
||||
action: 'stop',
|
||||
});
|
||||
});
|
||||
expect(stopResult).toMatchObject({ ok: true, data: { success: true } });
|
||||
await expect(page.getByText('cc-connect runtime stopped before the run completed')).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -36,10 +36,13 @@ describe('cc-connect BridgePlatform adapter', () => {
|
||||
expect(toCcConnectBridgeSessionKey('agent:main:main')).toBe('clawx:main:main');
|
||||
expect(toCcConnectBridgeSessionKey('agent:research:desk')).toBe('clawx:research:desk');
|
||||
expect(toCcConnectBridgeSessionKey('agent:main:cron:job-123')).toBe('clawx:main:cron:job-123');
|
||||
expect(toCcConnectBridgeSessionKey('agent:research:feishu:chat-1:user-1')).toBe('feishu:chat-1:user-1');
|
||||
expect(toCcConnectBridgeSessionKey('agent:research:feishu:chat-1:user-1:$session:s2')).toBe('feishu:chat-1:user-1');
|
||||
expect(toCcConnectBridgeSessionKey('clawx:main:main')).toBe('clawx:main:main');
|
||||
expect(toCcConnectBridgeSessionKey('feishu:chat-1:user-1')).toBe('feishu:chat-1:user-1');
|
||||
expect(ccConnectProjectNameForSessionKey('agent:research:desk')).toBe('clawx-research');
|
||||
expect(ccConnectProjectNameForSessionKey('agent:research:cron:job-123')).toBe('clawx-research');
|
||||
expect(ccConnectProjectNameForSessionKey('agent:research:feishu:chat-1:user-1')).toBe('clawx-research');
|
||||
expect(ccConnectProjectNameForSessionKey('feishu:chat-1:user-1')).toBe('clawx-main');
|
||||
});
|
||||
|
||||
@@ -129,6 +132,11 @@ describe('cc-connect BridgePlatform adapter', () => {
|
||||
message: 'ping',
|
||||
idempotencyKey: 'idem-1',
|
||||
})).resolves.toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) }));
|
||||
await expect(adapter.send({
|
||||
sessionKey: 'agent:coder:feishu:chat-1:user-1',
|
||||
message: 'channel ping',
|
||||
idempotencyKey: 'idem-2',
|
||||
})).resolves.toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(emitted).toEqual(expect.arrayContaining([
|
||||
@@ -155,6 +163,11 @@ describe('cc-connect BridgePlatform adapter', () => {
|
||||
session_key: 'clawx:research:desk',
|
||||
project: 'clawx-research',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'message',
|
||||
session_key: 'feishu:chat-1:user-1',
|
||||
project: 'clawx-coder',
|
||||
}),
|
||||
]));
|
||||
await expect(adapter.listSessions()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -163,6 +176,12 @@ describe('cc-connect BridgePlatform adapter', () => {
|
||||
derivedTitle: 'ping',
|
||||
lastMessagePreview: 'pong',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'agent:coder:feishu:chat-1:user-1',
|
||||
agentId: 'coder',
|
||||
derivedTitle: 'channel ping',
|
||||
lastMessagePreview: 'pong',
|
||||
}),
|
||||
]);
|
||||
await adapter.close();
|
||||
} finally {
|
||||
@@ -959,6 +978,82 @@ describe('cc-connect BridgePlatform adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('ends pending runs on close so they cannot block reply routing after reconnect', async () => {
|
||||
const server = new WebSocketServer({ port: 0 });
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.once('listening', () => {
|
||||
const address = server.address();
|
||||
resolve(typeof address === 'object' && address ? address.port : 0);
|
||||
});
|
||||
});
|
||||
const emitted: Array<[string, unknown]> = [];
|
||||
let sentMessages = 0;
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
socket.on('message', (data) => {
|
||||
const parsed = JSON.parse(String(data)) as Record<string, unknown>;
|
||||
if (parsed.type === 'register') {
|
||||
socket.send(JSON.stringify({ type: 'register_ack', ok: true }));
|
||||
return;
|
||||
}
|
||||
if (parsed.type !== 'message') return;
|
||||
sentMessages += 1;
|
||||
if (sentMessages === 2) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'reply',
|
||||
session_key: parsed.session_key,
|
||||
content: 'reply after reconnect',
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const adapter = new CcConnectBridgeAdapter({
|
||||
port,
|
||||
token: 'token',
|
||||
project: 'clawx-main',
|
||||
emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never,
|
||||
});
|
||||
|
||||
try {
|
||||
const first = await adapter.send({
|
||||
sessionKey: 'agent:main:first',
|
||||
message: 'never answered',
|
||||
idempotencyKey: 'idem-before-close',
|
||||
});
|
||||
await adapter.close();
|
||||
|
||||
expect(emitted).toEqual(expect.arrayContaining([
|
||||
['chat:runtime-event', expect.objectContaining({
|
||||
type: 'run.ended',
|
||||
runId: first.runId,
|
||||
sessionKey: 'agent:main:first',
|
||||
status: 'error',
|
||||
error: 'cc-connect runtime stopped before the run completed',
|
||||
})],
|
||||
]));
|
||||
|
||||
const second = await adapter.send({
|
||||
sessionKey: 'agent:main:second',
|
||||
message: 'answer this',
|
||||
idempotencyKey: 'idem-after-close',
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(emitted).toEqual(expect.arrayContaining([
|
||||
['chat:runtime-event', expect.objectContaining({
|
||||
type: 'run.ended',
|
||||
runId: second.runId,
|
||||
sessionKey: 'agent:main:second',
|
||||
status: 'completed',
|
||||
})],
|
||||
]));
|
||||
});
|
||||
} finally {
|
||||
await adapter.close();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('maps bridge replies without reply_ctx back to the pending app run', async () => {
|
||||
const server = new WebSocketServer({ port: 0 });
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user