Skip to content
23 changes: 22 additions & 1 deletion src/acp/built-in-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { Model } from '@/types'
import {
createBuiltInAdapter,
harnessSignature,
withThinkingSessionOverride,
type BuiltInAdapterOptions,
type ResolvedPiModel,
} from './built-in-adapter'
Expand Down Expand Up @@ -94,7 +95,7 @@ describe('harnessSignature', () => {

it('changes when the openai-compat context window changes', () => {
expect(harnessSignature(openaiCompat(), 'sys')).not.toBe(
harnessSignature(openaiCompat({ contextWindow: 200000 }), 'sys'),
harnessSignature(openaiCompat({ contextWindow: 200_000 }), 'sys'),
)
})

Expand All @@ -103,6 +104,26 @@ describe('harnessSignature', () => {
})
})

describe('withThinkingSessionOverride', () => {
it('leaves resolved config alone when thinking is on or model is not thinking-capable', () => {
const base = openaiCompat({ reasoning: true })
expect(withThinkingSessionOverride(base, { startWithReasoning: 1 }, true)).toBe(base)
expect(withThinkingSessionOverride(base, { startWithReasoning: 1 }, undefined)).toBe(base)
expect(withThinkingSessionOverride(base, { startWithReasoning: 0 }, false)).toBe(base)
})

it('forces thinking off for anthropic and openai-compat when the chip is off', () => {
expect(withThinkingSessionOverride(anthropic(), { startWithReasoning: 1 }, false)).toEqual({
...anthropic(),
thinkingLevel: 'off',
})
expect(withThinkingSessionOverride(openaiCompat({ reasoning: true }), { startWithReasoning: 1 }, false)).toEqual({
descriptor: { ...openaiCompat({ reasoning: true }).descriptor, reasoning: false },
thinkingLevel: 'off',
})
})
})

