mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 17:02:22 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc9d2740e | ||
|
|
f058ccf28f | ||
|
|
2a965cb462 | ||
|
|
9fe334dec2 | ||
|
|
8afad35d2f | ||
|
|
92f966424f | ||
|
|
a378c31261 |
+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 启动前会重新生成这份兼容投影。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user