feat: v1.2.8 - Codex双Agent、图片上传、主题系统、会话加载优化
- Codex双Agent接入:共享后端内核,前台隔离会话/设置/导入 - 图片上传:Claude (stream-json) 和 Codex (--image) 均支持拖拽/粘贴/选择上传 - 主题系统:CoolVibe Light 视觉方案,主题入口移至二级页 - 会话加载优化:加载遮罩、热会话缓存、切后台内容不丢失 - 移动端增强:侧栏手势、运行状态标签、按钮比例修复 - 后端重构:agent-runtime.js / codex-rollouts.js 模块拆分 - 回归脚本:npm run regression 隔离式测试
This commit is contained in:
@@ -7,5 +7,8 @@ PORT=8002
|
||||
# Claude CLI 路径(默认在 PATH 中查找 claude)
|
||||
CLAUDE_PATH=claude
|
||||
|
||||
# Codex CLI 路径(默认在 PATH 中查找 codex)
|
||||
CODEX_PATH=codex
|
||||
|
||||
# PushPlus Token(可选,首次启动会自动迁移到 config/notify.json)
|
||||
PUSHPLUS_TOKEN=
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,8 +1,10 @@
|
||||
node_modules/
|
||||
sessions/
|
||||
logs/
|
||||
attachments/
|
||||
.env
|
||||
config/notify.json
|
||||
config/auth.json
|
||||
config/model.json
|
||||
config/codex.json
|
||||
CLAUDE.md
|
||||
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,5 +1,41 @@
|
||||
# 更新记录
|
||||
|
||||
- **v1.2.8**
|
||||
- **Codex 双 Agent 接入**
|
||||
- 新建会话时可选择 Claude 或 Codex,复用 cc-web 现有的多会话、后台进程、断线续挂和文件 I/O 核心逻辑
|
||||
- 侧边栏按 Agent 隔离会话列表、最近会话记忆与默认新建行为
|
||||
- Agent 切换入口收进顶部栏标签下拉
|
||||
- Claude / Codex 各有独立设置入口,Codex 设置简化为 `local/custom + API Profile`
|
||||
- Codex 会话通过 `codex exec --json` / `codex exec resume --json` 对接,stdin 文件喂入 prompt,持久化 thread id 用于续接
|
||||
- `/model`、`/cost`、`/help` 对 Codex 会话做了适配
|
||||
- **Codex 本地历史导入**
|
||||
- 扫描 `~/.codex/sessions/` 下的 rollout `.jsonl`,解析用户消息、助手输出、函数调用与 token 使用量后导入到 cc-web
|
||||
- 删除 Codex 会话时同步清理对应 rollout 文件与本地线程元数据
|
||||
- **图片上传**
|
||||
- Claude 和 Codex 会话均支持图片消息:拖拽、粘贴或点击附件按钮上传
|
||||
- 客户端自动压缩(WebP),服务端附件缓存(7 天 TTL)
|
||||
- Claude 通过 `--input-format stream-json` 传入 base64 图片,Codex 通过 `--image` 参数传入
|
||||
- 历史消息仅保留图片文字标签,不渲染原图
|
||||
- 单条消息最多 4 张图片
|
||||
- **会话加载体验优化**
|
||||
- 回退并收敛长会话加载策略:最近消息先到,更老消息分批补充
|
||||
- 新增会话加载遮罩与加载期不可操作状态
|
||||
- 热会话缓存(最近 4 个,strong/weak 两级命中策略)
|
||||
- 修复切后台再切回时运行中内容短暂消失的问题(`preserveStreaming` 机制)
|
||||
- **主题系统**
|
||||
- 引入完整的主题系统与 CoolVibe Light 视觉方案
|
||||
- 主题入口从设置页顶部改为「外观 → 界面主题」二级页
|
||||
- 加载遮罩适配 washi / coolvibe / editorial 三套主题变量
|
||||
- **移动端交互增强**
|
||||
- 对话区任意位置右滑唤起侧栏、侧栏打开后左滑关闭
|
||||
- 运行中状态覆盖移动端 cwd 标签显示
|
||||
- 修复附件按钮、新会话分裂按钮的移动端比例失调
|
||||
- **其他改进**
|
||||
- 后端重构:spawn spec 与事件解析抽离到 `lib/agent-runtime.js`,Codex rollout 解析在 `lib/codex-rollouts.js`
|
||||
- 新增隔离式回归脚本 `npm run regression`,使用 mock CLI 在临时目录中校验主路径
|
||||
- 设置页说明卡移除,Codex Web Search 解释文案移除
|
||||
- 删除确认弹窗按 Agent 动态提示 Claude / Codex 的本地删除影响
|
||||
|
||||
- **v1.2.7**
|
||||
- 新增导入本地 CLI 会话:扫描 `~/.claude/projects/` 下的 `.jsonl`,解析后导入到 cc-web,可续接历史对话。
|
||||
- 新增新建会话指定工作目录:创建会话时弹窗设置 cwd,spawn 子进程时使用该目录,header 显示当前工作路径。
|
||||
|
||||
31
README.en.md
31
README.en.md
@@ -1,6 +1,6 @@
|
||||
# CC-Web
|
||||
|
||||
A lightweight web interface for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI, so you can run and monitor workflows directly from a browser.
|
||||
A lightweight browser interface for Claude Code and Codex, designed to keep each agent close to its native CLI workflow while sharing the same web shell.
|
||||
|
||||

|
||||

