修正Direct聊天中断上下文恢复\n删除打开项目自动重发旧消息\n持久化partial并在新消息前回放完整历史

This commit is contained in:
2026-09-02 18:01:00 +08:00
parent 1186f6f47c
commit cfcaff3af0
+57 -79
View File
@@ -293,56 +293,6 @@ function directCodexConversationMessageId(
return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`;
}
function directCodexConversationTurnId(
message: ChatMessage,
role: ChatMessage['role'],
) {
if (message.role !== role) {
return null;
}
const messageId = message.messageId?.trim() ?? '';
const roleSuffix = `:${role}`;
if (
!messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) ||
!messageId.endsWith(roleSuffix)
) {
return null;
}
const turnId = messageId.slice(
DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length,
-roleSuffix.length,
);
return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId) ? turnId : null;
}
export function unansweredDirectCodexConversationTurn(messages: ChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (!message) {
continue;
}
const turnId = directCodexConversationTurnId(message, 'user');
if (!turnId) {
continue;
}
const assistantMessageId = directCodexConversationMessageId(
turnId,
'assistant',
);
if (
messages.some(
(candidate) =>
candidate.role === 'assistant' &&
candidate.messageId?.trim() === assistantMessageId,
)
) {
continue;
}
return { prompt: message.text, turnId };
}
return null;
}
export function isDirectCodexTurnAlreadyRunningError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return message
@@ -540,6 +490,12 @@ export function App({
useState<number | null>(null);
const [directCodexTransientReply, setDirectCodexTransientReply] =
useState('');
const directCodexTransientReplyRef = useRef('');
const directCodexInterruptedPartialRef = useRef<{
projectPath: string;
text: string;
messageId: string;
} | null>(null);
const [
directCodexTransientReplyUpdatedAt,
setDirectCodexTransientReplyUpdatedAt,
@@ -579,6 +535,7 @@ export function App({
setDirectCodexProgress('');
setDirectCodexProgressUpdatedAt(null);
setDirectCodexTransientReply('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
}
@@ -592,6 +549,7 @@ export function App({
}
activeDirectCodexTurnRef.current = null;
setDirectCodexTransientReply('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
return true;
}
@@ -1232,8 +1190,6 @@ export function App({
activeDirectCodexTurnRef.current = null;
setDirectCodexProgress('处理失败,正在同步错误');
setDirectCodexProgressUpdatedAt(updatedAt);
setDirectCodexTransientReply('');
setDirectCodexTransientReplyUpdatedAt(null);
return;
}
if (payload.status === 'completed') {
@@ -1245,6 +1201,7 @@ export function App({
}
if (typeof payload.accumulatedText === 'string') {
setDirectCodexTransientReply(payload.accumulatedText);
directCodexTransientReplyRef.current = payload.accumulatedText;
setDirectCodexTransientReplyUpdatedAt(updatedAt);
}
},
@@ -1840,9 +1797,8 @@ export function App({
);
if (localProjectPathRef.current === nextProjectPath) {
// Any history read started before this terminal append may hold
// a user-only snapshot. Invalidate it before releasing the
// in-memory claim so that stale hydration cannot replay the
// same billable Direct turn.
// A stale history snapshot may still be missing this terminal
// append. Invalidate it before releasing the in-memory claim.
projectSupervisorHistoryLoadVersionRef.current += 1;
}
recoveredDirectCodexTurnClaimsRef.current.delete(claimKey);
@@ -2698,9 +2654,6 @@ export function App({
projectConversation.messages,
supervisorConversation?.messages ?? [],
);
const unansweredDirectTurn = directCodexProductRuntime
? unansweredDirectCodexConversationTurn(conversationMessages)
: null;
if (
conversationContainsProjectSupervisorResponseStream(
supervisorConversation?.messages ?? [],
@@ -2752,24 +2705,6 @@ export function App({
});
return nextConversationMessages;
});
if (unansweredDirectTurn) {
const claimKey = `${nextProjectPath}\u0000${unansweredDirectTurn.turnId}`;
void Promise.resolve().then(() => {
if (
projectSupervisorHistoryLoadVersionRef.current !== loadVersion ||
localProjectPathRef.current !== nextProjectPath ||
activeDirectCodexTurnRef.current ||
recoveredDirectCodexTurnClaimsRef.current.has(claimKey)
) {
return;
}
recoveredDirectCodexTurnClaimsRef.current.add(claimKey);
void executeChatAgentReply({
prompt: unansweredDirectTurn.prompt,
clientTurnId: unansweredDirectTurn.turnId,
});
});
}
} catch (error) {
if (
projectSupervisorHistoryLoadVersionRef.current !== loadVersion ||
@@ -5459,6 +5394,20 @@ export function App({
},
},
);
const persistDirectPartialMessage = (messageId: string, text: string) =>
directInvoke<LocalConversationResult>(
'append_local_conversation_message',
{
projectPath: directProjectPath,
agentId: null,
messageId,
message: {
role: 'assistant',
content: `${text.trim()}\nunexpected interrupt happened here`,
agentId: null,
},
},
);
const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`;
recoveredDirectCodexTurnClaimsRef.current.add(
recoveredDirectCodexTurnClaimKey,
@@ -5473,9 +5422,21 @@ export function App({
setDirectCodexProgress('已发送消息,正在等待陶泥儿回复');
setDirectCodexProgressUpdatedAt(Date.now());
setDirectCodexTransientReply('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
setProjectSupervisorRuntimeError('');
try {
const interruptedPartial = directCodexInterruptedPartialRef.current;
if (
interruptedPartial?.projectPath === directProjectPath &&
interruptedPartial.text.trim()
) {
await persistDirectPartialMessage(
interruptedPartial.messageId,
interruptedPartial.text,
);
directCodexInterruptedPartialRef.current = null;
}
// Persist the original user intent and stable turn identity before
// Codex can start any billable or externally visible work. The
// regular conversation writer may race this call, but messageId
@@ -5526,10 +5487,10 @@ export function App({
}
}
// Rust persists a successful Direct reply before returning Ok. The
// browser append is redundant, so the hydrated-turn claim can be
// browser append is redundant, so the in-memory turn claim can be
// released without reopening the Provider side effect. Invalidate
// any user-only history snapshot captured before Rust committed the
// terminal reply first.
// any history snapshot captured before Rust committed the terminal
// reply first.
if (localProjectPathRef.current === directProjectPath) {
projectSupervisorHistoryLoadVersionRef.current += 1;
}
@@ -5565,6 +5526,23 @@ export function App({
'陶泥儿智能创作',
true,
);
const partial = directCodexTransientReplyRef.current.trim();
if (partial) {
const partialMessageId =
globalThis.crypto?.randomUUID?.() ||
`direct-partial-${Date.now().toString(36)}`;
directCodexInterruptedPartialRef.current = {
projectPath: directProjectPath,
text: partial,
messageId: partialMessageId,
};
try {
await persistDirectPartialMessage(partialMessageId, partial);
} catch {
// The next user send retries this idempotent append before
// constructing the replay prompt.
}
}
try {
await persistDirectAssistantMessage(visibleMessage);
if (localProjectPathRef.current === directProjectPath) {