1183 lines
32 KiB
JavaScript
Executable File
1183 lines
32 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 || {};
|
||
const developerInstructions = String(settings.developer_instructions || '');
|
||
return JSON.stringify({
|
||
mode: collaborationMode?.mode || null,
|
||
hasModel: Boolean(settings.model),
|
||
hasDeveloperInstructions: /Codex sub-agent runtime rules/.test(developerInstructions),
|
||
hasSchemaDrivenSubagents: /current runtime tool schema/.test(developerInstructions),
|
||
hasLegacyV1Guidance: /fork_context|additional wait_agent rounds/.test(developerInstructions),
|
||
hasReasoningEffort: Object.prototype.hasOwnProperty.call(settings, 'reasoning_effort'),
|
||
reasoningEffort: settings.reasoning_effort || null,
|
||
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 emitNestedChildCollabTurn() {
|
||
const threadId = 'child-thread-v2';
|
||
const turnId = 'child-turn-v2';
|
||
const grandchildThreadId = 'grandchild-thread-v2';
|
||
const childThread = ensureThread(threadId);
|
||
childThread.activeTurnId = turnId;
|
||
send({
|
||
method: 'turn/started',
|
||
params: {
|
||
threadId,
|
||
turn: { id: turnId, status: 'running', items: [] },
|
||
},
|
||
});
|
||
send({
|
||
method: 'turn/plan/updated',
|
||
params: {
|
||
threadId,
|
||
turnId,
|
||
plan: [
|
||
{ step: '定位子线程路由', status: 'completed' },
|
||
{ step: '同步父卡片进度', status: 'in_progress' },
|
||
{ step: '完成集成回归', status: 'pending' },
|
||
],
|
||
},
|
||
});
|
||
send({
|
||
method: 'item/agentMessage/delta',
|
||
params: {
|
||
threadId,
|
||
turnId,
|
||
itemId: 'child-agent-msg-v2',
|
||
delta: 'V2 子代理最终消息:父级子代理路由正常。',
|
||
},
|
||
});
|
||
send({
|
||
method: 'item/completed',
|
||
params: {
|
||
threadId,
|
||
turnId,
|
||
completedAtMs: Date.now(),
|
||
item: {
|
||
id: 'grandchild-activity-v2',
|
||
type: 'subAgentActivity',
|
||
kind: 'started',
|
||
agentThreadId: grandchildThreadId,
|
||
agentPath: '/root/v2_parent/v2_grandchild',
|
||
prompt: '验证 Multi-agent V2 嵌套子代理路由。',
|
||
},
|
||
},
|
||
});
|
||
emitChildCollabTurn(
|
||
grandchildThreadId,
|
||
'grandchild-turn-v2',
|
||
'V2 孙代理最终消息:嵌套路由正常。'
|
||
);
|
||
send({
|
||
method: 'item/completed',
|
||
params: {
|
||
threadId,
|
||
turnId,
|
||
completedAtMs: Date.now(),
|
||
item: {
|
||
id: 'child-agent-msg-v2',
|
||
type: 'agentMessage',
|
||
content: [{ type: 'text', text: 'V2 子代理最终消息:父级子代理路由正常。' }],
|
||
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 v2/i.test(text)) {
|
||
send({
|
||
method: 'item/completed',
|
||
params: {
|
||
threadId: thread.id,
|
||
turnId,
|
||
completedAtMs: Date.now(),
|
||
item: {
|
||
id: 'child-activity-v2',
|
||
type: 'subAgentActivity',
|
||
kind: 'started',
|
||
agentThreadId: 'child-thread-v2',
|
||
agentPath: '/root/v2_parent',
|
||
prompt: '验证 Multi-agent V2 子代理路由。',
|
||
},
|
||
},
|
||
});
|
||
emitNestedChildCollabTurn();
|
||
send({
|
||
method: 'item/started',
|
||
params: {
|
||
threadId: thread.id,
|
||
turnId,
|
||
startedAtMs: Date.now(),
|
||
item: {
|
||
id: 'wait-empty-v2',
|
||
type: 'collabAgentToolCall',
|
||
tool: 'wait_agent',
|
||
receiverThreadIds: [],
|
||
agentsStates: {},
|
||
status: 'inProgress',
|
||
},
|
||
},
|
||
});
|
||
send({
|
||
method: 'item/completed',
|
||
params: {
|
||
threadId: thread.id,
|
||
turnId,
|
||
completedAtMs: Date.now(),
|
||
item: {
|
||
id: 'wait-empty-v2',
|
||
type: 'collabAgentToolCall',
|
||
tool: 'wait_agent',
|
||
receiverThreadIds: [],
|
||
agentsStates: {},
|
||
status: 'completed',
|
||
},
|
||
},
|
||
});
|
||
} else 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 completeTurnWithoutTerminalNotification(thread, turnId, text) {
|
||
if (thread.activeTurnId !== turnId) return;
|
||
const responseText = `Codex App stale turn output: ${text}`;
|
||
send({
|
||
method: 'item/agentMessage/delta',
|
||
params: {
|
||
threadId: thread.id,
|
||
turnId,
|
||
itemId: 'agent-msg',
|
||
delta: responseText,
|
||
},
|
||
});
|
||
send({
|
||
method: 'item/completed',
|
||
params: {
|
||
threadId: thread.id,
|
||
turnId,
|
||
completedAtMs: Date.now(),
|
||
item: {
|
||
id: 'agent-msg',
|
||
type: 'agentMessage',
|
||
content: [{ type: 'text', text: responseText }],
|
||
status: 'completed',
|
||
},
|
||
},
|
||
});
|
||
send({
|
||
method: 'thread/tokenUsage/updated',
|
||
params: {
|
||
threadId: thread.id,
|
||
turnId,
|
||
tokenUsage: tokenUsage(text),
|
||
},
|
||
});
|
||
|
||
// 模拟 app-server 已结束内部 turn,但终态通知在传输中丢失。
|
||
thread.activeTurnId = null;
|
||
thread.timer = null;
|
||
thread.steers = [];
|
||
}
|
||
|
||
function completeGoalBackgroundTurn(thread, objective) {
|
||
const turnId = `goal-turn-${crypto.randomUUID()}`;
|
||
const text = `Goal background output: ${objective}`;
|
||
thread.activeTurnId = turnId;
|
||
send({
|
||
method: 'turn/started',
|
||
params: {
|
||
threadId: thread.id,
|
||
turn: { id: turnId, status: 'running', items: [] },
|
||
},
|
||
});
|
||
send({
|
||
method: 'item/agentMessage/delta',
|
||
params: {
|
||
threadId: thread.id,
|
||
turnId,
|
||
itemId: 'goal-agent-msg',
|
||
delta: text,
|
||
},
|
||
});
|
||
send({
|
||
method: 'item/completed',
|
||
params: {
|
||
threadId: thread.id,
|
||
turnId,
|
||
completedAtMs: Date.now(),
|
||
item: {
|
||
id: 'goal-agent-msg',
|
||
type: 'agentMessage',
|
||
text,
|
||
status: 'completed',
|
||
},
|
||
},
|
||
});
|
||
send({
|
||
method: 'thread/tokenUsage/updated',
|
||
params: {
|
||
threadId: thread.id,
|
||
turnId,
|
||
tokenUsage: tokenUsage(text),
|
||
},
|
||
});
|
||
send({
|
||
method: 'turn/completed',
|
||
params: {
|
||
threadId: thread.id,
|
||
turn: {
|
||
id: turnId,
|
||
status: 'completed',
|
||
items: [],
|
||
},
|
||
},
|
||
});
|
||
thread.activeTurnId = null;
|
||
}
|
||
|
||
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 (/^codexapp stale running first$/i.test(text)) {
|
||
completeTurnWithoutTerminalNotification(thread, turnId, text);
|
||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||
}
|
||
|
||
if (/^codexapp expected turn mismatch first$/i.test(text)) {
|
||
send({
|
||
method: 'item/agentMessage/delta',
|
||
params: {
|
||
threadId: thread.id,
|
||
turnId,
|
||
itemId: 'agent-msg',
|
||
delta: `Codex App expected turn mismatch fixture: ${text}`,
|
||
},
|
||
});
|
||
// 保留一个不同的活动 turn,确保 steer 返回“不匹配”而不是“无活动 turn”。
|
||
thread.activeTurnId = `app-turn-mismatch-${crypto.randomUUID()}`;
|
||
return { 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) {
|
||
return {
|
||
error: {
|
||
code: -32001,
|
||
message: 'no active turn to steer',
|
||
},
|
||
};
|
||
}
|
||
if (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 } });
|
||
if (params.objective) {
|
||
setTimeout(() => completeGoalBackgroundTurn(thread, objective), 50);
|
||
}
|
||
}, 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);
|
||
});
|