修复进行中会话有时无法被取消

This commit is contained in:
xintaofei
2026-03-22 23:20:01 +08:00
parent 58ddcb818b
commit ad49d9e9ec
3 changed files with 57 additions and 25 deletions

View File

@@ -1454,11 +1454,13 @@ async fn run_conversation_loop<'a>(
let cx = session.connection();
let sid = session.session_id().clone();
let prompt_request = PromptRequest::new(sid.clone(), prompt_blocks);
let prompt_response = cx
.clone()
.send_request_to(Agent, prompt_request)
.block_task();
tokio::pin!(prompt_response);
// Use Box::pin (heap) instead of tokio::pin! (stack) so the
// future can be moved into a background task on cancel.
let mut prompt_response = Box::pin(
cx.clone()
.send_request_to(Agent, prompt_request)
.block_task(),
);
let mut tracked_terminal_tool_calls: HashMap<String, TrackedTerminalToolCall> =
HashMap::new();
let mut terminal_poll_interval = tokio::time::interval(
@@ -1659,6 +1661,25 @@ async fn run_conversation_loop<'a>(
RequestPermissionOutcome::Cancelled,
));
}
// Immediately emit TurnComplete so the frontend
// transitions out of "prompting" and the user can
// send new messages. Don't wait for the agent —
// it may be slow to respond or not respond at all.
let _ = handle.emit(
"acp://event",
AcpEvent::TurnComplete {
connection_id: conn_id.into(),
session_id: sid.0.to_string(),
stop_reason: "cancelled".into(),
},
);
// Drain the prompt response in the background so
// the SACP library doesn't log "receiver dropped"
// errors when the agent eventually responds.
tokio::spawn(async move {
let _ = prompt_response.await;
});
break;
}
Some(ConnectionCommand::Disconnect) | None => {
eprintln!(

View File

@@ -209,6 +209,10 @@ const ConversationTabView = memo(function ConversationTabView({
const statusUpdatedRef = useRef(false)
const selectedAgentRef = useRef(selectedAgent)
const createConversationPendingRef = useRef(false)
// When the turn finishes (cancel / complete) before createConversation
// resolves, we can't update the DB status yet. This ref records the
// desired status so the createConversation callback can apply it.
const deferredStatusRef = useRef<string | null>(null)
const externalIdSavedRef = useRef(false)
const sessionIdRef = useRef<string | null>(null)
const syncCancelRef = useRef<(() => void) | null>(null)
@@ -333,8 +337,21 @@ const ConversationTabView = memo(function ConversationTabView({
syncCancelRef.current?.()
syncCancelRef.current = null
const targetStatus =
connStatus === "disconnected" || connStatus === "error"
? null
: "pending_review"
const persistedId = dbConvIdRef.current
if (!persistedId) return
if (!persistedId) {
// Conversation hasn't been persisted yet (createConversation still
// in flight). Record the desired status so the create callback
// can apply it once the DB row exists.
if (targetStatus) {
deferredStatusRef.current = targetStatus
}
return
}
// Async patch metadata (usage, duration_ms, model, session_stats)
if (persistedId > 0) {
@@ -344,8 +361,8 @@ const ConversationTabView = memo(function ConversationTabView({
)
}
if (connStatus !== "disconnected" && connStatus !== "error") {
updateConversationStatus(persistedId, "pending_review")
if (targetStatus) {
updateConversationStatus(persistedId, targetStatus)
.then(() => refreshConversations())
.catch((e: unknown) =>
console.error("[ConversationTabView] update status:", e)
@@ -573,7 +590,12 @@ const ConversationTabView = memo(function ConversationTabView({
buildConversationDraftStorageKey(selectedAgent, newConversationId)
)
statusUpdatedRef.current = false
updateConversationStatus(newConversationId, "in_progress")
// If the turn already finished while we were creating the
// conversation, apply the deferred status directly instead
// of setting "in_progress" (which would never be updated).
const initialStatus = deferredStatusRef.current ?? "in_progress"
deferredStatusRef.current = null
updateConversationStatus(newConversationId, initialStatus)
.then(() => refreshConversations())
.catch((e: unknown) =>
console.error("[ConversationTabView] update status:", e)

View File

@@ -1893,22 +1893,11 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
[]
)
const cancel = useCallback(
async (contextKey: string) => {
const conn = storeRef.current.connections.get(contextKey)
if (!conn) return
await acpCancel(conn.connectionId)
// Optimistically transition status so the UI stops showing
// "responding" immediately. If the agent was slow to respond to
// the CancelNotification the frontend would otherwise stay stuck
// in the "prompting" state indefinitely.
const current = storeRef.current.connections.get(contextKey)
if (current?.status === "prompting") {
dispatch({ type: "STATUS_CHANGED", contextKey, status: "connected" })
}
},
[dispatch]
)
const cancel = useCallback(async (contextKey: string) => {
const conn = storeRef.current.connections.get(contextKey)
if (!conn) return
await acpCancel(conn.connectionId)
}, [])
const respondPermission = useCallback(
async (contextKey: string, requestId: string, optionId: string) => {