fix: 恢复 Agent 提问中断状态

This commit is contained in:
Wenjie Zhang 2026-06-06 17:46:19 +08:00
parent 410dbf6d1d
commit e5553f1bfc
8 changed files with 622 additions and 121 deletions

View File

@ -339,29 +339,19 @@ async def get_active_run_by_thread(*, thread_id: str, current_uid: str, db: Asyn
from sqlalchemy import select from sqlalchemy import select
from yuxi.storage.postgres.models_business import AgentRun from yuxi.storage.postgres.models_business import AgentRun
active_result = await db.execute( # 线程内的 run 是串行的,最近一条 run 即代表线程当前状态。
# 已被回复的 interrupted run 会被更晚创建的 resume run 取代,因此不会再被当作待处理中断返回。
result = await db.execute(
select(AgentRun) select(AgentRun)
.where( .where(
AgentRun.thread_id == thread_id, AgentRun.thread_id == thread_id,
AgentRun.uid == str(current_uid), AgentRun.uid == str(current_uid),
AgentRun.run_type.in_(["chat", "resume"]), AgentRun.run_type.in_(["chat", "resume"]),
AgentRun.status.in_(["pending", "running", "cancel_requested"]),
) )
.order_by(AgentRun.created_at.desc()) .order_by(AgentRun.created_at.desc())
.limit(1) .limit(1)
) )
run = active_result.scalar_one_or_none() run = result.scalar_one_or_none()
if not run: if run and run.status in ("pending", "running", "cancel_requested", "interrupted"):
interrupted_result = await db.execute( return {"run": run.to_dict()}
select(AgentRun) return {"run": None}
.where(
AgentRun.thread_id == thread_id,
AgentRun.uid == str(current_uid),
AgentRun.run_type.in_(["chat", "resume"]),
AgentRun.status == "interrupted",
)
.order_by(AgentRun.created_at.desc())
.limit(1)
)
run = interrupted_result.scalar_one_or_none()
return {"run": run.to_dict() if run else None}

View File

@ -0,0 +1,85 @@
"""测试内置 ask_user_question 工具的格式契约。"""
import json
import pytest
from yuxi.agents.toolkits.buildin import tools
def test_ask_user_question_interrupt_payload_and_result_format(monkeypatch):
captured_payloads = []
expected_answer = {"style": "simple"}
def fake_interrupt(payload):
captured_payloads.append(payload)
return expected_answer
monkeypatch.setattr(tools, "interrupt", fake_interrupt)
result = tools.ask_user_question.func(
questions=[
{
"question_id": "style",
"question": "选择界面风格",
"options": [
{"label": "简洁 (Recommended)", "value": "simple"},
{"label": "详细", "value": "detailed"},
],
"multi_select": False,
"allow_other": False,
}
]
)
expected_questions = [
{
"question_id": "style",
"question": "选择界面风格",
"options": [
{"label": "简洁 (Recommended)", "value": "simple"},
{"label": "详细", "value": "detailed"},
],
"multi_select": False,
"allow_other": False,
}
]
assert captured_payloads == [{"questions": expected_questions, "source": "ask_user_question"}]
assert result == {"questions": expected_questions, "answer": expected_answer}
def test_ask_user_question_accepts_json_string_questions(monkeypatch):
captured_payloads = []
monkeypatch.setattr(tools, "interrupt", lambda payload: captured_payloads.append(payload) or {"q-1": "A"})
result = tools.ask_user_question.func(
questions=json.dumps(
[
{
"question": "选择一个选项",
"options": ["A", "B"],
"allow_other": False,
}
],
ensure_ascii=False,
)
)
assert captured_payloads[0]["source"] == "ask_user_question"
assert captured_payloads[0]["questions"] == [
{
"question_id": "q-1",
"question": "选择一个选项",
"options": [{"label": "A", "value": "A"}, {"label": "B", "value": "B"}],
"multi_select": False,
"allow_other": False,
}
]
assert result["answer"] == {"q-1": "A"}
def test_ask_user_question_rejects_empty_questions():
with pytest.raises(ValueError, match="questions 至少需要包含一个有效问题"):
tools.ask_user_question.func(questions=[])

View File

@ -101,8 +101,8 @@
<div class="bottom" :class="{ 'start-screen': !conversations.length }"> <div class="bottom" :class="{ 'start-screen': !conversations.length }">
<!-- 人工审批弹窗 - 放在输入框上方 --> <!-- 人工审批弹窗 - 放在输入框上方 -->
<HumanApprovalModal <HumanApprovalModal
:visible="approvalState.showModal" :visible="currentApprovalModalVisible"
:questions="approvalState.questions" :questions="currentApprovalQuestions"
@submit="handleQuestionSubmit" @submit="handleQuestionSubmit"
@cancel="handleQuestionCancel" @cancel="handleQuestionCancel"
/> />
@ -954,18 +954,38 @@ const currentThreadConfigNotice = computed(() => {
return threadConfigNoticeMap.value[currentChatId.value] || null return threadConfigNoticeMap.value[currentChatId.value] || null
}) })
const currentApprovalModalVisible = computed(
() =>
approvalState.showModal &&
Boolean(approvalState.threadId) &&
approvalState.threadId === currentChatId.value
)
const currentApprovalQuestions = computed(() =>
currentApprovalModalVisible.value ? approvalState.questions : []
)
const shouldSuppressRefsForApproval = () => const shouldSuppressRefsForApproval = () =>
approvalState.showModal || currentApprovalModalVisible.value ||
Boolean( Boolean(
approvalState.threadId && approvalState.threadId &&
chatState.currentThreadId === approvalState.threadId && currentChatId.value === approvalState.threadId &&
isProcessing.value isProcessing.value
) )
// Refs // Refs
const shouldShowRefs = computed(() => { const shouldShowRefs = computed(() => {
const convs = conversations.value
const lastConv = convs.length ? convs[convs.length - 1] : null
return (conv) => { return (conv) => {
return getLastMessage(conv) && conv.status !== 'streaming' && !shouldSuppressRefsForApproval() if (!getLastMessage(conv) || conv.status === 'streaming' || shouldSuppressRefsForApproval()) {
return false
}
// resume refs
// isReplyLoading/ isStreaming false
if (conv === lastConv && (isProcessing.value || isReplyLoading.value)) {
return false
}
return true
} }
}) })
@ -1510,6 +1530,10 @@ const startChatMainResizeObserver = () => {
} }
onMounted(() => { onMounted(() => {
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handlePageVisibilityChange)
}
nextTick(() => { nextTick(() => {
const chatMainContainer = document.querySelector('.chat-main') const chatMainContainer = document.querySelector('.chat-main')
if (chatMainContainer) { if (chatMainContainer) {
@ -1532,6 +1556,9 @@ onDeactivated(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handlePageVisibilityChange)
}
scrollController.cleanup() scrollController.cleanup()
stopChatMainResizeObserver() stopChatMainResizeObserver()
stopStreamingStateRefresh() stopStreamingStateRefresh()
@ -1719,11 +1746,21 @@ const handleAttachmentRemove = async (attachment) => {
} }
// ==================== ==================== // ==================== ====================
const { approvalState, processApprovalInStream } = useApproval({ const {
approvalState,
processApprovalInStream,
restoreInterruptFromThreadState,
hideApprovalState
} = useApproval({
getThreadState, getThreadState,
fetchThreadMessages fetchThreadMessages
}) })
const restorePendingInterruptForThread = (threadId) => {
if (!threadId) return false
return restoreInterruptFromThreadState(threadId)
}
const { handleStreamChunk } = useAgentStreamHandler({ const { handleStreamChunk } = useAgentStreamHandler({
getThreadState, getThreadState,
processApprovalInStream, processApprovalInStream,
@ -1739,9 +1776,35 @@ const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } =
fetchAgentState, fetchAgentState,
resetOnGoingConv, resetOnGoingConv,
onScrollToBottom: () => scrollController.scrollToBottom(), onScrollToBottom: () => scrollController.scrollToBottom(),
streamSmoother streamSmoother,
onInterruptDetected: ({ threadId }) => {
restorePendingInterruptForThread(threadId)
},
onTerminalDetected: ({ threadId, touchedThreadIds = [] }) => {
if (approvalState.threadId === threadId || touchedThreadIds.includes(approvalState.threadId)) {
hideApprovalState()
}
}
}) })
const resumeCurrentRunForVisiblePage = async () => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return
const threadId = currentChatId.value
if (!threadId) return
try {
await resumeActiveRunForThread(threadId)
restorePendingInterruptForThread(threadId)
} catch (error) {
console.warn('Failed to resume current run after page became visible:', error)
}
}
const handlePageVisibilityChange = () => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return
void resumeCurrentRunForVisiblePage()
}
// ==================== CHAT ACTIONS ==================== // ==================== CHAT ACTIONS ====================
// //
const getFirstNonPinnedChat = (chatList) => { const getFirstNonPinnedChat = (chatList) => {
@ -1809,6 +1872,7 @@ const selectChat = async (chatId) => {
await handleAgentStateRefresh(chatId) await handleAgentStateRefresh(chatId)
syncThreadConfigSnapshot(chatId, { overwrite: false }) syncThreadConfigSnapshot(chatId, { overwrite: false })
await resumeActiveRunForThread(chatId) await resumeActiveRunForThread(chatId)
restorePendingInterruptForThread(chatId)
} }
const selectThreadFromRoute = async (threadId) => { const selectThreadFromRoute = async (threadId) => {
@ -1885,6 +1949,10 @@ const handleSendMessage = async ({ image } = {}) => {
const threadState = getThreadState(threadId) const threadState = getThreadState(threadId)
if (!threadState) return if (!threadState) return
threadState.pendingInterrupt = null
if (approvalState.threadId === threadId) {
hideApprovalState()
}
const pendingAttachments = [...currentPendingThreadAttachments.value] const pendingAttachments = [...currentPendingThreadAttachments.value]
const pendingAttachmentFileIds = pendingAttachments const pendingAttachmentFileIds = pendingAttachments
@ -1966,6 +2034,10 @@ const handleSendOrStop = async (payload) => {
if (isProcessing.value && threadState?.activeRunId) { if (isProcessing.value && threadState?.activeRunId) {
try { try {
await agentApi.cancelAgentRun(threadState.activeRunId) await agentApi.cancelAgentRun(threadState.activeRunId)
threadState.pendingInterrupt = null
if (approvalState.threadId === threadId) {
hideApprovalState()
}
message.info('已发送取消请求') message.info('已发送取消请求')
} catch (error) { } catch (error) {
handleChatError(error, 'stop') handleChatError(error, 'stop')
@ -1979,6 +2051,7 @@ const handleSendOrStop = async (payload) => {
// ==================== ==================== // ==================== ====================
const handleApprovalWithStream = async (answer) => { const handleApprovalWithStream = async (answer) => {
const threadId = approvalState.threadId const threadId = approvalState.threadId
const parentRunId = approvalState.parentRunId
if (!threadId) { if (!threadId) {
message.error('无效的提问请求') message.error('无效的提问请求')
approvalState.showModal = false approvalState.showModal = false
@ -1992,14 +2065,17 @@ const handleApprovalWithStream = async (answer) => {
return return
} }
if (!approvalState.parentRunId) { if (!parentRunId) {
message.error('无法找到需要恢复的运行任务') message.error('无法找到需要恢复的运行任务')
approvalState.showModal = false approvalState.showModal = false
return return
} }
const pendingInterrupt = threadState.pendingInterrupt
try { try {
approvalState.showModal = false hideApprovalState()
threadState.pendingInterrupt = null
threadState.isStreaming = true threadState.isStreaming = true
resetOnGoingConv(threadId) resetOnGoingConv(threadId)
const resumeRequestId = createClientRequestId() const resumeRequestId = createClientRequestId()
@ -2009,7 +2085,7 @@ const handleApprovalWithStream = async (answer) => {
thread_id: threadId, thread_id: threadId,
meta: { request_id: resumeRequestId }, meta: { request_id: resumeRequestId },
resume: answer, resume: answer,
parent_run_id: approvalState.parentRunId, parent_run_id: parentRunId,
resume_request_id: resumeRequestId resume_request_id: resumeRequestId
}) })
const runId = runResp?.run_id const runId = runResp?.run_id
@ -2018,6 +2094,10 @@ const handleApprovalWithStream = async (answer) => {
} }
await startRunStream(threadId, runId, '0-0') await startRunStream(threadId, runId, '0-0')
} catch (error) { } catch (error) {
if (pendingInterrupt) {
threadState.pendingInterrupt = pendingInterrupt
restorePendingInterruptForThread(threadId)
}
threadState.isStreaming = false threadState.isStreaming = false
threadState.replyLoadingVisible = false threadState.replyLoadingVisible = false
handleChatError(error, 'resume') handleChatError(error, 'resume')
@ -2179,6 +2259,12 @@ const showMsgRefs = (msg) => {
return false return false
} }
// resume /
// isReplyLoading / isStreaming false
if (msg.isLast && (isProcessing.value || isReplyLoading.value)) {
return false
}
// refs // refs
if (msg.isLast && msg.status === 'finished') { if (msg.isLast && msg.status === 'finished') {
return ['copy', 'sources'] return ['copy', 'sources']
@ -2333,6 +2419,12 @@ watch(
watch(currentChatId, (threadId, oldThreadId) => { watch(currentChatId, (threadId, oldThreadId) => {
if (threadId === oldThreadId) return if (threadId === oldThreadId) return
if (!threadId || approvalState.threadId !== threadId) {
hideApprovalState()
}
if (threadId) {
restorePendingInterruptForThread(threadId)
}
emit('thread-change', threadId || '') emit('thread-change', threadId || '')
}) })
</script> </script>

View File

@ -1,11 +1,45 @@
<template> <template>
<BaseToolCall :tool-call="toolCall" hide-params> <BaseToolCall
:tool-call="toolCall"
:appearance="appearance"
:default-expanded="defaultExpanded"
:force-show-result="questions.length > 0"
hide-params
>
<template #header> <template #header>
<div class="sep-header"> <div class="sep-header">
<span class="note">提问</span> <span class="note">提问</span>
<span class="separator">|</span> <span class="separator">|</span>
<span class="description">{{ shortQuestionSummary }}</span> <span class="description">{{ shortQuestionSummary }}</span>
<span v-if="userAnswer" class="tag tag-answered"> 已回答: {{ displayAnswer }} </span> <span v-if="displayAnswer" class="tag tag-answered"> 已回答: {{ displayAnswer }} </span>
</div>
</template>
<template #result>
<div class="ask-question-result">
<div v-if="questions.length" class="question-list">
<div
v-for="(questionItem, questionIndex) in questions"
:key="questionItem.questionId || questionIndex"
class="question-item"
>
<div class="question-title">
<span class="question-index">{{ questionIndex + 1 }}</span>
<span class="question-text">{{ questionItem.question }}</span>
</div>
<div v-if="questionItem.operation" class="operation-row">
<span class="row-label">操作</span>
<span class="operation-text">{{ questionItem.operation }}</span>
</div>
<div v-if="getQuestionAnswerText(questionItem)" class="answer-row">
<span class="row-label">回答</span>
<span class="answer-text">{{ getQuestionAnswerText(questionItem) }}</span>
</div>
</div>
</div>
<div v-else class="no-question">暂无提问内容</div>
</div> </div>
</template> </template>
</BaseToolCall> </BaseToolCall>
@ -14,51 +48,55 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import BaseToolCall from '../BaseToolCall.vue' import BaseToolCall from '../BaseToolCall.vue'
import { normalizeQuestions } from '@/utils/questionUtils'
const props = defineProps({ const props = defineProps({
toolCall: { toolCall: {
type: Object, type: Object,
required: true required: true
},
appearance: {
type: String,
default: 'card'
},
defaultExpanded: {
type: Boolean,
default: false
} }
}) })
// const parseJsonValue = (value, fallback = null) => {
if (value === undefined || value === null || value === '') return fallback
if (typeof value === 'object') return value
try {
return JSON.parse(value)
} catch {
return fallback
}
}
const parsedArgs = computed(() => { const parsedArgs = computed(() => {
const args = props.toolCall.args || props.toolCall.function?.arguments const args = props.toolCall.args ?? props.toolCall.function?.arguments
if (!args) return {} return parseJsonValue(args, {})
if (typeof args === 'object') return args
try {
return JSON.parse(args)
} catch {
return {}
}
}) })
//
const parsedResult = computed(() => { const parsedResult = computed(() => {
const content = props.toolCall.tool_call_result?.content const content = props.toolCall.tool_call_result?.content ?? props.toolCall.result
if (!content) return null return parseJsonValue(content, null)
if (typeof content === 'object') return content
try {
return JSON.parse(content)
} catch {
return null
}
}) })
const questions = computed(() => { const questions = computed(() => {
const rawQuestions = parsedArgs.value.questions const argsQuestions = parsedArgs.value?.questions
if (!Array.isArray(rawQuestions)) return [] if (Array.isArray(argsQuestions) && argsQuestions.length) {
return normalizeQuestions(argsQuestions)
}
return rawQuestions const resultQuestions = parsedResult.value?.questions
.map((item) => { if (Array.isArray(resultQuestions) && resultQuestions.length) {
if (!item || typeof item !== 'object') return null return normalizeQuestions(resultQuestions)
const question = String(item.question || '').trim() }
if (!question) return null
const questionId = String(item.question_id || item.questionId || '').trim() return []
return { questionId, question }
})
.filter(Boolean)
}) })
const shortQuestionSummary = computed(() => { const shortQuestionSummary = computed(() => {
@ -72,54 +110,85 @@ const shortQuestionSummary = computed(() => {
return `${shortFirstQuestion}${questions.value.length}` return `${shortFirstQuestion}${questions.value.length}`
}) })
//
const userAnswer = computed(() => { const userAnswer = computed(() => {
const result = parsedResult.value const result = parsedResult.value
if (!result) return null if (!result) return null
return result.user_answer || result.answer || null return result.user_answer || result.answer || null
}) })
const formatSingleAnswer = (answer) => { const getOptionLabel = (value, questionItem) => {
const rawValue = String(value || '').trim()
if (!rawValue) return ''
const option = questionItem?.options?.find(
(item) => String(item.value) === rawValue || String(item.label) === rawValue
)
return option?.label || rawValue
}
const formatSingleAnswer = (answer, questionItem) => {
if (Array.isArray(answer)) { if (Array.isArray(answer)) {
return answer.join(', ') return answer
.map((item) => getOptionLabel(item, questionItem))
.filter(Boolean)
.join('、')
} }
if (answer && typeof answer === 'object') { if (answer && typeof answer === 'object') {
if (answer.type === 'other') { if (answer.type === 'other') {
return `Other: ${String(answer.text || '').trim()}` const selected = Array.isArray(answer.selected)
? answer.selected.map((item) => getOptionLabel(item, questionItem)).filter(Boolean)
: []
const text = String(answer.text || '').trim()
return [...selected, text ? `其他: ${text}` : '其他'].join('、')
} }
return JSON.stringify(answer) return JSON.stringify(answer)
} }
return String(answer)
return getOptionLabel(answer, questionItem)
} }
// const getQuestionAnswer = (questionItem) => {
const displayAnswer = computed(() => {
const answer = userAnswer.value const answer = userAnswer.value
if (!answer) return '' if (answer === null || answer === undefined || answer === '') return undefined
if (Array.isArray(answer)) { if (questions.value.length === 1 && (typeof answer !== 'object' || Array.isArray(answer))) {
return answer.join(', ') return answer
} }
if (answer && typeof answer === 'object') { if (answer && typeof answer === 'object' && !Array.isArray(answer)) {
if (answer.type === 'other') { if (Object.prototype.hasOwnProperty.call(answer, questionItem.questionId)) {
return `Other: ${String(answer.text || '').trim()}` return answer[questionItem.questionId]
} }
const entries = Object.entries(answer) if (questions.value.length === 1 && answer.type === 'other') {
if (!entries.length) return '' return answer
}
}
const summary = entries return undefined
.map(([questionId, value]) => { }
const title = String(questionId || '').trim()
return `${title}: ${formatSingleAnswer(value)}`
})
.join(' | ')
const getQuestionAnswerText = (questionItem) => {
const answer = getQuestionAnswer(questionItem)
if (answer === undefined || answer === null || answer === '') return ''
return formatSingleAnswer(answer, questionItem)
}
const displayAnswer = computed(() => {
if (!userAnswer.value) return ''
const summaries = questions.value
.map((questionItem) => getQuestionAnswerText(questionItem))
.filter(Boolean)
if (!summaries.length) {
const text = formatSingleAnswer(userAnswer.value, questions.value[0])
return text.length > 120 ? text.slice(0, 120) + '...' : text
}
const summary = summaries.join(' | ')
return summary.length > 120 ? summary.slice(0, 120) + '...' : summary return summary.length > 120 ? summary.slice(0, 120) + '...' : summary
}
return String(answer)
}) })
</script> </script>
@ -127,11 +196,100 @@ const displayAnswer = computed(() => {
.sep-header { .sep-header {
.tag-answered { .tag-answered {
margin-left: 8px; margin-left: 8px;
color: var(--green-600); color: var(--color-success-700);
background: var(--green-50); background: var(--color-success-50);
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 45%;
flex-shrink: 0;
}
}
.ask-question-result {
padding: 8px 0;
.question-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.question-item {
border: 1px solid var(--gray-100);
background: var(--gray-25);
border-radius: 6px;
padding: 10px 12px;
}
.question-title {
display: flex;
align-items: flex-start;
gap: 8px;
color: var(--gray-800);
font-size: 13px;
line-height: 1.5;
}
.question-index {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 20px;
border-radius: 50%;
color: var(--main-700);
background: var(--main-50);
font-size: 12px;
font-weight: 600;
}
.question-text {
min-width: 0;
word-break: break-word;
}
.operation-row,
.answer-row {
margin-top: 8px;
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
line-height: 1.5;
}
.row-label {
color: var(--gray-500);
flex: 0 0 auto;
}
.operation-text,
.answer-text {
min-width: 0;
color: var(--gray-700);
word-break: break-word;
}
.answer-row {
padding-top: 8px;
border-top: 1px solid var(--gray-100);
}
.answer-text {
color: var(--color-success-700);
font-weight: 500;
}
.no-question {
padding: 12px;
color: var(--gray-500);
font-size: 13px;
text-align: center;
} }
} }
</style> </style>

View File

@ -7,7 +7,8 @@ import {
resolveRunResumeAfterSeq resolveRunResumeAfterSeq
} from '@/utils/runStreamResume' } from '@/utils/runStreamResume'
const RUN_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'interrupted']) const RUN_INTERRUPTED_STATUS = 'interrupted'
const RUN_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
const ACTIVE_RUN_STORAGE_TTL_MS = 60 * 60 * 1000 const ACTIVE_RUN_STORAGE_TTL_MS = 60 * 60 * 1000
const ACTIVE_RUN_CLIENT_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` const ACTIVE_RUN_CLIENT_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
@ -102,7 +103,9 @@ export function useAgentRunStream({
fetchAgentState, fetchAgentState,
resetOnGoingConv, resetOnGoingConv,
onScrollToBottom, onScrollToBottom,
streamSmoother streamSmoother,
onInterruptDetected = null,
onTerminalDetected = null
}) { }) {
const saveActiveRunSnapshot = (threadId, runId, lastSeq = '0-0') => { const saveActiveRunSnapshot = (threadId, runId, lastSeq = '0-0') => {
if (!threadId || !runId) return if (!threadId || !runId) return
@ -142,28 +145,91 @@ export function useAgentRunStream({
} }
} }
const notifyInterruptDetected = (threadId, runId, run = null) => {
if (typeof onInterruptDetected !== 'function') return
onInterruptDetected({ threadId, runId, run })
}
const notifyTerminalDetected = (threadId, runId, touchedThreadIds) => {
if (typeof onTerminalDetected !== 'function') return
onTerminalDetected({ threadId, runId, touchedThreadIds: [...touchedThreadIds] })
}
const hasPendingInterruptForRun = (threadState, runId) => {
const pendingInterrupt = threadState?.pendingInterrupt
if (!pendingInterrupt?.questions?.length) return false
return !pendingInterrupt.parentRunId || pendingInterrupt.parentRunId === runId
}
const hasPendingInterruptInThreads = (threadIds, runId) => {
return [...threadIds].some((id) => hasPendingInterruptForRun(getThreadState(id), runId))
}
const clearPendingInterruptForRun = (threadId, runId) => {
const threadState = getThreadState(threadId)
if (hasPendingInterruptForRun(threadState, runId)) {
threadState.pendingInterrupt = null
}
}
const finalizeRunStream = ( const finalizeRunStream = (
threadId, threadId,
runId, runId,
touchedThreadIds, touchedThreadIds,
{ delay = 200, scroll = false } = {} { delay = 200, scroll = false, status = '' } = {}
) => { ) => {
const ts = getThreadState(threadId) const ts = getThreadState(threadId)
if (!ts || ts.activeRunId !== runId) return if (!ts || ts.activeRunId !== runId) return
const isInterrupted =
status === RUN_INTERRUPTED_STATUS && hasPendingInterruptInThreads(touchedThreadIds, runId)
touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id)) touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id))
ts.isStreaming = false ts.isStreaming = false
if (isInterrupted) {
ts.activeRunId = runId
saveActiveRunSnapshot(threadId, runId, ts.runLastSeq)
} else {
ts.activeRunId = null ts.activeRunId = null
clearActiveRunSnapshot(threadId)
touchedThreadIds.forEach((id) => clearPendingInterruptForRun(id, runId))
}
ts.lastRetryableJobTry = null ts.lastRetryableJobTry = null
ts.replyLoadingVisible = false ts.replyLoadingVisible = false
ts.pendingRequestId = null ts.pendingRequestId = null
clearActiveRunSnapshot(threadId)
fetchThreadMessages({ agentId: unref(currentAgentId), threadId, delay }).finally(() => { fetchThreadMessages({ agentId: unref(currentAgentId), threadId, delay }).finally(() => {
resetOnGoingConv(threadId) resetOnGoingConv(threadId)
fetchAgentState(unref(currentAgentId), threadId) fetchAgentState(unref(currentAgentId), threadId)
if (scroll) onScrollToBottom() if (scroll) onScrollToBottom()
if (isInterrupted) {
notifyInterruptDetected(threadId, runId)
} else {
notifyTerminalDetected(threadId, runId, touchedThreadIds)
}
}) })
} }
const preserveInterruptedRun = async (threadId, run, snapshot = null) => {
const ts = getThreadState(threadId)
if (!ts || !run?.id) return false
streamSmoother?.flushThread(threadId)
ts.activeRunId = run.id
ts.runLastSeq = normalizeRunSeq(snapshot?.last_seq || ts.runLastSeq || '0-0')
ts.lastRetryableJobTry = null
ts.isStreaming = false
ts.replyLoadingVisible = false
ts.pendingRequestId = null
saveActiveRunSnapshot(threadId, run.id, ts.runLastSeq)
try {
await fetchThreadMessages({ agentId: unref(currentAgentId), threadId })
} catch (e) {
console.warn('Failed to refresh messages for interrupted run:', threadId, e)
}
fetchAgentState(unref(currentAgentId), threadId)
notifyInterruptDetected(threadId, run.id, run)
return true
}
const scheduleRunReconnect = (threadId, runId, delay = 500) => { const scheduleRunReconnect = (threadId, runId, delay = 500) => {
const ts = getThreadState(threadId) const ts = getThreadState(threadId)
if (!ts || ts.activeRunId !== runId) return if (!ts || ts.activeRunId !== runId) return
@ -263,8 +329,10 @@ export function useAgentRunStream({
if (event === 'end') { if (event === 'end') {
sawTerminalEvent = true sawTerminalEvent = true
if (RUN_TERMINAL_STATUSES.has(terminalStatus)) { if (terminalStatus === RUN_INTERRUPTED_STATUS) {
finalizeRunStream(threadId, runId, touchedThreadIds) finalizeRunStream(threadId, runId, touchedThreadIds, { status: terminalStatus })
} else if (RUN_TERMINAL_STATUSES.has(terminalStatus)) {
finalizeRunStream(threadId, runId, touchedThreadIds, { status: terminalStatus })
} else { } else {
touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id)) touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id))
ts.isStreaming = false ts.isStreaming = false
@ -281,8 +349,14 @@ export function useAgentRunStream({
try { try {
const runRes = await agentApi.getAgentRun(runId) const runRes = await agentApi.getAgentRun(runId)
const run = runRes?.run const run = runRes?.run
if (run && RUN_TERMINAL_STATUSES.has(run.status)) { if (run?.status === RUN_INTERRUPTED_STATUS) {
finalizeRunStream(threadId, runId, touchedThreadIds) if (hasPendingInterruptInThreads(touchedThreadIds, run.id)) {
await preserveInterruptedRun(threadId, run)
} else {
finalizeRunStream(threadId, runId, touchedThreadIds, { status: run.status })
}
} else if (run && RUN_TERMINAL_STATUSES.has(run.status)) {
finalizeRunStream(threadId, runId, touchedThreadIds, { status: run.status })
} else { } else {
scheduleRunReconnect(threadId, runId) scheduleRunReconnect(threadId, runId)
} }
@ -319,7 +393,37 @@ export function useAgentRunStream({
const resumeActiveRunForThread = async (threadId) => { const resumeActiveRunForThread = async (threadId) => {
if (!threadId) return if (!threadId) return
const ts = getThreadState(threadId) const ts = getThreadState(threadId)
if (!ts || ts.runStreamAbortController) return if (!ts) return
if (ts.runStreamAbortController) {
if (!ts.activeRunId) return
try {
const runRes = await agentApi.getAgentRun(ts.activeRunId)
const run = runRes?.run
if (run?.status === RUN_INTERRUPTED_STATUS) {
stopRunStreamSubscription(threadId)
const snapshot = loadActiveRunSnapshot(threadId)
if (hasPendingInterruptForRun(ts, run.id)) {
await preserveInterruptedRun(threadId, run, snapshot)
} else {
resetOnGoingConv(threadId)
await startRunStream(threadId, run.id, '0-0')
}
} else if (run && RUN_TERMINAL_STATUSES.has(run.status)) {
stopRunStreamSubscription(threadId)
ts.activeRunId = null
ts.isStreaming = false
ts.replyLoadingVisible = false
ts.pendingRequestId = null
clearPendingInterruptForRun(threadId, run.id)
clearActiveRunSnapshot(threadId)
notifyTerminalDetected(threadId, run.id, new Set([threadId]))
}
} catch (e) {
console.warn('Failed to refresh active run while stream is open:', threadId, e)
}
return
}
const snapshot = loadActiveRunSnapshot(threadId) const snapshot = loadActiveRunSnapshot(threadId)
if (snapshot?.run_id) { if (snapshot?.run_id) {
@ -329,7 +433,14 @@ export function useAgentRunStream({
try { try {
const runRes = await agentApi.getAgentRun(snapshot.run_id) const runRes = await agentApi.getAgentRun(snapshot.run_id)
const run = runRes?.run const run = runRes?.run
if (run && !RUN_TERMINAL_STATUSES.has(run.status)) { if (run?.status === RUN_INTERRUPTED_STATUS) {
// 仅当本地仍持有该中断时才据快照恢复;否则不能仅凭快照重放旧中断
// (可能已被回复),交由下方 active_run 做权威判定。
if (hasPendingInterruptForRun(ts, run.id)) {
await preserveInterruptedRun(threadId, run, snapshot)
return
}
} else if (run && !RUN_TERMINAL_STATUSES.has(run.status)) {
const afterSeq = resolveRunResumeAfterSeq({ const afterSeq = resolveRunResumeAfterSeq({
snapshot, snapshot,
threadState: ts threadState: ts
@ -350,6 +461,15 @@ export function useAgentRunStream({
try { try {
const active = await agentApi.getThreadActiveRun(threadId) const active = await agentApi.getThreadActiveRun(threadId)
const run = active?.run const run = active?.run
if (run?.status === RUN_INTERRUPTED_STATUS) {
if (hasPendingInterruptForRun(ts, run.id)) {
await preserveInterruptedRun(threadId, run)
return
}
resetOnGoingConv(threadId)
await startRunStream(threadId, run.id, '0-0')
return
}
if (run && !RUN_TERMINAL_STATUSES.has(run.status)) { if (run && !RUN_TERMINAL_STATUSES.has(run.status)) {
resetOnGoingConv(threadId) resetOnGoingConv(threadId)
await startRunStream(threadId, run.id, '0-0') await startRunStream(threadId, run.id, '0-0')
@ -364,7 +484,9 @@ export function useAgentRunStream({
ts.isStreaming = false ts.isStreaming = false
ts.replyLoadingVisible = false ts.replyLoadingVisible = false
ts.pendingRequestId = null ts.pendingRequestId = null
ts.pendingInterrupt = null
clearActiveRunSnapshot(threadId) clearActiveRunSnapshot(threadId)
notifyTerminalDetected(threadId, null, new Set([threadId]))
} }
return { return {

View File

@ -1,6 +1,7 @@
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { handleChatError } from '@/utils/errorHandler' import { handleChatError } from '@/utils/errorHandler'
import { unref } from 'vue' import { unref } from 'vue'
import { extractPendingInterrupt } from '@/composables/useApproval'
const serializeToolArgs = (args) => { const serializeToolArgs = (args) => {
if (typeof args === 'string') return args if (typeof args === 'string') return args
@ -158,6 +159,7 @@ export function useAgentStreamHandler({
threadState.isStreaming = false threadState.isStreaming = false
threadState.replyLoadingVisible = false threadState.replyLoadingVisible = false
threadState.pendingRequestId = null threadState.pendingRequestId = null
threadState.pendingInterrupt = null
} }
return true return true
@ -208,6 +210,7 @@ export function useAgentStreamHandler({
threadState.isStreaming = false threadState.isStreaming = false
threadState.replyLoadingVisible = false threadState.replyLoadingVisible = false
threadState.pendingRequestId = null threadState.pendingRequestId = null
threadState.pendingInterrupt = null
console.log(`${debugPrefix}[finished]`, { console.log(`${debugPrefix}[finished]`, {
threadId, threadId,
currentAgentId: unref(currentAgentId), currentAgentId: unref(currentAgentId),
@ -239,6 +242,10 @@ export function useAgentStreamHandler({
threadState.isStreaming = false threadState.isStreaming = false
threadState.replyLoadingVisible = false threadState.replyLoadingVisible = false
threadState.pendingRequestId = null threadState.pendingRequestId = null
const pendingInterrupt = extractPendingInterrupt(chunk, threadId)
if (pendingInterrupt) {
threadState.pendingInterrupt = pendingInterrupt
}
} }
// 如果有 message 字段,显示提示(例如:敏感内容检测) // 如果有 message 字段,显示提示(例如:敏感内容检测)
if (chunkMessage) { if (chunkMessage) {

View File

@ -29,6 +29,7 @@ export function useAgentThreadState({
lastRetryableJobTry: null, lastRetryableJobTry: null,
replyLoadingVisible: false, replyLoadingVisible: false,
pendingRequestId: null, pendingRequestId: null,
pendingInterrupt: null,
onGoingConv: createOnGoingConvState(), onGoingConv: createOnGoingConvState(),
agentState: null agentState: null
} }

View File

@ -1,6 +1,11 @@
import { reactive } from 'vue' import { reactive } from 'vue'
import { normalizeQuestions } from '@/utils/questionUtils' import { normalizeQuestions } from '@/utils/questionUtils'
const APPROVAL_REQUIRED_STATUSES = new Set([
'ask_user_question_required',
'human_approval_required'
])
const extractQuestionPayload = (chunk) => { const extractQuestionPayload = (chunk) => {
const interruptInfo = chunk?.interrupt_info || {} const interruptInfo = chunk?.interrupt_info || {}
const rawQuestions = chunk?.questions || interruptInfo?.questions || [] const rawQuestions = chunk?.questions || interruptInfo?.questions || []
@ -13,6 +18,19 @@ const extractQuestionPayload = (chunk) => {
} }
} }
export const extractPendingInterrupt = (chunk, threadId) => {
const payload = extractQuestionPayload(chunk)
if (!payload.questions.length) return null
return {
questions: payload.questions,
source: payload.source,
status: chunk?.status || '',
threadId: chunk?.thread_id || threadId,
parentRunId: chunk?.run_id || chunk?.parent_run_id || null
}
}
export function useApproval({ getThreadState, fetchThreadMessages }) { export function useApproval({ getThreadState, fetchThreadMessages }) {
const approvalState = reactive({ const approvalState = reactive({
showModal: false, showModal: false,
@ -22,34 +40,15 @@ export function useApproval({ getThreadState, fetchThreadMessages }) {
parentRunId: null parentRunId: null
}) })
const processApprovalInStream = (chunk, threadId, currentAgentId) => { const applyInterruptToApprovalState = (pendingInterrupt, fallbackThreadId) => {
if (
chunk.status !== 'ask_user_question_required' &&
chunk.status !== 'human_approval_required'
) {
return false
}
const threadState = getThreadState(threadId)
if (!threadState) return false
const payload = extractQuestionPayload(chunk)
if (!payload.questions.length) return false
threadState.isStreaming = false
approvalState.showModal = true approvalState.showModal = true
approvalState.questions = payload.questions approvalState.questions = pendingInterrupt.questions
approvalState.status = chunk.status || '' approvalState.status = pendingInterrupt.status || ''
approvalState.threadId = chunk.thread_id || threadId approvalState.threadId = pendingInterrupt.threadId || fallbackThreadId
approvalState.parentRunId = chunk.run_id || null approvalState.parentRunId = pendingInterrupt.parentRunId || null
fetchThreadMessages({ agentId: currentAgentId, threadId })
return true
} }
const resetApprovalState = () => { const clearApprovalState = () => {
approvalState.showModal = false approvalState.showModal = false
approvalState.questions = [] approvalState.questions = []
approvalState.status = '' approvalState.status = ''
@ -57,9 +56,56 @@ export function useApproval({ getThreadState, fetchThreadMessages }) {
approvalState.parentRunId = null approvalState.parentRunId = null
} }
const processApprovalInStream = (chunk, threadId, currentAgentId) => {
if (!APPROVAL_REQUIRED_STATUSES.has(chunk.status)) {
return false
}
const threadState = getThreadState(threadId)
if (!threadState) return false
const pendingInterrupt = extractPendingInterrupt(chunk, threadId)
if (!pendingInterrupt) return false
threadState.isStreaming = false
threadState.pendingInterrupt = pendingInterrupt
applyInterruptToApprovalState(pendingInterrupt, threadId)
fetchThreadMessages({ agentId: currentAgentId, threadId })
return true
}
const restoreInterruptFromThreadState = (threadId) => {
const threadState = getThreadState(threadId)
const pendingInterrupt = threadState?.pendingInterrupt
if (!pendingInterrupt?.questions?.length) return false
threadState.isStreaming = false
threadState.replyLoadingVisible = false
threadState.pendingRequestId = null
applyInterruptToApprovalState(pendingInterrupt, threadId)
return true
}
const hideApprovalState = () => {
clearApprovalState()
}
const resetApprovalState = () => {
const threadState = getThreadState(approvalState.threadId)
if (threadState) {
threadState.pendingInterrupt = null
}
clearApprovalState()
}
return { return {
approvalState, approvalState,
processApprovalInStream, processApprovalInStream,
restoreInterruptFromThreadState,
hideApprovalState,
resetApprovalState resetApprovalState
} }
} }