diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index f9c8e325..048ba9d6 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -824,13 +824,18 @@ export function useChat(options: UseChatOptions): UseChatReturn { currentAssistantId.value = null } - streamPhase.value = data.status === 'awaiting_approval' ? 'awaiting_approval' + streamPhase.value = errorFired ? 'idle' + : data.status === 'awaiting_approval' ? 'awaiting_approval' : data.status === 'stopped' ? 'stopped' : 'completed' if (data.status !== 'awaiting_approval') { phaseInfo.value = null compactStatus.value = null lifecycleStage.value = null - expirePendingApprovals(data.status === 'stopped' ? 'stopped' : 'completed') + // An error may be followed by a protocol-level done event. Its status + // does not resolve an approval or turn the failed request into success. + if (!errorFired) { + expirePendingApprovals(data.status === 'stopped' ? 'stopped' : 'completed') + } } // Safety cleanup for queue state (no-op if queued_input_started already handled it) @@ -846,13 +851,15 @@ export function useChat(options: UseChatOptions): UseChatReturn { : data.status === 'interrupted' ? 'interrupted' : data.status === 'awaiting_approval' ? 'awaiting_approval' : 'completed' - onStreamEnd?.({ - conversationId: data.conversationId || streamConversationId, - reason, - assistantMessageId: data.assistantMessageId, - persisted: data.persisted, - messageCount: data.messageCount, - }) + if (!errorFired) { + onStreamEnd?.({ + conversationId: data.conversationId || streamConversationId, + reason, + assistantMessageId: data.assistantMessageId, + persisted: data.persisted, + messageCount: data.messageCount, + }) + } // Re-attach SSE if any generative task is still in flight, so the eventual // async_task_completed event reaches us live (otherwise the user has to @@ -861,7 +868,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { const reconnectableStatus = !data.status || data.status === 'completed' || data.status === 'idle' - if (reconnectableStatus + if (!errorFired && reconnectableStatus && !reconnectingForAsyncTasks && pendingAsyncTaskIds.size > 0 && streamConversationId) { @@ -918,7 +925,8 @@ export function useChat(options: UseChatOptions): UseChatReturn { lifecycleStage.value = null // Clear queue on error to avoid stale state messageQueue.clear() - expirePendingApprovals('failed') + // The approval may still be pending after a rejected request. The view + // reconciles it against the server's pending list in onStreamEnd. if (errorFired) return errorFired = true diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index e59f7bbc..f1432bf5 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -808,6 +808,32 @@ const { // 流结束后刷新会话列表(更新 lastActiveTime / 标题等) await loadConversations() if (meta.conversationId && meta.conversationId === currentConversationId.value) { + if (meta.reason === 'error') { + // Keep the failed turn visible, but take approval state from the + // server. A rejected approval SSE error leaves its pending row open. + try { + const approvalRes: any = await chatApi.getPendingApprovals(meta.conversationId) + if (meta.conversationId !== currentConversationId.value) return + const serverIds = new Set((approvalRes.data || []).map((p: any) => p.pendingId)) + messages.value = messages.value.map((m) => { + const pending = (m as any).metadata?.pendingApproval + if (!pending?.pendingId || pending.status !== 'pending_approval') return m + const active = serverIds.has(pending.pendingId) + return { + ...m, + status: active ? 'awaiting_approval' + : m.status === 'awaiting_approval' ? 'failed' : m.status, + metadata: { + ...(m as any).metadata, + currentPhase: active ? 'awaiting_approval' : undefined, + pendingApproval: { ...pending, status: active ? 'pending_approval' : 'expired' }, + }, + } + }) + } catch { + // Keep the local pending card if the authoritative read is unavailable. + } + } // Skip DB refresh for awaiting_approval / interrupted / error: // - awaiting_approval / interrupted: avoids overwriting local-only state // or breaking message ordering. @@ -2065,10 +2091,6 @@ async function handleSendMessage(content: string, pendingApprovalId?: string) { return } - // 乐观更新审批状态 - const decision = trimmed === '/approve' ? 'approved' : 'denied' - ;(pendingMsg as any).metadata.pendingApproval.status = decision - inputText.value = '' chatInputRef.value?.clear?.() @@ -2082,8 +2104,6 @@ async function handleSendMessage(content: string, pendingApprovalId?: string) { }) } catch (e: any) { console.error('Approval stream failed:', e) - // 回滚乐观更新 - ;(pendingMsg as any).metadata.pendingApproval.status = 'pending_approval' mcToast.error(e?.message || 'Approval failed') } return