reconcile skipped legacy queued input in chat

This commit is contained in:
mateaix 2026-09-15 06:33:13 +08:00
parent cbe04d9515
commit 59d66a673e
9 changed files with 77 additions and 6 deletions

View File

@ -1552,8 +1552,17 @@ public class ChatController {
throw new IllegalStateException("Legacy queued input claim was lost");
broadcastEvent(conversationId, "warning", Map.of(
"message", "排队消息缺少Goal选择快照内容已保存请重新发送"));
conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, emitterDone);
broadcastEvent(conversationId, "queued_input_skipped", Map.of(
"conversationId", conversationId,
"message", preConsumedInput.message() == null ? "" : preConsumedInput.message(),
"reason", "managed_goal_selection_unknown"));
if (hasQueuedInput(conversationId)) {
sseExecutor.execute(() -> startQueuedMessage(conversationId, emitter, emitterDone,
requesterId, baseUrl));
} else {
conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, emitterDone);
}
return;
}

View File

@ -59,7 +59,7 @@ Built-in shell/code execution is not OS-isolated from the service host. Selectin
Recovery attempts receive guidance to inspect existing evidence before repeating work. If the first recovered segment is deferred before execution, its recovery context is retained for the next claim. Ordinary continuation after an executed segment does not become a new recovery.
From V199, queued Web input stores the authenticated account ID at enqueue time, and ordinary Web replay carries the conversation workspace. V201 also stores the selected managed Goal ID at enqueue time. A queued turn keeps that identity if the Goal ends before dequeue; approval then refuses to execute the stale selection. An old queue row without a selection snapshot is saved as user text and requires a fresh request when its conversation has managed Goal history. Managed operations still recheck the account, ownership and current requirements. Legacy queue items do not gain an asserted identity from a username; users must resend an authenticated request for managed JSON operations. Persistent Goal workers retain their existing attempt-owner validation when consuming input; this does not introduce an account path without a lease check.
From V199, queued Web input stores the authenticated account ID at enqueue time, and ordinary Web replay carries the conversation workspace. V201 also stores the selected managed Goal ID at enqueue time. A queued turn keeps that identity if the Goal ends before dequeue; approval then refuses to execute the stale selection. An old queue row without a selection snapshot is saved as user text and requires a fresh request when its conversation has managed Goal history. The chat interface clears that queued item and prompts the user to resend; later queued items continue. Managed operations still recheck the account, ownership and current requirements. Legacy queue items do not gain an asserted identity from a username; users must resend an authenticated request for managed JSON operations. Persistent Goal workers retain their existing attempt-owner validation when consuming input; this does not introduce an account path without a lease check.
Approval replay restores the persisted runtime identity; approval does not renew an expired attempt lease or override account revocation. Legacy snapshots without an authenticated account ID cannot gain managed JSON access from a display username alone.

View File

@ -57,7 +57,7 @@ V198 同样以绝对时间保存调度租约截止。升级时旧租约失效,
内置 shell/code 执行没有与服务宿主做操作系统隔离。选择 JSON 验收不会把这些工具变成沙箱;此协议不能抵抗能访问数据库凭据或文件的宿主代码,环境变量名称过滤和工作区路径检查也不能替代隔离。租约截止从绝对时刻计算,覆盖夏令时回拨;调度显示字段仍使用本地时间戳。
Web排队消息从V199起保存入队时已认证账户的内部ID普通Web续跑同时携带当前会话工作区V201还在入队时保存选定的受管Goal ID。若Goal在出队前终结排队请求仍保留其身份随后批准会拒绝旧选择。升级前没有选择快照的队列行若会话有受管Goal历史就保存为用户文字并要求重新发送。受管工具执行时仍重新校验账户、归属和当前要求。旧队列项不按用户名补造身份不能用于受管JSON操作需要用户重新发送已认证请求。持久Goal工作器消费输入时继续使用原有attempt owner校验没有转换成免租约的账户路径。
Web排队消息从V199起保存入队时已认证账户的内部ID普通Web续跑同时携带当前会话工作区V201还在入队时保存选定的受管Goal ID。若Goal在出队前终结排队请求仍保留其身份随后批准会拒绝旧选择。升级前没有选择快照的队列行若会话有受管Goal历史就保存为用户文字并要求重新发送。聊天页面会移除这条排队状态并提示重新发送,后续排队消息继续处理。受管工具执行时仍重新校验账户、归属和当前要求。旧队列项不按用户名补造身份不能用于受管JSON操作需要用户重新发送已认证请求。持久Goal工作器消费输入时继续使用原有attempt owner校验没有转换成免租约的账户路径。
恢复执行会收到先核实已有证据、不要重放未知副作用的提示。首次恢复执行若在实际运行前延期,下一次领取仍保留恢复关联;已经执行过后的普通续跑不会因此变成新恢复。

