Fix cross-conversation reply auto-resume

This commit is contained in:
shiyue
2026-06-22 18:22:53 +08:00
parent a50933807f
commit e15736e302
5 changed files with 410 additions and 243 deletions

View File

@@ -286,6 +286,10 @@ function nextMessage(messages, ws, predicate, timeoutMs = 15000) {
});
}
function isSessionCompletionMessage(msg, sessionId) {
return (msg?.type === 'done' || msg?.type === 'background_done') && msg.sessionId === sessionId;
}
function createFakeClaudeHistory(homeDir) {
const projectDir = path.join(homeDir, '.claude', 'projects', 'tmp-project');
mkdirp(projectDir);
@@ -854,6 +858,7 @@ async function main() {
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');
assert(mcpCreateReply.body.replyDelivery === 'auto_run' && mcpCreateReply.body.sourceAutoRun === true, 'MCP create requestReply should declare source auto-run delivery');
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreateReply.body.conversationId);
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
@@ -862,14 +867,19 @@ async function main() {
message.crossConversation?.processed === true
))
));
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, codexSession.sessionId));
const storedMcpCreateReply = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreateReply.body.conversationId}.json`), 'utf8'));
const storedMcpCreateSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
assert(storedMcpCreateReply.messages.some((message) => message.crossConversation?.replyRequestId === mcpCreateReply.body.requestId), 'MCP create requestReply should persist waiting metadata on the new conversation');
assert(storedMcpCreateSource.messages.some((message) => (
message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId &&
message.crossConversation?.processed === true &&
message.crossConversation?.autoRun === false
)), 'MCP create requestReply should send a processed display-only reply back to source');
const storedMcpCreateReplyIndex = storedMcpCreateSource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId);
assert(storedMcpCreateReplyIndex >= 0, 'MCP create requestReply should send a processed display-only reply back to source');
assert(storedMcpCreateSource.messages[storedMcpCreateReplyIndex].crossConversation?.processed === true, 'MCP create requestReply should mark the returned message processed');
assert(storedMcpCreateSource.messages[storedMcpCreateReplyIndex].crossConversation?.autoRun === true, 'MCP create requestReply should mark the returned message as auto-run');
assert(storedMcpCreateSource.messages.slice(storedMcpCreateReplyIndex + 1).some((message) => (
message.role === 'assistant' &&
/mcp create request reply/.test(String(message.content || '')) &&
/子对话/.test(String(message.content || ''))
)), 'MCP create requestReply should continue the source session after the child reply');
const crossTargetCwd = path.join(tempRoot, 'codex-mcp-cross-target');
mkdirp(crossTargetCwd);
@@ -934,7 +944,7 @@ async function main() {
});
assert(requestReply.status === 200 && requestReply.body?.ok, `MCP request reply should succeed: ${JSON.stringify(requestReply.body)}`);
assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP request reply should return a waiting request id');
assert(requestReply.body.replyDelivery === 'display_only' && requestReply.body.sourceAutoRun === false, 'MCP request reply should declare display-only delivery without source auto-run');
assert(requestReply.body.replyDelivery === 'auto_run' && requestReply.body.sourceAutoRun === true, 'MCP request reply should declare source auto-run delivery');
const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossReplyTargetSession.sessionId &&
@@ -951,6 +961,7 @@ async function main() {
message.crossConversation?.processed === true
))
));
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, codexSession.sessionId));
const storedReplyTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossReplyTargetSession.sessionId}.json`), 'utf8'));
const storedReplySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
@@ -963,15 +974,16 @@ async function main() {
assert(storedReplyMessage.role === 'assistant', 'Returned cross message should be persisted as display-only assistant content');
assert(storedReplyMessage.crossConversation.reply === true, 'Returned cross message should be marked as a reply');
assert(storedReplyMessage.crossConversation.processed === true, 'Returned cross message should persist a processed marker');
assert(storedReplyMessage.crossConversation.autoRun === false, 'Returned cross message should not auto-run the source session again');
assert(storedReplyMessage.crossConversation.autoRun === true, 'Returned cross message should mark source auto-run');
assert(storedReplyMessage.ccwebDisplayOnly === true, 'Returned cross message should be marked display-only');
assert(/线程「/.test(storedReplyMessage.content || '') && /已返回消息/.test(storedReplyMessage.content || ''), 'Returned cross message should include reply heading');
assert(/Codex mock handled/.test(storedReplyMessage.content || ''), 'Returned cross message should include target assistant output');
assert(!storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => (
assert(storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => (
message.role === 'assistant' &&
/Codex mock handled/.test(String(message.content || '')) &&
/已返回消息/.test(String(message.content || ''))
)), 'Returned cross message should not trigger the source session to run again');
/cross reply requested/.test(String(message.content || '')) &&
/子对话/.test(String(message.content || ''))
)), 'Returned cross message should trigger the source session to run again');
const busySourceCwd = path.join(tempRoot, 'codex-mcp-busy-source');
mkdirp(busySourceCwd);
@@ -995,6 +1007,7 @@ async function main() {
});
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');
assert(busyRequestReply.body.replyDelivery === 'auto_run' && busyRequestReply.body.sourceAutoRun === true, 'Busy source request reply should declare source auto-run delivery');
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) &&
@@ -1038,7 +1051,7 @@ async function main() {
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 nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, busySourceSession.sessionId), 8000);
await waitForJsonCondition(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
session.messages.some((message) => (
@@ -1051,6 +1064,14 @@ async function main() {
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');
assert(storedBusySource.messages[busyReplyIndex].crossConversation?.autoRun === true, 'Queued reply should mark source auto-run');
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, busySourceSession.sessionId), 8000);
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
assert(storedBusySource.messages.slice(busyReplyIndex + 1).some((message) => (
message.role === 'assistant' &&
/busy source reply requested/.test(String(message.content || '')) &&
/子对话/.test(String(message.content || ''))
)), 'Busy source should auto-run after the queued child reply is flushed');
const returnedPendingDetail = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',