chore: rebuild release package
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user