feat: enhance codex app and cross-conversation messaging
This commit is contained in:
497
scripts/mock-codex-app-server.js
Executable file
497
scripts/mock-codex-app-server.js
Executable file
@@ -0,0 +1,497 @@
|
||||
#!/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();
|
||||
let nextServerRequestId = 1;
|
||||
|
||||
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 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 || '')),
|
||||
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: [],
|
||||
});
|
||||
}
|
||||
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 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 env = ccwebConfig?.env || {};
|
||||
const payload = {
|
||||
ok: true,
|
||||
currentConversationId: env.CC_WEB_SOURCE_SESSION_ID || null,
|
||||
sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || null,
|
||||
hasCcwebMcpConfig: Boolean(ccwebConfig),
|
||||
};
|
||||
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 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 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 (/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: [] } };
|
||||
}
|
||||
|
||||
const delay = /slow/i.test(text) ? 900 : 80;
|
||||
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 === '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') {
|
||||
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 === '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);
|
||||
});
|
||||
Reference in New Issue
Block a user