fix: Codex /compact 支持与自动续跑
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
function readStdin() {
|
function readStdin() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -14,9 +17,22 @@ function readStdin() {
|
|||||||
(async function main() {
|
(async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const isResume = args[0] === 'exec' && args[1] === 'resume';
|
const isResume = args[0] === 'exec' && args[1] === 'resume';
|
||||||
const threadId = isResume && args[2] ? args[2] : `mock-${crypto.randomUUID()}`;
|
const threadId = (() => {
|
||||||
|
if (!isResume) return `mock-${crypto.randomUUID()}`;
|
||||||
|
for (let i = args.length - 1; i >= 2; i--) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === '-' || String(arg).startsWith('-')) continue;
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
return `mock-${crypto.randomUUID()}`;
|
||||||
|
})();
|
||||||
const input = (await readStdin()).trim();
|
const input = (await readStdin()).trim();
|
||||||
const imageCount = args.filter((arg) => arg === '--image').length;
|
const imageCount = args.filter((arg) => arg === '--image').length;
|
||||||
|
const statePath = path.join(os.tmpdir(), `cc-web-mock-codex-${threadId}.json`);
|
||||||
|
let state = {};
|
||||||
|
try {
|
||||||
|
state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||||
|
} catch {}
|
||||||
|
|
||||||
process.stdout.write(`${JSON.stringify({ type: 'thread.started', thread_id: threadId })}\n`);
|
process.stdout.write(`${JSON.stringify({ type: 'thread.started', thread_id: threadId })}\n`);
|
||||||
process.stdout.write(`${JSON.stringify({ type: 'turn.started' })}\n`);
|
process.stdout.write(`${JSON.stringify({ type: 'turn.started' })}\n`);
|
||||||
@@ -46,15 +62,36 @@ function readStdin() {
|
|||||||
})}\n`);
|
})}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input === '/compact') {
|
||||||
|
state.compacted = true;
|
||||||
|
fs.writeFileSync(statePath, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'trigger codex context limit' && !state.compacted) {
|
||||||
|
process.stdout.write(`${JSON.stringify({
|
||||||
|
type: 'turn.failed',
|
||||||
|
error: { message: 'Context window exceeded. Please use /compact and retry.' },
|
||||||
|
})}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = input === '/compact'
|
||||||
|
? 'Codex compact finished.'
|
||||||
|
: `Codex mock handled (${imageCount} image): ${input}`;
|
||||||
|
|
||||||
process.stdout.write(`${JSON.stringify({
|
process.stdout.write(`${JSON.stringify({
|
||||||
type: 'item.completed',
|
type: 'item.completed',
|
||||||
item: {
|
item: {
|
||||||
id: 'item_msg',
|
id: 'item_msg',
|
||||||
type: 'agent_message',
|
type: 'agent_message',
|
||||||
text: `Codex mock handled (${imageCount} image): ${input}`,
|
text: responseText,
|
||||||
},
|
},
|
||||||
})}\n`);
|
})}\n`);
|
||||||
|
|
||||||
|
if (input === 'trigger codex context limit' && state.compacted) {
|
||||||
|
try { fs.unlinkSync(statePath); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
process.stdout.write(`${JSON.stringify({
|
process.stdout.write(`${JSON.stringify({
|
||||||
type: 'turn.completed',
|
type: 'turn.completed',
|
||||||
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 5 },
|
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 5 },
|
||||||
|
|||||||
@@ -350,6 +350,28 @@ async function main() {
|
|||||||
assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode');
|
assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode');
|
||||||
assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url');
|
assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url');
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'message', text: '/compact', sessionId: firstMessageSession.sessionId, mode: 'yolo', agent: 'codex' }));
|
||||||
|
await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在执行 Codex \/compact/.test(msg.message || ''));
|
||||||
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
|
||||||
|
const compactDoneMsg = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || ''));
|
||||||
|
assert(/已执行 Codex \/compact/.test(compactDoneMsg.message || ''), 'Codex /compact should complete with Codex-specific status message');
|
||||||
|
|
||||||
|
const autoCompactCwd = path.join(tempRoot, 'codex-auto-compact');
|
||||||
|
mkdirp(autoCompactCwd);
|
||||||
|
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: autoCompactCwd, mode: 'yolo' }));
|
||||||
|
const autoCompactSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === autoCompactCwd);
|
||||||
|
ws.send(JSON.stringify({ type: 'message', text: 'warm up auto compact', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' }));
|
||||||
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId);
|
||||||
|
ws.send(JSON.stringify({ type: 'message', text: 'trigger codex context limit', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' }));
|
||||||
|
const autoCompactStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在按 Codex \/compact 自动压缩/.test(msg.message || ''));
|
||||||
|
assert(/Codex \/compact/.test(autoCompactStart.message || ''), 'Codex auto /compact should announce auto compact start');
|
||||||
|
const autoCompactDone = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || ''));
|
||||||
|
assert(/已执行 Codex \/compact/.test(autoCompactDone.message || ''), 'Codex auto /compact should finish compact step');
|
||||||
|
const autoCompactResume = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /按 Codex 压缩计划继续执行/.test(msg.message || ''));
|
||||||
|
assert(/继续执行/.test(autoCompactResume.message || ''), 'Codex auto /compact should announce retry');
|
||||||
|
const autoCompactRetryText = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && /trigger codex context limit/.test(msg.text || ''), 8000);
|
||||||
|
assert(/trigger codex context limit/.test(autoCompactRetryText.text || ''), 'Codex auto /compact should replay the failed prompt after compact');
|
||||||
|
|
||||||
const claudeAttachment = await uploadAttachment(port, token, {
|
const claudeAttachment = await uploadAttachment(port, token, {
|
||||||
filename: 'claude-test.png',
|
filename: 'claude-test.png',
|
||||||
mime: 'image/png',
|
mime: 'image/png',
|
||||||
|
|||||||
95
server.js
95
server.js
@@ -895,6 +895,39 @@ function formatRuntimeError(agent, raw, context = {}) {
|
|||||||
return `Claude 任务失败${exitInfo}:${condensed}`;
|
return `Claude 任务失败${exitInfo}:${condensed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactStartMessage(agent) {
|
||||||
|
return agent === 'codex'
|
||||||
|
? '正在执行 Codex /compact 压缩上下文,请稍候…'
|
||||||
|
: '正在执行 Claude 原生 /compact 压缩上下文,请稍候…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactDoneMessage(agent) {
|
||||||
|
return agent === 'codex'
|
||||||
|
? '上下文压缩完成。已执行 Codex /compact,下次继续在同一会话发送即可。'
|
||||||
|
: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact,下次继续在同一会话发送即可。';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactAutoStartMessage(agent) {
|
||||||
|
return agent === 'codex'
|
||||||
|
? '检测到上下文达到上限,正在按 Codex /compact 自动压缩,然后继续当前任务…'
|
||||||
|
: '检测到上下文达到上限,正在按 Claude Code 原版策略自动执行 /compact,然后继续当前任务…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactAutoResumeMessage(agent) {
|
||||||
|
return agent === 'codex'
|
||||||
|
? '检测到上一条请求因上下文过大失败,现已按 Codex 压缩计划继续执行。'
|
||||||
|
: '检测到上一条请求因上下文过大失败,现已自动按压缩计划继续执行。';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContextLimitError(agent, raw) {
|
||||||
|
const text = String(raw || '');
|
||||||
|
if (!text) return false;
|
||||||
|
if (agent === 'claude') {
|
||||||
|
return /Request too large \(max 20MB\)/i.test(text);
|
||||||
|
}
|
||||||
|
return /context\s+(window|length)|maximum context length|context limit|token limit|too many tokens|input.*too long|prompt.*too long|request too large|please use\s*\/compact|use\s*\/compact|reduce (the )?(input|prompt|message)|exceed(?:ed|s).*(token|context)/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
function handleProcessComplete(sessionId, exitCode, signal) {
|
function handleProcessComplete(sessionId, exitCode, signal) {
|
||||||
const entry = activeProcesses.get(sessionId);
|
const entry = activeProcesses.get(sessionId);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
@@ -906,7 +939,7 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const pendingRetry = pendingCompactRetries.get(sessionId) || null;
|
const pendingRetry = pendingCompactRetries.get(sessionId) || null;
|
||||||
let requestTooLarge = false;
|
let contextLimitExceeded = false;
|
||||||
|
|
||||||
// Read stderr for error clues
|
// Read stderr for error clues
|
||||||
let stderrSnippet = '';
|
let stderrSnippet = '';
|
||||||
@@ -918,13 +951,12 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
requestTooLarge = entry.agent === 'claude'
|
|
||||||
&& (/Request too large \(max 20MB\)/i.test(entry.fullText || '') || /Request too large \(max 20MB\)/i.test(stderrSnippet || ''));
|
|
||||||
const rawCompletionError = entry.lastError || (
|
const rawCompletionError = entry.lastError || (
|
||||||
((typeof exitCode === 'number' && exitCode !== 0) || (!!signal && signal !== 'SIGTERM'))
|
((typeof exitCode === 'number' && exitCode !== 0) || (!!signal && signal !== 'SIGTERM'))
|
||||||
? (stderrSnippet || null)
|
? (stderrSnippet || null)
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
contextLimitExceeded = isContextLimitError(entry.agent || 'claude', `${entry.fullText || ''}\n${stderrSnippet || ''}\n${rawCompletionError || ''}`);
|
||||||
const completionError = rawCompletionError ? formatRuntimeError(entry.agent || 'claude', rawCompletionError, { exitCode, signal }) : null;
|
const completionError = rawCompletionError ? formatRuntimeError(entry.agent || 'claude', rawCompletionError, { exitCode, signal }) : null;
|
||||||
if (!entry.lastError && rawCompletionError) entry.lastError = rawCompletionError;
|
if (!entry.lastError && rawCompletionError) entry.lastError = rawCompletionError;
|
||||||
|
|
||||||
@@ -943,7 +975,7 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
|||||||
usage: entry.lastUsage || null,
|
usage: entry.lastUsage || null,
|
||||||
error: rawCompletionError,
|
error: rawCompletionError,
|
||||||
stderr: stderrSnippet || null,
|
stderr: stderrSnippet || null,
|
||||||
requestTooLarge,
|
requestTooLarge: contextLimitExceeded,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Final read
|
// Final read
|
||||||
@@ -978,6 +1010,7 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let shouldReturnForFollowup = false;
|
let shouldReturnForFollowup = false;
|
||||||
|
let shouldAutoCompact = false;
|
||||||
|
|
||||||
activeProcesses.delete(sessionId);
|
activeProcesses.delete(sessionId);
|
||||||
cleanRunDir(sessionId);
|
cleanRunDir(sessionId);
|
||||||
@@ -987,30 +1020,28 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
|||||||
if (entry.ws) {
|
if (entry.ws) {
|
||||||
if (pendingSlash?.kind === 'compact') {
|
if (pendingSlash?.kind === 'compact') {
|
||||||
const retry = pendingCompactRetries.get(sessionId);
|
const retry = pendingCompactRetries.get(sessionId);
|
||||||
if (retry?.reason === 'auto') {
|
const autoRetryRequested = !!(retry?.text && retry?.reason === 'auto');
|
||||||
wsSend(entry.ws, { type: 'system_message', message: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact,下次继续在同一会话发送即可。' });
|
if (autoRetryRequested) {
|
||||||
pendingCompactRetries.delete(sessionId);
|
if (contextLimitExceeded) {
|
||||||
} else if (retry?.text) {
|
|
||||||
if (requestTooLarge) {
|
|
||||||
pendingCompactRetries.delete(sessionId);
|
pendingCompactRetries.delete(sessionId);
|
||||||
wsSend(entry.ws, { type: 'system_message', message: '已尝试执行 /compact,但仍未成功解除上下文超限。请手动缩小输入范围后重试。' });
|
wsSend(entry.ws, { type: 'system_message', message: '已尝试执行 /compact,但仍未成功解除上下文超限。请手动缩小输入范围后重试。' });
|
||||||
} else {
|
} else {
|
||||||
wsSend(entry.ws, { type: 'system_message', message: '检测到上一条请求因上下文过大失败,现已自动按压缩计划继续执行。' });
|
wsSend(entry.ws, { type: 'system_message', message: compactDoneMessage(entry.agent || 'claude') });
|
||||||
|
wsSend(entry.ws, { type: 'system_message', message: compactAutoResumeMessage(entry.agent || 'claude') });
|
||||||
shouldReturnForFollowup = true;
|
shouldReturnForFollowup = true;
|
||||||
pendingCompactRetries.delete(sessionId);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
wsSend(entry.ws, { type: 'system_message', message: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact,下次继续在同一会话发送即可。' });
|
wsSend(entry.ws, { type: 'system_message', message: compactDoneMessage(entry.agent || 'claude') });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestTooLarge && !pendingSlash && session && session.claudeSessionId) {
|
if (contextLimitExceeded && !pendingSlash && session && getRuntimeSessionId(session)) {
|
||||||
pendingCompactRetries.set(sessionId, { text: pendingRetry?.text || '', mode: pendingRetry?.mode || session.permissionMode || 'yolo', reason: 'auto' });
|
pendingCompactRetries.set(sessionId, { text: pendingRetry?.text || '', mode: pendingRetry?.mode || session.permissionMode || 'yolo', reason: 'auto' });
|
||||||
wsSend(entry.ws, { type: 'system_message', message: '检测到上下文达到上限,正在按 Claude Code 原版策略自动执行 /compact,然后继续当前任务…' });
|
wsSend(entry.ws, { type: 'system_message', message: compactAutoStartMessage(entry.agent || 'claude') });
|
||||||
shouldReturnForFollowup = true;
|
shouldAutoCompact = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completionError && !entry.errorSent) {
|
if (completionError && !entry.errorSent && !shouldAutoCompact) {
|
||||||
entry.errorSent = true;
|
entry.errorSent = true;
|
||||||
wsSend(entry.ws, { type: 'error', message: completionError });
|
wsSend(entry.ws, { type: 'error', message: completionError });
|
||||||
}
|
}
|
||||||
@@ -1041,7 +1072,7 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldReturnForFollowup && !requestTooLarge && pendingRetry && pendingRetry.text === (entry.fullText || '').trim()) {
|
if (!shouldReturnForFollowup && !shouldAutoCompact && !contextLimitExceeded && pendingRetry && pendingRetry.text === (entry.fullText || '').trim()) {
|
||||||
pendingCompactRetries.delete(sessionId);
|
pendingCompactRetries.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,12 +1085,12 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (requestTooLarge && !pendingSlash && session.claudeSessionId) {
|
if (shouldAutoCompact && entry.ws && entry.ws.readyState === 1 && session) {
|
||||||
pendingSlashCommands.set(sessionId, { kind: 'compact' });
|
pendingSlashCommands.set(sessionId, { kind: 'compact' });
|
||||||
handleMessage(entry.ws, { text: '/compact', sessionId, mode: session.permissionMode || 'yolo' }, { hideInHistory: true });
|
handleMessage(entry.ws, { text: '/compact', sessionId, mode: session.permissionMode || 'yolo' }, { hideInHistory: true });
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1698,10 +1729,6 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case '/compact': {
|
case '/compact': {
|
||||||
if (agent !== 'claude') {
|
|
||||||
wsSend(ws, { type: 'system_message', message: 'Codex 会话暂不支持 /compact。' });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!sessionId || !session) {
|
if (!sessionId || !session) {
|
||||||
wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' });
|
wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' });
|
||||||
break;
|
break;
|
||||||
@@ -1710,12 +1737,18 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
|||||||
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。' });
|
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。' });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!session.claudeSessionId) {
|
const runtimeId = getRuntimeSessionId(session);
|
||||||
wsSend(ws, { type: 'system_message', message: '当前会话尚未建立 Claude 上下文,暂时无需压缩。' });
|
if (!runtimeId) {
|
||||||
|
wsSend(ws, {
|
||||||
|
type: 'system_message',
|
||||||
|
message: agent === 'codex'
|
||||||
|
? '当前会话尚未建立 Codex 上下文,暂时无需压缩。'
|
||||||
|
: '当前会话尚未建立 Claude 上下文,暂时无需压缩。',
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
wsSend(ws, { type: 'system_message', message: '正在执行 Claude 原生 /compact 压缩上下文,请稍候…' });
|
wsSend(ws, { type: 'system_message', message: compactStartMessage(agent) });
|
||||||
pendingSlashCommands.set(session.id, { kind: 'compact' });
|
pendingSlashCommands.set(session.id, { kind: 'compact' });
|
||||||
handleMessage(ws, { text: '/compact', sessionId: session.id, mode: session.permissionMode || 'yolo' }, { hideInHistory: true });
|
handleMessage(ws, { text: '/compact', sessionId: session.id, mode: session.permissionMode || 'yolo' }, { hideInHistory: true });
|
||||||
break;
|
break;
|
||||||
@@ -1753,7 +1786,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
|||||||
wsSend(ws, {
|
wsSend(ws, {
|
||||||
type: 'system_message',
|
type: 'system_message',
|
||||||
message: agent === 'codex'
|
message: agent === 'codex'
|
||||||
? base + '\n/model [名称] — 查看/切换 Codex 模型(自由输入)'
|
? base + '\n/model [名称] — 查看/切换 Codex 模型(自由输入)\n/compact — 执行 Codex /compact 压缩上下文'
|
||||||
: base + '\n/model [名称] — 查看/切换模型(opus, sonnet, haiku)\n/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)',
|
: base + '\n/model [名称] — 查看/切换模型(opus, sonnet, haiku)\n/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -2111,7 +2144,7 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
session.permissionMode = mode;
|
session.permissionMode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hideInHistory && normalizedText !== '/compact' && session.claudeSessionId) {
|
if (!hideInHistory && normalizedText !== '/compact' && getRuntimeSessionId(session)) {
|
||||||
pendingCompactRetries.set(session.id, { text: normalizedText, mode: session.permissionMode || 'yolo', reason: 'normal' });
|
pendingCompactRetries.set(session.id, { text: normalizedText, mode: session.permissionMode || 'yolo', reason: 'normal' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user