chore: rebuild release package

This commit is contained in:
shiyue
2026-07-17 11:29:51 +08:00
parent 5c3401292d
commit fa15b54469
8 changed files with 448 additions and 7 deletions

View File

@@ -364,6 +364,47 @@ function completeTurn(thread, turnId, text, status = 'completed') {
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}`;
@@ -685,6 +726,26 @@ function startTurn(params) {
},
});
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) {
@@ -819,7 +880,15 @@ function interruptTurn(params) {
function steerTurn(params) {
const thread = ensureThread(params.threadId, params);
if (!thread.activeTurnId || thread.activeTurnId !== params.expectedTurnId) {
if (!thread.activeTurnId) {
return {
error: {
code: -32001,
message: 'no active turn to steer',
},
};
}
if (thread.activeTurnId !== params.expectedTurnId) {
return {
error: {
code: -32001,

View File

@@ -132,10 +132,11 @@ async function startServer(env) {
};
}
function connectWs(port, password) {
function connectWs(port, password, options = {}) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
const messages = [];
const receivedMessages = options.trackReceived ? [] : null;
let settled = false;
ws.on('open', () => {
@@ -144,9 +145,10 @@ function connectWs(port, password) {
ws.on('message', (buf) => {
const msg = JSON.parse(String(buf));
messages.push(msg);
if (receivedMessages) receivedMessages.push(msg);
if (msg.type === 'auth_result' && msg.success) {
settled = true;
resolve({ ws, messages, token: msg.token });
resolve({ ws, messages, receivedMessages, token: msg.token });
}
if (msg.type === 'auth_result' && !msg.success) {
settled = true;
@@ -1423,6 +1425,10 @@ function assertSessionSwitchResilienceContract() {
assert(frontendSource.includes('function requestSessionResume'), 'Frontend should request running-session resume without full history reload');
assert(frontendSource.includes("type: 'resume_session'"), 'Frontend should use resume_session for reconnecting running conversations');
assert(frontendSource.includes("case 'resume_session_result':"), 'Frontend should handle lightweight resume results');
assert(
/case 'resume_session_result':[\s\S]*?if \(!msg\.isRunning && currentSessionId && msg\.sessionId === currentSessionId\) \{[\s\S]*?finishGenerating\(msg\.sessionId \|\| currentSessionId\);[\s\S]*?\}[\s\S]*?break;/.test(frontendSource),
'Frontend idle resume result should finish generation state for the current session'
);
assert(frontendSource.includes('recoverCurrent: true'), 'Frontend fallback load_session should preserve the current running view');
const visibilityStart = frontendSource.indexOf("document.addEventListener('visibilitychange'");
const visibilityEnd = visibilityStart >= 0 ? frontendSource.indexOf("if (!authToken)", visibilityStart) : -1;
@@ -1515,6 +1521,175 @@ function assertSessionSwitchResilienceContract() {
);
}
function assertCodexAppStaleRunningRecoveryContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const completeBlock = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete');
const steerBlock = extractFunctionSource(serverSource, 'handleCodexAppSteerMessage');
assert(
completeBlock.includes('!options.deferPendingCrossConversationFlush') && completeBlock.includes('flushPendingCrossConversationReplies(sessionId)'),
'Stale recovery should be able to defer pending cross-conversation reply flushes'
);
assert(
steerBlock.includes('deferPendingCrossConversationFlush: true'),
'Stale steer recovery should defer pending cross-conversation reply flushes while replacing the turn'
);
assert(
steerBlock.includes('!activeCodexAppTurns.has(sessionId)') && steerBlock.includes('handleCodexAppMessage(ws, refreshedSession'),
'Stale steer recovery should only start the replacement turn while the active turn map is empty'
);
assert(
steerBlock.includes('mcpContext: entry.mcpContext || options.mcpContext || {}'),
'Stale steer recovery should prefer the original active entry MCP context'
);
}
async function runCodexAppStaleRunningRegression() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-stale-running-regression-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
const homeDir = path.join(tempRoot, 'home');
mkdirp(configDir);
mkdirp(sessionsDir);
mkdirp(logsDir);
mkdirp(homeDir);
const port = await getFreePort();
const password = 'StaleRunning!234';
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: password,
CC_WEB_INTERNAL_MCP_TOKEN: 'StaleRunningMcp!234',
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
}, async () => {
const { ws, messages, receivedMessages } = await connectWs(port, password, { trackReceived: true });
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
const sessionInfo = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir
));
const sessionId = sessionInfo.sessionId;
ws.send(JSON.stringify({
type: 'message',
text: 'codexapp stale running first',
sessionId,
mode: 'yolo',
agent: 'codexapp',
}));
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && session.isRunning)
));
const staleOutput = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === sessionId && /Codex App stale turn output/.test(msg.text || '')
));
assert(/codexapp stale running first/.test(staleOutput.text || ''), 'Stale running fixture should emit the first turn output');
await sleep(150);
ws.send(JSON.stringify({
type: 'message',
text: 'codexapp stale running follow-up',
sessionId,
mode: 'yolo',
agent: 'codexapp',
clientMessageId: 'regression-stale-running-follow-up',
}));
await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === sessionId &&
msg.clientMessageId === 'regression-stale-running-follow-up' &&
msg.status === 'pending'
), 5000);
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === sessionId, 5000);
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && !session.isRunning)
), 5000);
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && session.isRunning)
), 5000);
const replacementResume = await nextMessage(messages, ws, (msg) => (
msg.type === 'resume_generating' && msg.sessionId === sessionId
), 5000);
assert(replacementResume.text === '' && Array.isArray(replacementResume.toolCalls) && replacementResume.toolCalls.length === 0, 'Stale replacement should resume generation with an empty streaming payload');
const recoveredStatus = await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === sessionId &&
msg.clientMessageId === 'regression-stale-running-follow-up' &&
msg.status === 'inserted'
), 5000);
assert(!/失败/.test(recoveredStatus.message || ''), 'Recovered stale steer should update the UI as a non-failure');
const recoveredHint = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' && msg.sessionId === sessionId && /已自动开始新一轮对话/.test(msg.message || '')
), 5000);
assert(recoveredHint.transient === true, 'Recovered stale steer hint should be transient');
await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === sessionId && /codexapp stale running follow-up/.test(msg.text || '')
), 5000);
const replacementResumeIndex = receivedMessages.findIndex((msg) => msg.type === 'resume_generating' && msg.sessionId === sessionId);
const replacementDeltaIndex = receivedMessages.findIndex((msg) => msg.type === 'text_delta' && msg.sessionId === sessionId && /codexapp stale running follow-up/.test(msg.text || ''));
assert(replacementResumeIndex >= 0 && replacementResumeIndex < replacementDeltaIndex, 'Stale replacement should restore generating UI before its first text delta');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === sessionId, 5000);
const finalList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && !session.isRunning)
), 5000);
assert(finalList.sessions.some((session) => session.id === sessionId && !session.isRunning), 'Recovered follow-up should finish idle');
assert(!messages.some((msg) => msg.code === 'codexapp_steer_failed'), 'Recovered stale steer should not emit codexapp_steer_failed');
const stored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${sessionId}.json`), 'utf8'));
assert(stored.messages.filter((message) => message.role === 'user' && message.content === 'codexapp stale running follow-up').length === 1, 'Recovered follow-up user message should persist exactly once');
assert(stored.messages.filter((message) => message.role === 'assistant' && /Codex App stale turn output/.test(String(message.content || ''))).length === 1, 'Stale first turn output should persist exactly once');
assert(stored.messages.filter((message) => message.role === 'assistant' && /Codex App mock handled: codexapp stale running follow-up/.test(String(message.content || ''))).length === 1, 'Recovered follow-up output should persist exactly once');
const staleAssistantIndex = stored.messages.findIndex((message) => message.role === 'assistant' && /Codex App stale turn output/.test(String(message.content || '')));
const followUpUserIndex = stored.messages.findIndex((message) => message.role === 'user' && message.content === 'codexapp stale running follow-up');
const recoveredAssistantIndex = stored.messages.findIndex((message) => message.role === 'assistant' && /Codex App mock handled: codexapp stale running follow-up/.test(String(message.content || '')));
assert(staleAssistantIndex < followUpUserIndex && followUpUserIndex < recoveredAssistantIndex, 'Recovered history should keep stale assistant before follow-up user before recovered assistant');
assert(!fs.existsSync(path.join(sessionsDir, `${sessionId}-run`)), 'Recovered follow-up should clean the Codex App run directory after completion');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
const mismatchSession = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir && msg.sessionId !== sessionId
));
ws.send(JSON.stringify({
type: 'message',
text: 'codexapp expected turn mismatch first',
sessionId: mismatchSession.sessionId,
mode: 'yolo',
agent: 'codexapp',
}));
await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === mismatchSession.sessionId && /expected turn mismatch fixture/.test(msg.text || '')
), 5000);
await sleep(150);
ws.send(JSON.stringify({
type: 'message',
text: 'codexapp mismatch follow-up',
sessionId: mismatchSession.sessionId,
mode: 'yolo',
agent: 'codexapp',
clientMessageId: 'regression-expected-turn-mismatch',
}));
const mismatchFailed = await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === mismatchSession.sessionId &&
msg.clientMessageId === 'regression-expected-turn-mismatch' &&
msg.status === 'failed'
), 5000);
assert(/失败/.test(mismatchFailed.message || ''), 'Expected turn mismatch should keep the original failed steer status');
const mismatchError = await nextMessage(messages, ws, (msg) => (
msg.type === 'error' && msg.sessionId === mismatchSession.sessionId && msg.code === 'codexapp_steer_failed'
), 5000);
assert(/expectedTurnId does not match active turn/.test(mismatchError.message || ''), 'Expected turn mismatch should not trigger stale-turn recovery');
assert(!messages.some((msg) => msg.type === 'system_message' && msg.sessionId === mismatchSession.sessionId && /已自动开始新一轮对话/.test(msg.message || '')), 'Expected turn mismatch should not start a replacement turn');
ws.close();
});
}
function assertUnlimitedImageAttachmentsContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
@@ -1722,6 +1897,13 @@ async function main() {
console.log('Subagent card metadata regression checks passed.');
return;
}
if (regressionTarget === 'codexapp-stale-running') {
await runCodexAppStaleRunningRegression();
assertSessionSwitchResilienceContract();
assertCodexAppStaleRunningRecoveryContract();
console.log('Codex App stale running regression checks passed.');
return;
}
throw new Error(`Unknown regression target: ${regressionTarget}`);
}