fix(desktop): surface non-streaming fallback in chat status (#766)

api_retry heartbeats already reach the desktop status bar, but the
streaming-to-non-streaming fallback had no signal at all: the CLI only
flipped an internal flag, and the one-shot fallback response can take
minutes with zero incremental output, so the UI showed a bare spinner
the whole time.

- CLI: yield a {type:'system', subtype:'streaming_fallback', cause}
  message at both fallback sites (stream error/watchdog and 404 stream
  creation), mirroring the existing api_error -> api_retry path through
  query.ts passthrough, QueryEngine SDK output, and the SDK schema.
- Server: translate it to a streaming_fallback ServerMessage;
  unrecognized causes normalize to 'unknown' instead of dropping the
  event.
- Desktop: track it as active-turn state (cleared at the same 12 sites
  as apiRetry; a fallback notice supersedes the stale retry banner) and
  render a neutral pill with the turn timer - expected state, not an
  error, so no amber styling and nothing in the diagnostics panel.
- Retry banner now shows "retrying now" once the countdown elapses
  instead of sticking at "waiting 0s".

With 62241a31 disabling the non-streaming fallback for desktop CLI
sessions, this notice mainly covers the 404 gateway path (which skips
the disable check), callers that re-enable fallback via env, and
non-desktop SDK consumers.

Constraint: Retries and fallbacks are expected states per the
diagnostics severity standard - lightweight active-turn UI only, no
error-panel entries, no transcript persistence.
Tested: bun test src/server/__tests__/ws-memory-events.test.ts
Tested: bun run check:server
Tested: cd desktop && bun run test -- --run
Tested: cd desktop && bun run lint
Not-tested: live provider outage reproduction; verified via unit
coverage of the translation, store lifecycle, and indicator rendering.
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 18:32:28 +08:00
parent 275dab39cc
commit 67660ab4e5
18 changed files with 367 additions and 5 deletions

View File

@ -896,6 +896,25 @@ describe('MessageList nested tool calls', () => {
expect(screen.getByText(/waiting \d+s/)).toBeTruthy()
})
it('shows the non-streaming fallback notice in the active turn indicator', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'thinking',
streamingFallback: {
cause: 'watchdog',
receivedAt: Date.now(),
},
}),
},
})
render(<MessageList />)
expect(screen.getByTestId('streaming-fallback-indicator')).toBeTruthy()
expect(screen.getByText(/switched to non-streaming mode/)).toBeTruthy()
})
it('renders compact completion as an expandable timeline divider', () => {
useChatStore.setState({
sessions: {

View File

@ -91,4 +91,71 @@ describe('StreamingIndicator', () => {
expect(screen.queryByText(/tokens/)).toBeNull()
})
it('shows the non-streaming fallback notice while waiting for the one-shot response', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSession({
chatState: 'thinking',
streamingFallback: { cause: 'watchdog', receivedAt: Date.now() },
elapsedSeconds: 95,
}),
},
})
render(<StreamingIndicator />)
const notice = screen.getByTestId('streaming-fallback-indicator')
expect(notice.textContent).toContain('switched to non-streaming mode')
// 回合计时保留,证明"还在跑"而不是卡死。
expect(notice.textContent).toContain('1m 35s')
})
it('prefers the retry banner over the fallback notice when both are active', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSession({
chatState: 'thinking',
apiRetry: {
attempt: 2,
maxRetries: 10,
retryDelayMs: 60_000,
errorStatus: 529,
receivedAt: Date.now(),
},
streamingFallback: { cause: 'stream_error', receivedAt: Date.now() },
}),
},
})
render(<StreamingIndicator />)
// api_retry 携带更具体的进度(第 N 次/倒计时),优先级高于泛化的降级提示。
expect(screen.getByTestId('api-retry-indicator')).toBeTruthy()
expect(screen.queryByTestId('streaming-fallback-indicator')).toBeNull()
})
it('switches the retry banner from a countdown to "retrying now" once the delay elapses', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSession({
chatState: 'thinking',
apiRetry: {
attempt: 3,
maxRetries: 10,
retryDelayMs: 1000,
errorStatus: null,
// 倒计时早已结束:请求已在途,不该再显示"waiting 0s"。
receivedAt: Date.now() - 10_000,
},
}),
},
})
render(<StreamingIndicator />)
const banner = screen.getByTestId('api-retry-indicator')
expect(banner.textContent).toContain('retrying now')
expect(banner.textContent).not.toContain('waiting')
})
})

