diff --git a/backend/package/yuxi/services/agent_run_service.py b/backend/package/yuxi/services/agent_run_service.py
index 6148361b..740df31e 100644
--- a/backend/package/yuxi/services/agent_run_service.py
+++ b/backend/package/yuxi/services/agent_run_service.py
@@ -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}
diff --git a/backend/test/unit/agents/toolkits/buildin/test_ask_user_question.py b/backend/test/unit/agents/toolkits/buildin/test_ask_user_question.py
new file mode 100644
index 00000000..84a2ed32
--- /dev/null
+++ b/backend/test/unit/agents/toolkits/buildin/test_ask_user_question.py
@@ -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=[])
diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue
index cc8c831e..fe1f6629 100644
--- a/web/src/components/AgentChatComponent.vue
+++ b/web/src/components/AgentChatComponent.vue
@@ -101,8 +101,8 @@
@@ -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 || '')
})
diff --git a/web/src/components/ToolCallingResult/tools/AskUserQuestionTool.vue b/web/src/components/ToolCallingResult/tools/AskUserQuestionTool.vue
index 4f2f8ae5..58bd0a42 100644
--- a/web/src/components/ToolCallingResult/tools/AskUserQuestionTool.vue
+++ b/web/src/components/ToolCallingResult/tools/AskUserQuestionTool.vue
@@ -1,11 +1,45 @@
-
+
+
+
+
+
+
+
+
+ {{ questionIndex + 1 }}
+ {{ questionItem.question }}
+
+
+
+ 操作
+ {{ questionItem.operation }}
+
+
+
+ 回答
+ {{ getQuestionAnswerText(questionItem) }}
+
+
+
+
暂无提问内容
@@ -14,51 +48,55 @@
@@ -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;
}
}
diff --git a/web/src/composables/useAgentRunStream.js b/web/src/composables/useAgentRunStream.js
index f5b16092..64794640 100644
--- a/web/src/composables/useAgentRunStream.js
+++ b/web/src/composables/useAgentRunStream.js
@@ -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 {
diff --git a/web/src/composables/useAgentStreamHandler.js b/web/src/composables/useAgentStreamHandler.js
index 1fb6895d..503914af 100644
--- a/web/src/composables/useAgentStreamHandler.js
+++ b/web/src/composables/useAgentStreamHandler.js
@@ -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) {
diff --git a/web/src/composables/useAgentThreadState.js b/web/src/composables/useAgentThreadState.js
index f8f0c3dc..0dc73666 100644
--- a/web/src/composables/useAgentThreadState.js
+++ b/web/src/composables/useAgentThreadState.js
@@ -29,6 +29,7 @@ export function useAgentThreadState({
lastRetryableJobTry: null,
replyLoadingVisible: false,
pendingRequestId: null,
+ pendingInterrupt: null,
onGoingConv: createOnGoingConvState(),
agentState: null
}
diff --git a/web/src/composables/useApproval.js b/web/src/composables/useApproval.js
index eda58dbd..272eccff 100644
--- a/web/src/composables/useApproval.js
+++ b/web/src/composables/useApproval.js
@@ -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
}
}