Files
cc-web/scripts/mock-codex-app-server.js
2026-07-02 08:32:49 +08:00

923 lines
26 KiB
JavaScript
Executable File

#!/usr/bin/env node
const crypto = require('crypto');
const path = require('path');
const readline = require('readline');
const { spawn } = require('child_process');
const args = process.argv.slice(2);
if (args[0] !== 'app-server') {
const child = spawn(process.execPath, [path.join(__dirname, 'mock-codex.js'), ...args], {
stdio: 'inherit',
});
child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal);
process.exit(code || 0);
});
child.on('error', (err) => {
console.error(err.stack || err.message);
process.exit(1);
});
return;
}
const threads = new Map();
const pendingServerRequests = new Map();
const resumeMismatchThreads = new Set();
let nextServerRequestId = 1;
let mcpReloadCount = 0;
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function textFromInput(input) {
return (Array.isArray(input) ? input : [])
.map((item) => {
if (item?.type === 'text') return item.text || '';
if (item?.type === 'localImage') return `[image:${path.basename(item.path || 'image')}]`;
return '';
})
.filter(Boolean)
.join('\n')
.trim();
}
function tokenUsage(text) {
const inputTokens = Math.max(1, Math.ceil(String(text || '').length / 4));
const outputTokens = 7;
return {
last: {
inputTokens,
cachedInputTokens: 1,
outputTokens,
reasoningOutputTokens: 0,
totalTokens: inputTokens + outputTokens + 1,
},
total: {
inputTokens,
cachedInputTokens: 1,
outputTokens,
reasoningOutputTokens: 0,
totalTokens: inputTokens + outputTokens + 1,
},
};
}
function retryScenarioKey(text, marker) {
return new RegExp(marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(String(text || ''))
? marker
: String(text || '');
}
function collaborationSummary(params = {}) {
const collaborationMode = params.collaborationMode;
const settings = collaborationMode?.settings || {};
return JSON.stringify({
mode: collaborationMode?.mode || null,
hasModel: Boolean(settings.model),
hasDeveloperInstructions: /Codex sub-agent spawning rules/.test(String(settings.developer_instructions || '')),
hasWaitAgentRetryGuidance: /wait_agent[\s\S]*timeout_ms[\s\S]*additional wait_agent rounds/.test(String(settings.developer_instructions || '')),
hasReasoningEffort: Object.prototype.hasOwnProperty.call(settings, 'reasoning_effort'),
hasTopLevelModel: Object.prototype.hasOwnProperty.call(params, 'model'),
hasTopLevelEffort: Object.prototype.hasOwnProperty.call(params, 'effort'),
});
}
function ensureThread(threadId, params = {}) {
const id = threadId || `app-thread-${crypto.randomUUID()}`;
if (!threads.has(id)) {
threads.set(id, {
id,
cwd: params.cwd || process.cwd(),
dynamicTools: Array.isArray(params.dynamicTools) ? params.dynamicTools : [],
config: params.config && typeof params.config === 'object' ? params.config : {},
activeTurnId: null,
timer: null,
steers: [],
capacityRetryAttempts: new Map(),
reconnectRetryAttempts: new Map(),
goal: null,
});
}
const thread = threads.get(id);
if (Array.isArray(params.dynamicTools)) thread.dynamicTools = params.dynamicTools;
if (params.config && typeof params.config === 'object') thread.config = params.config;
return thread;
}
function threadPayload(thread) {
return {
id: thread.id,
cwd: thread.cwd,
status: thread.activeTurnId ? 'running' : 'idle',
turns: [],
};
}
function emitChildCollabTurn(threadId, turnId, finalMessage) {
const childThread = ensureThread(threadId);
childThread.activeTurnId = turnId;
send({
method: 'turn/started',
params: {
threadId,
turn: {
id: turnId,
status: 'running',
items: [],
},
},
});
send({
method: 'item/agentMessage/delta',
params: {
threadId,
turnId,
itemId: 'agent-msg',
delta: finalMessage,
},
});
send({
method: 'item/completed',
params: {
threadId,
turnId,
completedAtMs: Date.now(),
item: {
id: 'agent-msg',
type: 'agentMessage',
content: [{ type: 'text', text: finalMessage }],
status: 'completed',
},
},
});
send({
method: 'turn/completed',
params: {
threadId,
turn: {
id: turnId,
status: 'completed',
items: [],
},
},
});
childThread.activeTurnId = null;
}
function completeTurn(thread, turnId, text, status = 'completed') {
if (thread.activeTurnId !== turnId) return;
const suffix = thread.steers.length > 0 ? ` | steer: ${thread.steers.join(' | ')}` : '';
const responseText = `Codex App mock handled: ${text}${suffix}`;
send({
method: 'item/agentMessage/delta',
params: {
threadId: thread.id,
turnId,
itemId: 'agent-msg',
delta: responseText,
},
});
if (/tool/i.test(text)) {
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: 'tool-cmd',
type: 'commandExecution',
command: '/bin/bash -lc echo codexapp',
status: 'inProgress',
},
},
});
send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: thread.id,
turnId,
itemId: 'tool-cmd',
delta: 'codexapp\n',
},
});
send({
method: 'item/completed',
params: {
threadId: thread.id,
turnId,
completedAtMs: Date.now(),
item: {
id: 'tool-cmd',
type: 'commandExecution',
command: '/bin/bash -lc echo codexapp',
aggregatedOutput: 'codexapp\n',
exitCode: 0,
status: 'completed',
},
},
});
}
if (/huge output/i.test(text)) {
const hugeOutput = `huge-output-start\n${'0123456789abcdef'.repeat(30000)}\nhuge-output-end\n`;
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: 'huge-tool',
type: 'commandExecution',
command: '/bin/bash -lc huge-output',
status: 'inProgress',
},
},
});
send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: thread.id,
turnId,
itemId: 'huge-tool',
delta: hugeOutput,
},
});
send({
method: 'item/completed',
params: {
threadId: thread.id,
turnId,
completedAtMs: Date.now(),
item: {
id: 'huge-tool',
type: 'commandExecution',
command: '/bin/bash -lc huge-output',
aggregatedOutput: hugeOutput,
exitCode: 0,
status: 'completed',
},
},
});
}
if (/subagent/i.test(text)) {
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: 'tool-collab',
type: 'collabAgentToolCall',
tool: 'spawn_agent',
prompt: '整理前端改动并回报状态',
receiverThreadIds: ['child-thread-a', 'child-thread-b'],
agentsStates: {
'child-thread-a': {
name: '实现代理',
role: 'frontend',
status: 'in_progress',
summary: '正在补充结构化渲染与样式',
},
'child-thread-b': {
name: '验证代理',
role: 'qa',
status: 'completed',
summary: '已完成基础事件链检查',
},
},
status: 'inProgress',
},
},
});
send({
method: 'item/completed',
params: {
threadId: thread.id,
turnId,
completedAtMs: Date.now(),
item: {
id: 'tool-collab',
type: 'collabAgentToolCall',
tool: 'spawn_agent',
prompt: '整理前端改动并回报状态',
receiverThreadIds: ['child-thread-a', 'child-thread-b'],
agentsStates: {
'child-thread-a': {
name: '实现代理',
role: 'frontend',
status: 'completed',
summary: '结构化渲染已完成',
},
'child-thread-b': {
name: '验证代理',
role: 'qa',
status: 'completed',
summary: '事件链与持久化检查通过',
},
},
status: 'completed',
},
},
});
emitChildCollabTurn(
'child-thread-a',
'child-turn-a',
'子代理最终消息:结构化渲染和关闭按钮链路已完成。'
);
emitChildCollabTurn(
'child-thread-b',
'child-turn-b',
'子代理最终消息:事件路由、持久化和状态推送检查通过。'
);
}
send({
method: 'thread/tokenUsage/updated',
params: {
threadId: thread.id,
turnId,
tokenUsage: tokenUsage(`${text}${suffix}`),
},
});
send({
method: 'turn/completed',
params: {
threadId: thread.id,
turn: {
id: turnId,
status,
items: [],
},
},
});
thread.activeTurnId = null;
thread.timer = null;
thread.steers = [];
}
function requestClient(method, params, callback) {
const id = `mock-server-request-${nextServerRequestId++}`;
pendingServerRequests.set(id, callback);
send({ id, method, params });
}
function completeDynamicToolTurn(thread, turnId, text) {
const callId = 'dynamic-ccweb-list';
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: callId,
type: 'dynamicToolCall',
namespace: 'ccweb',
tool: 'ccweb_list_conversations',
arguments: { limit: 5 },
status: 'inProgress',
},
},
});
requestClient('item/tool/call', {
threadId: thread.id,
turnId,
callId,
namespace: 'ccweb',
tool: 'ccweb_list_conversations',
arguments: { limit: 5 },
}, (message) => {
const result = message.result || {};
send({
method: 'item/completed',
params: {
threadId: thread.id,
turnId,
completedAtMs: Date.now(),
item: {
id: callId,
type: 'dynamicToolCall',
namespace: 'ccweb',
tool: 'ccweb_list_conversations',
arguments: { limit: 5 },
status: result.success === false ? 'failed' : 'completed',
contentItems: result.contentItems || [],
success: result.success !== false,
durationMs: 1,
},
},
});
completeTurn(thread, turnId, `dynamic result: ${JSON.stringify(result)}`);
});
}
function completeMcpToolTurn(thread, turnId) {
const itemId = 'mcp-ccweb-list';
const ccwebConfig = thread.config?.['mcp_servers.ccweb'] || null;
const projectConfig = thread.config?.['mcp_servers.reg-app-project'] || null;
const env = ccwebConfig?.env || {};
let urlSourceSessionId = null;
let urlSourceHopCount = null;
try {
if (ccwebConfig?.url) {
const parsedUrl = new URL(ccwebConfig.url);
urlSourceSessionId = parsedUrl.searchParams.get('sourceSessionId') || null;
urlSourceHopCount = parsedUrl.searchParams.get('sourceHopCount') || null;
}
} catch {}
const payload = {
ok: true,
currentConversationId: env.CC_WEB_SOURCE_SESSION_ID || urlSourceSessionId,
sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || urlSourceHopCount,
hasCcwebMcpConfig: Boolean(ccwebConfig),
hasProjectMcpConfig: Boolean(projectConfig),
ccwebType: ccwebConfig?.type || (ccwebConfig?.url ? 'streamable_http' : (ccwebConfig?.command ? 'stdio' : null)),
ccwebUrl: ccwebConfig?.url || null,
ccwebBearerTokenEnvVar: ccwebConfig?.bearer_token_env_var || null,
ccwebCommand: ccwebConfig?.command || null,
ccwebArgs: ccwebConfig?.args || null,
};
const itemBase = {
id: itemId,
type: 'mcpToolCall',
server: 'ccweb',
tool: 'ccweb_list_conversations',
arguments: { limit: 5 },
};
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
...itemBase,
status: 'inProgress',
},
},
});
send({
method: 'item/completed',
params: {
threadId: thread.id,
turnId,
completedAtMs: Date.now(),
item: {
...itemBase,
status: 'completed',
result: {
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
structuredContent: payload,
isError: false,
},
},
},
});
completeTurn(thread, turnId, `mcp result: ${JSON.stringify(payload)}`);
}
function emitCapacityError(thread, turnId) {
send({
method: 'error',
params: {
threadId: thread.id,
turnId,
type: 'error',
error: {
type: 'service_unavailable_error',
code: 'server_is_overloaded',
message: 'Our servers are currently overloaded. Please try again later.',
param: null,
},
sequence_number: 2,
},
});
thread.activeTurnId = null;
}
function emitPartialCapacityOutput(thread, turnId) {
send({
method: 'item/agentMessage/delta',
params: {
threadId: thread.id,
turnId,
itemId: 'agent-msg',
delta: 'partial capacity output before retry',
},
});
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: 'capacity-tool',
type: 'commandExecution',
command: '/bin/bash -lc echo capacity',
status: 'inProgress',
},
},
});
send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: thread.id,
turnId,
itemId: 'capacity-tool',
delta: 'capacity tool output\n',
},
});
}
function completeGuidedInputTurn(thread, turnId) {
requestClient('item/tool/requestUserInput', {
threadId: thread.id,
turnId,
itemId: 'guided-input-call',
questions: [{
id: 'choice',
header: '选择',
question: '选择一个测试答案',
isOther: true,
isSecret: false,
options: [
{ label: 'A', description: '测试选项 A' },
{ label: 'B', description: '测试选项 B' },
],
}],
}, (message) => {
const answer = message.result?.answers?.choice?.answers?.[0] || 'empty';
completeTurn(thread, turnId, `guided answer: ${answer}`);
});
}
function completeApprovalTurn(thread, turnId) {
requestClient('item/commandExecution/requestApproval', {
threadId: thread.id,
turnId,
itemId: 'approval-command-call',
reason: 'Need to run an approval-gated command',
command: 'echo approved',
cwd: thread.cwd,
}, (message) => {
const decision = message.result?.decision || 'missing';
completeTurn(thread, turnId, `approval decision: ${decision}`);
});
}
function completeEmptyReasoningTurn(thread, turnId, text) {
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: 'reasoning-empty',
type: 'reasoning',
content: [],
summary: [],
status: 'inProgress',
},
},
});
send({
method: 'item/completed',
params: {
threadId: thread.id,
turnId,
completedAtMs: Date.now(),
item: {
id: 'reasoning-empty',
type: 'reasoning',
content: [],
summary: [],
status: 'completed',
},
},
});
completeTurn(thread, turnId, text);
}
function startTurn(params) {
const thread = ensureThread(params.threadId, params);
const turnId = `app-turn-${crypto.randomUUID()}`;
const text = textFromInput(params.input);
thread.activeTurnId = turnId;
thread.steers = [];
send({
method: 'turn/started',
params: {
threadId: thread.id,
turn: {
id: turnId,
status: 'running',
items: [],
},
},
});
if (/runtime warning/i.test(text)) {
const message = 'Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.';
for (let i = 0; i < 2; i += 1) {
send({
method: 'warning',
params: {
threadId: thread.id,
turnId,
message,
},
});
}
}
if (/codexapp capacity retry/i.test(text)) {
const retryKey = retryScenarioKey(text, 'codexapp capacity retry');
const attempts = (thread.capacityRetryAttempts.get(retryKey) || 0) + 1;
thread.capacityRetryAttempts.set(retryKey, attempts);
if (attempts <= 2) {
if (attempts === 2) emitPartialCapacityOutput(thread, turnId);
emitCapacityError(thread, turnId);
return { turn: { id: turnId, status: 'running', items: [] } };
}
}
if (/codexapp reconnect retry/i.test(text)) {
const retryKey = retryScenarioKey(text, 'codexapp reconnect retry');
const attempts = (thread.reconnectRetryAttempts.get(retryKey) || 0) + 1;
thread.reconnectRetryAttempts.set(retryKey, attempts);
if (attempts === 1) {
emitPartialCapacityOutput(thread, turnId);
send({
method: 'error',
params: {
threadId: thread.id,
turnId,
message: 'Reconnecting... 1/5',
},
});
thread.activeTurnId = null;
return { turn: { id: turnId, status: 'running', items: [] } };
}
}
if (/codexapp retry thread mismatch/i.test(text)) {
const retryKey = retryScenarioKey(text, 'codexapp retry thread mismatch');
const attempts = (thread.capacityRetryAttempts.get(retryKey) || 0) + 1;
thread.capacityRetryAttempts.set(retryKey, attempts);
if (attempts === 1) {
resumeMismatchThreads.add(thread.id);
emitCapacityError(thread, turnId);
return { turn: { id: turnId, status: 'running', items: [] } };
}
}
if (/collaboration/i.test(text)) {
completeTurn(thread, turnId, `collaboration mode: ${collaborationSummary(params)}`);
return { turn: { id: turnId, status: 'running', items: [] } };
}
if (/empty reasoning/i.test(text)) {
completeEmptyReasoningTurn(thread, turnId, text);
return { turn: { id: turnId, status: 'running', items: [] } };
}
if (/dynamic/i.test(text)) {
completeMcpToolTurn(thread, turnId);
return { turn: { id: turnId, status: 'running', items: [] } };
}
if (/guided/i.test(text)) {
if (!params.collaborationMode || params.collaborationMode.mode !== 'plan') {
completeTurn(thread, turnId, 'guided input unavailable outside plan');
return { turn: { id: turnId, status: 'running', items: [] } };
}
completeGuidedInputTurn(thread, turnId);
return { turn: { id: turnId, status: 'running', items: [] } };
}
if (/approval/i.test(text)) {
completeApprovalTurn(thread, turnId);
return { turn: { id: turnId, status: 'running', items: [] } };
}
const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80;
if (/recover/i.test(text)) {
send({
method: 'item/agentMessage/delta',
params: {
threadId: thread.id,
turnId,
itemId: 'agent-msg',
delta: `partial before restart: ${text}`,
},
});
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: 'recover-tool',
type: 'commandExecution',
command: '/bin/bash -lc echo recover',
status: 'inProgress',
},
},
});
send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: thread.id,
turnId,
itemId: 'recover-tool',
delta: 'recover tool output\n',
},
});
}
thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);
return { turn: { id: turnId, status: 'running', items: [] } };
}
function interruptTurn(params) {
const thread = ensureThread(params.threadId, params);
if (thread.timer) clearTimeout(thread.timer);
if (thread.activeTurnId === params.turnId) {
completeTurn(thread, params.turnId, 'interrupted', 'interrupted');
}
return {};
}
function steerTurn(params) {
const thread = ensureThread(params.threadId, params);
if (!thread.activeTurnId || thread.activeTurnId !== params.expectedTurnId) {
return {
error: {
code: -32001,
message: 'expectedTurnId does not match active turn',
},
};
}
const text = textFromInput(params.input);
if (text) thread.steers.push(text);
send({
method: 'item/agentMessage/delta',
params: {
threadId: thread.id,
turnId: thread.activeTurnId,
itemId: 'agent-msg',
delta: `\n[steer accepted: ${text}]`,
},
});
return { result: {} };
}
function handleRequest(message) {
const id = message.id;
const method = message.method;
const params = message.params || {};
if (id && !method && pendingServerRequests.has(id)) {
const callback = pendingServerRequests.get(id);
pendingServerRequests.delete(id);
callback(message);
return;
}
try {
if (method === 'initialize') {
send({ id, result: { serverInfo: { name: 'mock-codex-app-server', version: '0.0.0' } } });
return;
}
if (method === 'experimentalFeature/enablement/set') {
send({ id, result: { enablement: params.enablement || {} } });
return;
}
if (method === 'collaborationMode/list') {
send({ id, result: { data: [{ mode: 'default' }, { mode: 'plan' }] } });
return;
}
if (method === 'config/mcpServer/reload') {
mcpReloadCount += 1;
send({
method: 'mcpServer/startupStatus/updated',
params: {
server: 'ccweb',
state: 'starting',
message: 'ccweb MCP starting',
threadId: null,
},
});
send({
method: 'mcpServer/startupStatus/updated',
params: {
name: 'ccweb',
status: 'ready',
message: 'ccweb MCP ready CC_WEB_MCP_TOKEN=mock-secret-token',
threadId: null,
},
});
send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } });
return;
}
if (method === 'thread/start') {
const thread = ensureThread(null, params);
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
return;
}
if (method === 'thread/resume') {
if (params.threadId && resumeMismatchThreads.delete(params.threadId)) {
const thread = ensureThread(null, params);
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
return;
}
const thread = ensureThread(params.threadId, params);
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
return;
}
if (method === 'thread/goal/get') {
const thread = ensureThread(params.threadId, params);
send({ id, result: { goal: thread.goal } });
return;
}
if (method === 'thread/goal/set') {
const thread = ensureThread(params.threadId, params);
const now = Date.now();
const previous = thread.goal || {};
const objective = String(params.objective || previous.objective || 'mock goal').trim();
thread.goal = {
threadId: thread.id,
objective,
status: String(params.status || previous.status || 'active'),
tokenBudget: previous.tokenBudget ?? null,
tokensUsed: previous.tokensUsed ?? 3,
timeUsedSeconds: previous.timeUsedSeconds ?? 0,
createdAt: previous.createdAt || now,
updatedAt: now,
};
send({
method: 'thread/goal/updated',
params: { threadId: thread.id, goal: thread.goal },
});
setTimeout(() => {
send({ id, result: { goal: thread.goal } });
}, 250);
return;
}
if (method === 'thread/goal/clear') {
const thread = ensureThread(params.threadId, params);
const cleared = !!thread.goal;
thread.goal = null;
send({
method: 'thread/goal/cleared',
params: { threadId: thread.id },
});
send({ id, result: { cleared } });
return;
}
if (method === 'turn/start') {
send({ id, result: startTurn(params) });
return;
}
if (method === 'turn/steer') {
const result = steerTurn(params);
send({ id, ...result });
return;
}
if (method === 'turn/interrupt') {
send({ id, result: interruptTurn(params) });
return;
}
if (method === 'initialized') return;
send({ id, error: { code: -32601, message: `Unknown mock method: ${method}` } });
} catch (err) {
send({ id, error: { code: -32603, message: err.message || String(err) } });
}
}
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => {
if (!line.trim()) return;
let message;
try {
message = JSON.parse(line);
} catch {
return;
}
if (Object.prototype.hasOwnProperty.call(message, 'id')) handleRequest(message);
});