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 yuxi.storage.postgres.models_business import AgentRun
active_result = await db.execute(
# 线程内的 run 是串行的,最近一条 run 即代表线程当前状态。
# 已被回复的 interrupted run 会被更晚创建的 resume run 取代,因此不会再被当作待处理中断返回。
result = await db.execute(
select(AgentRun)
.where(
AgentRun.thread_id == thread_id,
AgentRun.uid == str(current_uid),
AgentRun.run_type.in_(["chat", "resume"]),
AgentRun.status.in_(["pending", "running", "cancel_requested"]),
)
.order_by(AgentRun.created_at.desc())
.limit(1)
)
run = active_result.scalar_one_or_none()
if not run:
interrupted_result = await db.execute(
select(AgentRun)
.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}
run = result.scalar_one_or_none()
if run and run.status in ("pending", "running", "cancel_requested", "interrupted"):
return {"run": run.to_dict()}
return {"run": 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 }">
<!-- 人工审批弹窗 - 放在输入框上方 -->
<HumanApprovalModal
:visible="approvalState.showModal"
:questions="approvalState.questions"
:visible="currentApprovalModalVisible"
:questions="currentApprovalQuestions"
@submit="handleQuestionSubmit"
@cancel="handleQuestionCancel"
/>
@ -954,18 +954,38 @@ const currentThreadConfigNotice = computed(() => {
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 = () =>
approvalState.showModal ||
currentApprovalModalVisible.value ||
Boolean(
approvalState.threadId &&
chatState.currentThreadId === approvalState.threadId &&
currentChatId.value === approvalState.threadId &&
isProcessing.value
)
// Refs
const shouldShowRefs = computed(() => {
const convs = conversations.value
const lastConv = convs.length ? convs[convs.length - 1] : null
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(() => {
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handlePageVisibilityChange)
}
nextTick(() => {
const chatMainContainer = document.querySelector('.chat-main')
if (chatMainContainer) {
@ -1532,6 +1556,9 @@ onDeactivated(() => {
})
onUnmounted(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handlePageVisibilityChange)
}
scrollController.cleanup()
stopChatMainResizeObserver()
stopStreamingStateRefresh()
@ -1719,11 +1746,21 @@ const handleAttachmentRemove = async (attachment) => {
}
// ==================== ====================
const { approvalState, processApprovalInStream } = useApproval({
const {
approvalState,
processApprovalInStream,
restoreInterruptFromThreadState,
hideApprovalState
} = useApproval({
getThreadState,
fetchThreadMessages
})
const restorePendingInterruptForThread = (threadId) => {
if (!threadId) return false
return restoreInterruptFromThreadState(threadId)
}
const { handleStreamChunk } = useAgentStreamHandler({
getThreadState,
processApprovalInStream,
@ -1739,9 +1776,35 @@ const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } =
fetchAgentState,
resetOnGoingConv,
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 ====================
//
const getFirstNonPinnedChat = (chatList) => {
@ -1809,6 +1872,7 @@ const selectChat = async (chatId) => {
await handleAgentStateRefresh(chatId)
syncThreadConfigSnapshot(chatId, { overwrite: false })
await resumeActiveRunForThread(chatId)
restorePendingInterruptForThread(chatId)
}
const selectThreadFromRoute = async (threadId) => {
@ -1885,6 +1949,10 @@ const handleSendMessage = async ({ image } = {}) => {
const threadState = getThreadState(threadId)
if (!threadState) return
threadState.pendingInterrupt = null
if (approvalState.threadId === threadId) {
hideApprovalState()
}
const pendingAttachments = [...currentPendingThreadAttachments.value]
const pendingAttachmentFileIds = pendingAttachments
@ -1966,6 +2034,10 @@ const handleSendOrStop = async (payload) => {
if (isProcessing.value && threadState?.activeRunId) {
try {
await agentApi.cancelAgentRun(threadState.activeRunId)
threadState.pendingInterrupt = null
if (approvalState.threadId === threadId) {
hideApprovalState()
}
message.info('已发送取消请求')
} catch (error) {
handleChatError(error, 'stop')
@ -1979,6 +2051,7 @@ const handleSendOrStop = async (payload) => {
// ==================== ====================
const handleApprovalWithStream = async (answer) => {
const threadId = approvalState.threadId
const parentRunId = approvalState.parentRunId
if (!threadId) {
message.error('无效的提问请求')
approvalState.showModal = false
@ -1992,14 +2065,17 @@ const handleApprovalWithStream = async (answer) => {
return
}
if (!approvalState.parentRunId) {
if (!parentRunId) {
message.error('无法找到需要恢复的运行任务')
approvalState.showModal = false
return
}
const pendingInterrupt = threadState.pendingInterrupt
try {
approvalState.showModal = false
hideApprovalState()
threadState.pendingInterrupt = null
threadState.isStreaming = true
resetOnGoingConv(threadId)
const resumeRequestId = createClientRequestId()
@ -2009,7 +2085,7 @@ const handleApprovalWithStream = async (answer) => {
thread_id: threadId,
meta: { request_id: resumeRequestId },
resume: answer,
parent_run_id: approvalState.parentRunId,
parent_run_id: parentRunId,
resume_request_id: resumeRequestId
})
const runId = runResp?.run_id
@ -2018,6 +2094,10 @@ const handleApprovalWithStream = async (answer) => {
}
await startRunStream(threadId, runId, '0-0')
} catch (error) {
if (pendingInterrupt) {
threadState.pendingInterrupt = pendingInterrupt
restorePendingInterruptForThread(threadId)
}
threadState.isStreaming = false
threadState.replyLoadingVisible = false
handleChatError(error, 'resume')
@ -2179,6 +2259,12 @@ const showMsgRefs = (msg) => {
return false
}
// resume /
// isReplyLoading / isStreaming false
if (msg.isLast && (isProcessing.value || isReplyLoading.value)) {
return false
}
// refs
if (msg.isLast && msg.status === 'finished') {
return ['copy', 'sources']
@ -2333,6 +2419,12 @@ watch(
watch(currentChatId, (threadId, oldThreadId) => {
if (threadId === oldThreadId) return
if (!threadId || approvalState.threadId !== threadId) {
hideApprovalState()
}
if (threadId) {
restorePendingInterruptForThread(threadId)
}
emit('thread-change', threadId || '')
})
</script>

View File

@ -1,11 +1,45 @@
<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>
<div class="sep-header">
<span class="note">提问</span>
<span class="separator">|</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>
</template>
</BaseToolCall>
@ -14,51 +48,55 @@
<script setup>
import { computed } from 'vue'
import BaseToolCall from '../BaseToolCall.vue'
import { normalizeQuestions } from '@/utils/questionUtils'
const props = defineProps({
toolCall: {
type: Object,
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 args = props.toolCall.args || props.toolCall.function?.arguments
if (!args) return {}
if (typeof args === 'object') return args
try {
return JSON.parse(args)
} catch {
return {}
}
const args = props.toolCall.args ?? props.toolCall.function?.arguments
return parseJsonValue(args, {})
})
//
const parsedResult = computed(() => {
const content = props.toolCall.tool_call_result?.content
if (!content) return null
if (typeof content === 'object') return content
try {
return JSON.parse(content)
} catch {
return null
}
const content = props.toolCall.tool_call_result?.content ?? props.toolCall.result
return parseJsonValue(content, null)
})
const questions = computed(() => {
const rawQuestions = parsedArgs.value.questions
if (!Array.isArray(rawQuestions)) return []
const argsQuestions = parsedArgs.value?.questions
if (Array.isArray(argsQuestions) && argsQuestions.length) {
return normalizeQuestions(argsQuestions)
}
return rawQuestions
.map((item) => {
if (!item || typeof item !== 'object') return null
const question = String(item.question || '').trim()
if (!question) return null
const questionId = String(item.question_id || item.questionId || '').trim()
return { questionId, question }
})
.filter(Boolean)
const resultQuestions = parsedResult.value?.questions
if (Array.isArray(resultQuestions) && resultQuestions.length) {
return normalizeQuestions(resultQuestions)
}
return []
})
const shortQuestionSummary = computed(() => {
@ -72,54 +110,85 @@ const shortQuestionSummary = computed(() => {
return `${shortFirstQuestion}${questions.value.length}`
})
//
const userAnswer = computed(() => {
const result = parsedResult.value
if (!result) return 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)) {
return answer.join(', ')
return answer
.map((item) => getOptionLabel(item, questionItem))
.filter(Boolean)
.join('、')
}
if (answer && typeof answer === 'object') {
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 String(answer)
return getOptionLabel(answer, questionItem)
}
//
const displayAnswer = computed(() => {
const getQuestionAnswer = (questionItem) => {
const answer = userAnswer.value
if (!answer) return ''
if (answer === null || answer === undefined || answer === '') return undefined
if (Array.isArray(answer)) {
return answer.join(', ')
if (questions.value.length === 1 && (typeof answer !== 'object' || Array.isArray(answer))) {
return answer
}
if (answer && typeof answer === 'object') {
if (answer.type === 'other') {
return `Other: ${String(answer.text || '').trim()}`
if (answer && typeof answer === 'object' && !Array.isArray(answer)) {
if (Object.prototype.hasOwnProperty.call(answer, questionItem.questionId)) {
return answer[questionItem.questionId]
}
const entries = Object.entries(answer)
if (!entries.length) return ''
const summary = entries
.map(([questionId, value]) => {
const title = String(questionId || '').trim()
return `${title}: ${formatSingleAnswer(value)}`
})
.join(' | ')
return summary.length > 120 ? summary.slice(0, 120) + '...' : summary
if (questions.value.length === 1 && answer.type === 'other') {
return answer
}
}
return String(answer)
return undefined
}
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
})
</script>
@ -127,11 +196,100 @@ const displayAnswer = computed(() => {
.sep-header {
.tag-answered {
margin-left: 8px;
color: var(--green-600);
background: var(--green-50);
color: var(--color-success-700);
background: var(--color-success-50);
padding: 2px 8px;
border-radius: 4px;
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>

View File

@ -7,7 +7,8 @@ import {
resolveRunResumeAfterSeq
} 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_CLIENT_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
@ -102,7 +103,9 @@ export function useAgentRunStream({
fetchAgentState,
resetOnGoingConv,
onScrollToBottom,
streamSmoother
streamSmoother,
onInterruptDetected = null,
onTerminalDetected = null
}) {
const saveActiveRunSnapshot = (threadId, runId, lastSeq = '0-0') => {
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 = (
threadId,
runId,
touchedThreadIds,
{ delay = 200, scroll = false } = {}
{ delay = 200, scroll = false, status = '' } = {}
) => {
const ts = getThreadState(threadId)
if (!ts || ts.activeRunId !== runId) return
const isInterrupted =
status === RUN_INTERRUPTED_STATUS && hasPendingInterruptInThreads(touchedThreadIds, runId)
touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id))
ts.isStreaming = false
ts.activeRunId = null
if (isInterrupted) {
ts.activeRunId = runId
saveActiveRunSnapshot(threadId, runId, ts.runLastSeq)
} else {
ts.activeRunId = null
clearActiveRunSnapshot(threadId)
touchedThreadIds.forEach((id) => clearPendingInterruptForRun(id, runId))
}
ts.lastRetryableJobTry = null
ts.replyLoadingVisible = false
ts.pendingRequestId = null
clearActiveRunSnapshot(threadId)
fetchThreadMessages({ agentId: unref(currentAgentId), threadId, delay }).finally(() => {
resetOnGoingConv(threadId)
fetchAgentState(unref(currentAgentId), threadId)
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 ts = getThreadState(threadId)
if (!ts || ts.activeRunId !== runId) return
@ -263,8 +329,10 @@ export function useAgentRunStream({
if (event === 'end') {
sawTerminalEvent = true
if (RUN_TERMINAL_STATUSES.has(terminalStatus)) {
finalizeRunStream(threadId, runId, touchedThreadIds)
if (terminalStatus === RUN_INTERRUPTED_STATUS) {
finalizeRunStream(threadId, runId, touchedThreadIds, { status: terminalStatus })
} else if (RUN_TERMINAL_STATUSES.has(terminalStatus)) {
finalizeRunStream(threadId, runId, touchedThreadIds, { status: terminalStatus })
} else {
touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id))
ts.isStreaming = false
@ -281,8 +349,14 @@ export function useAgentRunStream({
try {
const runRes = await agentApi.getAgentRun(runId)
const run = runRes?.run
if (run && RUN_TERMINAL_STATUSES.has(run.status)) {
finalizeRunStream(threadId, runId, touchedThreadIds)
if (run?.status === RUN_INTERRUPTED_STATUS) {
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 {
scheduleRunReconnect(threadId, runId)
}
@ -319,7 +393,37 @@ export function useAgentRunStream({
const resumeActiveRunForThread = async (threadId) => {
if (!threadId) return
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)
if (snapshot?.run_id) {
@ -329,7 +433,14 @@ export function useAgentRunStream({
try {
const runRes = await agentApi.getAgentRun(snapshot.run_id)
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({
snapshot,
threadState: ts
@ -350,6 +461,15 @@ export function useAgentRunStream({
try {
const active = await agentApi.getThreadActiveRun(threadId)
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)) {
resetOnGoingConv(threadId)
await startRunStream(threadId, run.id, '0-0')
@ -364,7 +484,9 @@ export function useAgentRunStream({
ts.isStreaming = false
ts.replyLoadingVisible = false
ts.pendingRequestId = null
ts.pendingInterrupt = null
clearActiveRunSnapshot(threadId)
notifyTerminalDetected(threadId, null, new Set([threadId]))
}
return {

View File

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

View File

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

View File

@ -1,6 +1,11 @@
import { reactive } from 'vue'
import { normalizeQuestions } from '@/utils/questionUtils'
const APPROVAL_REQUIRED_STATUSES = new Set([
'ask_user_question_required',
'human_approval_required'
])
const extractQuestionPayload = (chunk) => {
const interruptInfo = chunk?.interrupt_info || {}
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 }) {
const approvalState = reactive({
showModal: false,
@ -22,34 +40,15 @@ export function useApproval({ getThreadState, fetchThreadMessages }) {
parentRunId: null
})
const processApprovalInStream = (chunk, threadId, currentAgentId) => {
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
const applyInterruptToApprovalState = (pendingInterrupt, fallbackThreadId) => {
approvalState.showModal = true
approvalState.questions = payload.questions
approvalState.status = chunk.status || ''
approvalState.threadId = chunk.thread_id || threadId
approvalState.parentRunId = chunk.run_id || null
fetchThreadMessages({ agentId: currentAgentId, threadId })
return true
approvalState.questions = pendingInterrupt.questions
approvalState.status = pendingInterrupt.status || ''
approvalState.threadId = pendingInterrupt.threadId || fallbackThreadId
approvalState.parentRunId = pendingInterrupt.parentRunId || null
}
const resetApprovalState = () => {
const clearApprovalState = () => {
approvalState.showModal = false
approvalState.questions = []
approvalState.status = ''
@ -57,9 +56,56 @@ export function useApproval({ getThreadState, fetchThreadMessages }) {
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 {
approvalState,
processApprovalInStream,
restoreInterruptFromThreadState,
hideApprovalState,
resetApprovalState
}
}