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

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

View File

@@ -490,6 +490,11 @@ function assertFrontendGenerationControlsContract() {
/allowRuntimeInsert\s*=\s*isGenerating\s*&&\s*isCodexAppAgent\(currentAgent\)/.test(controlsBlock),
'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() {
@@ -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.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.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.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);
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.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.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');
@@ -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.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.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);
@@ -965,6 +972,103 @@ async function main() {
/已返回消息/.test(String(message.content || ''))
)), '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 mcpSpawnLine = processLogAfterMcp
.trim()