diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 9ede1565e0..cefe798165 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -643,7 +643,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> : aiChatManager.currentReasoningActive && !aiChatManager.currentReply && !aiChatManager.currentReasoning - ? 'Thinking' + ? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking') : undefined} /> {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 5384c7dc0b..30802b446c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -154,6 +154,21 @@ const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode' const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode' const WEB_SEARCH_ERROR_HINT = 'Web search is unavailable for this provider/model/key. Disable web search in workspace settings and try again.' +// The full explanation is shown once per browser; afterwards the hidden +// thinking is only hinted at discreetly in the typing indicator. +const REASONING_SUMMARY_WARNED_STORAGE_KEY = 'ai-chat-reasoning-summary-unverified-warned' + +function providerDisplayName(provider: string): string { + return provider === 'azure_openai' ? 'Azure OpenAI' : 'OpenAI' +} + +function reasoningSummaryUnavailableMessage(provider: string): string { + const verifyHint = + provider === 'azure_openai' + ? 'To display it, verify your organization with your provider, then reload this page.' + : 'To display it, verify your organization in the OpenAI platform settings (Settings > General), then reload this page.' + return `This model is reasoning, but your ${providerDisplayName(provider)} organization is not verified to generate reasoning summaries, so its thinking stays hidden. ${verifyHint}` +} export enum AIMode { SCRIPT = 'script', @@ -326,6 +341,30 @@ export class AIChatManager { currentReply = $state('') currentReasoning = $state('') currentReasoningActive = $state(false) + // The provider reasons but refuses to stream summaries (unverified OpenAI + // organization) — drives the discreet "Thinking (hidden)" indicator. Keyed + // by workspace:provider like the chat-loop fallback cache, so the hint never + // carries over to a provider or workspace whose summaries work. A list, not + // a scalar: several workspace/provider pairs can be unavailable at once, and + // the chat loop only notifies on first detection per pair. + private reasoningSummaryUnavailableFor = $state([]) + + private reasoningSummaryKey(provider: string): string { + return `${this.operatingWorkspace ?? ''}:${provider}` + } + + /** Label for the live "Thinking" indicator when thinking stays hidden for + * the current workspace/provider, undefined otherwise. */ + get reasoningHiddenIndicatorLabel(): string | undefined { + if (this.reasoningSummaryUnavailableFor.length === 0) { + return undefined + } + const provider = getCurrentModel().provider + if (!this.reasoningSummaryUnavailableFor.includes(this.reasoningSummaryKey(provider))) { + return undefined + } + return `Thinking (hidden, ${providerDisplayName(provider)} org not verified)` + } // Smooths the provider's bursty delivery into continuous typing by revealing // buffered text a slice per frame. The reply and the reasoning/thinking stream // each get their own reveal (independent buffers, both append to their own @@ -1716,6 +1755,18 @@ export class AIChatManager { } } + private notifyReasoningSummaryUnavailable = () => { + const provider = getCurrentModel().provider + const key = this.reasoningSummaryKey(provider) + if (!this.reasoningSummaryUnavailableFor.includes(key)) { + this.reasoningSummaryUnavailableFor = [...this.reasoningSummaryUnavailableFor, key] + } + if (getLocalSetting(REASONING_SUMMARY_WARNED_STORAGE_KEY) !== 'true') { + storeLocalSetting(REASONING_SUMMARY_WARNED_STORAGE_KEY, 'true') + sendUserToast(reasoningSummaryUnavailableMessage(provider), 'warning', [], undefined, 10000) + } + } + private chatRequest = async ({ messages, abortController, @@ -1735,6 +1786,7 @@ export class AIChatManager { systemMessage?: ChatCompletionSystemMessageParam onWebSearchUnavailable?: () => void }) => { + const onReasoningSummaryUnavailable = () => this.notifyReasoningSummaryUnavailable() try { // Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider // on each iteration. This is critical for changeModeTool (Navigator → Script/Flow) @@ -1784,6 +1836,7 @@ export class AIChatManager { this.skipResponsesApi = true }, onWebSearchUnavailable, + onReasoningSummaryUnavailable, getPendingUserMessage: () => { const pendingPrompt = this.pendingPrompt if (!pendingPrompt) return undefined diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts index 93acdfbd86..b9581e3d74 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts @@ -12,7 +12,8 @@ const mocks = vi.hoisted(() => ({ parseOpenAIResponsesCompletion: vi.fn(), getAnthropicCompletion: vi.fn(), parseAnthropicCompletion: vi.fn(), - resolveRequestReasoning: vi.fn() + resolveRequestReasoning: vi.fn(), + resolveEffectiveReasoning: vi.fn() })) vi.mock('../lib', () => ({ @@ -22,7 +23,8 @@ vi.mock('../lib', () => ({ })) vi.mock('../reasoningRegistry', () => ({ - resolveRequestReasoning: mocks.resolveRequestReasoning + resolveRequestReasoning: mocks.resolveRequestReasoning, + resolveEffectiveReasoning: mocks.resolveEffectiveReasoning })) vi.mock('./openai-responses', () => ({ @@ -50,12 +52,14 @@ function createConfig({ workspace, modelProvider = { provider: 'openai', model: 'gpt-4.1' }, callbacks = createCallbacks(), - onWebSearchUnavailable + onWebSearchUnavailable, + onReasoningSummaryUnavailable }: { workspace: string modelProvider?: ReasoningProviderModel callbacks?: ChatLoopConfig['callbacks'] onWebSearchUnavailable?: ChatLoopConfig['onWebSearchUnavailable'] + onReasoningSummaryUnavailable?: ChatLoopConfig['onReasoningSummaryUnavailable'] }): ChatLoopConfig { const messages: ChatCompletionMessageParam[] = [{ role: 'user', content: 'search this' }] @@ -73,7 +77,8 @@ function createConfig({ }, workspace, maxIterations: 1, - onWebSearchUnavailable + onWebSearchUnavailable, + onReasoningSummaryUnavailable } } @@ -291,6 +296,133 @@ describe('runChatLoop web search fallback', () => { }) }) +describe('runChatLoop reasoning summary fallback', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.providerSupportsWebSearch.mockReturnValue(false) + mocks.resolveRequestReasoning.mockReturnValue('high') + mocks.resolveEffectiveReasoning.mockReturnValue('high') + mocks.parseOpenAIResponsesCompletion.mockResolvedValue({ + shouldContinue: false, + tokenUsage + }) + }) + + it('retries once without the summary on the unverified-org error and caches per workspace/provider', async () => { + const onReasoningSummaryUnavailable = vi.fn() + const workspace = `workspace-${randomUUID()}` + const modelProvider: ReasoningProviderModel = { provider: 'openai', model: 'gpt-5.1' } + + mocks.getOpenAIResponsesCompletion + .mockRejectedValueOnce( + Object.assign( + new Error('Your organization must be verified to generate reasoning summaries.'), + { + status: 400, + param: 'reasoning.summary', + code: 'unsupported_value', + error: { type: 'invalid_request_error' } + } + ) + ) + .mockResolvedValue({}) + + await runChatLoop(createConfig({ workspace, modelProvider, onReasoningSummaryUnavailable })) + + expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2) + expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual( + expect.objectContaining({ reasoningSummary: true }) + ) + expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual( + expect.objectContaining({ reasoningSummary: false }) + ) + expect(onReasoningSummaryUnavailable).toHaveBeenCalledTimes(1) + expect(mocks.getCompletion).not.toHaveBeenCalled() + + // Cached: a later run in the same workspace skips the summary outright, + // but a different model on the same provider shares the cache entry. + await runChatLoop( + createConfig({ + workspace, + modelProvider: { provider: 'openai', model: 'o3' } + }) + ) + + expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(3) + expect(mocks.getOpenAIResponsesCompletion.mock.calls[2][3]).toEqual( + expect.objectContaining({ reasoningSummary: false }) + ) + }) + + it('composes with the web-search fallback when the errors arrive web-search first', async () => { + mocks.providerSupportsWebSearch.mockReturnValue(true) + const onReasoningSummaryUnavailable = vi.fn() + const onWebSearchUnavailable = vi.fn() + const workspace = `workspace-${randomUUID()}` + const modelProvider: ReasoningProviderModel = { provider: 'openai', model: 'gpt-5.1' } + + mocks.getOpenAIResponsesCompletion + .mockRejectedValueOnce( + Object.assign(new Error("Hosted tool 'web_search' is not supported with this model"), { + status: 400, + error: { type: 'invalid_request_error' } + }) + ) + .mockRejectedValueOnce( + Object.assign( + new Error('Your organization must be verified to generate reasoning summaries.'), + { + status: 400, + param: 'reasoning.summary', + code: 'unsupported_value', + error: { type: 'invalid_request_error' } + } + ) + ) + .mockResolvedValue({}) + + await runChatLoop( + createConfig({ + workspace, + modelProvider, + onReasoningSummaryUnavailable, + onWebSearchUnavailable + }) + ) + + expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(3) + expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual( + expect.objectContaining({ webSearch: true, reasoningSummary: true }) + ) + expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual( + expect.objectContaining({ webSearch: false, reasoningSummary: true }) + ) + expect(mocks.getOpenAIResponsesCompletion.mock.calls[2][3]).toEqual( + expect.objectContaining({ webSearch: false, reasoningSummary: false }) + ) + expect(onWebSearchUnavailable).toHaveBeenCalledTimes(1) + expect(onReasoningSummaryUnavailable).toHaveBeenCalledTimes(1) + expect(mocks.getCompletion).not.toHaveBeenCalled() + }) + + it('does not request a summary when reasoning is explicitly off via a disable token', async () => { + // gpt-5.1+ reasoning is turned off with the explicit 'none' effort on the + // wire, while the effective reasoning resolves to undefined. + mocks.resolveRequestReasoning.mockReturnValue('none') + mocks.resolveEffectiveReasoning.mockReturnValue(undefined) + mocks.getOpenAIResponsesCompletion.mockResolvedValue({}) + const workspace = `workspace-${randomUUID()}` + + await runChatLoop( + createConfig({ workspace, modelProvider: { provider: 'openai', model: 'gpt-5.1' } }) + ) + + expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual( + expect.objectContaining({ reasoningEffort: 'none', reasoningSummary: false }) + ) + }) +}) + describe('runChatLoop lastIterationUsage', () => { beforeEach(() => { vi.resetAllMocks() diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index edf5be4d07..cd1421dd0b 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -6,7 +6,11 @@ import type { ChatCompletionUserMessageParam } from 'openai/resources/chat/completions.mjs' import { getCompletion, parseOpenAICompletion, providerSupportsWebSearch } from '../lib' -import { resolveRequestReasoning, type ReasoningProviderModel } from '../reasoningRegistry' +import { + resolveEffectiveReasoning, + resolveRequestReasoning, + type ReasoningProviderModel +} from '../reasoningRegistry' import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' import { usesAnthropicMessagesApi } from '../modelConfig' import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses' @@ -48,6 +52,12 @@ export interface ChatLoopConfig { skipResponsesApi?: boolean onSkipResponsesApi?: () => void onWebSearchUnavailable?: () => void + /** + * Called when the provider refuses to generate reasoning summaries (OpenAI + * gates them behind organization verification). The request is retried + * without summaries, so reasoning happens but stays hidden. + */ + onReasoningSummaryUnavailable?: () => void /** Return a pending user message to inject between iterations, or undefined. */ getPendingUserMessage?: () => ChatCompletionUserMessageParam | undefined /** @@ -107,10 +117,24 @@ export function truncateToToolPairedPrefix( const unsupportedWebSearchCache = new Set() const WEB_SEARCH_UNAVAILABLE_STATUS_CODES = new Set([400, 403, 404]) +// Reasoning-summary availability is an org-level property of the provider +// credentials (OpenAI organization verification), not of the model — key by +// workspace + provider. In-memory on purpose: a page reload re-probes, so a +// freshly verified organization starts getting summaries again. +const unsupportedReasoningSummaryCache = new Set() +const REASONING_SUMMARY_UNAVAILABLE_STATUS_CODES = new Set([400, 403]) + function getWebSearchCacheKey(workspace: string, modelProvider: ReasoningProviderModel): string { return [workspace, modelProvider.provider, modelProvider.model].join(':') } +function getReasoningSummaryCacheKey( + workspace: string, + modelProvider: ReasoningProviderModel +): string { + return [workspace, modelProvider.provider].join(':') +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } @@ -193,6 +217,44 @@ function shouldRetryWithoutWebSearch(err: unknown): boolean { return status === undefined || WEB_SEARCH_UNAVAILABLE_STATUS_CODES.has(status) } +function getErrorParam(err: unknown): string | undefined { + if (!isRecord(err)) { + return undefined + } + const candidates = [err.param] + if (isRecord(err.error)) { + candidates.push(err.error.param) + } + return candidates.find((param): param is string => typeof param === 'string') +} + +// Unverified OpenAI organizations get a 400 on the reasoning.summary param +// ("Your organization must be verified to generate reasoning summaries"). +function shouldRetryWithoutReasoningSummary(err: unknown): boolean { + const status = getErrorStatus(err) + if (status !== undefined && !REASONING_SUMMARY_UNAVAILABLE_STATUS_CODES.has(status)) { + return false + } + if (getErrorParam(err) === 'reasoning.summary') { + return true + } + const message = getErrorText(err).toLowerCase() + return ( + message.includes('reasoning.summary') || + /verified to (?:generate|stream) reasoning summar/.test(message) + ) +} + +function markReasoningSummaryUnsupported( + cacheKey: string, + err: unknown, + onReasoningSummaryUnavailable?: () => void +) { + unsupportedReasoningSummaryCache.add(cacheKey) + console.warn('Reasoning summaries unavailable; retrying without them:', err) + onReasoningSummaryUnavailable?.() +} + function markWebSearchUnsupported( cacheKey: string, err: unknown, @@ -212,6 +274,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise => { const completion = await getOpenAIResponsesCompletion( messageParams, @@ -284,7 +355,8 @@ export async function runChatLoop(config: ChatLoopConfig): Promise { + reasoningSummaryParts++ + if (reasoningSummaryParts > 1) { + callbacks.onReasoningDelta?.('\n\n') + } + }) + runner.on('response.reasoning_summary_text.delta', (event) => { + callbacks.onReasoningDelta?.(event.delta) + }) + // Handle new output items (including function calls) runner.on('response.output_item.added', (event) => { const item = event.item