View File

@ -100,6 +100,7 @@ class ChatControllerDurableQueueTest {
AgentService agents = mock(AgentService.class);
ConversationService conversations = mock(ConversationService.class);
ConversationInputQueueStore queue = mock(ConversationInputQueueStore.class);
ChatStreamTracker streams = mock(ChatStreamTracker.class);
var input = new QueuedInput(92L, "conv", 2L, "alice", "old queued text",
List.of(), "claimed", "claim", null, null,
LocalDateTime.now(), LocalDateTime.now(), 42L, null);
@ -115,7 +116,7 @@ class ChatControllerDurableQueueTest {
var runs = mock(vip.mate.goal.service.GoalApprovalRunService.class);
when(runs.hasManagedGoalHistory("conv", "2")).thenReturn(true);
ChatController controller = new ChatController(agents, conversations, mock(ApprovalWorkflowService.class),
mock(ChatStreamTracker.class), new ObjectMapper(), mock(ConversationCompletionPublisher.class),
streams, new ObjectMapper(), mock(ConversationCompletionPublisher.class),
mock(MemoryOwnerResolver.class), mock(ChatUploadLocationResolver.class),
mock(OfficePreviewService.class), queue);
org.springframework.test.util.ReflectionTestUtils.setField(controller, "goalApprovalRuns", runs);
@ -127,7 +128,49 @@ class ChatControllerDurableQueueTest {
org.mockito.Mockito.verify(conversations).saveMessage("conv", "user", "old queued text", List.of(), "queued");
org.mockito.Mockito.verify(queue).bindMessage(eq(92L), any(), eq(101L), any());
org.mockito.Mockito.verify(queue).consume(eq(92L), any(), any());
org.mockito.Mockito.verify(streams).broadcast(eq("conv"), eq("queued_input_skipped"),
org.mockito.ArgumentMatchers.contains("old queued text"));
org.mockito.Mockito.verifyNoInteractions(agents);
}
@Test
void skippingAnOldRowStillRunsTheNextQueuedMessage() {
AgentService agents = mock(AgentService.class);
ConversationService conversations = mock(ConversationService.class);
ConversationInputQueueStore queue = mock(ConversationInputQueueStore.class);
var old = new QueuedInput(92L, "conv", 2L, "alice", "old",
List.of(), "claimed", "first", 100L, null,
LocalDateTime.now(), LocalDateTime.now(), 42L, null);
var next = new QueuedInput(93L, "conv", 2L, "alice", "next",
List.of(), "claimed", "second", 101L, null,
LocalDateTime.now(), LocalDateTime.now(), 42L, 0L);
when(queue.claimNext(eq("conv"), any(), any()))
.thenReturn(java.util.Optional.of(old), java.util.Optional.of(next));
when(queue.consume(eq(92L), any(), any())).thenReturn(true);
when(queue.consume(eq(93L), any(), any())).thenReturn(true);
when(queue.countQueued("conv")).thenReturn(1);
var conversation = new vip.mate.workspace.conversation.model.ConversationEntity();
conversation.setConversationId("conv"); conversation.setAgentId(2L); conversation.setWorkspaceId(3L);
when(conversations.findByConversationId("conv")).thenReturn(conversation);
var runs = mock(vip.mate.goal.service.GoalApprovalRunService.class);
when(runs.hasManagedGoalHistory("conv", "2")).thenReturn(true);
when(runs.captureSelectedGoal(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(agents.chatStructuredStream(eq(2L), eq("next"), eq("conv"), eq("alice"), any(), any()))
.thenReturn(reactor.core.publisher.Flux.never());
ChatController controller = new ChatController(agents, conversations, mock(ApprovalWorkflowService.class),
mock(ChatStreamTracker.class), new ObjectMapper(), mock(ConversationCompletionPublisher.class),
mock(MemoryOwnerResolver.class), mock(ChatUploadLocationResolver.class),
mock(OfficePreviewService.class), queue);
org.springframework.test.util.ReflectionTestUtils.setField(controller, "goalApprovalRuns", runs);
org.springframework.test.util.ReflectionTestUtils.invokeMethod(controller, "startQueuedMessage", "conv",
new org.springframework.web.servlet.mvc.method.annotation.SseEmitter(),
new java.util.concurrent.atomic.AtomicBoolean(false), "alice", "http://localhost");
org.mockito.Mockito.verify(agents, org.mockito.Mockito.timeout(2000))
.chatStructuredStream(eq(2L), eq("next"), eq("conv"), eq("alice"), any(), any());
org.mockito.Mockito.verify(queue).consume(eq(92L), any(), any());
org.mockito.Mockito.verify(queue).consume(eq(93L), any(), any());
}
}

View File

@ -97,6 +97,8 @@ export interface UseChatOptions {
* The caller should perform history reconcile / persistence in this callback.
*/
onStreamEnd?: (meta: StreamEndMeta) => void
/** A legacy queued message was saved as text but needs a fresh request. */
onQueuedInputSkipped?: () => void
}
/** Metadata emitted when a stream ends */
@ -219,7 +221,7 @@ export function buildChatStreamRequestBody(content: string, options: SendMessage
}
export function useChat(options: UseChatOptions): UseChatReturn {
const { baseUrl, token, onStreamEnd } = options
const { baseUrl, token, onStreamEnd, onQueuedInputSkipped } = options
const thinkingLevelRef = options.thinkingLevel
/**
@ -1845,6 +1847,19 @@ export function useChat(options: UseChatOptions): UseChatReturn {
lifecycleStage.value = { stage: 'connecting', since: Date.now() }
})
stream.on('queued_input_skipped', (data) => {
if (isStaleEvent(data)) return
// The server saved this legacy queued input as user text without running
// it. Remove only that queue entry; later queued inputs may still run.
const queued = messageQueue.dequeue()
const content = data.message || queued?.content || ''
if (content) {
createUserMessage(content, queued?.contentParts, data.conversationId || streamConversationId)
}
streamPhase.value = messageQueue.hasQueued.value ? 'queued' : 'idle'
onQueuedInputSkipped?.()
})
// ===== Async task completion events (video / image / music generation) =====
stream.on('async_task_completed', (data) => {
if (isStaleEvent(data)) return

View File

@ -36,6 +36,7 @@ export type SSEEventType =
| 'turn_interrupted'
| 'queued_input_accepted'
| 'queued_input_started'
| 'queued_input_skipped'
// 异步任务事件
| 'async_task_progress'
| 'async_task_completed'

View File

@ -639,6 +639,7 @@ export default {
queuedCancel: 'Cancel',
queuedReplace: 'Message queued. Press Enter to replace...',
queuedBadge: '{count} queued',
queuedLegacyResend: 'Queued message was saved, but its Goal selection is unknown. Please send it again.',
// Stream status
streamStopAction: 'Stop generation',
streamQueueAction: 'Send after current response',

View File

@ -639,6 +639,7 @@ export default {
queuedCancel: '取消',
queuedReplace: '消息已排队,按回车替换...',
queuedBadge: '{count} 条排队',
queuedLegacyResend: '排队消息已保存,但无法确认原来的目标选择。请重新发送。',
// 流状态
streamStopAction: '停止生成',
streamQueueAction: '当前回复结束后发送',

View File

@ -848,6 +848,7 @@ const {
}
}
},
onQueuedInputSkipped: () => mcToast.warning(t('chat.queuedLegacyResend')),
})
const teamRunRouteQuery = computed(() => readTeamRunRouteQuery(route.query))