describe('createBuiltInAdapter persistent harness', () => {
it('refreshes prompt/tools per send and rebuilds only for regenerate revision', async () => {
const model = {
Expand Down
29 changes: 27 additions & 2 deletions src/acp/built-in-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
resolveOpenAiCompatConnection,
type PreparedAiRequestConfig,
} from '@/ai/fetch'
import { isThinkingDisabledForSend } from '@/ai/thinking-session'
import type { Agent, AgentAdapter, AgentAdapterContext } from '@/types/acp'
import type { Model, ModelProfile, ThunderboltUIMessage } from '@/types'
import { extractLastUserText, resolveSkillTokenInstructions } from '@/skills/resolve-skill-system-messages'
Expand Down Expand Up @@ -271,6 +272,28 @@ const resolvePiModel = (
}
}

/**
* Honors the composer Thinking chip when the selected model can reason: chip
* off forces `thinkingLevel: 'off'` and clears openai-compat `reasoning`.
*/
export const withThinkingSessionOverride = (
resolved: ResolvedPiModel,
model: Pick<Model, 'startWithReasoning'>,
thinkingEnabled: boolean | undefined,
): ResolvedPiModel => {
if (!isThinkingDisabledForSend(model, thinkingEnabled)) {
return resolved
}
if (resolved.descriptor.kind === 'openai-compat') {
return {
...resolved,
descriptor: { ...resolved.descriptor, reasoning: false },
thinkingLevel: 'off',
}
}
return { ...resolved, thinkingLevel: 'off' }
}

/** Compact non-cryptographic fingerprint (FNV-1a) of a secret, so the harness
* signature can detect an api-key change without embedding the plaintext key. */
const hashSecret = (value: string): string => {
Expand Down Expand Up @@ -434,10 +457,11 @@ const fetchViaHarness = async (
reconnectClient: context.reconnectClient,
httpClient: context.httpClient,
})
const resolved = resolvePiModel(agentCore, context, config.profile)
if (!resolved) {
const resolvedBase = resolvePiModel(agentCore, context, config.profile)
if (!resolvedBase) {
return fallback()
}
const resolved = withThinkingSessionOverride(resolvedBase, context.selectedModel, context.thinkingEnabled)

const messages = parseMessages(init)
const instructionBySlug = new Map(config.skills.map(({ name, instruction }) => [name, instruction]))
Expand Down Expand Up @@ -507,6 +531,7 @@ export const createBuiltInAdapter = (agent: Agent, options: BuiltInAdapterOption
reconnectClient: context.reconnectClient,
httpClient: context.httpClient,
getProxyFetch: context.getProxyFetch,
thinkingEnabled: context.thinkingEnabled,
})

// Route tool-capable Pi-serviceable models (anthropic + the OpenAI-wire family)
Expand Down
38 changes: 30 additions & 8 deletions src/ai/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import {
isFinalStep,
shouldRetry,
} from '@/ai/step-logic'
import {
isThinkingDisabledForSend,
openaiCompatThinkingProviderOptions,
withThinkingDisabledForSend,
} from '@/ai/thinking-session'
import { getAllSkills, getIntegrationStatus, getModel, getModelProfile, getSettings } from '@/dal'
import { getMessage } from '@/dal/chat-messages'
import { isWidgetSkillId } from '@/defaults/skills'
Expand Down Expand Up @@ -196,6 +201,9 @@ type AiFetchStreamingResponseOptions = {
* `ProxyFetchProvider` (`useProxyFetchGetter()`); non-React callers (eval
* scripts) build a `proxyFetch` directly and wrap it in `() => fn`. */
getProxyFetch: () => FetchFn
/** Per-conversation Thinking chip override. `false` disables thinking for
* models that advertise it; omit/`true` keeps the model default. */
thinkingEnabled?: boolean
}

/**
Expand Down Expand Up @@ -678,6 +686,7 @@ export const aiFetchStreamingResponse = async ({
reconnectClient,
httpClient,
getProxyFetch,
thinkingEnabled,
}: AiFetchStreamingResponseOptions) => {
const options = init as RequestInit & { body: string }
const body = JSON.parse(options.body)
Expand All @@ -689,13 +698,15 @@ export const aiFetchStreamingResponse = async ({
// reach this function the user turn is already persisted.

const db = getDb()
const { model, profile, supportsTools, sourceCollector, toolset, skills, mcpToolsMetadata, systemPrompt } =
await prepareAiRequestConfig({
modelId,
mcpClients,
reconnectClient,
httpClient,
})
const prepared = await prepareAiRequestConfig({
modelId,
mcpClients,
reconnectClient,
httpClient,
})
const thinkingDisabled = isThinkingDisabledForSend(prepared.model, thinkingEnabled)
const model = withThinkingDisabledForSend(prepared.model, thinkingEnabled)
const { profile, supportsTools, sourceCollector, toolset, skills, mcpToolsMetadata, systemPrompt } = prepared
if (!supportsTools) {
console.log('Model does not support tools, skipping tool setup')
}
Expand All @@ -709,9 +720,18 @@ export const aiFetchStreamingResponse = async ({
providerId: model.provider,
model: baseModel,
middleware: [
// Extract `<think>...</think>` from content when present. Do NOT pass
// `startWithReasoning` from `model.startWithReasoning` — that DB flag
// means “model can think / show Thinking chip”, while the middleware
// option means “treat all content as reasoning until </think>”.
// Ollama thinking models emit a native `reasoning` field and plain
// answer `content` with no closing tag; wiring the chip flag through
// here classifies the answer as reasoning forever and never emits
// reasoning-end (stuck spinner with the reply stuck in the muted
// Thinking pane).
extractReasoningMiddleware({
tagName: 'think',
startWithReasoning: Boolean(model.startWithReasoning),
startWithReasoning: false,
}),
],
})
Expand All @@ -733,6 +753,8 @@ export const aiFetchStreamingResponse = async ({
// rather than relying solely on the profile — custom OpenAI models may not have a profile.
...(model.vendor === 'openai' && { systemMessageMode: 'developer' as const }),
...profile?.providerOptions,
// Thinking chip off → ask OpenAI-compat endpoints (incl. Ollama) for no reasoning.
...openaiCompatThinkingProviderOptions(thinkingDisabled),
}
const providerOptions = Object.keys(rawOptions).length > 0 ? { [providerOptionsKey]: rawOptions } : undefined

Expand Down
45 changes: 45 additions & 0 deletions src/ai/thinking-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { describe, expect, it } from 'bun:test'
import {
isThinkingDisabledForSend,
openaiCompatThinkingProviderOptions,
withThinkingDisabledForSend,
} from './thinking-session'

describe('isThinkingDisabledForSend', () => {
it('is true only for thinking-capable models with the chip off', () => {
expect(isThinkingDisabledForSend({ startWithReasoning: 1 }, false)).toBe(true)
expect(isThinkingDisabledForSend({ startWithReasoning: 1 }, true)).toBe(false)
expect(isThinkingDisabledForSend({ startWithReasoning: 1 }, undefined)).toBe(false)
expect(isThinkingDisabledForSend({ startWithReasoning: 0 }, false)).toBe(false)
})
})

describe('withThinkingDisabledForSend', () => {
it('returns the same model reference when thinking stays on', () => {
const model = { startWithReasoning: 1 as const, name: 'qwen' }
expect(withThinkingDisabledForSend(model, true)).toBe(model)
expect(withThinkingDisabledForSend(model, undefined)).toBe(model)
})

it('returns a copy with startWithReasoning cleared when the chip is off', () => {
const model = { startWithReasoning: 1, name: 'qwen' }
const next = withThinkingDisabledForSend(model, false)
expect(next).not.toBe(model)
expect(next).toEqual({ startWithReasoning: 0, name: 'qwen' })
expect(model.startWithReasoning).toBe(1)
})
})

describe('openaiCompatThinkingProviderOptions', () => {
it('requests reasoningEffort none when the chip disabled thinking', () => {
expect(openaiCompatThinkingProviderOptions(true)).toEqual({ reasoningEffort: 'none' })
})

it('adds no provider options when thinking stays on', () => {
expect(openaiCompatThinkingProviderOptions(false)).toEqual({})
})
})
37 changes: 37 additions & 0 deletions src/ai/thinking-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import type { Model } from '@/types'

/**
* True when the selected model advertises thinking and the conversation chip
* has turned it off for this send.
*/
export const isThinkingDisabledForSend = (
model: Pick<Model, 'startWithReasoning'>,
thinkingEnabled: boolean | undefined,
): boolean => model.startWithReasoning === 1 && thinkingEnabled === false

/**
* Returns a model view with thinking forced off for this send when the chip
* disabled it. Does not mutate the stored model row (capability stays on so
* the composer chip remains visible).
*/
export const withThinkingDisabledForSend = <T extends Pick<Model, 'startWithReasoning'>>(
model: T,
thinkingEnabled: boolean | undefined,
): T => {
if (!isThinkingDisabledForSend(model, thinkingEnabled)) {
return model
}
return { ...model, startWithReasoning: 0 }
}

/**
* OpenAI-compat provider options for the Thinking chip. Chip off asks the
* endpoint (incl. Ollama) for no reasoning effort on this send.
*/
export const openaiCompatThinkingProviderOptions = (
thinkingDisabled: boolean,
): { reasoningEffort: 'none' } | Record<string, never> => (thinkingDisabled ? { reasoningEffort: 'none' } : {})
6 changes: 5 additions & 1 deletion src/chats/chat-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export const createAgentRoutingFetch = (
throw new Error('No session found')
}

const { chatThread, selectedAgent, selectedModel } = session
const { chatThread, selectedAgent, selectedModel, thinkingEnabled } = session

// Save the user message before invoking the adapter. This serves three
// purposes that previously only the built-in pipeline got for free:
Expand Down Expand Up @@ -226,12 +226,16 @@ export const createAgentRoutingFetch = (
? undefined
: (request: RequestPermissionRequest) => requestPermissionViaStore(id, selectedAgent.id, request)

// Per-conversation Thinking chip is applied in the legacy/Pi request
// path via `thinkingEnabled` — keep the stored model row as the source
// of capability (so the chip stays visible when toggled off).
return adapter.fetch(init, {
threadId: id,
chatThread,
acpSessionId: chatThread?.acpSessionId ?? null,
saveMessages,
selectedModel,
thinkingEnabled,
mcpClients,
reconnectClient,
httpClient,
Expand Down
7 changes: 6 additions & 1 deletion src/chats/chat-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export type ChatSession = {
retriesExhausted: boolean
selectedAgent: Agent
selectedModel: Model
/**
* Per-conversation override for models that advertise thinking
* (`startWithReasoning === 1`). `undefined` means “use the model default” (on).
*/
thinkingEnabled?: boolean
triggerData: AutomationRun | null
}

Expand Down Expand Up @@ -249,7 +254,7 @@ export const useChatStore = create<ChatStore>()((set, get) => ({
}

const nextSessions = new Map(sessions)
nextSessions.set(id, { ...session, selectedModel: model })
nextSessions.set(id, { ...session, selectedModel: model, thinkingEnabled: undefined })

set({ sessions: nextSessions })

Expand Down
20 changes: 17 additions & 3 deletions src/components/chat/chat-prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { isAgentAvailable as isAgentAvailable_default } from '@/acp/agent-availability'
import { preloadAgentConnection } from '@/acp/adapter-cache'
import { useCurrentChatSession } from '@/chats/chat-store'
import { useChatStore, useCurrentChatSession } from '@/chats/chat-store'
import { usePendingQuotes, usePendingQuotesStore } from '@/chats/pending-quotes-store'
import { useCreateItem } from '@/components/create-item/context'
import { estimateTokensForText } from '@/ai/tokenizers'
Expand Down Expand Up @@ -35,11 +35,12 @@ import { AlertCircle, Loader2, X } from 'lucide-react'
import { type ClipboardEvent, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useLocation as useLocation_default, useNavigate as useNavigate_default } from 'react-router'
import { ChatAddMenu } from './chat-add-menu'
import { ChatModelPicker } from './chat-model-picker'
import { ChatSkillsBar } from './chat-skills-bar'
import { ThinkingChip } from './thinking-chip'
import { ContextOverflowModal } from '../context-overflow-modal'
import { ContextUsageIndicator } from '../context-usage-indicator'
import { PromptInput } from '../ui/prompt-input'
import { ChatModelPicker } from './chat-model-picker'
import { buildAttachmentPart } from '@/lib/attachments'
import { buildQuotePart } from '@/lib/quotes'
import { QuoteChip } from './quote-chip'
Expand Down Expand Up @@ -177,8 +178,15 @@ export const ChatPromptInput = forwardRef<ChatPromptInputRef, ChatPromptInputPro
id: chatThreadId,
selectedAgent,
selectedModel,
thinkingEnabled,
} = useCurrentChatSession()
const updateSession = useChatStore((state) => state.updateSession)

const thinkingOn = thinkingEnabled !== false
const showThinkingChip = selectedModel.startWithReasoning === 1
const toggleThinking = useCallback(() => {
updateSession(chatThreadId, { thinkingEnabled: !thinkingOn })
}, [chatThreadId, thinkingOn, updateSession])
const { messages, status, stop, sendMessage } = useChat({
chat: chatInstance,
experimental_throttle: messageBookkeepingThrottleMs,
Expand Down Expand Up @@ -603,7 +611,13 @@ export const ChatPromptInput = forwardRef<ChatPromptInputRef, ChatPromptInputPro
// The model picker sits in the footer's right cluster, next to the send
// button; the connecting / connection-error status replaces it on the
// left, so the right side only renders it in the healthy state.
const footerEndElements = !isConnecting && !isConnectionError ? <ChatModelPicker /> : undefined
const footerEndElements =
!isConnecting && !isConnectionError ? (
<>
{showThinkingChip && <ThinkingChip enabled={thinkingOn} onToggle={toggleThinking} />}
<ChatModelPicker />
</>
) : undefined

const handleAddChipFromBar = useCallback(
(skill: Skill) => {
Expand Down
Loading
Loading