View File

@ -41,6 +41,7 @@ export function StreamingIndicator() {
const chatState = sessionState?.chatState ?? 'idle'
const statusVerb = sessionState?.statusVerb ?? ''
const apiRetry = sessionState?.apiRetry ?? null
const streamingFallback = sessionState?.streamingFallback ?? null
const elapsedSeconds = sessionState?.elapsedSeconds ?? 0
// chars ÷ 4 estimates output tokens for this turn, mirroring the CLI spinner.
const streamingTokens = Math.round((sessionState?.streamingResponseChars ?? 0) / 4)
@ -75,7 +76,9 @@ export function StreamingIndicator() {
{statusText}
</span>
<span className="text-amber-800 dark:text-amber-200">
{t('chat.retry.waiting', { seconds: formatRetrySeconds(remainingMs) })}
{remainingMs > 0
? t('chat.retry.waiting', { seconds: formatRetrySeconds(remainingMs) })
: t('chat.retry.retrying')}
</span>
{detailText && (
<span className="min-w-0 max-w-full truncate text-amber-700 dark:text-amber-200" title={detailText}>
@ -86,6 +89,32 @@ export function StreamingIndicator() {
)
}
if (streamingFallback) {
// 预期内的降级等待(非错误):非流式响应一次性返回,期间无增量输出。
// 用中性样式的轻提示 + 回合计时,与 api_retry 的警示横幅区分开。
return (
<div
data-testid="streaming-fallback-indicator"
role="status"
aria-live="polite"
className="mb-2 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1"
>
<RefreshCw size={12} strokeWidth={2.2} className="shrink-0 animate-spin text-[var(--color-text-secondary)]" aria-hidden="true" />
<span className="text-xs font-medium text-[var(--color-text-secondary)]">
{t('chat.fallback.title')}
</span>
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{t('chat.fallback.detail')}
</span>
{elapsedSeconds > 0 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{formatElapsed(elapsedSeconds)}
</span>
)}
</div>
)
}
let verb: string
if (statusVerb) {
verb = translateServerVerb(t, statusVerb)

View File

@ -1862,6 +1862,9 @@ export const en = {
'chat.retry.httpStatus': 'HTTP {status}',
'chat.retry.networkError': 'network error',
'chat.retry.waiting': 'waiting {seconds}s',
'chat.retry.retrying': 'retrying now…',
'chat.fallback.title': 'Network hiccup — switched to non-streaming mode',
'chat.fallback.detail': 'response arrives in one piece, this can take a while',
// ─── Tabs ──────────────────────────────────────
'tabs.close': 'Close',

View File

@ -1864,6 +1864,9 @@ export const jp: Record<TranslationKey, string> = {
'chat.retry.httpStatus': 'HTTP {status}',
'chat.retry.networkError': 'ネットワークエラー',
'chat.retry.waiting': '{seconds} 秒待機中',
'chat.retry.retrying': '再試行しています…',
'chat.fallback.title': 'ネットワーク不調のため非ストリーミングに切替',
'chat.fallback.detail': '応答は一括で届くため時間がかかることがあります',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '閉じる',

View File

@ -1864,6 +1864,9 @@ export const kr: Record<TranslationKey, string> = {
'chat.retry.httpStatus': 'HTTP {status}',
'chat.retry.networkError': '네트워크 오류',
'chat.retry.waiting': '{seconds}초 대기 중',
'chat.retry.retrying': '지금 다시 시도 중…',
'chat.fallback.title': '네트워크 불안정으로 비스트리밍 모드로 전환됨',
'chat.fallback.detail': '응답이 한 번에 도착하므로 시간이 걸릴 수 있습니다',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '닫기',

View File

@ -1864,6 +1864,9 @@ export const zh: Record<TranslationKey, string> = {
'chat.retry.httpStatus': 'HTTP {status}',
'chat.retry.networkError': '網路錯誤',
'chat.retry.waiting': '等待 {seconds}s',
'chat.retry.retrying': '正在重試…',
'chat.fallback.title': '網路波動,已切換為非串流請求',
'chat.fallback.detail': '回應將一次性返回,可能需要較長時間',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '關閉',

View File

@ -1864,6 +1864,9 @@ export const zh: Record<TranslationKey, string> = {
'chat.retry.httpStatus': 'HTTP {status}',
'chat.retry.networkError': '网络错误',
'chat.retry.waiting': '等待 {seconds}s',
'chat.retry.retrying': '正在重试…',
'chat.fallback.title': '网络波动,已切换为非流式请求',
'chat.fallback.detail': '响应将一次性返回,可能需要较长时间',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '关闭',

View File

@ -2888,6 +2888,72 @@ describe('chatStore history mapping', () => {
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.apiRetry).toBeNull()
})
it('tracks the streaming fallback notice and supersedes a stale retry banner', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
messages: [],
chatState: 'thinking',
statusVerb: 'Thinking',
apiRetry: {
attempt: 10,
maxRetries: 10,
retryDelayMs: 1000,
errorStatus: 529,
receivedAt: Date.now() - 5_000,
},
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'streaming_fallback',
cause: 'watchdog',
})
const fallbackSession = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(fallbackSession?.streamingFallback).toMatchObject({ cause: 'watchdog' })
// 旧的流式重试横幅针对已放弃的请求,必须被降级提示接管。
expect(fallbackSession?.apiRetry).toBeNull()
expect(fallbackSession?.chatState).toBe('thinking')
expect(fallbackSession?.statusVerb).toBe('')
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
// 非流式响应的首个内容块到达即清除降级提示。
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_start',
blockType: 'text',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingFallback).toBeNull()
})
it('keeps the fallback notice when idle and clears it on turn completion', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
messages: [],
chatState: 'idle',
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'streaming_fallback',
cause: '404_stream_creation',
})
// idle 会话收到降级信号说明回合仍在跑,状态条要回到 thinking。
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.chatState).toBe('thinking')
expect(session?.streamingFallback).toMatchObject({ cause: '404_stream_creation' })
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 0 },
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingFallback).toBeNull()
})
it('renders memory saved notifications as chat memory events', () => {
useChatStore.setState({
sessions: {

View File

@ -26,6 +26,7 @@ import type {
ComputerUsePermissionResponse,
GoalEventAction,
MemoryEventFile,
StreamingFallbackState,
UIAttachment,
UIMessage,
ServerMessage,
@ -104,6 +105,8 @@ export type PerSessionState = {
elapsedSeconds: number
statusVerb: string
apiRetry?: ApiRetryState | null
// 流式→非流式降级提示(活动回合状态,与 apiRetry 同清除时机)。
streamingFallback?: StreamingFallbackState | null
slashCommands: Array<{ name: string; description: string; argumentHint?: string }>
agentTaskNotifications: Record<string, AgentTaskNotification>
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
@ -139,6 +142,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
elapsedSeconds: 0,
statusVerb: '',
apiRetry: null,
streamingFallback: null,
slashCommands: [],
agentTaskNotifications: {},
backgroundAgentTasks: {},
@ -1008,6 +1012,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
streamingResponseChars: 0,
statusVerb: isMemberSession ? '' : randomSpinnerVerb(),
apiRetry: null,
streamingFallback: null,
elapsedTimer: timer,
connectionState: isMemberSession ? 'connected' : session.connectionState,
},
@ -1100,6 +1105,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
pendingPermission: null,
pendingComputerUsePermission: null,
apiRetry: null,
streamingFallback: null,
elapsedTimer: null,
},
},
@ -1229,6 +1235,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
elapsedTimer: null,
statusVerb: '',
apiRetry: null,
streamingFallback: null,
})),
}
})
@ -1397,6 +1404,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
streamingText: '',
chatState: 'idle',
apiRetry: null,
streamingFallback: null,
queuedUserMessages: [],
})) }))
},
@ -1445,7 +1453,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
? msg.verb
: '',
...(msg.state === 'idle' ? { activeThinkingId: null } : {}),
...(msg.state === 'idle' ? { apiRetry: null } : {}),
...(msg.state === 'idle' ? { apiRetry: null, streamingFallback: null } : {}),
...(nextMessages !== session.messages ? { messages: nextMessages } : {}),
...(shouldFlush ? {
streamingText: '',
@ -1491,6 +1499,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
chatState: 'streaming',
activeThinkingId: null,
apiRetry: null,
streamingFallback: null,
}))
} else if (msg.blockType === 'tool_use') {
clearPendingToolInputDelta(sessionId)
@ -1519,6 +1528,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
chatState: 'tool_executing',
activeThinkingId: null,
apiRetry: null,
streamingFallback: null,
}))
}
break
@ -1546,6 +1556,23 @@ export const useChatStore = create<ChatStore>((set, get) => ({
break
}
case 'streaming_fallback': {
// 进入非流式降级阶段:旧的重试横幅(针对失败的流式请求)已过时,
// 清掉换成降级提示;后续非流式重试到来的 api_retry 会重新接管显示。
update((session) => ({
streamingFallback: {
cause: msg.cause,
receivedAt: Date.now(),
},
apiRetry: null,
chatState: session.chatState === 'idle' ? 'thinking' : session.chatState,
activeThinkingId: null,
statusVerb: '',
}))
useTabStore.getState().updateTabStatus(sessionId, 'running')
break
}
case 'content_delta':
if (msg.text !== undefined) {
if (!get().sessions[sessionId]) break
@ -1722,6 +1749,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
chatState: 'permission_pending',
activeThinkingId: null,
apiRetry: null,
streamingFallback: null,
messages:
msg.toolName === 'AskUserQuestion'
? s.messages
@ -1756,6 +1784,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
chatState: 'permission_pending',
activeThinkingId: null,
apiRetry: null,
streamingFallback: null,
}))
break
@ -1783,6 +1812,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
pendingComputerUsePermission: null,
elapsedTimer: null,
apiRetry: null,
streamingFallback: null,
}))
useTabStore.getState().updateTabStatus(sessionId, 'idle')
const appendedCompletionMessage = completionMessages !== session.messages
@ -1848,6 +1878,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
pendingPermission: null,
pendingComputerUsePermission: null,
apiRetry: null,
streamingFallback: null,
}
})
useTabStore.getState().updateTabStatus(sessionId, 'error')
@ -1911,6 +1942,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
elapsedSeconds: 0,
statusVerb: '',
apiRetry: null,
streamingFallback: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
slashCommands: [],

