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);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ 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');
|
||||
const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js');
|
||||
const HAS_SQLITE3 = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' }).status === 0;
|
||||
|
||||
function mkdirp(dir) {
|
||||
@@ -397,6 +398,29 @@ async function main() {
|
||||
qqbot: { qmsgKey: '' },
|
||||
}, null, 2));
|
||||
|
||||
const skillDir = path.join(homeDir, '.codex', 'skills', 'regression-skill');
|
||||
mkdirp(skillDir);
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
|
||||
'---',
|
||||
'name: regression-skill',
|
||||
'description: Regression skill for composer suggestions.',
|
||||
'---',
|
||||
'',
|
||||
'# Regression Skill',
|
||||
'',
|
||||
'Use this only in regression tests.',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(configDir, 'prompts.json'), JSON.stringify({
|
||||
prompts: [
|
||||
{
|
||||
name: 'shipit',
|
||||
title: 'Ship It',
|
||||
description: 'Regression prompt template.',
|
||||
content: 'Regression prompt body from @shipit.',
|
||||
},
|
||||
],
|
||||
}, null, 2));
|
||||
|
||||
createFakeClaudeHistory(homeDir);
|
||||
createFakeCodexConfig(homeDir);
|
||||
const codexFixture = createFakeCodexHistory(homeDir);
|
||||
@@ -414,7 +438,7 @@ async function main() {
|
||||
CC_WEB_LOGS_DIR: logsDir,
|
||||
HOME: homeDir,
|
||||
CLAUDE_PATH: MOCK_CLAUDE,
|
||||
CODEX_PATH: MOCK_CODEX,
|
||||
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||||
}, async () => {
|
||||
await assertWsUpgradeRejected(port, '/not-ws');
|
||||
|
||||
@@ -467,11 +491,64 @@ async function main() {
|
||||
|
||||
const codexInitCwd = path.join(tempRoot, 'codex-space');
|
||||
mkdirp(codexInitCwd);
|
||||
fs.writeFileSync(path.join(codexInitCwd, 'context.txt'), 'Composer file context body.');
|
||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' }));
|
||||
const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === codexInitCwd);
|
||||
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
|
||||
assert(codexSession.model === 'gpt-5.5(xhigh)', 'Codex new_session should read default model from ~/.codex/config.toml');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'set_session_pinned', sessionId: codexSession.sessionId, pinned: true }));
|
||||
const pinnedAck = await nextMessage(messages, ws, (msg) => msg.type === 'session_pinned' && msg.sessionId === codexSession.sessionId);
|
||||
assert(pinnedAck.pinnedAt, 'Pinning a session should return pinnedAt');
|
||||
const pinnedList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexSession.sessionId && s.pinnedAt));
|
||||
assert(pinnedList.sessions[0].id === codexSession.sessionId, 'Pinned session should sort before regular sessions');
|
||||
let storedPinnedSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||||
assert(storedPinnedSession.pinnedAt === pinnedAck.pinnedAt, 'Pinned state should persist to session JSON');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'set_session_pinned', sessionId: codexSession.sessionId, pinned: false }));
|
||||
const unpinnedAck = await nextMessage(messages, ws, (msg) => msg.type === 'session_pinned' && msg.sessionId === codexSession.sessionId && !msg.pinnedAt);
|
||||
assert(unpinnedAck.pinnedAt === null, 'Unpinning a session should clear pinnedAt');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexSession.sessionId && !s.pinnedAt));
|
||||
storedPinnedSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||||
assert(storedPinnedSession.pinnedAt === null, 'Unpinned state should persist to session JSON');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash', trigger: '/', query: 'mo', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||||
const slashComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash');
|
||||
assert(slashComposer.items.some((item) => item.kind === 'command' && item.name === '/model'), 'Composer slash suggestions should include /model');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||||
const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill');
|
||||
assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt', trigger: '@', query: 'ship', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||||
const promptComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt');
|
||||
assert(promptComposer.items.some((item) => item.kind === 'prompt' && item.name === 'shipit'), 'Composer prompt suggestions should include configured prompt');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-file', trigger: '@', query: 'context', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||||
const fileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-file');
|
||||
assert(fileComposer.items.some((item) => item.kind === 'file' && item.name === 'context.txt'), 'Composer file suggestions should include cwd file');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: '@shipit @context.txt $regression-skill run composer regression',
|
||||
sessionId: codexSession.sessionId,
|
||||
mode: 'plan',
|
||||
agent: 'codex',
|
||||
}));
|
||||
const composerExpanded = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'text_delta' &&
|
||||
/BEGIN CC-WEB PROMPT: shipit/.test(msg.text || '') &&
|
||||
/Composer file context body/.test(msg.text || '')
|
||||
));
|
||||
assert(/Regression prompt body from @shipit/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand @prompt content');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
|
||||
const storedComposerSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||||
const storedComposerMessage = storedComposerSession.messages.find((message) => message.content === '@shipit @context.txt $regression-skill run composer regression');
|
||||
assert(storedComposerMessage, 'Composer message should persist original user text');
|
||||
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'shipit'), 'Composer message should persist prompt mention metadata');
|
||||
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'file' && mention.name === 'context.txt'), 'Composer message should persist file mention metadata');
|
||||
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'regression-skill'), 'Composer message should persist skill mention metadata');
|
||||
|
||||
const mcpList = await callInternalMcp(port, internalMcpToken, {
|
||||
tool: 'ccweb_list_conversations',
|
||||
sourceSessionId: codexSession.sessionId,
|
||||
@@ -504,21 +581,76 @@ async function main() {
|
||||
assert(crossUserBubble.message.crossConversation.hopCount === 1, 'Cross message should persist hop count');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
|
||||
const storedCrossTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossTargetSession.sessionId}.json`), 'utf8'));
|
||||
const storedCrossSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||||
const storedCrossMessage = storedCrossTarget.messages.find((message) => message.crossConversation?.messageId === crossSend.body.messageId);
|
||||
assert(storedCrossMessage?.content === 'cross hello from mcp', 'Cross message should be persisted in target session');
|
||||
assert(storedCrossMessage.crossConversation.sourceTitle === codexSession.title, 'Cross message should persist source title');
|
||||
assert(storedCrossMessage.crossConversation.sourceTitle === storedCrossSource.title, 'Cross message should persist source title');
|
||||
assert(storedCrossTarget.messages.some((message) => message.role === 'assistant' && /来自/.test(String(message.content || ''))), 'Cross message runtime prompt should include source context for the target agent');
|
||||
|
||||
const hopLimit = await callInternalMcp(port, internalMcpToken, {
|
||||
const hopAllowed = await callInternalMcp(port, internalMcpToken, {
|
||||
tool: 'ccweb_send_message',
|
||||
sourceSessionId: crossTargetSession.sessionId,
|
||||
sourceSessionId: codexSession.sessionId,
|
||||
sourceHopCount: 1,
|
||||
args: {
|
||||
targetConversationId: codexSession.sessionId,
|
||||
content: 'this should be blocked by hop limit',
|
||||
targetConversationId: crossTargetSession.sessionId,
|
||||
content: 'cross hop still allowed',
|
||||
},
|
||||
});
|
||||
assert(hopLimit.status === 400 && hopLimit.body?.code === 'hop_limit_exceeded', 'MCP cross send should enforce hop limit');
|
||||
assert(hopAllowed.status === 200 && hopAllowed.body?.ok, `MCP cross send should not enforce hop limit: ${JSON.stringify(hopAllowed.body)}`);
|
||||
const hopAllowedBubble = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_message' &&
|
||||
msg.sessionId === crossTargetSession.sessionId &&
|
||||
msg.message?.crossConversation?.messageId === hopAllowed.body.messageId &&
|
||||
msg.message?.content === 'cross hop still allowed'
|
||||
));
|
||||
assert(hopAllowedBubble.message.crossConversation.hopCount === 2, 'Cross message should keep incrementing hop count without blocking');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
|
||||
|
||||
const crossReplyTargetCwd = path.join(tempRoot, 'codex-mcp-cross-reply-target');
|
||||
mkdirp(crossReplyTargetCwd);
|
||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossReplyTargetCwd, mode: 'yolo' }));
|
||||
const crossReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossReplyTargetCwd);
|
||||
const requestReply = await callInternalMcp(port, internalMcpToken, {
|
||||
tool: 'ccweb_request_reply',
|
||||
sourceSessionId: codexSession.sessionId,
|
||||
sourceHopCount: 0,
|
||||
args: {
|
||||
targetConversationId: crossReplyTargetSession.sessionId,
|
||||
content: 'cross reply requested',
|
||||
},
|
||||
});
|
||||
assert(requestReply.status === 200 && requestReply.body?.ok, `MCP request reply should succeed: ${JSON.stringify(requestReply.body)}`);
|
||||
assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP request reply should return a waiting request id');
|
||||
const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_message' &&
|
||||
msg.sessionId === crossReplyTargetSession.sessionId &&
|
||||
msg.message?.crossConversation?.replyRequestId === requestReply.body.requestId &&
|
||||
msg.message?.crossConversation?.expectsReply === true &&
|
||||
msg.message?.content === 'cross reply requested'
|
||||
));
|
||||
assert(requestReplyTargetBubble.message.crossConversation.hopCount === 1, 'Request reply target message should persist hop count');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossReplyTargetSession.sessionId);
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
(msg.type === 'done' || msg.type === 'background_done') &&
|
||||
msg.sessionId === codexSession.sessionId
|
||||
));
|
||||
|
||||
const storedReplyTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossReplyTargetSession.sessionId}.json`), 'utf8'));
|
||||
const storedReplySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||||
const storedReplyRequestMessage = storedReplyTarget.messages.find((message) => message.crossConversation?.replyRequestId === requestReply.body.requestId);
|
||||
assert(storedReplyRequestMessage?.crossConversation?.expectsReply === true, 'Request reply target message should persist waiting metadata');
|
||||
assert(storedReplyTarget.messages.some((message) => message.role === 'assistant' && /cross reply requested/.test(String(message.content || ''))), 'Request reply target should produce an assistant reply');
|
||||
const storedReplyMessageIndex = storedReplySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === requestReply.body.requestId);
|
||||
assert(storedReplyMessageIndex >= 0, 'Request reply should send the target reply back to source session');
|
||||
const storedReplyMessage = storedReplySource.messages[storedReplyMessageIndex];
|
||||
assert(storedReplyMessage.crossConversation.reply === true, 'Returned cross message should be marked as a reply');
|
||||
assert(/线程「/.test(storedReplyMessage.content || '') && /已返回消息/.test(storedReplyMessage.content || ''), 'Returned cross message should include reply heading');
|
||||
assert(/Codex mock handled/.test(storedReplyMessage.content || ''), 'Returned cross message should include target assistant output');
|
||||
assert(storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => (
|
||||
message.role === 'assistant' &&
|
||||
/Codex mock handled/.test(String(message.content || '')) &&
|
||||
/已返回消息/.test(String(message.content || ''))
|
||||
)), 'Returned cross message should trigger the source session to run again');
|
||||
|
||||
const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
|
||||
const mcpSpawnLine = processLogAfterMcp
|
||||
@@ -634,6 +766,100 @@ async function main() {
|
||||
assert(/trigger codex context limit/.test(autoCompactRetry.text || ''), 'Codex auto /compact should replay the failed prompt after compact');
|
||||
}
|
||||
|
||||
const codexAppCwd = path.join(tempRoot, 'codexapp-space');
|
||||
mkdirp(codexAppCwd);
|
||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppCwd, mode: 'yolo' }));
|
||||
const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd);
|
||||
assert(codexAppSession.model === 'gpt-5.5(xhigh)', 'Codex App new_session should read default Codex model');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-skill', trigger: '$', query: 'reg', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
|
||||
const codexAppSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-skill');
|
||||
assert(codexAppSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Codex App composer skill suggestions should include local Codex skill');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration default probe', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppDefaultCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
|
||||
assert(/"mode":"default"/.test(codexAppDefaultCollab.text || ''), 'Codex App YOLO mode should pass default collaboration mode');
|
||||
assert(/"hasModel":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include model');
|
||||
assert(/"hasDeveloperInstructions":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include sub-agent developer instructions');
|
||||
assert(/"hasTopLevelModel":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate model at top level');
|
||||
assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp empty reasoning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
const storedCodexAppAfterReasoning = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||||
const hasEmptyReasoningTool = storedCodexAppAfterReasoning.messages
|
||||
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
|
||||
.some((tool) => (tool.kind === 'reasoning' || tool.meta?.kind === 'reasoning') && !String(tool.result || '').trim());
|
||||
assert(!hasEmptyReasoningTool, 'Codex App should not persist empty reasoning tool calls');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp tool prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
|
||||
const codexAppTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-cmd');
|
||||
assert(/codexapp/.test(codexAppTool.result || ''), 'Codex App should stream app-server tool results');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
let storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||||
const codexAppThreadId = storedCodexApp.codexAppThreadId;
|
||||
assert(codexAppThreadId, 'Codex App thread id should be persisted');
|
||||
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted');
|
||||
assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
|
||||
assert(codexAppDynamicTool.kind === 'mcp_tool_call', 'Codex App should surface ccweb MCP tool calls');
|
||||
assert(/currentConversationId/.test(codexAppDynamicTool.result || ''), 'Codex App MCP tool should return ccweb conversation data');
|
||||
assert(/"hasCcwebMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass ccweb MCP config');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration plan probe', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
|
||||
const codexAppPlanCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
|
||||
assert(/"mode":"plan"/.test(codexAppPlanCollab.text || ''), 'Codex App Plan mode should pass plan collaboration mode');
|
||||
assert(/"hasDeveloperInstructions":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep sub-agent developer instructions');
|
||||
assert(/"hasTopLevelModel":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate model at top level');
|
||||
assert(/"hasTopLevelEffort":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate effort at top level');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp guided prompt', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
|
||||
const guidedRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_user_input_request' && msg.sessionId === codexAppSession.sessionId);
|
||||
assert(guidedRequest.questions?.[0]?.id === 'choice', 'Codex App should forward request_user_input questions');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'codex_app_user_input_response',
|
||||
sessionId: codexAppSession.sessionId,
|
||||
requestId: guidedRequest.requestId,
|
||||
answers: { choice: { answers: ['A'] } },
|
||||
}));
|
||||
const guidedDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /guided answer: A/.test(msg.text || ''));
|
||||
assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
|
||||
await sleep(150);
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'runtime steer insert', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const steerDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /steer accepted: runtime steer insert/.test(msg.text || ''));
|
||||
assert(/runtime steer insert/.test(steerDelta.text || ''), 'Codex App running message should use turn/steer');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||||
assert(storedCodexApp.codexAppThreadId === codexAppThreadId, 'Codex App follow-up should resume the same app-server thread');
|
||||
assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === 'runtime steer insert'), 'Codex App steer message should be persisted as user history');
|
||||
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /runtime steer insert/.test(String(message.content || ''))), 'Codex App steered assistant output should be persisted');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp abort prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
|
||||
const codexAppRunningMcp = await callInternalMcp(port, internalMcpToken, {
|
||||
tool: 'ccweb_send_message',
|
||||
sourceSessionId: codexSession.sessionId,
|
||||
sourceHopCount: 0,
|
||||
args: {
|
||||
targetConversationId: codexAppSession.sessionId,
|
||||
content: 'running codexapp target should reject this',
|
||||
},
|
||||
});
|
||||
assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets');
|
||||
await sleep(150);
|
||||
ws.send(JSON.stringify({ type: 'abort' }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
const claudeAttachment = await uploadAttachment(port, token, {
|
||||
filename: 'claude-test.png',
|
||||
mime: 'image/png',
|
||||
|
||||
Reference in New Issue
Block a user