Skip to content

Commit 5ff0a02

Browse files
committed
fix: sdk accepts optional abort controller
1 parent b170d15 commit 5ff0a02

File tree

3 files changed

+280
-125
lines changed

3 files changed

+280
-125
lines changed

cli/src/hooks/use-send-message.ts

Lines changed: 22 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ const hiddenToolNames = new Set<ToolName | 'spawn_agent_inline'>([
3636
'spawn_agents',
3737
])
3838

39-
const STREAM_INACTIVITY_TIMEOUT_MS = 15_000
4039
const MAX_RETRIES_PER_MESSAGE = 3
4140
const RETRY_BACKOFF_BASE_DELAY_MS = 1_000
4241
const RETRY_BACKOFF_MAX_DELAY_MS = 8_000
@@ -360,10 +359,6 @@ export const useSendMessage = ({
360359
const retryAttemptsRef = useRef<Record<string, number>>({})
361360
const retryInFlightRef = useRef(false)
362361
const retryBackoffDelayRef = useRef(RETRY_BACKOFF_BASE_DELAY_MS)
363-
const streamInactivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
364-
null,
365-
)
366-
const streamInactivityTriggeredRef = useRef(false)
367362
const streamHadOutputRef = useRef(false)
368363
const currentRunContextRef = useRef<{
369364
userMessageId: string
@@ -597,52 +592,16 @@ export const useSendMessage = ({
597592
}
598593
}, [])
599594

600-
const clearStreamInactivityTimer = useCallback(() => {
601-
if (streamInactivityTimerRef.current) {
602-
clearTimeout(streamInactivityTimerRef.current)
603-
streamInactivityTimerRef.current = null
604-
}
605-
}, [])
606-
607-
const handleStreamInactivityTimeout = useCallback(() => {
608-
clearStreamInactivityTimer()
609-
const context = currentRunContextRef.current
610-
if (!context) {
611-
return
612-
}
613-
streamInactivityTriggeredRef.current = true
614-
schedulePendingRetry({
615-
...context,
616-
note: 'Timed out…',
617-
})
618-
const controller = abortControllerRef.current
619-
if (controller && !controller.signal.aborted) {
620-
controller.abort(new Error('Stream inactivity timeout'))
621-
}
622-
}, [abortControllerRef, clearStreamInactivityTimer, schedulePendingRetry])
623-
624-
const refreshStreamInactivityTimer = useCallback(() => {
625-
clearStreamInactivityTimer()
626-
if (!currentRunContextRef.current) {
627-
return
628-
}
629-
streamInactivityTimerRef.current = setTimeout(
630-
handleStreamInactivityTimeout,
631-
STREAM_INACTIVITY_TIMEOUT_MS,
632-
)
633-
}, [clearStreamInactivityTimer, handleStreamInactivityTimeout])
634-
635595
useEffect(() => {
636596
return () => {
637597
if (flushTimeoutRef.current) {
638598
clearTimeout(flushTimeoutRef.current)
639599
flushTimeoutRef.current = null
640600
}
641-
clearStreamInactivityTimer()
642601
currentRunContextRef.current = null
643602
flushPendingUpdates()
644603
}
645-
}, [clearStreamInactivityTimer, flushPendingUpdates])
604+
}, [flushPendingUpdates])
646605