View File

@ -108,6 +108,8 @@ export type ServerMessage =
errorType?: string
errorMessage?: string
}
// 流式请求失败、CLI 已降级为非流式重试:完整响应一次性返回,期间无增量输出。
| { type: 'streaming_fallback'; cause: StreamingFallbackCause }
| { type: 'error'; message: string; code: string; retryable?: boolean; businessErrorCode?: string }
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
| { type: 'pong' }
@ -136,6 +138,14 @@ export type ApiRetryState = {
receivedAt: number
}
export type StreamingFallbackCause = 'watchdog' | 'stream_error' | '404_stream_creation' | 'unknown'
// 活动回合状态(与 apiRetry 同生命周期),不进消息历史。
export type StreamingFallbackState = {
cause: StreamingFallbackCause
receivedAt: number
}
export type TeamMemberStatus = {
agentId: string
role: string

View File

@ -985,6 +985,15 @@ export class QueryEngine {
uuid: message.uuid,
}
}
if (message.subtype === 'streaming_fallback') {
yield {
type: 'system',
subtype: 'streaming_fallback' as const,
cause: message.cause,
session_id: getSessionId(),
uuid: message.uuid,
}
}
// Don't yield other system messages in headless mode
break
}

View File

@ -1587,6 +1587,20 @@ export const SDKAPIRetryMessageSchema = lazySchema(() =>
),
)
export const SDKStreamingFallbackMessageSchema = lazySchema(() =>
z
.object({
type: z.literal('system'),
subtype: z.literal('streaming_fallback'),
cause: z.enum(['watchdog', 'stream_error', '404_stream_creation']),
uuid: UUIDPlaceholder(),
session_id: z.string(),
})
.describe(
'Emitted when a streaming request fails and the query falls back to a non-streaming request. The fallback response arrives in one piece after a potentially long wait with no incremental output.',
),
)
export const SDKLocalCommandOutputMessageSchema = lazySchema(() =>
z
.object({
@ -1863,6 +1877,7 @@ export const SDKMessageSchema = lazySchema(() =>
SDKCompactBoundaryMessageSchema(),
SDKStatusMessageSchema(),
SDKAPIRetryMessageSchema(),
SDKStreamingFallbackMessageSchema(),
SDKLocalCommandOutputMessageSchema(),
SDKHookStartedMessageSchema(),
SDKHookProgressMessageSchema(),

View File

@ -270,6 +270,45 @@ describe('WebSocket API retry events', () => {
},
])
})
it('forwards CLI streaming_fallback messages with a recognized cause', () => {
// 形状对齐 QueryEngine 的 SDK 输出:{type:'system', subtype:'streaming_fallback', cause, ...}
expect(translateCliMessage({
type: 'system',
subtype: 'streaming_fallback',
cause: 'watchdog',
session_id: 'session-1',
uuid: 'uuid-1',
}, 'session-1')).toEqual([
{ type: 'streaming_fallback', cause: 'watchdog' },
])
expect(translateCliMessage({
type: 'system',
subtype: 'streaming_fallback',
cause: '404_stream_creation',
}, 'session-1')).toEqual([
{ type: 'streaming_fallback', cause: '404_stream_creation' },
])
})
it('normalizes unrecognized streaming_fallback causes to unknown instead of dropping the event', () => {
// 新 CLI + 旧枚举:提示本身比成因重要,不能丢消息。
expect(translateCliMessage({
type: 'system',
subtype: 'streaming_fallback',
cause: 'some_future_cause',
}, 'session-1')).toEqual([
{ type: 'streaming_fallback', cause: 'unknown' },
])
expect(translateCliMessage({
type: 'system',
subtype: 'streaming_fallback',
}, 'session-1')).toEqual([
{ type: 'streaming_fallback', cause: 'unknown' },
])
})
})
describe('WebSocket background task events', () => {

View File

@ -79,6 +79,9 @@ export type ServerMessage =
errorType?: string
errorMessage?: string
}
// 流式请求失败、CLI 已降级为非流式重试。非流式响应要等完整生成才返回,
// 期间没有任何增量输出,前端据此显示"慢速模式"轻提示而不是裸转圈。
| { type: 'streaming_fallback'; cause: StreamingFallbackCause }
| { type: 'error'; message: string; code: string; retryable?: boolean; businessErrorCode?: string }
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
| { type: 'pong' }
@ -97,6 +100,10 @@ export type TokenUsage = {
export type ChatState = 'idle' | 'thinking' | 'compacting' | 'tool_executing' | 'streaming' | 'permission_pending'
// 与 CLI 的 streaming_fallback cause 对齐unknown 兜底未来新增的 cause 值,
// 避免新 CLI + 旧 server 组合下丢消息。
export type StreamingFallbackCause = 'watchdog' | 'stream_error' | '404_stream_creation' | 'unknown'
export type TeamMemberStatus = {
agentId: string
role: string

View File

@ -7,7 +7,7 @@
*/
import type { ServerWebSocket } from 'bun'
import type { ClientMessage, ServerMessage } from './events.js'
import type { ClientMessage, ServerMessage, StreamingFallbackCause } from './events.js'
import * as os from 'node:os'
import {
ConversationStartupError,
@ -1692,6 +1692,9 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
const apiRetryMessage = toApiRetryServerMessage(cliMsg)
return apiRetryMessage ? [apiRetryMessage] : []
}
if (subtype === 'streaming_fallback') {
return [toStreamingFallbackServerMessage(cliMsg)]
}
if (subtype === 'init') {
// CLI 初始化完成 — 缓存 slash commands 并发送模型信息
// NOTE: Do NOT send status:idle here — the CLI init fires while
@ -1902,6 +1905,21 @@ function toApiRetryServerMessage(cliMsg: any): ServerMessage | null {
}
}
const STREAMING_FALLBACK_CAUSES: ReadonlySet<StreamingFallbackCause> = new Set([
'watchdog',
'stream_error',
'404_stream_creation',
])
function toStreamingFallbackServerMessage(cliMsg: any): ServerMessage {
// 未识别的 cause 兜底为 unknown 而不是丢消息:提示本身比成因重要。
const cause: StreamingFallbackCause =
typeof cliMsg.cause === 'string' && STREAMING_FALLBACK_CAUSES.has(cliMsg.cause as StreamingFallbackCause)
? (cliMsg.cause as StreamingFallbackCause)
: 'unknown'
return { type: 'streaming_fallback', cause }
}
function sendMessage(ws: ServerWebSocket<WebSocketData>, message: ServerMessage) {
ws.send(JSON.stringify(message))
}

View File

@ -47,6 +47,7 @@ import type {
Message,
StreamEvent,
SystemAPIErrorMessage,
SystemStreamingFallbackMessage,
UserMessage,
} from "../../types/message.js";
import {
@ -74,6 +75,7 @@ import { computeFingerprintFromMessages } from "../../utils/fingerprint.js";
import { captureAPIRequest, logError } from "../../utils/log.js";
import {
createAssistantAPIErrorMessage,
createSystemStreamingFallbackMessage,
createUserMessage,
ensureToolResultPairing,
normalizeContentFromAPI,
@ -767,7 +769,7 @@ export async function* queryModelWithStreaming({
signal: AbortSignal;
options: Options;
}): AsyncGenerator<
StreamEvent | AssistantMessage | SystemAPIErrorMessage,
StreamEvent | AssistantMessage | SystemAPIErrorMessage | SystemStreamingFallbackMessage,
void
> {
return yield* withStreamingVCR(messages, async function* () {
@ -1025,7 +1027,7 @@ async function* queryModel(
signal: AbortSignal,
options: Options,
): AsyncGenerator<
StreamEvent | AssistantMessage | SystemAPIErrorMessage,
StreamEvent | AssistantMessage | SystemAPIErrorMessage | SystemStreamingFallbackMessage,
void
> {
if (getAPIProvider() === "azureOpenAI") {
@ -2574,6 +2576,13 @@ async function* queryModel(
if (options.onStreamingFallback) {
options.onStreamingFallback();
}
// Surface the mode switch to consumers (SDK stream → desktop status
// bar): the non-streaming response arrives in one piece after a
// potentially long silent wait, so without this signal the UI shows a
// bare spinner the whole time.
yield createSystemStreamingFallbackMessage(
streamIdleAborted ? "watchdog" : "stream_error",
);
logEvent("tengu_streaming_fallback_to_non_streaming", {
model:
@ -2695,6 +2704,7 @@ async function* queryModel(
if (options.onStreamingFallback) {
options.onStreamingFallback();
}
yield createSystemStreamingFallbackMessage("404_stream_creation");
logEvent("tengu_streaming_fallback_to_non_streaming", {
model:

View File

@ -71,6 +71,7 @@ import type {
SystemPermissionRetryMessage,
SystemScheduledTaskFireMessage,
SystemStopHookSummaryMessage,
SystemStreamingFallbackMessage,
SystemTurnDurationMessage,
TombstoneMessage,
ToolUseSummaryMessage,
@ -4718,6 +4719,31 @@ export function createSystemAPIErrorMessage(
}
}
export type StreamingFallbackCause =
| 'watchdog'
| 'stream_error'
| '404_stream_creation'
/**
* Marks the switch from a failed streaming request to the non-streaming
* fallback. The fallback response arrives in one piece after a potentially
* long wait with zero incremental output, so UIs surface this as a lightweight
* active-turn status (level info an expected state, not an error).
*/
export function createSystemStreamingFallbackMessage(
cause: StreamingFallbackCause,
): SystemStreamingFallbackMessage {
return {
type: 'system',
subtype: 'streaming_fallback',
level: 'info',
content: `Streaming request failed (${cause.replace(/_/g, ' ')}); retrying in non-streaming mode`,
cause,
timestamp: new Date().toISOString(),
uuid: randomUUID(),
}
}
/**
* Checks if a message is a compact boundary marker
*/