Skip to content

Commit d93aeef

Browse files
committed
feat(chat): enhance background status handling and UI updates for terminal states
1 parent 9c2a830 commit d93aeef

File tree

3 files changed

+163
-45
lines changed

3 files changed

+163
-45
lines changed

src/api/providers/openai-native.ts

Lines changed: 87 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ export type OpenAiNativeModel = ReturnType<OpenAiNativeHandler["getModel"]>
3131
// Constants for model identification
3232
const GPT5_MODEL_PREFIX = "gpt-5"
3333

34+
// Marker for terminal background-mode failures so we don't attempt resume/poll fallbacks
35+
function createTerminalBackgroundError(message: string): Error {
36+
const err = new Error(message)
37+
;(err as any).isTerminalBackgroundError = true
38+
err.name = "TerminalBackgroundError"
39+
return err
40+
}
41+
function isTerminalBackgroundError(err: any): boolean {
42+
return !!(err && (err as any).isTerminalBackgroundError)
43+
}
44+
3445
export class OpenAiNativeHandler extends BaseProvider implements SingleCompletionHandler {
3546
protected options: ApiHandlerOptions
3647
private client: OpenAI
@@ -338,6 +349,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
338349
}
339350
}
340351
} catch (iterErr) {
352+
// If terminal failure, propagate and do not attempt resume/poll
353+
if (isTerminalBackgroundError(iterErr)) {
354+
throw iterErr
355+
}
341356
// Stream dropped mid-flight; attempt resume for background requests
342357
if (canAttemptResume()) {
343358
for await (const chunk of this.attemptResumeOrPoll(
@@ -352,6 +367,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
352367
throw iterErr
353368
}
354369
} catch (sdkErr: any) {
370+
// Propagate terminal background failures without fallback
371+
if (isTerminalBackgroundError(sdkErr)) {
372+
throw sdkErr
373+
}
355374
// Check if this is a 400 error about previous_response_id not found
356375
const errorMessage = sdkErr?.message || sdkErr?.error?.message || ""
357376
const is400Error = sdkErr?.status === 400 || sdkErr?.response?.status === 400
@@ -412,6 +431,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
412431
}
413432
return
414433
} catch (iterErr) {
434+
if (isTerminalBackgroundError(iterErr)) {
435+
throw iterErr
436+
}
415437
if (canAttemptResume()) {
416438
for await (const chunk of this.attemptResumeOrPoll(
417439
this.lastResponseId!,
@@ -425,6 +447,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
425447
throw iterErr
426448
}
427449
} catch (retryErr) {
450+
if (isTerminalBackgroundError(retryErr)) {
451+
throw retryErr
452+
}
428453
// If retry also fails, fall back to SSE
429454
try {
430455
yield* this.makeGpt5ResponsesAPIRequest(
@@ -436,6 +461,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
436461
)
437462
return
438463
} catch (fallbackErr) {
464+
if (isTerminalBackgroundError(fallbackErr)) {
465+
throw fallbackErr
466+
}
439467
if (canAttemptResume()) {
440468
for await (const chunk of this.attemptResumeOrPoll(
441469
this.lastResponseId!,
@@ -456,6 +484,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
456484
yield* this.makeGpt5ResponsesAPIRequest(requestBody, model, metadata, systemPrompt, messages)
457485
} catch (fallbackErr) {
458486
// If SSE fallback fails mid-stream and we can resume, try that
487+
if (isTerminalBackgroundError(fallbackErr)) {
488+
throw fallbackErr
489+
}
459490
if (canAttemptResume()) {
460491
for await (const chunk of this.attemptResumeOrPoll(
461492
this.lastResponseId!,
@@ -1058,9 +1089,20 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
10581089
else if (parsed.type === "response.error" || parsed.type === "error") {
10591090
// Error event from the API
10601091
if (parsed.error || parsed.message) {
1061-
throw new Error(
1062-
`Responses API error: ${parsed.error?.message || parsed.message || "Unknown error"}`,
1063-
)
1092+
const errMsg = `Responses API error: ${parsed.error?.message || parsed.message || "Unknown error"}`
1093+
// For background mode, treat as terminal to avoid futile resume attempts
1094+
if (this.currentRequestIsBackground) {
1095+
// Surface a failed status for UI lifecycle before terminating
1096+
yield {
1097+
type: "status",
1098+
mode: "background",
1099+
status: "failed",
1100+
...(parsed.response?.id ? { responseId: parsed.response.id } : {}),
1101+
}
1102+
throw createTerminalBackgroundError(errMsg)
1103+
}
1104+
// Non-background: propagate as a standard error
1105+
throw new Error(errMsg)
10641106
}
10651107
}
10661108
// Handle incomplete event
@@ -1096,7 +1138,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
10961138
}
10971139
// Response failed
10981140
if (parsed.error || parsed.message) {
1099-
throw new Error(
1141+
throw createTerminalBackgroundError(
11001142
`Response failed: ${parsed.error?.message || parsed.message || "Unknown failure"}`,
11011143
)
11021144
}
@@ -1227,6 +1269,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
12271269
// This can happen in certain edge cases and shouldn't break the flow
12281270
} catch (error) {
12291271
if (error instanceof Error) {
1272+
// Preserve terminal background errors so callers can avoid resume attempts
1273+
if ((error as any).isTerminalBackgroundError) {
1274+
throw error
1275+
}
12301276
throw new Error(`Error processing response stream: ${error.message}`)
12311277
}
12321278
throw new Error("Unexpected error processing response stream")
@@ -1264,25 +1310,25 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
12641310
},
12651311
})
12661312

1267-
if (!res.ok || !res.body) {
1313+
if (!res.ok) {
12681314
throw new Error(`Resume request failed (${res.status})`)
12691315
}
1316+
if (!res.body) {
1317+
throw new Error("Resume request failed (no body)")
1318+
}
12701319

12711320
this.resumeCutoffSequence = lastSeq
12721321

1273-
let emittedInProgress = false
1322+
// Handshake accepted: immediately switch UI from reconnecting -> in_progress
1323+
yield {
1324+
type: "status",
1325+
mode: "background",
1326+
status: "in_progress",
1327+
responseId,
1328+
}
1329+
12741330
try {
12751331
for await (const chunk of this.handleStreamResponse(res.body, model)) {
1276-
// After the handshake and first accepted chunk, emit in_progress once
1277-
if (!emittedInProgress) {
1278-
emittedInProgress = true
1279-
yield {
1280-
type: "status",
1281-
mode: "background",
1282-
status: "in_progress",
1283-
responseId,
1284-
}
1285-
}
12861332
// Avoid double-emitting in_progress if the inner handler surfaces it
12871333
if (chunk.type === "status" && (chunk as any).status === "in_progress") {
12881334
continue
@@ -1297,9 +1343,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
12971343
this.resumeCutoffSequence = undefined
12981344
throw e
12991345
}
1300-
} catch {
1301-
// Wait with backoff before next attempt
1346+
} catch (err) {
1347+
// If terminal error, don't keep retrying resume; fall back to polling immediately
13021348
const delay = resumeBaseDelayMs * Math.pow(2, attempt)
1349+
if (isTerminalBackgroundError(err)) {
1350+
break
1351+
}
1352+
// Otherwise retry with backoff
13031353
if (delay > 0) {
13041354
await new Promise((r) => setTimeout(r, delay))
13051355
}
@@ -1413,10 +1463,21 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
14131463
}
14141464

14151465
if (status === "failed" || status === "canceled") {
1416-
throw new Error(`Response ${status}: ${respId || responseId}`)
1466+
const detail: string | undefined = resp?.error?.message ?? raw?.error?.message
1467+
const msg = detail ? `Response ${status}: ${detail}` : `Response ${status}: ${respId || responseId}`
1468+
throw createTerminalBackgroundError(msg)
1469+
}
1470+
} catch (err) {
1471+
// If we've already emitted a terminal status, propagate to consumer to stop polling.
1472+
if (lastEmittedStatus === "failed" || lastEmittedStatus === "canceled") {
1473+
throw err
14171474
}
1418-
} catch {
1419-
// ignore transient poll errors
1475+
// Otherwise ignore transient poll errors
1476+
}
1477+
1478+
// Stop polling immediately on terminal background statuses
1479+
if (lastEmittedStatus === "failed" || lastEmittedStatus === "canceled") {
1480+
throw new Error(`Background polling terminated with status=${lastEmittedStatus} for ${responseId}`)
14201481
}
14211482

14221483
await new Promise((r) => setTimeout(r, pollIntervalMs))
@@ -1463,6 +1524,11 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
14631524
if (mappedStatus === "completed" || mappedStatus === "failed" || mappedStatus === "canceled") {
14641525
this.currentRequestIsBackground = undefined
14651526
}
1527+
// Throw terminal error to integrate with standard failure path (surfaced in UI)
1528+
if (mappedStatus === "failed" || mappedStatus === "canceled") {
1529+
const msg = (event as any)?.error?.message || (event as any)?.message || `Response ${mappedStatus}`
1530+
throw createTerminalBackgroundError(msg)
1531+
}
14661532
// Do not return; allow further handling (e.g., usage on done/completed)
14671533
}
14681534

src/core/task/Task.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1956,10 +1956,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
19561956
let item = await iterator.next()
19571957
while (!item.done) {
19581958
const chunk = item.value
1959-
item = await iterator.next()
19601959
if (!chunk) {
19611960
// Sometimes chunk is undefined, no idea that can cause
19621961
// it, but this workaround seems to fix it.
1962+
item = await iterator.next()
19631963
continue
19641964
}
19651965

@@ -2006,7 +2006,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
20062006
if (chunk.responseId) {
20072007
;(apiReqMsg as any).metadata.responseId = chunk.responseId
20082008
}
2009+
// Temporary debug to confirm UI metadata updates
2010+
console.log(
2011+
`[BackgroundMode] status update -> ${chunk.status} (resp=${chunk.responseId ?? "n/a"})`,
2012+
)
20092013
await this.updateClineMessage(apiReqMsg)
2014+
// Force state refresh to ensure UI recomputes derived labels/memos
2015+
const provider = this.providerRef.deref()
2016+
await provider?.postStateToWebview()
20102017
}
20112018
} catch {}
20122019
break
@@ -2060,6 +2067,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
20602067
"\n\n[Response interrupted by a tool use result. Only one tool may be used at a time and should be placed at the end of the message.]"
20612068
break
20622069
}
2070+
// Prefetch the next item after processing the current chunk.
2071+
// This ensures terminal status chunks (e.g., failed/canceled/completed)
2072+
// are not skipped when the provider throws on the following next().
2073+
item = await iterator.next()
20632074
}
20642075

20652076
// Create a copy of current token values to avoid race conditions
@@ -2384,12 +2395,31 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
23842395
continue
23852396
} else {
23862397
// If there's no assistant_responses, that means we got no text
2387-
// or tool_use content blocks from API which we should assume is
2388-
// an error.
2389-
await this.say(
2390-
"error",
2391-
"Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.",
2392-
)
2398+
// or tool_use content blocks from API which we should assume is an error.
2399+
// Prefer any streaming failure details captured on the last api_req_started message.
2400+
let errorText =
2401+
"Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output."
2402+
try {
2403+
const lastApiReqStartedIdx = findLastIndex(
2404+
this.clineMessages,
2405+
(m) => m.type === "say" && m.say === "api_req_started",
2406+
)
2407+
if (lastApiReqStartedIdx !== -1) {
2408+
const info = JSON.parse(
2409+
this.clineMessages[lastApiReqStartedIdx].text || "{}",
2410+
) as ClineApiReqInfo
2411+
if (
2412+
typeof info?.streamingFailedMessage === "string" &&
2413+
info.streamingFailedMessage.trim().length > 0
2414+
) {
2415+
errorText = info.streamingFailedMessage
2416+
}
2417+
}
2418+
} catch {
2419+
// ignore parse issues and keep default message
2420+
}
2421+
2422+
await this.say("error", errorText)
23932423

23942424
await this.addToApiConversationHistory({
23952425
role: "assistant",

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -531,27 +531,49 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
531531
return false
532532
}
533533

534-
const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true
534+
// Find the last api_req_started to inspect background status + payload
535+
const lastApiReqStarted = findLast(
536+
modifiedMessages,
537+
(message: ClineMessage) => message.say === "api_req_started",
538+
)
539+
540+
// Extract background terminal state and cancel reason/cost if present
541+
let bgDone = false
542+
let cancelReason: string | null | undefined = undefined
543+
let cost: any = undefined
544+
545+
if (lastApiReqStarted && lastApiReqStarted.say === "api_req_started") {
546+
const meta: any = (lastApiReqStarted as any).metadata
547+
const bgStatus = meta?.background === true ? meta?.backgroundStatus : undefined
548+
bgDone = bgStatus === "completed" || bgStatus === "failed" || bgStatus === "canceled"
549+
550+
try {
551+
if (lastApiReqStarted.text !== null && lastApiReqStarted.text !== undefined) {
552+
const info = JSON.parse(lastApiReqStarted.text)
553+
cost = info?.cost
554+
cancelReason = info?.cancelReason
555+
}
556+
} catch {
557+
// ignore malformed json
558+
}
559+
}
560+
561+
// If background reached a terminal state or the provider recorded a cancel reason,
562+
// treat UI as not streaming regardless of partial flags or missing cost.
563+
if (bgDone || cancelReason != null) {
564+
return false
565+
}
535566

567+
// Partial assistant content means streaming unless overridden by the terminal checks above.
568+
const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true
536569
if (isLastMessagePartial) {
537570
return true
538-
} else {
539-
const lastApiReqStarted = findLast(
540-
modifiedMessages,
541-
(message: ClineMessage) => message.say === "api_req_started",
542-
)
543-
544-
if (
545-
lastApiReqStarted &&
546-
lastApiReqStarted.text !== null &&
547-
lastApiReqStarted.text !== undefined &&
548-
lastApiReqStarted.say === "api_req_started"
549-
) {
550-
const cost = JSON.parse(lastApiReqStarted.text).cost
571+
}
551572

552-
if (cost === undefined) {
553-
return true // API request has not finished yet.
554-
}
573+
// Otherwise, if the API request hasn't finished (no cost yet), consider it streaming.
574+
if (lastApiReqStarted && lastApiReqStarted.say === "api_req_started") {
575+
if (cost === undefined) {
576+
return true
555577
}
556578
}
557579

0 commit comments

Comments
 (0)