fix cross-conversation replies and mobile session switching

This commit is contained in:
shiyue
2026-06-18 13:07:51 +08:00
parent a2126f4138
commit c1dc793841
7 changed files with 678 additions and 74 deletions

View File

@@ -54,7 +54,7 @@ const TOOLS = [
mode: { mode: {
type: 'string', type: 'string',
enum: ['default', 'plan', 'yolo'], enum: ['default', 'plan', 'yolo'],
description: '可选。权限模式,默认继承来源对话;不会自动写死为 plan。', description: '可选。权限模式,默认 yolo只有显式传 default/plan/yolo 时才使用指定模式。',
}, },
initialMessage: { initialMessage: {
type: 'string', type: 'string',
@@ -87,6 +87,36 @@ const TOOLS = [
additionalProperties: false, additionalProperties: false,
}, },
}, },
{
name: 'ccweb_list_pending_replies',
description: '列出当前来源对话等待中的跨对话回复,包括已完成但尚未处理的子对话返回摘要。',
inputSchema: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['all', 'waiting', 'ready', 'delivering', 'returned', 'failed'],
description: '可选。按回复状态过滤,默认 all。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_get_pending_reply',
description: '读取指定 requestId 的跨对话回复状态和正文;用于主线程判断是否继续追问指定子对话。',
inputSchema: {
type: 'object',
properties: {
requestId: {
type: 'string',
description: '等待回复 requestId。',
},
},
required: ['requestId'],
additionalProperties: false,
},
},
{ {
name: 'ccweb_request_reply', name: 'ccweb_request_reply',
description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后把回复作为已处理的只读消息写回当前对话;写回不会再次触发当前对话运行。', description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后把回复作为已处理的只读消息写回当前对话;写回不会再次触发当前对话运行。',

View File

@@ -2,7 +2,7 @@
(function () { (function () {
'use strict'; 'use strict';
const ASSET_VERSION = '20260617-codexapp-approval'; const ASSET_VERSION = '20260618-mobile-session-switch';
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
const RENDER_DEBOUNCE = 100; const RENDER_DEBOUNCE = 100;
const COMPOSER_SUGGESTION_DEBOUNCE = 120; const COMPOSER_SUGGESTION_DEBOUNCE = 120;
@@ -119,6 +119,7 @@
// --- State --- // --- State ---
let ws = null; let ws = null;
let wsAuthenticated = false;
let authToken = localStorage.getItem('cc-web-token'); let authToken = localStorage.getItem('cc-web-token');
let currentSessionId = null; let currentSessionId = null;
let sessions = []; let sessions = [];
@@ -162,6 +163,7 @@
let codexAppUserInputModal = null; let codexAppUserInputModal = null;
let codexAppApprovalModal = null; let codexAppApprovalModal = null;
let pendingNewSessionRequest = null; let pendingNewSessionRequest = null;
let pendingSessionSwitchRequest = null;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1'; let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false; let pendingInitialSessionLoad = false;
let noteMode = false; let noteMode = false;
@@ -959,8 +961,10 @@
} }
function normalizeSessionSnapshot(payload, options = {}) { function normalizeSessionSnapshot(payload, options = {}) {
const sessionId = payload.sessionId || payload.id || '';
return { return {
sessionId: payload.sessionId, sessionId,
id: sessionId,
messages: cloneMessages(payload.messages || []), messages: cloneMessages(payload.messages || []),
title: payload.title || '新会话', title: payload.title || '新会话',
mode: payload.mode || 'yolo', mode: payload.mode || 'yolo',
@@ -969,10 +973,19 @@
pinnedAt: payload.pinnedAt || null, pinnedAt: payload.pinnedAt || null,
hasUnread: !!payload.hasUnread, hasUnread: !!payload.hasUnread,
cwd: payload.cwd || null, cwd: payload.cwd || null,
projectName: payload.projectName || '',
oversized: !!payload.oversized,
fileBytes: Number(payload.fileBytes || 0),
totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0, totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0,
totalUsage: payload.totalUsage ? deepClone(payload.totalUsage) : null, totalUsage: payload.totalUsage ? deepClone(payload.totalUsage) : null,
updated: payload.updated || null, updated: payload.updated || null,
isRunning: !!payload.isRunning, isRunning: !!payload.isRunning,
waitingOnChildren: !!payload.waitingOnChildren,
pendingReplyCount: Number(payload.pendingReplyCount || 0),
readyReplyCount: Number(payload.readyReplyCount || 0),
waitingReplyCount: Number(payload.waitingReplyCount || 0),
failedReplyCount: Number(payload.failedReplyCount || 0),
pendingReplies: Array.isArray(payload.pendingReplies) ? deepClone(payload.pendingReplies) : [],
historyPending: !!payload.historyPending, historyPending: !!payload.historyPending,
complete: options.complete !== undefined ? !!options.complete : !payload.historyPending, complete: options.complete !== undefined ? !!options.complete : !payload.historyPending,
}; };
@@ -1053,7 +1066,7 @@
const entry = sessionCache.get(sessionId); const entry = sessionCache.get(sessionId);
const meta = getSessionMeta(sessionId); const meta = getSessionMeta(sessionId);
if (!entry?.snapshot?.complete || !meta) return 'miss'; if (!entry?.snapshot?.complete || !meta) return 'miss';
if (entry.version === (meta.updated || null) && !meta.hasUnread && !meta.isRunning) { if (entry.version === (meta.updated || null) && !meta.hasUnread && !meta.isRunning && !meta.waitingOnChildren) {
return 'strong'; return 'strong';
} }
return 'weak'; return 'weak';
@@ -1071,6 +1084,11 @@
snapshot.updated = meta.updated || snapshot.updated; snapshot.updated = meta.updated || snapshot.updated;
snapshot.pinnedAt = meta.pinnedAt || null; snapshot.pinnedAt = meta.pinnedAt || null;
snapshot.isRunning = !!meta.isRunning; snapshot.isRunning = !!meta.isRunning;
snapshot.waitingOnChildren = !!meta.waitingOnChildren;
snapshot.pendingReplyCount = Number(meta.pendingReplyCount || 0);
snapshot.readyReplyCount = Number(meta.readyReplyCount || 0);
snapshot.waitingReplyCount = Number(meta.waitingReplyCount || 0);
snapshot.failedReplyCount = Number(meta.failedReplyCount || 0);
} }
return snapshot; return snapshot;
} }
@@ -2431,7 +2449,7 @@
const hiddenOldSessions = []; const hiddenOldSessions = [];
regularSessions.forEach((session, index) => { regularSessions.forEach((session, index) => {
const shouldKeepVisible = session.id === currentSessionId || session.isRunning || session.hasUnread; const shouldKeepVisible = session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren;
const canCollapse = index >= visibleRegularLimit && isOlderThanOldSessionWindow(session, nowMs); const canCollapse = index >= visibleRegularLimit && isOlderThanOldSessionWindow(session, nowMs);
if (canCollapse && !shouldKeepVisible) { if (canCollapse && !shouldKeepVisible) {
hiddenOldSessions.push(session); hiddenOldSessions.push(session);
@@ -2492,7 +2510,10 @@
function createSessionListItem(session) { function createSessionListItem(session) {
const item = document.createElement('div'); const item = document.createElement('div');
const isPinned = !!session.pinnedAt; const isPinned = !!session.pinnedAt;
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}`; const waitingOnChildren = !!session.waitingOnChildren;
const readyReplyCount = Number(session.readyReplyCount || 0);
const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim();
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
item.dataset.id = session.id; item.dataset.id = session.id;
const sessionCwd = getSessionEffectiveCwd(session); const sessionCwd = getSessionEffectiveCwd(session);
if (sessionCwd) item.title = sessionCwd; if (sessionCwd) item.title = sessionCwd;
@@ -2501,6 +2522,7 @@
<span class="session-item-title">${escapeHtml(session.title || 'Untitled')}</span> <span class="session-item-title">${escapeHtml(session.title || 'Untitled')}</span>
${isPinned ? '<span class="session-item-pin-badge" title="已置顶">顶</span>' : ''} ${isPinned ? '<span class="session-item-pin-badge" title="已置顶">顶</span>' : ''}
${session.isRunning ? '<span class="session-item-status">运行中</span>' : ''} ${session.isRunning ? '<span class="session-item-status">运行中</span>' : ''}
${!session.isRunning && waitingOnChildren ? `<span class="session-item-status waiting">${escapeHtml(waitingLabel)}</span>` : ''}
</div> </div>
${session.hasUnread ? '<span class="session-unread-dot"></span>' : ''} ${session.hasUnread ? '<span class="session-unread-dot"></span>' : ''}
<span class="session-item-time">${timeAgo(session.updated)}</span> <span class="session-item-time">${timeAgo(session.updated)}</span>
@@ -2572,6 +2594,7 @@
return; return;
} }
closeSessionActionMenus(); closeSessionActionMenus();
if (isMobileInputMode()) closeSidebar();
openSession(session.id); openSession(session.id);
}); });
@@ -2595,12 +2618,36 @@
chatCwd.hidden = !currentCwd; chatCwd.hidden = !currentCwd;
} }
function currentSessionWaitState() {
const meta = currentSessionId ? getSessionMeta(currentSessionId) : null;
const cached = currentSessionId ? sessionCache.get(currentSessionId)?.snapshot : null;
return {
waitingOnChildren: !!(meta?.waitingOnChildren || cached?.waitingOnChildren),
pendingReplyCount: Number(meta?.pendingReplyCount ?? cached?.pendingReplyCount ?? 0),
readyReplyCount: Number(meta?.readyReplyCount ?? cached?.readyReplyCount ?? 0),
};
}
function setCurrentSessionRunningState(isRunning) { function setCurrentSessionRunningState(isRunning) {
const running = !!isRunning; const running = !!isRunning;
currentSessionRunning = running; currentSessionRunning = running;
if (chatRuntimeState) { if (chatRuntimeState) {
chatRuntimeState.hidden = !running; const waitState = currentSessionWaitState();
chatRuntimeState.textContent = running ? '运行中' : ''; if (running) {
chatRuntimeState.hidden = false;
chatRuntimeState.classList.remove('waiting');
chatRuntimeState.textContent = '运行中';
} else if (waitState.waitingOnChildren) {
chatRuntimeState.hidden = false;
chatRuntimeState.classList.add('waiting');
chatRuntimeState.textContent = waitState.readyReplyCount > 0
? `子对话已返回 ${waitState.readyReplyCount}`
: `等待子对话 ${waitState.pendingReplyCount || ''}`.trim();
} else {
chatRuntimeState.hidden = true;
chatRuntimeState.classList.remove('waiting');
chatRuntimeState.textContent = '';
}
} }
updateCwdBadge(); updateCwdBadge();
} }
@@ -2817,7 +2864,35 @@
renderEpoch++; renderEpoch++;
loadedHistorySessionId = null; loadedHistorySessionId = null;
setSessionLoading(sessionId, { blocking, label: options.label }); setSessionLoading(sessionId, { blocking, label: options.label });
send({ type: 'load_session', sessionId }); requestSessionLoad(sessionId, { blocking, label: options.label });
}
function requestSessionLoad(sessionId, options = {}) {
if (!sessionId) return;
pendingSessionSwitchRequest = {
sessionId,
blocking: options.blocking !== false,
label: options.label || '',
};
if (ws && ws.readyState === 1 && wsAuthenticated) {
flushPendingSessionSwitch();
return;
}
if (!ws || ws.readyState > 1) connect();
}
function flushPendingSessionSwitch() {
if (!pendingSessionSwitchRequest) return;
if (!ws || ws.readyState !== 1 || !wsAuthenticated) return;
const request = pendingSessionSwitchRequest;
pendingSessionSwitchRequest = null;
if (!activeSessionLoad) {
setSessionLoading(request.sessionId, {
blocking: request.blocking,
label: request.label || undefined,
});
}
ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId }));
} }
function showCachedSession(sessionId) { function showCachedSession(sessionId) {
@@ -3000,6 +3075,7 @@
const socket = new WebSocket(WS_URL); const socket = new WebSocket(WS_URL);
ws = socket; ws = socket;
wsAuthenticated = false;
socket.onopen = () => { socket.onopen = () => {
if (ws !== socket) return; if (ws !== socket) return;
@@ -3018,6 +3094,14 @@
socket.onclose = () => { socket.onclose = () => {
if (ws !== socket) return; if (ws !== socket) return;
ws = null; ws = null;
wsAuthenticated = false;
if (activeSessionLoad?.sessionId && !isPageUnloading) {
pendingSessionSwitchRequest = {
sessionId: activeSessionLoad.sessionId,
blocking: activeSessionLoad.blocking,
label: sessionLoadingLabel?.textContent || '',
};
}
clearSessionLoading(); clearSessionLoading();
scheduleReconnect(); scheduleReconnect();
}; };
@@ -3058,10 +3142,12 @@
case 'auth_result': case 'auth_result':
if (msg.success) { if (msg.success) {
authToken = msg.token; authToken = msg.token;
wsAuthenticated = true;
localStorage.setItem('cc-web-token', msg.token); localStorage.setItem('cc-web-token', msg.token);
document.dispatchEvent(new CustomEvent('cc-web-auth-restored')); document.dispatchEvent(new CustomEvent('cc-web-auth-restored'));
loginOverlay.hidden = true; loginOverlay.hidden = true;
app.hidden = false; app.hidden = false;
flushPendingSessionSwitch();
send({ type: 'get_codex_config' }); send({ type: 'get_codex_config' });
// Check if must change password // Check if must change password
if (msg.mustChangePassword) { if (msg.mustChangePassword) {
@@ -3070,7 +3156,10 @@
pendingInitialSessionLoad = true; pendingInitialSessionLoad = true;
} }
} else { } else {
pendingSessionSwitchRequest = null;
clearSessionLoading();
authToken = null; authToken = null;
wsAuthenticated = false;
localStorage.removeItem('cc-web-token'); localStorage.removeItem('cc-web-token');
document.dispatchEvent(new CustomEvent('cc-web-auth-failed')); document.dispatchEvent(new CustomEvent('cc-web-auth-failed'));
loginOverlay.hidden = false; loginOverlay.hidden = false;
@@ -3088,7 +3177,7 @@
break; break;
case 'session_list': case 'session_list':
sessions = msg.sessions || []; sessions = Array.isArray(msg.sessions) ? msg.sessions.map(normalizeSessionSnapshot) : [];
reconcileSessionCacheWithSessions(); reconcileSessionCacheWithSessions();
renderSessionList(); renderSessionList();
if (currentSessionId) { if (currentSessionId) {
@@ -3113,6 +3202,12 @@
projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : session.projectName || '', projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : session.projectName || '',
title: snapshot.title || session.title, title: snapshot.title || session.title,
pinnedAt: snapshot.pinnedAt || session.pinnedAt || null, pinnedAt: snapshot.pinnedAt || session.pinnedAt || null,
isRunning: snapshot.isRunning,
waitingOnChildren: snapshot.waitingOnChildren,
pendingReplyCount: snapshot.pendingReplyCount,
readyReplyCount: snapshot.readyReplyCount,
waitingReplyCount: snapshot.waitingReplyCount,
failedReplyCount: snapshot.failedReplyCount,
} }
: session : session
)); ));
@@ -3124,6 +3219,9 @@
suppressUnreadToast: false, suppressUnreadToast: false,
preserveStreaming: msg.sessionId === currentSessionId && msg.isRunning, preserveStreaming: msg.sessionId === currentSessionId && msg.isRunning,
}); });
if (msg.sessionId === currentSessionId) {
setCurrentSessionRunningState(!!msg.isRunning);
}
if (!msg.historyPending) { if (!msg.historyPending) {
if (activeSessionLoad?.sessionId === msg.sessionId) { if (activeSessionLoad?.sessionId === msg.sessionId) {
finalizeLoadedSession(msg.sessionId); finalizeLoadedSession(msg.sessionId);
@@ -3156,6 +3254,11 @@
snapshot.messages = Array.isArray(snapshot.messages) ? snapshot.messages : []; snapshot.messages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
snapshot.messages.push(deepClone(msg.message)); snapshot.messages.push(deepClone(msg.message));
snapshot.updated = msg.message.timestamp || new Date().toISOString(); snapshot.updated = msg.message.timestamp || new Date().toISOString();
if (msg.message.crossConversation?.replyToRequestId) {
snapshot.readyReplyCount = Math.max(0, Number(snapshot.readyReplyCount || 0) - 1);
snapshot.pendingReplyCount = Math.max(0, Number(snapshot.pendingReplyCount || 0) - 1);
snapshot.waitingOnChildren = Number(snapshot.pendingReplyCount || 0) > 0;
}
}); });
} }
if (msg.sessionId === currentSessionId && msg.message) { if (msg.sessionId === currentSessionId && msg.message) {
@@ -3164,7 +3267,9 @@
if (welcome) welcome.remove(); if (welcome) welcome.remove();
messagesDiv.appendChild(buildMsgElement(msg.message)); messagesDiv.appendChild(buildMsgElement(msg.message));
scrollToBottom(); scrollToBottom();
setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
} }
renderSessionList();
break; break;
case 'session_renamed': case 'session_renamed':
@@ -5479,9 +5584,10 @@
const hasActiveSession = group.sessions.some((session) => session.id === currentSessionId); const hasActiveSession = group.sessions.some((session) => session.id === currentSessionId);
const hasUnreadSession = group.sessions.some((session) => session.hasUnread); const hasUnreadSession = group.sessions.some((session) => session.hasUnread);
const hasRunningSession = group.sessions.some((session) => session.isRunning); const hasRunningSession = group.sessions.some((session) => session.isRunning);
const hasWaitingSession = group.sessions.some((session) => session.waitingOnChildren);
const groupBodyId = `session-project-body-${groupIndex}`; const groupBodyId = `session-project-body-${groupIndex}`;
const groupEl = document.createElement('section'); const groupEl = document.createElement('section');
groupEl.className = `session-project-group${isCollapsed ? ' collapsed' : ''}${hasActiveSession ? ' has-active-session' : ''}${hasUnreadSession ? ' has-unread-session' : ''}${hasRunningSession ? ' has-running-session' : ''}`; groupEl.className = `session-project-group${isCollapsed ? ' collapsed' : ''}${hasActiveSession ? ' has-active-session' : ''}${hasUnreadSession ? ' has-unread-session' : ''}${hasRunningSession ? ' has-running-session' : ''}${hasWaitingSession ? ' has-waiting-session' : ''}`;
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'session-project-header'; header.className = 'session-project-header';
@@ -6278,9 +6384,6 @@
if (currentSessionId) { if (currentSessionId) {
send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode }); send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode });
} }
if (currentMode === 'default') {
appendSystemMessage('⚠ 由于项目设计与 CLI 原生逻辑不同,默认模式的授权申请功能暂未实现,建议搭配 Plan 或 YOLO 模式使用。');
}
}); });
msgInput.addEventListener('input', () => { msgInput.addEventListener('input', () => {

View File

@@ -154,6 +154,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="app.js?v=20260617-skill-openai-yaml"></script> <script src="app.js?v=20260618-mobile-session-switch"></script>
</body> </body>
</html> </html>

View File

@@ -1260,6 +1260,17 @@ body.session-loading-active {
.session-project-group.has-running-session.collapsed .session-project-count::after { .session-project-group.has-running-session.collapsed .session-project-count::after {
animation: pulse 1.1s infinite; animation: pulse 1.1s infinite;
} }
.session-project-group.has-waiting-session:not(.has-running-session).collapsed .session-project-count::after {
content: '';
width: 6px;
height: 6px;
margin-left: 5px;
border-radius: 50%;
background: rgba(120, 126, 140, 0.72);
}
.session-project-group.has-waiting-session:not(.has-running-session).collapsed .session-project-count::after {
animation: none;
}
.session-item { .session-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1331,6 +1342,14 @@ body.session-loading-active {
background: currentColor; background: currentColor;
animation: pulse 1.1s infinite; animation: pulse 1.1s infinite;
} }
.session-item-status.waiting {
background: rgba(120, 126, 140, 0.12);
border-color: rgba(120, 126, 140, 0.22);
color: #667085;
}
.session-item-status.waiting::before {
animation: none;
}
.session-item-time { .session-item-time {
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
@@ -1660,6 +1679,14 @@ body.session-loading-active {
background: currentColor; background: currentColor;
animation: pulse 1.1s infinite; animation: pulse 1.1s infinite;
} }
.chat-runtime-state.waiting {
background: rgba(120, 126, 140, 0.12);
border-color: rgba(120, 126, 140, 0.22);
color: #667085;
}
.chat-runtime-state.waiting::before {
animation: none;
}
.cost-display { .cost-display {
display: none !important; display: none !important;
} }

View File

@@ -90,6 +90,9 @@ function sleep(ms) {
if (input === 'slow cross-session prompt') { if (input === 'slow cross-session prompt') {
await sleep(800); await sleep(800);
} }
if (input === 'very slow cross-session prompt') {
await sleep(2500);
}
const responseText = input === '/compact' const responseText = input === '/compact'
? 'Codex compact finished.' ? 'Codex compact finished.'

View File

@@ -490,6 +490,11 @@ function assertFrontendGenerationControlsContract() {
/allowRuntimeInsert\s*=\s*isGenerating\s*&&\s*isCodexAppAgent\(currentAgent\)/.test(controlsBlock), /allowRuntimeInsert\s*=\s*isGenerating\s*&&\s*isCodexAppAgent\(currentAgent\)/.test(controlsBlock),
'Codex App should keep the runtime insert send button visible while generating' 'Codex App should keep the runtime insert send button visible while generating'
); );
const staleDefaultApprovalWarning = ['默认模式的', '授权申请功能', '暂未实现'].join('');
assert(
!source.includes(staleDefaultApprovalWarning),
'Frontend should not show the stale default-mode approval warning after Codex App approvals are supported'
);
} }
function assertFrontendComposerMcpContract() { function assertFrontendComposerMcpContract() {
@@ -817,13 +822,14 @@ async function main() {
assert(mcpCreate.status === 200 && mcpCreate.body?.ok, `MCP create conversation should succeed: ${JSON.stringify(mcpCreate.body)}`); assert(mcpCreate.status === 200 && mcpCreate.body?.ok, `MCP create conversation should succeed: ${JSON.stringify(mcpCreate.body)}`);
assert(mcpCreate.body.agent === 'codex', 'MCP create conversation should ignore agent args and inherit the source agent'); assert(mcpCreate.body.agent === 'codex', 'MCP create conversation should ignore agent args and inherit the source agent');
assert(mcpCreate.body.cwd === codexInitCwd, 'MCP create conversation should inherit source cwd by default'); assert(mcpCreate.body.cwd === codexInitCwd, 'MCP create conversation should inherit source cwd by default');
assert(mcpCreate.body.mode === 'plan', 'MCP create conversation should inherit source mode by default'); assert(mcpCreate.body.mode === 'yolo', 'MCP create conversation should default to yolo when mode is omitted');
assert(mcpCreate.body.status === 'running', 'MCP create with initialMessage should start the new conversation'); assert(mcpCreate.body.status === 'running', 'MCP create with initialMessage should start the new conversation');
assert(mcpCreate.body.messageId, 'MCP create with initialMessage should return the delivered message id'); assert(mcpCreate.body.messageId, 'MCP create with initialMessage should return the delivered message id');
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId); await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId);
const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8')); const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8'));
assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title'); assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title');
assert(storedMcpCreated.agent === 'codex', 'MCP created conversation should persist the inherited source agent'); assert(storedMcpCreated.agent === 'codex', 'MCP created conversation should persist the inherited source agent');
assert(storedMcpCreated.permissionMode === 'yolo', 'MCP created conversation should persist yolo as the default mode');
assert(storedMcpCreated.createdFrom?.sourceSessionId === codexSession.sessionId, 'MCP created conversation should persist source metadata'); assert(storedMcpCreated.createdFrom?.sourceSessionId === codexSession.sessionId, 'MCP created conversation should persist source metadata');
assert(storedMcpCreated.messages.some((message) => message.content === 'mcp created initial prompt' && message.crossConversation?.sourceSessionId === codexSession.sessionId), 'MCP created conversation should persist the initial cross-conversation message'); assert(storedMcpCreated.messages.some((message) => message.content === 'mcp created initial prompt' && message.crossConversation?.sourceSessionId === codexSession.sessionId), 'MCP created conversation should persist the initial cross-conversation message');
assert(storedMcpCreated.messages.some((message) => message.role === 'assistant' && /mcp created initial prompt/.test(String(message.content || ''))), 'MCP created conversation should run the initial prompt'); assert(storedMcpCreated.messages.some((message) => message.role === 'assistant' && /mcp created initial prompt/.test(String(message.content || ''))), 'MCP created conversation should run the initial prompt');
@@ -844,6 +850,7 @@ async function main() {
}); });
assert(mcpCreateReply.status === 200 && mcpCreateReply.body?.ok, `MCP create conversation with requestReply should succeed: ${JSON.stringify(mcpCreateReply.body)}`); assert(mcpCreateReply.status === 200 && mcpCreateReply.body?.ok, `MCP create conversation with requestReply should succeed: ${JSON.stringify(mcpCreateReply.body)}`);
assert(mcpCreateReply.body.agent === 'codex', 'MCP create requestReply should inherit source agent even if args.agent is passed'); assert(mcpCreateReply.body.agent === 'codex', 'MCP create requestReply should inherit source agent even if args.agent is passed');
assert(mcpCreateReply.body.mode === 'yolo', 'MCP create requestReply should default to yolo when mode is omitted');
assert(mcpCreateReply.body.cwd === mcpReplyCreateCwd, 'MCP create conversation should use an explicit absolute cwd'); assert(mcpCreateReply.body.cwd === mcpReplyCreateCwd, 'MCP create conversation should use an explicit absolute cwd');
assert(mcpCreateReply.body.requestId && mcpCreateReply.body.replyStatus === 'waiting', 'MCP create requestReply should return a waiting request id'); assert(mcpCreateReply.body.requestId && mcpCreateReply.body.replyStatus === 'waiting', 'MCP create requestReply should return a waiting request id');
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreateReply.body.conversationId); await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreateReply.body.conversationId);
@@ -965,6 +972,103 @@ async function main() {
/已返回消息/.test(String(message.content || '')) /已返回消息/.test(String(message.content || ''))
)), 'Returned cross message should not trigger the source session to run again'); )), 'Returned cross message should not trigger the source session to run again');
const busySourceCwd = path.join(tempRoot, 'codex-mcp-busy-source');
mkdirp(busySourceCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: busySourceCwd, mode: 'yolo' }));
const busySourceSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === busySourceCwd);
ws.send(JSON.stringify({ type: 'message', text: 'very slow cross-session prompt', sessionId: busySourceSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === busySourceSession.sessionId && s.isRunning));
const busyReplyTargetCwd = path.join(tempRoot, 'codex-mcp-busy-reply-target');
mkdirp(busyReplyTargetCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: busyReplyTargetCwd, mode: 'yolo' }));
const busyReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === busyReplyTargetCwd);
const busyRequestReply = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_request_reply',
sourceSessionId: busySourceSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: busyReplyTargetSession.sessionId,
content: 'busy source reply requested',
},
});
assert(busyRequestReply.status === 200 && busyRequestReply.body?.ok, `MCP busy source request reply should succeed: ${JSON.stringify(busyRequestReply.body)}`);
assert(busyRequestReply.body.requestId && busyRequestReply.body.status === 'waiting', 'Busy source request reply should return a waiting request id');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busyReplyTargetSession.sessionId);
await waitForJsonCondition(path.join(configDir, 'cross-conversation-replies.json'), (state) => (
Array.isArray(state.replies) &&
state.replies.some((reply) => (
reply.requestId === busyRequestReply.body.requestId &&
reply.sourceConversationId === busySourceSession.sessionId &&
reply.status === 'ready' &&
/busy source reply requested/.test(String(reply.replyText || ''))
))
));
let storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
assert(!storedBusySource.messages.some((message) => message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId), 'Busy source should not receive display-only reply while it is still running');
const busyPendingList = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_pending_replies',
sourceSessionId: busySourceSession.sessionId,
args: { status: 'ready' },
});
assert(busyPendingList.status === 200 && busyPendingList.body?.ok, `MCP pending reply list should succeed: ${JSON.stringify(busyPendingList.body)}`);
assert(busyPendingList.body.waitingOnChildren === true, 'Pending reply list should report waitingOnChildren while ready reply is queued');
assert(busyPendingList.body.readyReplyCount === 1, 'Pending reply list should count ready replies');
assert(busyPendingList.body.replies.some((reply) => reply.requestId === busyRequestReply.body.requestId && reply.status === 'ready'), 'Pending reply list should include the queued ready reply');
const busyPendingDetail = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',
sourceSessionId: busySourceSession.sessionId,
args: { requestId: busyRequestReply.body.requestId },
});
assert(busyPendingDetail.status === 200 && busyPendingDetail.body?.ok, `MCP pending reply detail should succeed: ${JSON.stringify(busyPendingDetail.body)}`);
assert(busyPendingDetail.body.status === 'ready', 'Pending reply detail should expose ready status');
assert(/busy source reply requested/.test(String(busyPendingDetail.body.replyText || '')), 'Pending reply detail should expose target assistant output');
const busyConversationList = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: busySourceSession.sessionId,
args: { limit: 50 },
});
assert(busyConversationList.status === 200 && busyConversationList.body?.ok, `MCP conversation list with waiting state should succeed: ${JSON.stringify(busyConversationList.body)}`);
assert(busyConversationList.body.waitingOnChildren === true && busyConversationList.body.readyReplyCount === 1, 'MCP list should expose source waiting state');
const busySourceSummary = busyConversationList.body.conversations.find((item) => item.id === busySourceSession.sessionId);
assert(busySourceSummary?.status === 'running', 'MCP list should still mark the busy source as running before it completes');
assert(busySourceSummary?.waitingOnChildren === true && busySourceSummary?.readyReplyCount === 1, 'MCP list should expose queued child replies on the source conversation');
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === busySourceSession.sessionId, 8000);
await waitForJsonCondition(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
session.messages.some((message) => (
message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId &&
message.crossConversation?.processed === true &&
message.ccwebDisplayOnly === true
))
));
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
const busyReplyIndex = storedBusySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId);
assert(busyReplyIndex > 0, 'Busy source should receive queued display-only reply after its run completes');
assert(storedBusySource.messages[busyReplyIndex - 1]?.role === 'assistant' && /very slow cross-session prompt/.test(String(storedBusySource.messages[busyReplyIndex - 1].content || '')), 'Queued reply should be appended after the source run assistant message');
const returnedPendingDetail = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',
sourceSessionId: busySourceSession.sessionId,
args: { requestId: busyRequestReply.body.requestId },
});
assert(returnedPendingDetail.status === 200 && returnedPendingDetail.body?.ok, 'Returned pending reply detail should remain queryable from source history');
assert(returnedPendingDetail.body.status === 'returned' && returnedPendingDetail.body.returned === true, 'Returned pending reply detail should report returned status');
ws.send(JSON.stringify({ type: 'load_session', sessionId: busySourceSession.sessionId }));
const loadedBusySource = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === busySourceSession.sessionId);
assert(loadedBusySource.isRunning === false, 'Busy source should be idle after background run completed');
assert(loadedBusySource.waitingOnChildren === false && loadedBusySource.pendingReplyCount === 0, 'Busy source should clear waiting state after queued reply is flushed');
ws.send(JSON.stringify({ type: 'message', text: 'source remains usable after queued child reply', sessionId: busySourceSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busySourceSession.sessionId);
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
assert(storedBusySource.messages.some((message) => message.role === 'user' && message.content === 'source remains usable after queued child reply'), 'Source conversation should accept normal user messages after queued child reply is flushed');
const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8'); const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
const mcpSpawnLine = processLogAfterMcp const mcpSpawnLine = processLogAfterMcp
.trim() .trim()

453
server.js
View File

@@ -133,6 +133,7 @@ const MODEL_CONFIG_PATH = path.join(CONFIG_DIR, 'model.json');
const CODEX_CONFIG_PATH = path.join(CONFIG_DIR, 'codex.json'); const CODEX_CONFIG_PATH = path.join(CONFIG_DIR, 'codex.json');
const PROMPTS_CONFIG_PATH = path.join(CONFIG_DIR, 'prompts.json'); const PROMPTS_CONFIG_PATH = path.join(CONFIG_DIR, 'prompts.json');
const BANNED_IPS_PATH = path.join(CONFIG_DIR, 'banned_ips.json'); const BANNED_IPS_PATH = path.join(CONFIG_DIR, 'banned_ips.json');
const CROSS_CONVERSATION_REPLIES_PATH = path.join(CONFIG_DIR, 'cross-conversation-replies.json');
fs.mkdirSync(SESSIONS_DIR, { recursive: true }); fs.mkdirSync(SESSIONS_DIR, { recursive: true });
fs.mkdirSync(LOGS_DIR, { recursive: true }); fs.mkdirSync(LOGS_DIR, { recursive: true });
@@ -3210,6 +3211,210 @@ function cleanRunDir(sessionId) {
} catch {} } catch {}
} }
function normalizeCrossConversationReplyState(raw = {}) {
const requestId = String(raw.requestId || '').trim();
if (!requestId) return null;
const status = ['waiting', 'ready', 'delivering', 'returned', 'failed'].includes(raw.status)
? raw.status
: 'waiting';
const sourceConversationId = sanitizeId(raw.sourceConversationId || raw.sourceSessionId || '');
const targetConversationId = sanitizeId(raw.targetConversationId || raw.targetSessionId || raw.conversationId || '');
if (!sourceConversationId || !targetConversationId) return null;
return {
requestId,
messageId: String(raw.messageId || '').trim() || null,
sourceConversationId,
sourceTitle: String(raw.sourceTitle || '').trim() || 'Untitled',
targetConversationId,
targetTitle: String(raw.targetTitle || '').trim() || 'Untitled',
status,
createdAt: String(raw.createdAt || '').trim() || new Date().toISOString(),
hopCount: Math.max(0, Number.parseInt(String(raw.hopCount || 0), 10) || 0),
replyText: truncateTextValue(String(raw.replyText || ''), CROSS_CONVERSATION_MAX_CONTENT_CHARS),
completedAt: raw.completedAt || null,
returnedAt: raw.returnedAt || null,
replyMessageId: raw.replyMessageId || null,
lastError: raw.lastError || null,
};
}
function serializeCrossConversationReplies() {
const replies = [];
for (const pending of pendingCrossConversationReplies.values()) {
const normalized = normalizeCrossConversationReplyState(pending);
if (!normalized) continue;
if (normalized.status === 'returned') continue;
replies.push(normalized);
}
return {
version: 1,
updatedAt: new Date().toISOString(),
replies,
};
}
function saveCrossConversationReplies() {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
writeFileAtomicSync(CROSS_CONVERSATION_REPLIES_PATH, JSON.stringify(serializeCrossConversationReplies(), null, 2));
} catch (err) {
plog('WARN', 'cross_conversation_replies_save_failed', {
error: err?.message || String(err || ''),
});
}
}
function loadCrossConversationReplies() {
try {
if (!fs.existsSync(CROSS_CONVERSATION_REPLIES_PATH)) return;
const parsed = JSON.parse(fs.readFileSync(CROSS_CONVERSATION_REPLIES_PATH, 'utf8'));
const list = Array.isArray(parsed?.replies) ? parsed.replies : [];
for (const item of list) {
const normalized = normalizeCrossConversationReplyState(item);
if (!normalized || normalized.status === 'returned') continue;
pendingCrossConversationReplies.set(normalized.requestId, normalized);
}
} catch (err) {
plog('WARN', 'cross_conversation_replies_load_failed', {
error: err?.message || String(err || ''),
});
}
}
function setPendingCrossConversationReply(requestId, pending) {
const normalized = normalizeCrossConversationReplyState({ ...pending, requestId });
if (!normalized) return null;
pendingCrossConversationReplies.set(normalized.requestId, normalized);
saveCrossConversationReplies();
return normalized;
}
function updatePendingCrossConversationReply(requestId, updater) {
const existing = pendingCrossConversationReplies.get(String(requestId || '').trim());
if (!existing) return null;
const draft = { ...existing };
updater(draft);
const normalized = normalizeCrossConversationReplyState(draft);
if (!normalized) {
pendingCrossConversationReplies.delete(existing.requestId);
saveCrossConversationReplies();
return null;
}
pendingCrossConversationReplies.set(normalized.requestId, normalized);
saveCrossConversationReplies();
return normalized;
}
function deletePendingCrossConversationReply(requestId) {
const normalizedRequestId = String(requestId || '').trim();
if (!normalizedRequestId) return false;
const deleted = pendingCrossConversationReplies.delete(normalizedRequestId);
if (deleted) saveCrossConversationReplies();
return deleted;
}
function deleteCrossConversationRepliesForSession(sessionId) {
const normalizedId = sanitizeId(sessionId || '');
if (!normalizedId) return 0;
let deleted = 0;
for (const [requestId, pending] of pendingCrossConversationReplies.entries()) {
if (pending.sourceConversationId === normalizedId || pending.targetConversationId === normalizedId) {
pendingCrossConversationReplies.delete(requestId);
deleted += 1;
}
}
if (deleted > 0) saveCrossConversationReplies();
return deleted;
}
function crossConversationReplySummary(pending = {}) {
return {
requestId: pending.requestId,
status: pending.status,
sourceConversationId: pending.sourceConversationId,
sourceTitle: pending.sourceTitle || 'Untitled',
targetConversationId: pending.targetConversationId,
targetTitle: pending.targetTitle || 'Untitled',
createdAt: pending.createdAt || null,
completedAt: pending.completedAt || null,
returnedAt: pending.returnedAt || null,
replyMessageId: pending.replyMessageId || null,
preview: truncateTextValue(pending.replyText || '', 240),
};
}
function listCrossConversationRepliesForSource(sourceSessionId, options = {}) {
const sourceId = sanitizeId(sourceSessionId || '');
if (!sourceId) return [];
const statuses = Array.isArray(options.statuses) && options.statuses.length > 0
? new Set(options.statuses)
: null;
const output = [];
for (const pending of pendingCrossConversationReplies.values()) {
if (pending.sourceConversationId !== sourceId) continue;
if (statuses && !statuses.has(pending.status)) continue;
output.push(crossConversationReplySummary(pending));
}
output.sort((a, b) => new Date(a.completedAt || a.createdAt || 0) - new Date(b.completedAt || b.createdAt || 0));
return output;
}
function crossConversationWaitState(sessionId) {
const replies = listCrossConversationRepliesForSource(sessionId, {
statuses: ['waiting', 'ready', 'delivering', 'failed'],
});
const readyReplies = replies.filter((reply) => reply.status === 'ready');
const waitingReplies = replies.filter((reply) => reply.status === 'waiting' || reply.status === 'delivering');
const failedReplies = replies.filter((reply) => reply.status === 'failed');
return {
waitingOnChildren: replies.length > 0,
pendingReplyCount: replies.length,
readyReplyCount: readyReplies.length,
waitingReplyCount: waitingReplies.length,
failedReplyCount: failedReplies.length,
pendingReplies: replies,
};
}
function findCrossConversationReplyInTargetSession(targetSession, requestId) {
const normalizedRequestId = String(requestId || '').trim();
const messages = Array.isArray(targetSession?.messages) ? targetSession.messages : [];
if (!normalizedRequestId || messages.length === 0) return '';
const requestIndex = messages.findIndex((message) => (
message?.crossConversation?.replyRequestId === normalizedRequestId
));
if (requestIndex < 0) return '';
for (let index = messages.length - 1; index > requestIndex; index -= 1) {
const message = messages[index];
if (message?.role !== 'assistant') continue;
if (message?.ccwebDisplayOnly) continue;
const text = extractCrossConversationReplyText(message.content);
if (text) return text;
}
return '';
}
function reconcilePendingCrossConversationReplies() {
let changed = false;
for (const pending of pendingCrossConversationReplies.values()) {
if (pending.status !== 'waiting') continue;
if (isSessionRunning(pending.targetConversationId)) continue;
const targetSession = loadSession(pending.targetConversationId);
const replyText = findCrossConversationReplyInTargetSession(targetSession, pending.requestId);
if (!replyText) continue;
pending.status = 'ready';
pending.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS);
pending.completedAt = pending.completedAt || new Date().toISOString();
if (targetSession?.title) pending.targetTitle = targetSession.title;
changed = true;
}
if (changed) saveCrossConversationReplies();
for (const pending of pendingCrossConversationReplies.values()) {
if (pending.status === 'ready') deliverCrossConversationReply(pending.requestId);
}
return changed;
}
function codexAppStatePath(sessionId) { function codexAppStatePath(sessionId) {
return path.join(runDir(sessionId), CODEX_APP_STATE_FILE); return path.join(runDir(sessionId), CODEX_APP_STATE_FILE);
} }
@@ -3512,6 +3717,7 @@ function sendSessionList(ws) {
for (const f of files) { for (const f of files) {
const meta = loadSessionMetaFromFile(path.join(SESSIONS_DIR, f)); const meta = loadSessionMetaFromFile(path.join(SESSIONS_DIR, f));
if (!meta) continue; if (!meta) continue;
const waitState = crossConversationWaitState(meta.id);
sessions.push({ sessions.push({
id: meta.id, id: meta.id,
title: meta.title || 'Untitled', title: meta.title || 'Untitled',
@@ -3522,6 +3728,11 @@ function sendSessionList(ws) {
cwd: meta.cwd || '', cwd: meta.cwd || '',
projectName: meta.projectName || '', projectName: meta.projectName || '',
isRunning: isSessionRunning(meta.id), isRunning: isSessionRunning(meta.id),
waitingOnChildren: waitState.waitingOnChildren,
pendingReplyCount: waitState.pendingReplyCount,
readyReplyCount: waitState.readyReplyCount,
waitingReplyCount: waitState.waitingReplyCount,
failedReplyCount: waitState.failedReplyCount,
oversized: !!meta.oversized, oversized: !!meta.oversized,
fileBytes: meta.fileBytes || 0, fileBytes: meta.fileBytes || 0,
}); });
@@ -3564,6 +3775,7 @@ function clampMcpLimit(value) {
} }
function listConversationSummaries(args = {}, sourceSessionId = '') { function listConversationSummaries(args = {}, sourceSessionId = '') {
reconcilePendingCrossConversationReplies();
const agentFilter = VALID_AGENTS.has(args.agent) ? args.agent : ''; const agentFilter = VALID_AGENTS.has(args.agent) ? args.agent : '';
const statusFilter = ['running', 'idle'].includes(args.status) ? args.status : 'all'; const statusFilter = ['running', 'idle'].includes(args.status) ? args.status : 'all';
const limit = clampMcpLimit(args.limit); const limit = clampMcpLimit(args.limit);
@@ -3579,6 +3791,7 @@ function listConversationSummaries(args = {}, sourceSessionId = '') {
const status = running ? 'running' : 'idle'; const status = running ? 'running' : 'idle';
if (agentFilter && agent !== agentFilter) continue; if (agentFilter && agent !== agentFilter) continue;
if (statusFilter !== 'all' && status !== statusFilter) continue; if (statusFilter !== 'all' && status !== statusFilter) continue;
const waitState = crossConversationWaitState(meta.id);
conversations.push({ conversations.push({
id: meta.id, id: meta.id,
title: meta.title || 'Untitled', title: meta.title || 'Untitled',
@@ -3590,14 +3803,24 @@ function listConversationSummaries(args = {}, sourceSessionId = '') {
projectName: meta.projectName || '', projectName: meta.projectName || '',
isCurrent: meta.id === sourceSessionId, isCurrent: meta.id === sourceSessionId,
oversized: !!meta.oversized, oversized: !!meta.oversized,
waitingOnChildren: waitState.waitingOnChildren,
pendingReplyCount: waitState.pendingReplyCount,
readyReplyCount: waitState.readyReplyCount,
waitingReplyCount: waitState.waitingReplyCount,
}); });
} }
} catch {} } catch {}
conversations.sort(compareSessionsForList); conversations.sort(compareSessionsForList);
const sourceWaitState = crossConversationWaitState(sourceSessionId);
return { return {
ok: true, ok: true,
currentConversationId: sourceSessionId || null, currentConversationId: sourceSessionId || null,
waitingOnChildren: sourceWaitState.waitingOnChildren,
pendingReplyCount: sourceWaitState.pendingReplyCount,
readyReplyCount: sourceWaitState.readyReplyCount,
waitingReplyCount: sourceWaitState.waitingReplyCount,
pendingReplies: sourceWaitState.pendingReplies,
conversations: conversations.slice(0, limit), conversations: conversations.slice(0, limit),
}; };
} }
@@ -3635,6 +3858,8 @@ function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount =
sourceSession, sourceSession,
strict: true, strict: true,
requireAbsoluteCwd: true, requireAbsoluteCwd: true,
inheritSourceMode: false,
defaultMode: 'yolo',
createdFrom: sourceSession ? { createdFrom: sourceSession ? {
kind: 'mcp', kind: 'mcp',
sourceSessionId: sourceSession.id, sourceSessionId: sourceSession.id,
@@ -3778,7 +4003,7 @@ function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHop
if (requestId) { if (requestId) {
crossConversation.expectsReply = true; crossConversation.expectsReply = true;
crossConversation.replyRequestId = requestId; crossConversation.replyRequestId = requestId;
pendingCrossConversationReplies.set(requestId, { setPendingCrossConversationReply(requestId, {
requestId, requestId,
messageId, messageId,
sourceConversationId: sourceId, sourceConversationId: sourceId,
@@ -3808,7 +4033,7 @@ function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHop
}); });
if (!result?.ok) { if (!result?.ok) {
if (requestId) pendingCrossConversationReplies.delete(requestId); if (requestId) deletePendingCrossConversationReply(requestId);
return mcpToolError(result?.code || 'send_failed', result?.message || '跨对话消息发送失败。', { return mcpToolError(result?.code || 'send_failed', result?.message || '跨对话消息发送失败。', {
sourceConversationId: sourceId, sourceConversationId: sourceId,
targetConversationId: targetId, targetConversationId: targetId,
@@ -3836,6 +4061,56 @@ function requestCrossConversationReply(args = {}, sourceSessionId = '', sourceHo
return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount, { expectReply: true }); return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount, { expectReply: true });
} }
function listPendingCrossConversationReplies(args = {}, sourceSessionId = '') {
reconcilePendingCrossConversationReplies();
const sourceId = sanitizeId(sourceSessionId || '');
if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
const status = String(args.status || 'all').trim();
const statuses = status && status !== 'all' ? [status] : ['waiting', 'ready', 'delivering', 'failed'];
const validStatuses = statuses.filter((item) => ['waiting', 'ready', 'delivering', 'returned', 'failed'].includes(item));
const replies = listCrossConversationRepliesForSource(sourceId, {
statuses: validStatuses.length > 0 ? validStatuses : ['waiting', 'ready', 'delivering', 'failed'],
});
return {
ok: true,
sourceConversationId: sourceId,
waitingOnChildren: replies.length > 0,
pendingReplyCount: replies.length,
readyReplyCount: replies.filter((reply) => reply.status === 'ready').length,
replies,
};
}
function getPendingCrossConversationReply(args = {}, sourceSessionId = '') {
reconcilePendingCrossConversationReplies();
const sourceId = sanitizeId(sourceSessionId || '');
const requestId = String(args.requestId || '').trim();
if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
if (!requestId) return mcpToolError('missing_request_id', '缺少 requestId。');
const pending = pendingCrossConversationReplies.get(requestId);
if (!pending || pending.sourceConversationId !== sourceId) {
const sourceSession = loadSession(sourceId);
if (hasProcessedCrossConversationReply(sourceSession, requestId)) {
const message = sourceSession.messages.find((item) => item?.crossConversation?.replyToRequestId === requestId);
return {
ok: true,
requestId,
status: 'returned',
returned: true,
replyText: extractCrossConversationReplyText(message?.content || ''),
message: message || null,
};
}
return mcpToolError('reply_not_found', '未找到该跨对话等待回复。', { requestId });
}
return {
ok: true,
...crossConversationReplySummary(pending),
returned: pending.status === 'returned',
replyText: pending.replyText || '',
};
}
function deliverCrossConversationReply(requestId) { function deliverCrossConversationReply(requestId) {
const pending = pendingCrossConversationReplies.get(requestId); const pending = pendingCrossConversationReplies.get(requestId);
if (!pending || pending.status !== 'ready') return false; if (!pending || pending.status !== 'ready') return false;
@@ -3843,16 +4118,23 @@ function deliverCrossConversationReply(requestId) {
const sourceSession = loadSession(pending.sourceConversationId); const sourceSession = loadSession(pending.sourceConversationId);
const targetSession = loadSession(pending.targetConversationId); const targetSession = loadSession(pending.targetConversationId);
if (!sourceSession || !targetSession) { if (!sourceSession || !targetSession) {
pending.status = 'failed'; updatePendingCrossConversationReply(requestId, (draft) => {
pending.lastError = sourceSession ? 'target_not_found' : 'source_not_found'; draft.status = 'failed';
pendingCrossConversationReplies.delete(requestId); draft.lastError = sourceSession ? 'target_not_found' : 'source_not_found';
});
broadcastSessionList();
return false;
}
if (isSessionRunning(sourceSession.id)) {
broadcastSessionList();
return false; return false;
} }
if (isSessionRunning(sourceSession.id)) return false;
if (hasProcessedCrossConversationReply(sourceSession, requestId)) { if (hasProcessedCrossConversationReply(sourceSession, requestId)) {
pending.status = 'returned'; updatePendingCrossConversationReply(requestId, (draft) => {
pending.returnedAt = pending.returnedAt || new Date().toISOString(); draft.status = 'returned';
pendingCrossConversationReplies.delete(requestId); draft.returnedAt = draft.returnedAt || new Date().toISOString();
});
deletePendingCrossConversationReply(requestId);
return true; return true;
} }
@@ -3872,7 +4154,9 @@ function deliverCrossConversationReply(requestId) {
autoRun: false, autoRun: false,
}; };
pending.status = 'delivering'; updatePendingCrossConversationReply(requestId, (draft) => {
draft.status = 'delivering';
});
const replyMessage = { const replyMessage = {
role: 'assistant', role: 'assistant',
content: replyContent, content: replyContent,
@@ -3894,10 +4178,12 @@ function deliverCrossConversationReply(requestId) {
}); });
} }
pending.status = 'returned'; updatePendingCrossConversationReply(requestId, (draft) => {
pending.returnedAt = now; draft.status = 'returned';
pending.replyMessageId = replyMessageId; draft.returnedAt = now;
pendingCrossConversationReplies.delete(requestId); draft.replyMessageId = replyMessageId;
});
deletePendingCrossConversationReply(requestId);
broadcastSessionList(); broadcastSessionList();
return true; return true;
} }
@@ -3926,10 +4212,13 @@ function completeCrossConversationReply(requestId, entry = {}, targetSession = n
replyText = '(目标对话没有返回可用文本。)'; replyText = '(目标对话没有返回可用文本。)';
} }
pending.status = 'ready'; updatePendingCrossConversationReply(normalizedRequestId, (draft) => {
pending.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS); draft.status = 'ready';
pending.completedAt = new Date().toISOString(); draft.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS);
if (targetSession?.title) pending.targetTitle = targetSession.title; draft.completedAt = new Date().toISOString();
if (targetSession?.title) draft.targetTitle = targetSession.title;
});
broadcastSessionList();
return deliverCrossConversationReply(normalizedRequestId); return deliverCrossConversationReply(normalizedRequestId);
} }
@@ -3941,6 +4230,10 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
return createMcpConversation(args, sourceSessionId, sourceHopCount); return createMcpConversation(args, sourceSessionId, sourceHopCount);
case 'ccweb_send_message': case 'ccweb_send_message':
return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount); return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount);
case 'ccweb_list_pending_replies':
return listPendingCrossConversationReplies(args, sourceSessionId);
case 'ccweb_get_pending_reply':
return getPendingCrossConversationReply(args, sourceSessionId);
case 'ccweb_request_reply': case 'ccweb_request_reply':
return requestCrossConversationReply(args, sourceSessionId, sourceHopCount); return requestCrossConversationReply(args, sourceSessionId, sourceHopCount);
default: default:
@@ -5444,7 +5737,9 @@ function createPersistentConversationSession(args = {}, options = {}) {
const strict = !!options.strict; const strict = !!options.strict;
const explicitCwd = typeof args.cwd === 'string' && args.cwd.trim(); const explicitCwd = typeof args.cwd === 'string' && args.cwd.trim();
const fallbackAgent = getSessionAgent(sourceSession) || options.defaultAgent || 'claude'; const fallbackAgent = getSessionAgent(sourceSession) || options.defaultAgent || 'claude';
const fallbackMode = sourceSession?.permissionMode || options.defaultMode || 'yolo'; const fallbackMode = options.inheritSourceMode === false
? (options.defaultMode || 'yolo')
: (sourceSession?.permissionMode || options.defaultMode || 'yolo');
const agentResult = resolveConversationAgent(args.agent, fallbackAgent, { strict }); const agentResult = resolveConversationAgent(args.agent, fallbackAgent, { strict });
if (!agentResult.ok) return agentResult; if (!agentResult.ok) return agentResult;
const modeResult = resolveConversationMode(args.mode, fallbackMode, { strict }); const modeResult = resolveConversationMode(args.mode, fallbackMode, { strict });
@@ -5494,6 +5789,7 @@ function createPersistentConversationSession(args = {}, options = {}) {
} }
function buildSessionInfoPayload(session) { function buildSessionInfoPayload(session) {
const waitState = crossConversationWaitState(session.id);
return { return {
type: 'session_info', type: 'session_info',
sessionId: session.id, sessionId: session.id,
@@ -5510,6 +5806,12 @@ function buildSessionInfoPayload(session) {
hasUnread: false, hasUnread: false,
historyPending: false, historyPending: false,
isRunning: false, isRunning: false,
waitingOnChildren: waitState.waitingOnChildren,
pendingReplyCount: waitState.pendingReplyCount,
readyReplyCount: waitState.readyReplyCount,
waitingReplyCount: waitState.waitingReplyCount,
failedReplyCount: waitState.failedReplyCount,
pendingReplies: waitState.pendingReplies,
}; };
} }
@@ -5558,20 +5860,24 @@ function handleLoadHistoryPage(ws, msg = {}) {
} }
function handleLoadSession(ws, sessionId) { function handleLoadSession(ws, sessionId) {
reconcilePendingCrossConversationReplies();
const session = loadSession(sessionId); const session = loadSession(sessionId);
if (!session) { if (!session) {
return wsSend(ws, { type: 'error', message: 'Session not found' }); return wsSend(ws, { type: 'error', message: 'Session not found' });
} }
if (getSessionAgent(session) === 'claude' && !session.cwd && session.claudeSessionId) { flushPendingCrossConversationReplies(sessionId);
const localMeta = resolveClaudeSessionLocalMeta(session.claudeSessionId); const refreshedSession = loadSession(sessionId) || session;
if (getSessionAgent(refreshedSession) === 'claude' && !refreshedSession.cwd && refreshedSession.claudeSessionId) {
const localMeta = resolveClaudeSessionLocalMeta(refreshedSession.claudeSessionId);
if (localMeta?.cwd) { if (localMeta?.cwd) {
session.cwd = localMeta.cwd; refreshedSession.cwd = localMeta.cwd;
if (!session.importedFrom && localMeta.projectDir) session.importedFrom = localMeta.projectDir; if (!refreshedSession.importedFrom && localMeta.projectDir) refreshedSession.importedFrom = localMeta.projectDir;
saveSession(session); saveSession(refreshedSession);
} }
} }
const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(session.messages); const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(refreshedSession.messages);
const effectiveCwd = session.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null; const effectiveCwd = refreshedSession.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null;
const waitState = crossConversationWaitState(sessionId);
// Detach ws from any previous session's process // Detach ws from any previous session's process
detachWsFromActiveRuntimes(ws); detachWsFromActiveRuntimes(ws);
@@ -5579,39 +5885,45 @@ function handleLoadSession(ws, sessionId) {
wsSessionMap.set(ws, sessionId); wsSessionMap.set(ws, sessionId);
// Read and clear unread flag // Read and clear unread flag
const hadUnread = !!session.hasUnread; const hadUnread = !!refreshedSession.hasUnread;
if (session.hasUnread) { if (refreshedSession.hasUnread) {
session.hasUnread = false; refreshedSession.hasUnread = false;
saveSession(session); saveSession(refreshedSession);
} }
wsSend(ws, { wsSend(ws, {
type: 'session_info', type: 'session_info',
sessionId: session.id, sessionId: refreshedSession.id,
messages: recentMessages, messages: recentMessages,
title: session.title, title: refreshedSession.title,
pinnedAt: session.pinnedAt || null, pinnedAt: refreshedSession.pinnedAt || null,
mode: session.permissionMode || 'yolo', mode: refreshedSession.permissionMode || 'yolo',
model: sessionModelLabel(session), model: sessionModelLabel(refreshedSession),
agent: getSessionAgent(session), agent: getSessionAgent(refreshedSession),
hasUnread: hadUnread, hasUnread: hadUnread,
cwd: effectiveCwd, cwd: effectiveCwd,
totalCost: session.totalCost || 0, totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || null, totalUsage: session.totalUsage || null,
historyTotal: session.messages.length, historyTotal: refreshedSession.messages.length,
historyBuffered, historyBuffered,
historyCursor: historyRemaining, historyCursor: historyRemaining,
historyTruncated: historyRemaining > 0, historyTruncated: historyRemaining > 0,
historyPending: olderChunks.length > 0, historyPending: olderChunks.length > 0,
updated: session.updated, updated: refreshedSession.updated,
isRunning: isSessionRunning(sessionId), isRunning: isSessionRunning(sessionId),
waitingOnChildren: waitState.waitingOnChildren,
pendingReplyCount: waitState.pendingReplyCount,
readyReplyCount: waitState.readyReplyCount,
waitingReplyCount: waitState.waitingReplyCount,
failedReplyCount: waitState.failedReplyCount,
pendingReplies: waitState.pendingReplies,
}); });
if (olderChunks.length > 0) { if (olderChunks.length > 0) {
olderChunks.forEach((chunk, index) => { olderChunks.forEach((chunk, index) => {
wsSend(ws, { wsSend(ws, {
type: 'session_history_chunk', type: 'session_history_chunk',
sessionId: session.id, sessionId: refreshedSession.id,
messages: chunk, messages: chunk,
remaining: Math.max(0, olderChunks.length - index - 1), remaining: Math.max(0, olderChunks.length - index - 1),
historyCursor: index === olderChunks.length - 1 ? historyRemaining : null, historyCursor: index === olderChunks.length - 1 ? historyRemaining : null,
@@ -5719,6 +6031,7 @@ function deleteCodexLocalSession(session) {
function handleDeleteSession(ws, sessionId) { function handleDeleteSession(ws, sessionId) {
pendingSlashCommands.delete(sessionId); pendingSlashCommands.delete(sessionId);
pendingCompactRetries.delete(sessionId); pendingCompactRetries.delete(sessionId);
deleteCrossConversationRepliesForSession(sessionId);
for (const [threadId, child] of ccwebMcpChildThreads.entries()) { for (const [threadId, child] of ccwebMcpChildThreads.entries()) {
if (child.parentSessionId === sessionId) ccwebMcpChildThreads.delete(threadId); if (child.parentSessionId === sessionId) ccwebMcpChildThreads.delete(threadId);
} }
@@ -5952,7 +6265,11 @@ function handleMessage(ws, msg, options = {}) {
: `图片: ${savedAttachments[0]?.filename || 'image'}`; : `图片: ${savedAttachments[0]?.filename || 'image'}`;
let session; let session;
if (sessionId) session = loadSession(sessionId); if (sessionId) {
reconcilePendingCrossConversationReplies();
flushPendingCrossConversationReplies(sessionId);
session = loadSession(sessionId);
}
if (!session) { if (!session) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const agent = normalizeAgent(msg.agent); const agent = normalizeAgent(msg.agent);
@@ -6027,23 +6344,7 @@ function handleMessage(ws, msg, options = {}) {
} }
if (!sessionId) { if (!sessionId) {
wsSend(ws, { wsSend(ws, buildSessionInfoPayload(session));
type: 'session_info',
sessionId: currentSessionId,
messages: session.messages,
title: session.title,
pinnedAt: session.pinnedAt || null,
mode: session.permissionMode || 'yolo',
model: sessionModelLabel(session),
agent: getSessionAgent(session),
cwd: session.cwd || null,
totalCost: session.totalCost || 0,
totalUsage: session.totalUsage || null,
updated: session.updated,
hasUnread: false,
historyPending: false,
isRunning: false,
});
} }
if (ws && options.emitUserMessage && persistedUserMessage) { if (ws && options.emitUserMessage && persistedUserMessage) {
wsSend(ws, { type: 'session_message', sessionId: currentSessionId, message: persistedUserMessage }); wsSend(ws, { type: 'session_message', sessionId: currentSessionId, message: persistedUserMessage });
@@ -6701,6 +7002,38 @@ function codexAppCommunicationDynamicTools() {
additionalProperties: false, additionalProperties: false,
}, },
}, },
{
name: 'ccweb_list_pending_replies',
namespace: 'ccweb',
description: '列出当前来源对话等待中的跨对话回复,包括已完成但尚未处理的子对话返回摘要。',
inputSchema: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['all', 'waiting', 'ready', 'delivering', 'returned', 'failed'],
description: '可选。按回复状态过滤,默认 all。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_get_pending_reply',
namespace: 'ccweb',
description: '读取指定 requestId 的跨对话回复状态和正文;用于判断是否继续追问指定子对话。',
inputSchema: {
type: 'object',
required: ['requestId'],
properties: {
requestId: {
type: 'string',
description: '等待回复 requestId。',
},
},
additionalProperties: false,
},
},
{ {
name: 'ccweb_create_conversation', name: 'ccweb_create_conversation',
namespace: 'ccweb', namespace: 'ccweb',
@@ -6720,7 +7053,7 @@ function codexAppCommunicationDynamicTools() {
mode: { mode: {
type: 'string', type: 'string',
enum: ['default', 'plan', 'yolo'], enum: ['default', 'plan', 'yolo'],
description: '可选。权限模式,默认继承来源对话。', description: '可选。权限模式,默认 yolo只有显式传 default/plan/yolo 时才使用指定模式。',
}, },
initialMessage: { initialMessage: {
type: 'string', type: 'string',
@@ -6776,6 +7109,8 @@ function handleCodexAppDynamicToolCall(routed, params = {}) {
tool !== 'ccweb_list_conversations' && tool !== 'ccweb_list_conversations' &&
tool !== 'ccweb_create_conversation' && tool !== 'ccweb_create_conversation' &&
tool !== 'ccweb_send_message' && tool !== 'ccweb_send_message' &&
tool !== 'ccweb_list_pending_replies' &&
tool !== 'ccweb_get_pending_reply' &&
tool !== 'ccweb_request_reply' tool !== 'ccweb_request_reply'
) return null; ) return null;
@@ -8052,7 +8387,9 @@ function handleListCwdSuggestions(ws) {
} }
// === Startup === // === Startup ===
loadCrossConversationReplies();
recoverProcesses(); recoverProcesses();
reconcilePendingCrossConversationReplies();
// Periodic heartbeat: log active processes status every 60s // Periodic heartbeat: log active processes status every 60s
setInterval(() => { setInterval(() => {