647606
const sendMessage = useCallback<SendMessageFn>(
648607
async (params: ParamsOf<SendMessageFn>) => {
@@ -1119,30 +1078,37 @@ export const useSendMessage = ({
11191078
updateChainInProgress(true)
11201079
let hasReceivedContent = false
11211080
let actualCredits: number | undefined = undefined
1081+
let settled = false
11221082

11231083
streamHadOutputRef.current = false
11241084

11251085
const abortController = new AbortController()
11261086
abortControllerRef.current = abortController
1087+
1088+
// Abort listener for immediate UI cleanup.
1089+
// Note: With shared controller, both SDK (timeout) and user (Ctrl+C) can abort.
1090+
// This listener only updates UI state - the error handler manages retries.
11271091
abortController.signal.addEventListener('abort', () => {
1128-
clearStreamInactivityTimer()
1129-
currentRunContextRef.current = null
1092+
if (settled) {
1093+
return
1094+
}
1095+
// Update UI immediately for responsive feedback
11301096
streamHadOutputRef.current = false
11311097
setStreamStatus('idle')
1132-
setCanProcessQueue(false)
11331098
updateChainInProgress(false)
11341099
timerController.stop('aborted')
11351100

1136-
markAiMessageInterrupted(aiMessageId)
1101+
// Note: We intentionally do NOT:
1102+
// - Set canProcessQueue = false (would block retries)
1103+
// - Clear currentRunContextRef (error handler needs it)
1104+
// - Mark message as interrupted (error handler handles it)
11371105
})
11381106

11391107
currentRunContextRef.current = {
11401108
userMessageId,
11411109
content,
11421110
agentMode,
11431111
}
1144-
streamInactivityTriggeredRef.current = false
1145-
refreshStreamInactivityTimer()
11461112

11471113
try {
11481114
// Load local agent definitions from .agents directory
@@ -1166,12 +1132,11 @@ export const useSendMessage = ({
11661132
agent: selectedAgentDefinition ?? agentId ?? fallbackAgent,
11671133
prompt: content,
11681134
previousRun: previousRunStateRef.current ?? undefined,
1169-
signal: abortController.signal,
1135+
abortController: abortController,
11701136
agentDefinitions: agentDefinitions,
11711137
maxAgentSteps: 40,
11721138

11731139
handleStreamChunk: (event) => {
1174-
refreshStreamInactivityTimer()
11751140
if (!streamHadOutputRef.current) {
11761141
streamHadOutputRef.current = true
11771142
}
@@ -1243,7 +1208,6 @@ export const useSendMessage = ({
12431208
{ errorMessage: event.message },
12441209
'SDK error event received',
12451210
)
1246-
clearStreamInactivityTimer()
12471211
currentRunContextRef.current = null
12481212
// Stop streaming and update UI
12491213
setStreamStatus('idle')
@@ -1300,8 +1264,6 @@ export const useSendMessage = ({
13001264

13011265
if (typeof text !== 'string' || !text) return
13021266

1303-
refreshStreamInactivityTimer()
1304-
13051267
// Track if main agent (no agentId) started streaming
13061268
if (!hasReceivedContent && !event.agentId) {
13071269
hasReceivedContent = true
@@ -1946,7 +1908,6 @@ export const useSendMessage = ({
19461908
})
19471909

19481910
if (!runState.output || runState.output.type === 'error') {
1949-
clearStreamInactivityTimer()
19501911
currentRunContextRef.current = null
19511912
streamHadOutputRef.current = false
19521913
setCanProcessQueue(false)
@@ -1981,7 +1942,6 @@ export const useSendMessage = ({
19811942
return
19821943
}
19831944

1984-
clearStreamInactivityTimer()
19851945
currentRunContextRef.current = null
19861946
streamHadOutputRef.current = false
19871947
setStreamStatus('idle')
@@ -2020,13 +1980,14 @@ export const useSendMessage = ({
20201980
}
20211981
}),
20221982
)
1983+
settled = true
20231984
delete retryAttemptsRef.current[userMessageId]
20241985
} catch (error) {
1986+
settled = true
20251987
logger.error(
20261988
{ error: getErrorObject(error) },
20271989
'SDK client.run() failed',
20281990
)
2029-
clearStreamInactivityTimer()
20301991
currentRunContextRef.current = null
20311992
streamHadOutputRef.current = false
20321993
setStreamStatus('idle')
@@ -2036,10 +1997,10 @@ export const useSendMessage = ({
20361997

20371998
const errorMessage =
20381999
error instanceof Error ? error.message : 'Unknown error occurred'
2039-
const timedOutDueToCli = streamInactivityTriggeredRef.current
2040-
if (!timedOutDueToCli) {
2041-
markAiMessageInterrupted(aiMessageId)
2042-
}
2000+
2001+
// Mark message as interrupted when an error occurs
2002+
markAiMessageInterrupted(aiMessageId)
2003+
20432004
applyMessageUpdate((prev) =>
20442005
prev.map((msg) => {
20452006
if (msg.id !== aiMessageId) {
@@ -2067,10 +2028,8 @@ export const useSendMessage = ({
20672028
userMessageId in pendingRetriesRef.current
20682029
const timedOutDueToSdk =
20692030
isNetworkError(error) && Boolean(error.streamTimedOut)
2070-
streamInactivityTriggeredRef.current = false
20712031

20722032
const shouldRetryError =
2073-
timedOutDueToCli ||
20742033
timedOutDueToSdk ||
20752034
!isConnectedRef.current ||
20762035
isRetryableError(error)
@@ -2091,7 +2050,7 @@ export const useSendMessage = ({
20912050
if (!pendingAlreadyScheduled) {
20922051
const note = !isConnectedRef.current
20932052
? 'Waiting for connection…'
2094-
: timedOutDueToCli || timedOutDueToSdk
2053+
: timedOutDueToSdk
20952054
? 'Timed out…'
20962055
: 'Stream interrupted'
20972056

@@ -2133,8 +2092,6 @@ export const useSendMessage = ({
21332092
resumeQueue,
21342093
clearPendingRetryForMessage,
21352094
schedulePendingRetry,
2136-
clearStreamInactivityTimer,
2137-
refreshStreamInactivityTimer,
21382095
isConnectedRef,
21392096
markAiMessageInterrupted,
21402097
markUserMessageFailed,

0 commit comments

Comments
 (0)