|
||||
@@ -15,21 +15,27 @@ A lightweight web interface for [Claude Code](https://docs.anthropic.com/en/docs
|
||||
|
||||
## Features
|
||||
|
||||
- **Lightweight runtime(1.6MB)**: low backend overhead, browser-based control panel.
|
||||
- **Lightweight runtime**: low backend overhead, browser-based control panel.
|
||||
- **Dual-agent sessions**: create Claude or Codex sessions on the same backend core.
|
||||
- **Agent-isolated views**: switching Claude / Codex only shows that agent's sessions, recent state, settings, and import entry points.
|
||||
- **Agent-specific settings**: Claude keeps template-based model config; Codex has its own path, default model, mode, and search settings.
|
||||
- **Multi-session management**: create, switch, rename, and delete sessions; deleting a session also removes the local Claude history record.
|
||||
- **Local history import**: import Claude history from `~/.claude/projects/` and Codex rollout history from `~/.codex/sessions/`.
|
||||
- **Session resume**: context continuity via `--resume`; you can also reattach via SSH + `tmux attach -t claude` when needed.
|
||||
- **Background task support**: Claude processes continue after browser disconnect and notify you on completion.
|
||||
- **Multi-channel notifications**: PushPlus / Telegram / ServerChan / Feishu bot / QQ (Qmsg), configurable in Web UI.
|
||||
- **Process persistence**: detached subprocess + PID files; running tasks survive service restarts.
|
||||
- **Multi-API switching**: configure multiple API profiles and switch between them instantly from the UI.
|
||||
- **Password-based auth**: initial password generation, forced first-login reset, and password change in Web UI.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js** >= 18
|
||||
- **Claude Code CLI** installed and configured (`claude` command available)
|
||||
- **Claude Code CLI** and/or **Codex CLI** installed and configured
|
||||
|
||||
```bash
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
npm install -g @openai/codex
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -66,6 +72,7 @@ After startup, open `http://localhost:8002` and sign in with your password.
|
||||
| `CC_WEB_PASSWORD` | No | Auto-generated | Web login password (migrated into `config/auth.json` on first start) |
|
||||
| `PORT` | No | `8002` | Service port |
|
||||
| `CLAUDE_PATH` | No | `claude` | Executable path to Claude CLI |
|
||||
| `CODEX_PATH` | No | `codex` | Executable path to Codex CLI |
|
||||
| `PUSHPLUS_TOKEN` | No | - | PushPlus token (migrated into notification config on first start) |
|
||||
|
||||
### Notification Configuration
|
||||
@@ -107,6 +114,8 @@ cc-web/
|
||||
│ └── auth.json # Auth config (generated at runtime)
|
||||
├── sessions/ # Chat history JSON files (generated at runtime)
|
||||
├── logs/ # Process lifecycle logs (generated at runtime)
|
||||
├── lib/ # Agent runtime + Codex rollout parsing helpers
|
||||
├── scripts/ # Regression tooling + mock CLIs
|
||||
├── .env.example # Environment variable template
|
||||
├── start.bat # Windows startup script
|
||||
├── .gitignore
|
||||
@@ -119,10 +128,10 @@ cc-web/
|
||||
### Process Model
|
||||
|
||||
```text
|
||||
Browser ←WebSocket→ Node.js (server.js) ←file I/O→ Claude CLI (detached)
|
||||
Browser ←WebSocket→ Node.js (server.js) ←file I/O→ Claude / Codex CLI (detached)
|
||||
```
|
||||
|
||||
- Each user message spawns a `claude -p --output-format stream-json` subprocess.
|
||||
- Each user message spawns either a Claude or Codex subprocess depending on the session agent.
|
||||
- Subprocesses use `detached: true` + `proc.unref()` and run independently from Node.js lifecycle.
|
||||
- stdin/stdout/stderr are bridged via files in `sessions/{id}-run/`.
|
||||
- PID is persisted to disk and recovered after service restart (`recoverProcesses()`).
|
||||
@@ -216,7 +225,7 @@ server {
|
||||
|
||||
### Windows Deployment
|
||||
|
||||
Use this mode when running CC-Web on a personal PC and controlling Claude Code from mobile.
|
||||
Use this mode when running CC-Web on a personal PC and controlling Claude / Codex from mobile.
|
||||
|
||||
Start with `start.bat`, or run manually:
|
||||
|
||||
@@ -235,6 +244,14 @@ node server.js
|
||||
|
||||
## Release Notes
|
||||
|
||||
- **v1.2.8**
|
||||
- **Dual-agent (Codex)**: create Claude or Codex sessions on the same backend; agent-isolated sidebar, settings, and import
|
||||
- **Image upload**: drag, paste, or attach images in both Claude and Codex sessions; client-side WebP compression, 7-day server cache, up to 4 images per message
|
||||
- **Session loading**: loading overlay, hot session cache (4 slots, strong/weak hit), fix for streaming content disappearing on tab switch
|
||||
- **Theme system**: full theme engine with CoolVibe Light, washi, and editorial variants; theme picker moved to sub-page
|
||||
- **Mobile UX**: swipe-to-open/close sidebar, running-state badge replaces cwd label, button sizing fixes
|
||||
- **Backend refactor**: spawn spec + event parsing extracted to `lib/agent-runtime.js`; isolated regression script `npm run regression`
|
||||
|
||||
- **v1.2.2**
|
||||
- Aligned context compression with Claude Code native behavior: `/compact` is now actually sent to CLI instead of doing a local pseudo-reset.
|
||||
- Added automatic overflow recovery: when `Request too large (max 20MB)` occurs, CC-Web runs `/compact` and replays the failed prompt automatically.
|
||||
@@ -250,4 +267,4 @@ node server.js
|
||||
|
||||
## Notes
|
||||
|
||||
- This project currently targets Claude Code only.
|
||||
- Claude support is still the more mature path, while Codex now supports isolated sessions, resume, import, background execution, and local cleanup.
|
||||
|
||||
33
README.md
33
README.md
@@ -1,6 +1,6 @@
|
||||
# CC-Web
|
||||
|
||||
Claude Code 轻量级 Web 远程工具 — 在浏览器中与 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 交互。
|
||||
Claude Code / Codex 轻量级 Web 远程工具 — 在浏览器中与本机 CLI Agent 交互。
|
||||
|
||||

|
||||

|
||||
@@ -24,20 +24,27 @@ https://github.com/ZgDaniel/cc-web 给我装!
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **超轻量(1.6MB)** — 后端性能占用少,前端通过 web 访问
|
||||
- **超轻量** — 后端性能占用少,前端通过 web 访问
|
||||
- **双 Agent 会话** — 新建会话时可选择 Claude 或 Codex,沿用相同的 Web 会话与后台任务模型
|
||||
- **Agent 视图隔离** — 侧边栏切换 Claude / Codex 后,仅展示当前 Agent 的会话与最近记录,互不干扰
|
||||
- **独立 Agent 设置** — Claude 与 Codex 拥有各自的设置入口与默认行为,保持贴近各自原生 CLI 的使用方式
|
||||
- **多会话管理** — 创建、切换、重命名、删除会话,删除时同步清除本地 Claude 历史记录
|
||||
- **会话续接** — 基于 `--resume` 实现跨消息上下文保持,也可通过 SSH 使用 `tmux attach -t claude` 命令加入会话
|
||||
- **本地历史导入** — Claude 可导入 `~/.claude/projects/` 会话;Codex 可导入 `~/.codex/sessions/` rollout 历史
|
||||
- **后台任务** — 关闭浏览器后 Claude 进程继续运行,完成后推送通知
|
||||
- **多渠道通知** — 支持 PushPlus / Telegram / Server酱 / 飞书机器人 / QQ(Qmsg),Web UI 内可视化配置
|
||||
- **进程持久化** — detached 进程 + PID 文件,服务重启不丢失运行中的任务
|
||||
- **多 API 切换** — 可配置多个 API 方案,UI 中一键切换,即时生效
|
||||
- **密码认证** — 自动生成初始密码、首次登录强制改密、Web UI 修改密码
|
||||
- **隔离式回归脚本** — `npm run regression` 在临时目录中使用 mock Claude / Codex CLI 校验主路径,不污染真实数据
|
||||
|
||||
## 前提条件
|
||||
|
||||
- **Node.js** >= 18
|
||||
- **Claude Code CLI** 已安装并配置(`claude` 命令可用)
|
||||
- **Claude Code CLI** 或 **Codex CLI** 已安装并配置
|
||||
```bash
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
npm install -g @openai/codex
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
@@ -75,6 +82,10 @@ copy .env.example .env & REM 可选
|
||||
| `CC_WEB_PASSWORD` | 否 | 自动生成 | Web 登录密码(首次启动自动迁移到 `config/auth.json`) |
|
||||
| `PORT` | 否 | `8002` | 服务监听端口 |
|
||||
| `CLAUDE_PATH` | 否 | `claude` | Claude CLI 可执行文件路径 |
|
||||
| `CODEX_PATH` | 否 | `codex` | Codex CLI 可执行文件路径 |
|
||||
| `CC_WEB_CONFIG_DIR` | 否 | `./config` | 配置目录覆写(主要供隔离测试使用) |
|
||||
| `CC_WEB_SESSIONS_DIR` | 否 | `./sessions` | 会话目录覆写(主要供隔离测试使用) |
|
||||
| `CC_WEB_LOGS_DIR` | 否 | `./logs` | 日志目录覆写(主要供隔离测试使用) |
|
||||
| `PUSHPLUS_TOKEN` | 否 | - | PushPlus Token(首次启动自动迁移到通知配置) |
|
||||
|
||||
### 通知配置
|
||||
@@ -106,16 +117,24 @@ copy .env.example .env & REM 可选
|
||||
```
|
||||
cc-web/
|
||||
├── server.js # Node.js 后端(HTTP + WebSocket + 进程管理 + 通知)
|
||||
├── lib/
|
||||
│ ├── agent-runtime.js # Claude / Codex 运行时适配层
|
||||
│ └── codex-rollouts.js # Codex rollout 历史解析
|
||||
├── public/
|
||||
│ ├── index.html # 页面结构
|
||||
│ ├── app.js # 前端逻辑(WebSocket 通信、UI 交互)
|
||||
│ ├── style.css # 样式(和风暖色调主题)
|
||||
│ └── sw.js # Service Worker(移动端推送通知)
|
||||
├── config/
|
||||
│ ├── codex.json # Codex 独立配置(运行时生成)
|
||||
│ ├── notify.json # 通知渠道配置(运行时生成)
|
||||
│ └── auth.json # 密码配置(运行时生成)
|
||||
├── sessions/ # 对话历史 JSON 文件(运行时生成)
|
||||
├── logs/ # 进程生命周期日志(运行时生成)
|
||||
├── scripts/
|
||||
│ ├── regression.js # 隔离式回归脚本
|
||||
│ ├── mock-claude.js # 回归用 mock Claude CLI
|
||||
│ └── mock-codex.js # 回归用 mock Codex CLI
|
||||
├── .env.example # 环境变量模板
|
||||
├── start.bat # Windows 一键启动脚本
|
||||
├── .gitignore
|
||||
@@ -128,14 +147,15 @@ cc-web/
|
||||
### 进程模型
|
||||
|
||||
```
|
||||
浏览器 ←WebSocket→ Node.js (server.js) ←文件I/O→ Claude CLI (detached)
|
||||
浏览器 ←WebSocket→ Node.js (server.js) ←文件I/O→ Claude / Codex CLI (detached)
|
||||
```
|
||||
|
||||
- 每条用户消息 spawn 一个 `claude -p --output-format stream-json` 子进程
|
||||
- 每条用户消息会根据当前会话 Agent,spawn Claude 或 Codex 子进程
|
||||
- 进程使用 `detached: true` + `proc.unref()`,独立于 Node.js 生命周期
|
||||
- stdin/stdout/stderr 通过文件传递(`sessions/{id}-run/`),不使用 pipe
|
||||
- PID 持久化到文件,服务重启后自动恢复(`recoverProcesses()`)
|
||||
- 使用 `FileTailer` 实时监听输出文件变化,流式推送给前端
|
||||
- Claude / Codex 的 spawn spec 与事件解析分别由 `lib/agent-runtime.js` 管理
|
||||
|
||||
### 后台任务流程
|
||||
|
||||
@@ -247,4 +267,5 @@ node server.js
|
||||
|
||||
## 补充说明
|
||||
|
||||
- 暂时只支持 Claude Code,后续再 vibe codex
|
||||
- 当前已支持 Claude Code 与 Codex;Claude 侧能力更完整,Codex 侧以会话续接、后台执行和命令流展示为主
|
||||
- 每次大改动后建议先执行 `npm run regression`
|
||||
|
||||
390
lib/agent-runtime.js
Normal file
390
lib/agent-runtime.js
Normal file
@@ -0,0 +1,390 @@
|
||||
function createAgentRuntime(deps) {
|
||||
const {
|
||||
processEnv,
|
||||
CLAUDE_PATH,
|
||||
CODEX_PATH,
|
||||
MODEL_MAP,
|
||||
loadModelConfig,
|
||||
applyCustomTemplateToSettings,
|
||||
loadCodexConfig,
|
||||
prepareCodexCustomRuntime,
|
||||
wsSend,
|
||||
truncateObj,
|
||||
sanitizeToolInput,
|
||||
loadSession,
|
||||
saveSession,
|
||||
setRuntimeSessionId,
|
||||
getRuntimeSessionId,
|
||||
} = deps;
|
||||
|
||||
function buildClaudeSpawnSpec(session, options = {}) {
|
||||
const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0;
|
||||
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
||||
if (hasAttachments) args.push('--input-format', 'stream-json');
|
||||
const permMode = session.permissionMode || 'yolo';
|
||||
switch (permMode) {
|
||||
case 'yolo':
|
||||
args.push('--dangerously-skip-permissions');
|
||||
break;
|
||||
case 'plan':
|
||||
args.push('--permission-mode', 'plan');
|
||||
break;
|
||||
case 'default':
|
||||
break;
|
||||
}
|
||||
if (session.claudeSessionId) {
|
||||
args.push('--resume', session.claudeSessionId);
|
||||
}
|
||||
if (session.model) {
|
||||
const validModels = new Set(Object.values(MODEL_MAP));
|
||||
if (validModels.has(session.model)) {
|
||||
args.push('--model', session.model);
|
||||
}
|
||||
}
|
||||
|
||||
const env = { ...processEnv };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.CLAUDE_CODE;
|
||||
delete env.CC_WEB_PASSWORD;
|
||||
for (const k of Object.keys(env)) {
|
||||
if (k.startsWith('ANTHROPIC_')) delete env[k];
|
||||
}
|
||||
|
||||
const modelCfg = loadModelConfig();
|
||||
if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
|
||||
const tpl = (modelCfg.templates || []).find((t) => t.name === modelCfg.activeTemplate);
|
||||
if (tpl) applyCustomTemplateToSettings(tpl);
|
||||
}
|
||||
|
||||
return {
|
||||
command: CLAUDE_PATH,
|
||||
args,
|
||||
env,
|
||||
cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(),
|
||||
parser: 'claude',
|
||||
mode: permMode,
|
||||
resume: !!session.claudeSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexSpawnSpec(session, options = {}) {
|
||||
const codexConfig = loadCodexConfig();
|
||||
const runtimeConfig = prepareCodexCustomRuntime(codexConfig);
|
||||
if (runtimeConfig?.error) {
|
||||
return { error: runtimeConfig.error };
|
||||
}
|
||||
const runtimeId = getRuntimeSessionId(session);
|
||||
const args = ['exec'];
|
||||
if (runtimeId) args.push('resume');
|
||||
args.push('--json', '--skip-git-repo-check');
|
||||
|
||||
const permMode = session.permissionMode || 'yolo';
|
||||
switch (permMode) {
|
||||
case 'yolo':
|
||||
args.push('--dangerously-bypass-approvals-and-sandbox');
|
||||
break;
|
||||
case 'plan':
|
||||
args.push('-s', 'read-only');
|
||||
break;
|
||||
case 'default':
|
||||
default:
|
||||
args.push('--full-auto');
|
||||
break;
|
||||
}
|
||||
|
||||
const effectiveModel = session.model;
|
||||
if (effectiveModel) args.push('--model', effectiveModel);
|
||||
if (Array.isArray(options.attachments)) {
|
||||
for (const attachment of options.attachments) {
|
||||
if (attachment?.path) args.push('--image', attachment.path);
|
||||
}
|
||||
}
|
||||
if (runtimeId) {
|
||||
args.push(runtimeId, '-');
|
||||
} else {
|
||||
if (session.cwd) args.push('-C', session.cwd);
|
||||
args.push('-');
|
||||
}
|
||||
|
||||
const env = { ...processEnv };
|
||||
delete env.CC_WEB_PASSWORD;
|
||||
delete env.CLAUDECODE;
|
||||
delete env.CLAUDE_CODE;
|
||||
if (runtimeConfig?.mode === 'custom') {
|
||||
env.CODEX_HOME = runtimeConfig.homeDir;
|
||||
env.OPENAI_API_KEY = runtimeConfig.apiKey;
|
||||
delete env.OPENAI_BASE_URL;
|
||||
}
|
||||
|
||||
return {
|
||||
command: CODEX_PATH,
|
||||
args,
|
||||
env,
|
||||
cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(),
|
||||
parser: 'codex',
|
||||
mode: permMode,
|
||||
resume: !!runtimeId,
|
||||
};
|
||||
}
|
||||
|
||||
function codexToolName(item) {
|
||||
switch (item?.type) {
|
||||
case 'command_execution':
|
||||
return 'CommandExecution';
|
||||
case 'mcp_tool_call':
|
||||
return 'McpToolCall';
|
||||
case 'file_change':
|
||||
return 'FileChange';
|
||||
case 'reasoning':
|
||||
return 'Reasoning';
|
||||
default:
|
||||
return item?.type || 'CodexItem';
|
||||
}
|
||||
}
|
||||
|
||||
function codexToolInput(item) {
|
||||
if (!item) return null;
|
||||
if (item.type === 'command_execution') return { command: item.command || '' };
|
||||
return truncateObj(item, 500);
|
||||
}
|
||||
|
||||
function codexToolMeta(item) {
|
||||
if (!item) return null;
|
||||
switch (item.type) {
|
||||
case 'command_execution':
|
||||
return {
|
||||
kind: 'command_execution',
|
||||
title: 'Shell Command',
|
||||
subtitle: item.command || '',
|
||||
exitCode: typeof item.exit_code === 'number' ? item.exit_code : null,
|
||||
status: item.status || null,
|
||||
};
|
||||
case 'mcp_tool_call':
|
||||
return {
|
||||
kind: 'mcp_tool_call',
|
||||
title: 'MCP Tool',
|
||||
subtitle: item.tool_name || item.name || item.server_name || '',
|
||||
status: item.status || null,
|
||||
};
|
||||
case 'file_change':
|
||||
return {
|
||||
kind: 'file_change',
|
||||
title: 'File Change',
|
||||
subtitle: item.path || item.file_path || '',
|
||||
status: item.status || null,
|
||||
};
|
||||
case 'reasoning':
|
||||
return {
|
||||
kind: 'reasoning',
|
||||
title: 'Reasoning',
|
||||
subtitle: typeof item.text === 'string' ? item.text.slice(0, 120) : '',
|
||||
status: item.status || null,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
kind: item.type || 'codex_item',
|
||||
title: codexToolName(item),
|
||||
subtitle: '',
|
||||
status: item.status || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function codexToolResult(item) {
|
||||
if (!item) return '';
|
||||
if (typeof item.aggregated_output === 'string' && item.aggregated_output) return item.aggregated_output;
|
||||
if (typeof item.text === 'string' && item.text) return item.text;
|
||||
return JSON.stringify(truncateObj(item, 1200));
|
||||
}
|
||||
|
||||
function ensureCodexToolCall(entry, item) {
|
||||
let tc = entry.toolCalls.find((t) => t.id === item.id);
|
||||
if (tc) {
|
||||
tc.name = codexToolName(item);
|
||||
tc.kind = item.type || tc.kind || null;
|
||||
tc.meta = codexToolMeta(item) || tc.meta || null;
|
||||
if (tc.input == null) tc.input = codexToolInput(item);
|
||||
return tc;
|
||||
}
|
||||
tc = {
|
||||
name: codexToolName(item),
|
||||
id: item.id,
|
||||
kind: item.type || null,
|
||||
meta: codexToolMeta(item),
|
||||
input: codexToolInput(item),
|
||||
done: false,
|
||||
};
|
||||
entry.toolCalls.push(tc);
|
||||
wsSend(entry.ws, {
|
||||
type: 'tool_start',
|
||||
name: tc.name,
|
||||
toolUseId: item.id,
|
||||
input: tc.input,
|
||||
kind: tc.kind,
|
||||
meta: tc.meta,
|
||||
});
|
||||
return tc;
|
||||
}
|
||||
|
||||
function processClaudeEvent(entry, event, sessionId) {
|
||||
if (!event || !event.type) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'system':
|
||||
if (event.session_id) {
|
||||
const session = loadSession(sessionId);
|
||||
if (session) {
|
||||
session.claudeSessionId = event.session_id;
|
||||
saveSession(session);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'assistant': {
|
||||
const content = event.message?.content;
|
||||
if (!Array.isArray(content)) break;
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
entry.fullText += block.text;
|
||||
wsSend(entry.ws, { type: 'text_delta', text: block.text });
|
||||
} else if (block.type === 'tool_use') {
|
||||
const toolInput = sanitizeToolInput(block.name, block.input);
|
||||
const tc = { name: block.name, id: block.id, input: toolInput, done: false };
|
||||
entry.toolCalls.push(tc);
|
||||
wsSend(entry.ws, { type: 'tool_start', name: block.name, toolUseId: block.id, input: tc.input });
|
||||
} else if (block.type === 'tool_result') {
|
||||
const resultText = typeof block.content === 'string'
|
||||
? block.content
|
||||
: Array.isArray(block.content)
|
||||
? block.content.map((c) => c.text || '').join('\n')
|
||||
: JSON.stringify(block.content);
|
||||
const tc = entry.toolCalls.find((t) => t.id === block.tool_use_id);
|
||||
if (tc) {
|
||||
tc.done = true;
|
||||
tc.result = resultText.slice(0, 2000);
|
||||
}
|
||||
wsSend(entry.ws, { type: 'tool_end', toolUseId: block.tool_use_id, result: resultText.slice(0, 2000) });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.session_id) {
|
||||
const session = loadSession(sessionId);
|
||||
if (session && !session.claudeSessionId) {
|
||||
session.claudeSessionId = event.session_id;
|
||||
saveSession(session);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
const session = loadSession(sessionId);
|
||||
if (session) {
|
||||
if (event.session_id) session.claudeSessionId = event.session_id;
|
||||
if (event.total_cost_usd) session.totalCost = (session.totalCost || 0) + event.total_cost_usd;
|
||||
saveSession(session);
|
||||
}
|
||||
entry.lastCost = event.total_cost_usd || null;
|
||||
if (entry.ws && event.total_cost_usd !== undefined) {
|
||||
wsSend(entry.ws, { type: 'cost', costUsd: session?.totalCost || 0 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processCodexEvent(entry, event, sessionId) {
|
||||
if (!event || !event.type) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'thread.started': {
|
||||
if (!event.thread_id) break;
|
||||
const session = loadSession(sessionId);
|
||||
if (session) {
|
||||
setRuntimeSessionId(session, event.thread_id);
|
||||
saveSession(session);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'item.started': {
|
||||
const item = event.item;
|
||||
if (!item || !item.id || item.type === 'agent_message') break;
|
||||
ensureCodexToolCall(entry, item);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'item.completed': {
|
||||
const item = event.item;
|
||||
if (!item || !item.id) break;
|
||||
if (item.type === 'agent_message') {
|
||||
if (item.text) {
|
||||
entry.fullText += item.text;
|
||||
wsSend(entry.ws, { type: 'text_delta', text: item.text });
|
||||
}
|
||||
break;
|
||||
}
|
||||
const tc = ensureCodexToolCall(entry, item);
|
||||
const resultText = codexToolResult(item).slice(0, 2000);
|
||||
tc.done = true;
|
||||
tc.result = resultText;
|
||||
wsSend(entry.ws, {
|
||||
type: 'tool_end',
|
||||
toolUseId: item.id,
|
||||
result: resultText,
|
||||
kind: tc.kind,
|
||||
meta: tc.meta,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'turn.completed': {
|
||||
const usage = event.usage || null;
|
||||
entry.lastUsage = usage;
|
||||
const session = loadSession(sessionId);
|
||||
if (session && usage) {
|
||||
session.totalUsage = {
|
||||
inputTokens: (session.totalUsage?.inputTokens || 0) + (usage.input_tokens || 0),
|
||||
cachedInputTokens: (session.totalUsage?.cachedInputTokens || 0) + (usage.cached_input_tokens || 0),
|
||||
outputTokens: (session.totalUsage?.outputTokens || 0) + (usage.output_tokens || 0),
|
||||
};
|
||||
saveSession(session);
|
||||
wsSend(entry.ws, { type: 'usage', totalUsage: session.totalUsage });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'turn.failed': {
|
||||
const message = event.error?.message || 'Codex 任务失败';
|
||||
entry.lastError = message;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
if (event.message) {
|
||||
if (/^Reconnecting\.\.\./.test(event.message)) {
|
||||
wsSend(entry.ws, { type: 'system_message', message: event.message });
|
||||
} else {
|
||||
entry.lastError = event.message;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function processRuntimeEvent(entry, event, sessionId) {
|
||||
if (entry.agent === 'codex') processCodexEvent(entry, event, sessionId);
|
||||
else processClaudeEvent(entry, event, sessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
buildClaudeSpawnSpec,
|
||||
buildCodexSpawnSpec,
|
||||
processClaudeEvent,
|
||||
processCodexEvent,
|
||||
processRuntimeEvent,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createAgentRuntime };
|
||||
205
lib/codex-rollouts.js
Normal file
205
lib/codex-rollouts.js
Normal file
@@ -0,0 +1,205 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function createCodexRolloutStore(deps) {
|
||||
const { codexSessionsDir, sessionsDir, normalizeSession, sanitizeToolInput } = deps;
|
||||
|
||||
function extractCodexMessageText(content) {
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter((item) => item && (item.type === 'input_text' || item.type === 'output_text'))
|
||||
.map((item) => item.text || '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
function appendAssistantContent(turn, text) {
|
||||
if (!turn || !text || !text.trim()) return;
|
||||
turn.content = turn.content ? `${turn.content}\n\n${text}` : text;
|
||||
}
|
||||
|
||||
function parseCodexRolloutLines(lines) {
|
||||
const messages = [];
|
||||
const pendingToolCalls = new Map();
|
||||
const meta = { threadId: null, cwd: null, title: '', updatedAt: null, cliVersion: null, source: null };
|
||||
const totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
||||
let currentAssistant = null;
|
||||
let sawRealUserMessage = false;
|
||||
const fallbackUserMessages = [];
|
||||
|
||||
function ensureAssistant(ts) {
|
||||
if (!currentAssistant) {
|
||||
currentAssistant = { role: 'assistant', content: '', toolCalls: [], timestamp: ts || null };
|
||||
} else if (!currentAssistant.timestamp && ts) {
|
||||
currentAssistant.timestamp = ts;
|
||||
}
|
||||
return currentAssistant;
|
||||
}
|
||||
|
||||
function flushAssistant() {
|
||||
if (!currentAssistant) return;
|
||||
if ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0) {
|
||||
messages.push(currentAssistant);
|
||||
}
|
||||
currentAssistant = null;
|
||||
pendingToolCalls.clear();
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let entry;
|
||||
try { entry = JSON.parse(trimmed); } catch { continue; }
|
||||
const ts = entry.timestamp || null;
|
||||
if (ts) meta.updatedAt = ts;
|
||||
|
||||
if (entry.type === 'session_meta') {
|
||||
meta.threadId = entry.payload?.id || meta.threadId;
|
||||
meta.cwd = entry.payload?.cwd || meta.cwd;
|
||||
meta.cliVersion = entry.payload?.cli_version || meta.cliVersion;
|
||||
meta.source = entry.payload?.source || meta.source;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') {
|
||||
const total = entry.payload?.info?.total_token_usage || null;
|
||||
const usage = entry.payload?.info?.last_token_usage || null;
|
||||
if (total) {
|
||||
totalUsage.inputTokens = Math.max(totalUsage.inputTokens, total.input_tokens || 0);
|
||||
totalUsage.cachedInputTokens = Math.max(totalUsage.cachedInputTokens, total.cached_input_tokens || 0);
|
||||
totalUsage.outputTokens = Math.max(totalUsage.outputTokens, total.output_tokens || 0);
|
||||
} else if (usage) {
|
||||
totalUsage.inputTokens += usage.input_tokens || 0;
|
||||
totalUsage.cachedInputTokens += usage.cached_input_tokens || 0;
|
||||
totalUsage.outputTokens += usage.output_tokens || 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
|
||||
const text = String(entry.payload?.message || '').trim();
|
||||
if (text) {
|
||||
sawRealUserMessage = true;
|
||||
flushAssistant();
|
||||
if (!meta.title) meta.title = text.slice(0, 80).replace(/\n/g, ' ');
|
||||
messages.push({ role: 'user', content: text, timestamp: ts });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type !== 'response_item') continue;
|
||||
|
||||
const payload = entry.payload || {};
|
||||
switch (payload.type) {
|
||||
case 'message': {
|
||||
if (payload.role === 'assistant') {
|
||||
const text = extractCodexMessageText(payload.content);
|
||||
if (text.trim()) {
|
||||
if (currentAssistant && ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0)) {
|
||||
flushAssistant();
|
||||
}
|
||||
appendAssistantContent(ensureAssistant(ts), text);
|
||||
}
|
||||
} else if (payload.role === 'user' && !sawRealUserMessage) {
|
||||
const text = extractCodexMessageText(payload.content);
|
||||
if (text.trim()) {
|
||||
fallbackUserMessages.push({ role: 'user', content: text, timestamp: ts });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'function_call': {
|
||||
const assistant = ensureAssistant(ts);
|
||||
const toolUseId = payload.call_id || payload.id || crypto.randomUUID();
|
||||
const tc = {
|
||||
name: payload.name || 'FunctionCall',
|
||||
id: toolUseId,
|
||||
input: sanitizeToolInput(payload.name || 'FunctionCall', payload.arguments || ''),
|
||||
done: false,
|
||||
};
|
||||
assistant.toolCalls.push(tc);
|
||||
pendingToolCalls.set(toolUseId, tc);
|
||||
break;
|
||||
}
|
||||
case 'function_call_output': {
|
||||
const assistant = ensureAssistant(ts);
|
||||
const toolUseId = payload.call_id || crypto.randomUUID();
|
||||
let tc = pendingToolCalls.get(toolUseId);
|
||||
if (!tc) {
|
||||
tc = { name: 'FunctionCall', id: toolUseId, input: null, done: false };
|
||||
assistant.toolCalls.push(tc);
|
||||
pendingToolCalls.set(toolUseId, tc);
|
||||
}
|
||||
tc.done = true;
|
||||
tc.result = (typeof payload.output === 'string'
|
||||
? payload.output
|
||||
: JSON.stringify(payload.output || '')).slice(0, 2000);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
flushAssistant();
|
||||
if (!sawRealUserMessage && fallbackUserMessages.length > 0) {
|
||||
const fallback = fallbackUserMessages[0];
|
||||
if (!meta.title) meta.title = fallback.content.trim().slice(0, 80).replace(/\n/g, ' ');
|
||||
return { meta, messages: fallbackUserMessages.concat(messages), totalUsage };
|
||||
}
|
||||
return { meta, messages, totalUsage };
|
||||
}
|
||||
|
||||
function walkFiles(dir, files = []) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return files;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkFiles(fullPath, files);
|
||||
else if (entry.isFile()) files.push(fullPath);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function getCodexRolloutFiles() {
|
||||
if (!fs.existsSync(codexSessionsDir)) return [];
|
||||
return walkFiles(codexSessionsDir, []).filter((filePath) => filePath.endsWith('.jsonl')).sort().reverse();
|
||||
}
|
||||
|
||||
function getImportedCodexThreadIds() {
|
||||
const imported = new Set();
|
||||
try {
|
||||
for (const f of fs.readdirSync(sessionsDir).filter((name) => name.endsWith('.json'))) {
|
||||
try {
|
||||
const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf8')));
|
||||
if (session.codexThreadId) imported.add(session.codexThreadId);
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return imported;
|
||||
}
|
||||
|
||||
function parseCodexRolloutFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = parseCodexRolloutLines(content.split('\n'));
|
||||
parsed.filePath = filePath;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parseCodexRolloutLines,
|
||||
getCodexRolloutFiles,
|
||||
getImportedCodexThreadIds,
|
||||
parseCodexRolloutFile,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createCodexRolloutStore };
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cc-web",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cc-web",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.8",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "cc-web",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
"start": "node server.js",
|
||||
"regression": "node scripts/regression.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
|
||||
2342
public/app.js
2342
public/app.js
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,12 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>CC-Web</title>
|
||||
<script>
|
||||
(function () {
|
||||
var theme = localStorage.getItem('cc-web-theme') || 'washi';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||
</head>
|
||||
@@ -15,7 +21,7 @@
|
||||
<div class="login-box">
|
||||
<div class="login-logo">CC</div>
|
||||
<h2>CC-Web</h2>
|
||||
<p>Claude Code Web Chat</p>
|
||||
<p>Claude / Codex Web Chat</p>
|
||||
<form id="login-form">
|
||||
<input type="password" id="login-password" placeholder="输入密码" autocomplete="current-password" autofocus>
|
||||
<label class="remember-label">
|
||||
@@ -54,13 +60,19 @@
|
||||
<header class="chat-header">
|
||||
<button id="menu-btn" class="menu-btn" title="菜单">☰</button>
|
||||
<span id="chat-title" class="chat-title">新会话</span>
|
||||
<button id="chat-agent-btn" class="chat-agent-btn" type="button" aria-haspopup="menu" aria-expanded="false">Claude</button>
|
||||
<div id="chat-agent-menu" class="chat-agent-menu" hidden>
|
||||
<button type="button" class="chat-agent-option active" data-agent="claude">Claude</button>
|
||||
<button type="button" class="chat-agent-option" data-agent="codex">Codex</button>
|
||||
</div>
|
||||
<span id="chat-runtime-state" class="chat-runtime-state" hidden>运行中</span>
|
||||
<span id="chat-cwd" class="chat-cwd" hidden></span>
|
||||
<select id="mode-select" class="mode-select" title="权限模式">
|
||||
<option value="yolo">YOLO</option>
|
||||
<option value="default">默认</option>
|
||||
<option value="plan">Plan</option>
|
||||
</select>
|
||||
<span id="cost-display" class="cost-display"></span>
|
||||
<span id="cost-display" class="cost-display" hidden></span>
|
||||
</header>
|
||||
|
||||
<div class="messages-wrap">
|
||||
@@ -68,7 +80,7 @@
|
||||
<div class="welcome-msg">
|
||||
<div class="welcome-icon">✿</div>
|
||||
<h3>欢迎使用 CC-Web</h3>
|
||||
<p>开始与 Claude Code 对话</p>
|
||||
<p>开始与 Claude 对话</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-scrollbar" id="custom-scrollbar">
|
||||
@@ -80,7 +92,14 @@
|
||||
<div id="cmd-menu" class="cmd-menu" hidden></div>
|
||||
|
||||
<div class="input-area">
|
||||
<div id="attachment-tray" class="attachment-tray" hidden></div>
|
||||
<div class="input-wrapper">
|
||||
<input id="image-upload-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple hidden>
|
||||
<button id="attach-btn" class="attach-btn" title="上传图片" type="button">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05l-8.49 8.49a6 6 0 1 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<textarea id="msg-input" rows="1" placeholder="输入消息… 输入 / 查看指令" autocomplete="off"></textarea>
|
||||
<button id="send-btn" class="send-btn" title="发送">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -97,6 +116,17 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="session-loading-overlay" class="session-loading-overlay" hidden aria-live="polite" aria-hidden="true">
|
||||
<div class="session-loading-card" role="status" aria-busy="true">
|
||||
<div class="session-loading-badge">Session</div>
|
||||
<div class="session-loading-title">会话加载中</div>
|
||||
<div id="session-loading-label" class="session-loading-label">正在整理消息与上下文…</div>
|
||||
<div class="session-loading-bar">
|
||||
<span class="session-loading-bar-fill"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
|
||||
1072
public/style.css
1072
public/style.css
File diff suppressed because it is too large
Load Diff
52
scripts/mock-claude.js
Executable file
52
scripts/mock-claude.js
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function readStdin() {
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => { data += chunk; });
|
||||
process.stdin.on('end', () => resolve(data));
|
||||
});
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const resumeIndex = args.indexOf('--resume');
|
||||
const inputFormatIndex = args.indexOf('--input-format');
|
||||
const sessionId = resumeIndex >= 0 && args[resumeIndex + 1]
|
||||
? args[resumeIndex + 1]
|
||||
: crypto.randomUUID();
|
||||
|
||||
const input = (await readStdin()).trim();
|
||||
const usesStreamJson = inputFormatIndex >= 0 && args[inputFormatIndex + 1] === 'stream-json';
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ type: 'system', session_id: sessionId })}\n`);
|
||||
|
||||
let text = '';
|
||||
if (usesStreamJson) {
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(input.split('\n').find(Boolean) || '{}'); } catch {}
|
||||
const blocks = payload?.message?.content || [];
|
||||
const imageCount = blocks.filter((block) => block.type === 'image').length;
|
||||
const promptText = blocks.filter((block) => block.type === 'text').map((block) => block.text || '').join(' ').trim();
|
||||
text = `Claude mock handled stream-json (${imageCount} image): ${promptText || '[no text]'}`;
|
||||
} else if (input === '/compact') {
|
||||
text = 'Claude compact finished.';
|
||||
} else {
|
||||
text = `Claude mock handled: ${input}`;
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
type: 'assistant',
|
||||
session_id: sessionId,
|
||||
message: { content: [{ type: 'text', text }] },
|
||||
})}\n`);
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
type: 'result',
|
||||
session_id: sessionId,
|
||||
total_cost_usd: 0,
|
||||
})}\n`);
|
||||
})();
|
||||
62
scripts/mock-codex.js
Executable file
62
scripts/mock-codex.js
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function readStdin() {
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => { data += chunk; });
|
||||
process.stdin.on('end', () => resolve(data));
|
||||
});
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const isResume = args[0] === 'exec' && args[1] === 'resume';
|
||||
const threadId = isResume && args[2] ? args[2] : `mock-${crypto.randomUUID()}`;
|
||||
const input = (await readStdin()).trim();
|
||||
const imageCount = args.filter((arg) => arg === '--image').length;
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ type: 'thread.started', thread_id: threadId })}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ type: 'turn.started' })}\n`);
|
||||
|
||||
if (/pwd/i.test(input)) {
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
type: 'item.started',
|
||||
item: {
|
||||
id: 'item_cmd',
|
||||
type: 'command_execution',
|
||||
command: '/bin/bash -lc pwd',
|
||||
aggregated_output: '',
|
||||
exit_code: null,
|
||||
status: 'in_progress',
|
||||
},
|
||||
})}\n`);
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
id: 'item_cmd',
|
||||
type: 'command_execution',
|
||||
command: '/bin/bash -lc pwd',
|
||||
aggregated_output: '/tmp/mock-codex\n',
|
||||
exit_code: 0,
|
||||
status: 'completed',
|
||||
},
|
||||
})}\n`);
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
id: 'item_msg',
|
||||
type: 'agent_message',
|
||||
text: `Codex mock handled (${imageCount} image): ${input}`,
|
||||
},
|
||||
})}\n`);
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
type: 'turn.completed',
|
||||
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 5 },
|
||||
})}\n`);
|
||||
})();
|
||||
403
scripts/regression.js
Normal file
403
scripts/regression.js
Normal file
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const REPO_DIR = path.resolve(__dirname, '..');
|
||||
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
|
||||
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
|
||||
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
|
||||
|
||||
function mkdirp(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function sql(dbPath, statement) {
|
||||
const result = spawnSync('sqlite3', [dbPath, statement], { encoding: 'utf8' });
|
||||
if (result.status !== 0) throw new Error(result.stderr || `sqlite3 failed: ${statement}`);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function waitForPort(port, timeoutMs = 10000) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const probe = spawnSync('bash', ['-lc', `ss -tln | grep -q ':${port} '`], { encoding: 'utf8' });
|
||||
if (probe.status === 0) return;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(`Timed out waiting for port ${port}`);
|
||||
}
|
||||
|
||||
async function withServer(env, fn) {
|
||||
const child = spawn('/usr/bin/node', [SERVER_PATH], {
|
||||
cwd: REPO_DIR,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
||||
|
||||
try {
|
||||
await waitForPort(env.PORT, 10000);
|
||||
await fn({ child, stdout: () => stdout, stderr: () => stderr });
|
||||
} finally {
|
||||
child.kill('SIGTERM');
|
||||
await sleep(300);
|
||||
if (!child.killed) child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
function connectWs(port, password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||
const messages = [];
|
||||
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'auth', password }));
|
||||
});
|
||||
ws.on('message', (buf) => {
|
||||
const msg = JSON.parse(String(buf));
|
||||
messages.push(msg);
|
||||
if (msg.type === 'auth_result' && msg.success) resolve({ ws, messages, token: msg.token });
|
||||
if (msg.type === 'auth_result' && !msg.success) reject(new Error('Auth failed'));
|
||||
});
|
||||
ws.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadAttachment(port, token, { filename, mime, data }) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/attachments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': mime,
|
||||
'X-Filename': encodeURIComponent(filename),
|
||||
},
|
||||
body: data,
|
||||
});
|
||||
const payload = await response.json();
|
||||
assert(response.ok && payload.ok, `Attachment upload failed: ${payload.message || response.status}`);
|
||||
return payload.attachment;
|
||||
}
|
||||
|
||||
function nextMessage(messages, ws, predicate, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const started = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
const found = messages.find(predicate);
|
||||
if (found) {
|
||||
clearInterval(timer);
|
||||
resolve(found);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - started > timeoutMs) {
|
||||
clearInterval(timer);
|
||||
reject(new Error('Timed out waiting for expected WebSocket message'));
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
function createFakeClaudeHistory(homeDir) {
|
||||
const projectDir = path.join(homeDir, '.claude', 'projects', 'tmp-project');
|
||||
mkdirp(projectDir);
|
||||
const sessionId = 'claude-import-test';
|
||||
const filePath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
cwd: '/tmp/project-a',
|
||||
timestamp: '2026-03-12T00:00:00.000Z',
|
||||
message: { content: 'Claude import prompt' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: '2026-03-12T00:00:02.000Z',
|
||||
message: { content: [{ type: 'text', text: 'Claude import answer' }] },
|
||||
}),
|
||||
];
|
||||
fs.writeFileSync(filePath, `${lines.join('\n')}\n`);
|
||||
return { sessionId, projectDir: 'tmp-project', filePath };
|
||||
}
|
||||
|
||||
function createFakeCodexHistory(homeDir) {
|
||||
const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '03', '12');
|
||||
mkdirp(sessionsDir);
|
||||
const threadId = 'codex-import-thread';
|
||||
const rolloutPath = path.join(sessionsDir, 'rollout-2026-03-12T00-00-00-codex-import-thread.jsonl');
|
||||
const rolloutLines = [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-12T00:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: threadId, cwd: '/tmp/project-b', cli_version: '0.114.0', source: 'exec' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-12T00:00:00.100Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: '# AGENTS.md wrapper should be ignored' }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-12T00:00:01.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'Codex import prompt' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-12T00:00:02.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Codex import answer' }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-12T00:00:03.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: { total_token_usage: { input_tokens: 20, cached_input_tokens: 5, output_tokens: 8 } },
|
||||
},
|
||||
}),
|
||||
];
|
||||
fs.writeFileSync(rolloutPath, `${rolloutLines.join('\n')}\n`);
|
||||
|
||||
const stateDb = path.join(homeDir, '.codex', 'state_5.sqlite');
|
||||
mkdirp(path.dirname(stateDb));
|
||||
sql(stateDb, `
|
||||
PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sandbox_policy TEXT NOT NULL,
|
||||
approval_mode TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
has_user_event INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
cli_version TEXT NOT NULL DEFAULT '',
|
||||
first_user_message TEXT NOT NULL DEFAULT '',
|
||||
agent_nickname TEXT,
|
||||
agent_role TEXT,
|
||||
memory_mode TEXT NOT NULL DEFAULT 'enabled'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS stage1_outputs (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
source_updated_at INTEGER NOT NULL,
|
||||
raw_memory TEXT NOT NULL,
|
||||
rollout_summary TEXT NOT NULL,
|
||||
generated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
|
||||
thread_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
input_schema TEXT NOT NULL,
|
||||
PRIMARY KEY(thread_id, position)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
ts_nanos INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
message TEXT,
|
||||
module_path TEXT,
|
||||
file TEXT,
|
||||
line INTEGER,
|
||||
thread_id TEXT,
|
||||
process_uuid TEXT,
|
||||
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO threads (id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, cli_version)
|
||||
VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, 'exec', 'OpenAI', '/tmp/project-b', 'Codex import prompt', '{}', 'never', '0.114.0');
|
||||
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
|
||||
`);
|
||||
|
||||
const logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite');
|
||||
sql(logsDb, `
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
ts_nanos INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
message TEXT,
|
||||
module_path TEXT,
|
||||
file TEXT,
|
||||
line INTEGER,
|
||||
thread_id TEXT,
|
||||
process_uuid TEXT,
|
||||
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
|
||||
`);
|
||||
|
||||
return { threadId, rolloutPath, stateDb, logsDb };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
||||
const configDir = path.join(tempRoot, 'config');
|
||||
const sessionsDir = path.join(tempRoot, 'sessions');
|
||||
const logsDir = path.join(tempRoot, 'logs');
|
||||
const homeDir = path.join(tempRoot, 'home');
|
||||
mkdirp(configDir);
|
||||
mkdirp(sessionsDir);
|
||||
mkdirp(logsDir);
|
||||
mkdirp(homeDir);
|
||||
|
||||
fs.writeFileSync(path.join(configDir, 'notify.json'), JSON.stringify({
|
||||
provider: 'off',
|
||||
pushplus: { token: '' },
|
||||
telegram: { botToken: '', chatId: '' },
|
||||
serverchan: { sendKey: '' },
|
||||
feishu: { webhook: '' },
|
||||
qqbot: { qmsgKey: '' },
|
||||
}, null, 2));
|
||||
|
||||
createFakeClaudeHistory(homeDir);
|
||||
const codexFixture = createFakeCodexHistory(homeDir);
|
||||
|
||||
const port = 9102;
|
||||
const password = 'Regression!234';
|
||||
|
||||
await withServer({
|
||||
PORT: String(port),
|
||||
CC_WEB_PASSWORD: password,
|
||||
CC_WEB_CONFIG_DIR: configDir,
|
||||
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||||
CC_WEB_LOGS_DIR: logsDir,
|
||||
HOME: homeDir,
|
||||
CLAUDE_PATH: MOCK_CLAUDE,
|
||||
CODEX_PATH: MOCK_CODEX,
|
||||
}, async () => {
|
||||
const { ws, messages, token } = await connectWs(port, password);
|
||||
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'save_codex_config',
|
||||
config: {
|
||||
mode: 'custom',
|
||||
activeProfile: 'Regression Profile',
|
||||
profiles: [{ name: 'Regression Profile', apiKey: 'sk-regression', apiBase: 'https://example.com/v1' }],
|
||||
enableSearch: true,
|
||||
},
|
||||
}));
|
||||
const codexConfigMsg = await nextMessage(messages, ws, (msg) => msg.type === 'codex_config');
|
||||
assert(codexConfigMsg.config.mode === 'custom', 'Codex config mode save/load failed');
|
||||
assert(codexConfigMsg.config.activeProfile === 'Regression Profile', 'Codex active profile save/load failed');
|
||||
assert(Array.isArray(codexConfigMsg.config.profiles) && codexConfigMsg.config.profiles[0]?.apiKey.includes('****'), 'Codex profile API key should be masked');
|
||||
assert(codexConfigMsg.config.supportsSearch === false, 'Codex config should expose unsupported search capability');
|
||||
assert(codexConfigMsg.config.enableSearch === false, 'Codex config should ignore unsupported search toggle');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: '/tmp/codex-space', mode: 'plan' }));
|
||||
const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === '/tmp/codex-space');
|
||||
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
|
||||
assert(codexSession.model === null, 'Codex new_session should not inject a default model');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/model gpt-5.3-codex', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' }));
|
||||
const codexModelChanged = await nextMessage(messages, ws, (msg) => msg.type === 'model_changed' && msg.model === 'gpt-5.3-codex');
|
||||
assert(codexModelChanged.model === 'gpt-5.3-codex', 'Codex /model should accept arbitrary Codex model names');
|
||||
|
||||
const codexAttachment = await uploadAttachment(port, token, {
|
||||
filename: 'codex-test.png',
|
||||
mime: 'image/png',
|
||||
data: Buffer.from('codex-image'),
|
||||
});
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'first codex prompt', attachments: [codexAttachment], mode: 'yolo', agent: 'codex' }));
|
||||
const firstMessageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'first codex prompt');
|
||||
assert(firstMessageSession.agent === 'codex', 'First-message path created wrong agent');
|
||||
const runningSessionList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning));
|
||||
assert(runningSessionList.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning), 'Running Codex session should be marked as isRunning');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
|
||||
const processLog = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
|
||||
const spawnLine = processLog
|
||||
.trim()
|
||||
.split('\n')
|
||||
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8)));
|
||||
assert(spawnLine && !spawnLine.includes('--search') && spawnLine.includes('--image'), 'Codex exec should attach images and not append unsupported --search flag');
|
||||
const runtimeToml = fs.readFileSync(path.join(configDir, 'codex-runtime-home', 'config.toml'), 'utf8');
|
||||
assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode');
|
||||
assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url');
|
||||
|
||||
const claudeAttachment = await uploadAttachment(port, token, {
|
||||
filename: 'claude-test.png',
|
||||
mime: 'image/png',
|
||||
data: Buffer.from('claude-image'),
|
||||
});
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'describe attachment', attachments: [claudeAttachment], mode: 'yolo', agent: 'claude' }));
|
||||
const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachment');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
|
||||
const claudeSpawnLine = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
|
||||
assert(claudeSpawnLine && claudeSpawnLine.includes('--input-format stream-json'), 'Claude image message should switch stdin to stream-json');
|
||||
const storedClaudeSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
|
||||
assert(Array.isArray(storedClaudeSession.messages?.[0]?.attachments) && storedClaudeSession.messages[0].attachments.length === 1, 'Claude message should persist attachment metadata');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'list_native_sessions' }));
|
||||
const nativeSessions = await nextMessage(messages, ws, (msg) => msg.type === 'native_sessions');
|
||||
assert(nativeSessions.groups?.length > 0, 'Claude native session listing failed');
|
||||
const firstClaude = nativeSessions.groups[0].sessions[0];
|
||||
ws.send(JSON.stringify({ type: 'import_native_session', sessionId: firstClaude.sessionId, projectDir: nativeSessions.groups[0].dir }));
|
||||
const importedClaude = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'Claude import prompt');
|
||||
assert(importedClaude.messages?.[0]?.content === 'Claude import prompt', 'Claude import parsed wrong first message');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'list_codex_sessions' }));
|
||||
const codexSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
|
||||
const importedCodexItem = codexSessions.sessions.find((item) => item.threadId === codexFixture.threadId);
|
||||
assert(importedCodexItem, 'Codex session listing failed');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'import_codex_session', threadId: importedCodexItem.threadId, rolloutPath: importedCodexItem.rolloutPath }));
|
||||
const importedCodex = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'Codex import prompt');
|
||||
assert(importedCodex.messages?.[0]?.content === 'Codex import prompt', 'Codex import kept wrapper instructions');
|
||||
assert(importedCodex.totalUsage?.inputTokens === 20, 'Codex import usage parse failed');
|
||||
|
||||
const importedSessionId = importedCodex.sessionId;
|
||||
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedSessionId }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedSessionId));
|
||||
|
||||
assert(!fs.existsSync(path.join(sessionsDir, `${importedSessionId}.json`)), 'Deleting Codex session did not remove session JSON');
|
||||
assert(!fs.existsSync(codexFixture.rolloutPath), 'Deleting Codex session did not remove rollout file');
|
||||
assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row');
|
||||
|
||||
ws.close();
|
||||
console.log('Regression checks passed.');
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.stack || err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user