feat: OpenAI provider/types/UI updates; provider state persistence

This commit is contained in:
hannesrudolph 2025-08-10 23:01:28 -06:00 committed by daniel-lxs
parent bce579f4c1
commit ddf30eb221
No known key found for this signature in database
GPG key ID: 21C74479048B3AA6
16 changed files with 1601 additions and 2616 deletions

View file

@ -4,7 +4,7 @@ import { z } from "zod"
* ReasoningEffort
*/
export const reasoningEfforts = ["low", "medium", "high"] as const
export const reasoningEfforts = ["minimal", "low", "medium", "high"] as const
export const reasoningEffortsSchema = z.enum(reasoningEfforts)
@ -44,11 +44,19 @@ export const modelInfoSchema = z.object({
supportsImages: z.boolean().optional(),
supportsComputerUse: z.boolean().optional(),
supportsPromptCache: z.boolean(),
// Whether this model supports temperature. Some Responses models (e.g. o-series) do not.
supportsTemperature: z.boolean().optional(),
// Capability flag to indicate whether the model supports an output verbosity parameter
supportsVerbosity: z.boolean().optional(),
supportsReasoningBudget: z.boolean().optional(),
requiredReasoningBudget: z.boolean().optional(),
supportsReasoningEffort: z.boolean().optional(),
// Whether this model supports Responses API reasoning summaries
supportsReasoningSummary: z.boolean().optional(),
// The role to use for the system prompt ('system' or 'developer')
systemPromptRole: z.enum(["system", "developer"]).optional(),
// The default temperature for the model
defaultTemperature: z.number().optional(),
supportedParameters: z.array(modelParametersSchema).optional(),
inputPrice: z.number().optional(),
outputPrice: z.number().optional(),

View file

@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest"
import { openAiNativeModels } from "../openai.js"
import type { ModelInfo } from "../../model.js"
describe("openAiNativeModels temperature invariants", () => {
it("models with supportsTemperature === false must not specify defaultTemperature", () => {
for (const [_id, info] of Object.entries(openAiNativeModels)) {
const modelInfo = info as ModelInfo & { supportsTemperature?: boolean; defaultTemperature?: number }
if (modelInfo.supportsTemperature === false) {
expect(modelInfo.defaultTemperature).toBeUndefined()
}
}
})
it("gpt-5 family models must have supportsTemperature: false and no defaultTemperature", () => {
const gpt5Ids = ["gpt-5-2025-08-07", "gpt-5-mini-2025-08-07", "gpt-5-nano-2025-08-07"]
for (const id of gpt5Ids) {
const info = openAiNativeModels[id as keyof typeof openAiNativeModels] as ModelInfo & { supportsTemperature?: boolean; defaultTemperature?: number }
expect(info).toBeDefined()
expect(info.supportsTemperature).toBe(false)
expect(info.defaultTemperature).toBeUndefined()
}
})
})

View file

@ -3,7 +3,7 @@ import type { ModelInfo } from "../model.js"
// https://openai.com/api/pricing/
export type OpenAiNativeModelId = keyof typeof openAiNativeModels
export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-5-2025-08-07"
export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-5"
export const openAiNativeModels = {
"gpt-5-2025-08-07": {
@ -19,6 +19,28 @@ export const openAiNativeModels = {
description: "GPT-5: The best model for coding and agentic tasks across domains",
// supportsVerbosity is a new capability; ensure ModelInfo includes it
supportsVerbosity: true,
// GPT-5 supports Responses API reasoning summaries
supportsReasoningSummary: true,
systemPromptRole: "developer",
supportsTemperature: false,
},
"gpt-5": {
maxTokens: 128000,
contextWindow: 400000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: true,
reasoningEffort: "medium",
inputPrice: 1.25,
outputPrice: 10.0,
cacheReadsPrice: 0.13,
description: "GPT-5: The best model for coding and agentic tasks across domains",
// supportsVerbosity is a new capability; ensure ModelInfo includes it
supportsVerbosity: true,
// GPT-5 supports Responses API reasoning summaries
supportsReasoningSummary: true,
systemPromptRole: "developer",
supportsTemperature: false,
},
"gpt-5-mini-2025-08-07": {
maxTokens: 128000,
@ -32,6 +54,27 @@ export const openAiNativeModels = {
cacheReadsPrice: 0.03,
description: "GPT-5 Mini: A faster, more cost-efficient version of GPT-5 for well-defined tasks",
supportsVerbosity: true,
// GPT-5 supports Responses API reasoning summaries
supportsReasoningSummary: true,
systemPromptRole: "developer",
supportsTemperature: false,
},
"gpt-5-mini": {
maxTokens: 128000,
contextWindow: 400000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: true,
reasoningEffort: "medium",
inputPrice: 0.25,
outputPrice: 2.0,
cacheReadsPrice: 0.03,
description: "GPT-5 Mini: A faster, more cost-efficient version of GPT-5 for well-defined tasks",
supportsVerbosity: true,
// GPT-5 supports Responses API reasoning summaries
supportsReasoningSummary: true,
systemPromptRole: "developer",
supportsTemperature: false,
},
"gpt-5-nano-2025-08-07": {
maxTokens: 128000,
@ -45,6 +88,27 @@ export const openAiNativeModels = {
cacheReadsPrice: 0.01,
description: "GPT-5 Nano: Fastest, most cost-efficient version of GPT-5",
supportsVerbosity: true,
// GPT-5 supports Responses API reasoning summaries
supportsReasoningSummary: true,
systemPromptRole: "developer",
supportsTemperature: false,
},
"gpt-5-nano": {
maxTokens: 128000,
contextWindow: 400000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: true,
reasoningEffort: "medium",
inputPrice: 0.05,
outputPrice: 0.4,
cacheReadsPrice: 0.01,
description: "GPT-5 Nano: Fastest, most cost-efficient version of GPT-5",
supportsVerbosity: true,
// GPT-5 supports Responses API reasoning summaries
supportsReasoningSummary: true,
systemPromptRole: "developer",
supportsTemperature: false,
},
"gpt-4.1": {
maxTokens: 32_768,
@ -54,6 +118,9 @@ export const openAiNativeModels = {
inputPrice: 2,
outputPrice: 8,
cacheReadsPrice: 0.5,
systemPromptRole: "system",
defaultTemperature: 0,
supportsTemperature: true,
},
"gpt-4.1-mini": {
maxTokens: 32_768,
@ -63,6 +130,9 @@ export const openAiNativeModels = {
inputPrice: 0.4,
outputPrice: 1.6,
cacheReadsPrice: 0.1,
systemPromptRole: "system",
defaultTemperature: 0,
supportsTemperature: true,
},
"gpt-4.1-nano": {
maxTokens: 32_768,
@ -72,6 +142,9 @@ export const openAiNativeModels = {
inputPrice: 0.1,
outputPrice: 0.4,
cacheReadsPrice: 0.025,
systemPromptRole: "system",
defaultTemperature: 0,
supportsTemperature: true,
},
o3: {
maxTokens: 100_000,
@ -83,26 +156,8 @@ export const openAiNativeModels = {
cacheReadsPrice: 0.5,
supportsReasoningEffort: true,
reasoningEffort: "medium",
},
"o3-high": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.0,
outputPrice: 8.0,
cacheReadsPrice: 0.5,
reasoningEffort: "high",
},
"o3-low": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.0,
outputPrice: 8.0,
cacheReadsPrice: 0.5,
reasoningEffort: "low",
systemPromptRole: "developer",
supportsTemperature: false,
},
"o4-mini": {
maxTokens: 100_000,
@ -114,26 +169,8 @@ export const openAiNativeModels = {
cacheReadsPrice: 0.275,
supportsReasoningEffort: true,
reasoningEffort: "medium",
},
"o4-mini-high": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.275,
reasoningEffort: "high",
},
"o4-mini-low": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.275,
reasoningEffort: "low",
systemPromptRole: "developer",
supportsTemperature: false,
},
"o3-mini": {
maxTokens: 100_000,
@ -145,26 +182,8 @@ export const openAiNativeModels = {
cacheReadsPrice: 0.55,
supportsReasoningEffort: true,
reasoningEffort: "medium",
},
"o3-mini-high": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.55,
reasoningEffort: "high",
},
"o3-mini-low": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.55,
reasoningEffort: "low",
systemPromptRole: "developer",
supportsTemperature: false,
},
o1: {
maxTokens: 100_000,
@ -174,15 +193,8 @@ export const openAiNativeModels = {
inputPrice: 15,
outputPrice: 60,
cacheReadsPrice: 7.5,
},
"o1-preview": {
maxTokens: 32_768,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 15,
outputPrice: 60,
cacheReadsPrice: 7.5,
systemPromptRole: "developer",
supportsTemperature: false,
},
"o1-mini": {
maxTokens: 65_536,
@ -192,15 +204,8 @@ export const openAiNativeModels = {
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.55,
},
"gpt-4.5-preview": {
maxTokens: 16_384,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 75,
outputPrice: 150,
cacheReadsPrice: 37.5,
systemPromptRole: "developer",
supportsTemperature: false,
},
"gpt-4o": {
maxTokens: 16_384,
@ -210,6 +215,9 @@ export const openAiNativeModels = {
inputPrice: 2.5,
outputPrice: 10,
cacheReadsPrice: 1.25,
systemPromptRole: "system",
defaultTemperature: 0,
supportsTemperature: true,
},
"gpt-4o-mini": {
maxTokens: 16_384,
@ -219,6 +227,8 @@ export const openAiNativeModels = {
inputPrice: 0.15,
outputPrice: 0.6,
cacheReadsPrice: 0.075,
systemPromptRole: "system",
defaultTemperature: 0,
},
"codex-mini-latest": {
maxTokens: 16_384,
@ -240,13 +250,11 @@ export const openAiModelInfoSaneDefaults: ModelInfo = {
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
defaultTemperature: 0,
}
// https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation
// https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#api-specs
export const azureOpenAiDefaultApiVersion = "2024-08-01-preview"
export const OPENAI_NATIVE_DEFAULT_TEMPERATURE = 0
export const GPT5_DEFAULT_TEMPERATURE = 1.0
export const OPENAI_AZURE_AI_INFERENCE_PATH = "/models/chat/completions"

View file

@ -51,6 +51,20 @@ export interface ApiHandlerCreateMessageMetadata {
* Used to enforce "skip once" after a condense operation.
*/
suppressPreviousResponseId?: boolean
/**
* Force this call to operate statelessly (providers should set store=false and
* suppress any previous_response_id). Intended for the first call after local
* context rewriting (condense or sliding-window).
*/
forceStateless?: boolean
/**
* Optional stable cache key for OpenAI Responses API caching.
* When provided, providers that support it should pass it as prompt_cache_key.
* Per-call metadata takes precedence over handler options.
*/
promptCacheKey?: string
}
export interface ApiHandler {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
import * as path from "path"
import * as fs from "fs/promises"
import { safeWriteJson } from "../../utils/safeWriteJson"
import { fileExistsAtPath } from "../../utils/fs"
import { getTaskDirectoryPath } from "../../utils/storage"
import { GlobalFileNames } from "../../shared/globalFileNames"
/**
* Persistent state for OpenAI Native (Responses API) provider.
* Stores encrypted reasoning content (via conversationHistory) and lineage (lastResponseId)
* so stateless flows can be resumed across pauses, crashes, and task switches.
*/
export type OpenAiNativePersistentState = {
lastResponseId?: string
conversationHistory: any[]
/**
* Pairing of assistant turn -> its encrypted reasoning artifact, for precise stateless restoration.
* Each item is a Responses API input item containing the encrypted artifact for that responseId.
*/
encryptedArtifacts?: Array<{ responseId: string; item: any }>
}
export type ReadOpenAiNativeStateOptions = {
taskId: string
globalStoragePath: string
}
export type SaveOpenAiNativeStateOptions = ReadOpenAiNativeStateOptions & {
state: OpenAiNativePersistentState
}
/**
* Read provider state persisted for a specific task.
* Returns undefined if no state exists yet.
*/
export async function readOpenAiNativeState({
taskId,
globalStoragePath,
}: ReadOpenAiNativeStateOptions): Promise<OpenAiNativePersistentState | undefined> {
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
const filePath = path.join(taskDir, GlobalFileNames.openAiNativeState)
try {
if (await fileExistsAtPath(filePath)) {
const raw = await fs.readFile(filePath, "utf8")
const parsed = JSON.parse(raw)
// Basic shape sanity
if (parsed && typeof parsed === "object" && Array.isArray(parsed.conversationHistory || [])) {
return parsed as OpenAiNativePersistentState
}
}
} catch (error) {
console.error(`[OpenAiNativeState] Failed to read state for task ${taskId}:`, error)
}
return undefined
}
/**
* Persist provider state to the task directory (atomic via safeWriteJson).
*/
export async function saveOpenAiNativeState({
taskId,
globalStoragePath,
state,
}: SaveOpenAiNativeStateOptions): Promise<void> {
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
const filePath = path.join(taskDir, GlobalFileNames.openAiNativeState)
try {
await safeWriteJson(filePath, state)
} catch (error) {
console.error(`[OpenAiNativeState] Failed to write state for task ${taskId}:`, error)
}
}

View file

@ -254,6 +254,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
isAssistantMessageParserEnabled = false
private lastUsedInstructions?: string
private skipPrevResponseIdOnce: boolean = false
private forceStatelessNextCallOnce: boolean = false
// Re-entrancy guard for the first post-condense/sliding-window call
private _postCondenseFirstCallScheduled?: boolean
private _postCondenseFirstCallInFlight?: boolean
constructor({
provider,
@ -841,6 +845,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
{ isNonInteractive: true } /* options */,
contextCondense,
)
// Ensure the immediate next model call is stateless after manual condense,
// and suppress previous_response_id once to avoid lineage mismatches.
this.skipPrevResponseIdOnce = true
this.forceStatelessNextCallOnce = true
// Mark that the immediate next call is the post-condense first call (one-shot)
this._postCondenseFirstCallScheduled = true
this._postCondenseFirstCallInFlight = false
try {
this.providerRef
.deref()
?.log(`[post-condense] manual condense scheduled first-turn stateless call for task ${this.taskId}`)
} catch {}
}
async say(
@ -2015,6 +2031,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
state?.listApiConfigMeta.find((profile) => profile.name === state?.currentApiConfigName)?.id ??
"default"
// Track whether the immediate next call must be stateless due to local context rewriting.
let forceStatelessNextCall = false
const truncateResult = await truncateConversationIfNeeded({
messages: this.apiConversationHistory,
totalTokens: contextTokens,
@ -2030,15 +2049,20 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
profileThresholds,
currentProfileId,
})
if (truncateResult.messages !== this.apiConversationHistory) {
const didRewriteContext = truncateResult.messages !== this.apiConversationHistory
if (didRewriteContext) {
await this.overwriteApiConversationHistory(truncateResult.messages)
}
if (truncateResult.error) {
await this.say("condense_context_error", truncateResult.error)
} else if (truncateResult.summary) {
// A condense operation occurred; for the next GPT5 API call we should NOT
// send previous_response_id so the request reflects the fresh condensed context.
this.skipPrevResponseIdOnce = true
forceStatelessNextCall = true
const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
@ -2052,6 +2076,36 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
{ isNonInteractive: true } /* options */,
contextCondense,
)
} else if (didRewriteContext) {
// Sliding-window truncation occurred (messages changed without a condense summary).
// Force the immediate next call to be stateless to align server state with locally rewritten context.
forceStatelessNextCall = true
}
// Persist the decision for this turn so we can include it in metadata for the next call.
if (forceStatelessNextCall) {
this.forceStatelessNextCallOnce = true
// Schedule one-shot guard for the first call after condense/sliding-window.
// Do not reset inFlight if a first-turn call is already in progress.
if (!this._postCondenseFirstCallScheduled) {
this._postCondenseFirstCallScheduled = true
this._postCondenseFirstCallInFlight = false
try {
this.providerRef
.deref()
?.log(
`[post-condense] scheduled first-turn guard (stateless next call) for task ${this.taskId}`,
)
} catch {}
} else {
try {
this.providerRef
.deref()
?.log(
`[post-condense] guard already scheduled; leaving in-flight state unchanged (task ${this.taskId})`,
)
} catch {}
}
}
}
@ -2102,12 +2156,36 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
...(previousResponseId ? { previousResponseId } : {}),
// If a condense just occurred, explicitly suppress continuity fallback for the next call
...(this.skipPrevResponseIdOnce ? { suppressPreviousResponseId: true } : {}),
// If either condense or sliding-window rewrote the local context, force stateless for the next call.
...(this.forceStatelessNextCallOnce ? { forceStateless: true } : {}),
}
// Reset skip flag after applying (it only affects the immediate next call)
// Reset one-shot flags after applying (they only affect the immediate next call)
if (this.skipPrevResponseIdOnce) {
this.skipPrevResponseIdOnce = false
}
if (this.forceStatelessNextCallOnce) {
this.forceStatelessNextCallOnce = false
}
// Re-entrancy guard: one-shot in-flight guard for the first post-condense/sliding-window call.
// If an external second trigger arrives while the first is in-flight, no-op the duplicate.
if (this._postCondenseFirstCallScheduled) {
if (this._postCondenseFirstCallInFlight && retryAttempt === 0) {
// Duplicate external trigger detected - no-op for this call
try {
this.providerRef
.deref()
?.log(`[post-condense] suppressing duplicate first-turn trigger (task ${this.taskId})`)
} catch {}
return
}
// Acquire the guard for this first-call window
this._postCondenseFirstCallInFlight = true
try {
this.providerRef.deref()?.log(`[post-condense] acquired first-turn guard (task ${this.taskId})`)
} catch {}
}
const stream = this.api.createMessage(systemPrompt, cleanConversationHistory, metadata)
const iterator = stream[Symbol.asyncIterator]()
@ -2176,6 +2254,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// incremented retry count.
yield* this.attemptApiRequest(retryAttempt + 1)
// After the retried call completes, release the post-condense guard
this._postCondenseFirstCallScheduled = false
this._postCondenseFirstCallInFlight = false
try {
this.providerRef
.deref()
?.log(`[post-condense] released first-turn guard after retry completion (task ${this.taskId})`)
} catch {}
return
} else {
const { response } = await this.ask(
@ -2193,6 +2280,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Delegate generator output from the recursive call.
yield* this.attemptApiRequest()
// After the retried call completes, release the post-condense guard
this._postCondenseFirstCallScheduled = false
this._postCondenseFirstCallInFlight = false
return
}
}
@ -2206,6 +2297,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// effectively passes along all subsequent chunks from the original
// stream.
yield* iterator
// Release one-shot post-condense guard after successful stream completion
this._postCondenseFirstCallScheduled = false
this._postCondenseFirstCallInFlight = false
try {
this.providerRef
.deref()
?.log(`[post-condense] released first-turn guard after completion (task ${this.taskId})`)
} catch {}
}
// Checkpoints
@ -2283,6 +2382,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Getters
public get isPostCondenseFirstCallScheduled(): boolean {
return !!this._postCondenseFirstCallScheduled
}
public get isPostCondenseFirstCallInFlight(): boolean {
return !!this._postCondenseFirstCallInFlight
}
public get cwd() {
return this.workspacePath
}

View file

@ -18,6 +18,7 @@ import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-sear
import { MultiFileSearchReplaceDiffStrategy } from "../../diff/strategies/multi-file-search-replace"
import { EXPERIMENT_IDS } from "../../../shared/experiments"
import * as slidingWindowModule from "../../sliding-window"
// Mock delay before any imports that might use it
vi.mock("delay", () => ({
__esModule: true,
@ -1615,3 +1616,152 @@ describe("Cline", () => {
})
})
})
// Additional tests for stateless override behavior after condense/sliding-window
describe("Stateless overrides after context rewriting", () => {
const makeSimpleStream = (text: string = "ok"): AsyncGenerator<ApiStreamChunk> =>
(async function* () {
yield { type: "text", text } as any
})() as any
const makeProvider = () =>
({
context: { globalStorageUri: { fsPath: "/tmp/test-storage" } },
getState: vi.fn().mockResolvedValue({
// minimal state used by attemptApiRequest
apiConfiguration: { apiProvider: "anthropic", apiModelId: "claude-3" },
autoApprovalEnabled: true,
alwaysApproveResubmit: false,
requestDelaySeconds: 0,
autoCondenseContext: true,
autoCondenseContextPercent: 100,
profileThresholds: {},
listApiConfigMeta: [],
}),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
log: vi.fn(),
}) as any
it("passes metadata.forceStateless=true (and suppressPreviousResponseId) on the next call after condense", async () => {
const provider = makeProvider()
const cline = new Task({
provider,
apiConfiguration: { apiProvider: "anthropic", apiModelId: "claude-3" },
task: "test",
startTask: false,
})
// Force contextTokens > 0 so the condense/sliding-window logic runs
vi.spyOn(cline, "getTokenUsage").mockReturnValue({ contextTokens: 100 } as any)
// Mock truncateConversationIfNeeded to simulate a condense (summary present)
const condenseMessages = [
{ role: "user", content: [{ type: "text", text: "Please continue from the following summary:" }] },
{ role: "assistant", content: [{ type: "text", text: "Condensed summary" }], isSummary: true },
] as any
const truncateSpy = vi.spyOn(slidingWindowModule, "truncateConversationIfNeeded").mockResolvedValue({
messages: condenseMessages,
summary: "Condensed summary",
cost: 0,
newContextTokens: 50,
prevContextTokens: 100,
} as any)
// Spy on createMessage to capture metadata
const cmSpy = vi.spyOn(cline.api, "createMessage").mockReturnValue(makeSimpleStream("done"))
const it1 = cline.attemptApiRequest(0)
await it1.next()
expect(truncateSpy).toHaveBeenCalled()
expect(cmSpy).toHaveBeenCalled()
const call = cmSpy.mock.calls[0]
const metadata = call?.[2] as any
expect(metadata).toBeDefined()
expect(metadata.forceStateless).toBe(true)
// After condense we also suppress previous_response_id
expect(metadata.suppressPreviousResponseId).toBe(true)
})
it("passes metadata.forceStateless=true (without suppressPreviousResponseId) on the next call after sliding-window truncation", async () => {
const provider = makeProvider()
const cline = new Task({
provider,
apiConfiguration: { apiProvider: "anthropic", apiModelId: "claude-3" },
task: "test",
startTask: false,
})
// Force contextTokens > 0 so the condense/sliding-window logic runs
vi.spyOn(cline, "getTokenUsage").mockReturnValue({ contextTokens: 200 } as any)
// Mock truncateConversationIfNeeded to simulate sliding-window truncation (no summary, messages changed)
const truncatedMessages = [
{ role: "user", content: [{ type: "text", text: "First message" }] },
{ role: "assistant", content: [{ type: "text", text: "Fourth message" }] },
{ role: "user", content: [{ type: "text", text: "Fifth message" }] },
] as any
const truncateSpy = vi.spyOn(slidingWindowModule, "truncateConversationIfNeeded").mockResolvedValue({
messages: truncatedMessages,
summary: "",
cost: 0,
prevContextTokens: 200,
} as any)
// Spy on createMessage to capture metadata
const cmSpy = vi.spyOn(cline.api, "createMessage").mockReturnValue(makeSimpleStream("done"))
const it1 = cline.attemptApiRequest(0)
await it1.next()
expect(truncateSpy).toHaveBeenCalled()
expect(cmSpy).toHaveBeenCalled()
const call = cmSpy.mock.calls[0]
const metadata = call?.[2] as any
expect(metadata).toBeDefined()
expect(metadata.forceStateless).toBe(true)
// Sliding-window path does not set suppressPreviousResponseId in metadata (provider will suppress via forceStateless)
expect(metadata.suppressPreviousResponseId).toBeUndefined()
})
it("only initiates one provider call for the first post-condense turn, even if two triggers fire", async () => {
const provider = makeProvider()
const cline = new Task({
provider,
apiConfiguration: { apiProvider: "anthropic", apiModelId: "claude-3" },
task: "test",
startTask: false,
})
// Ensure condense/sliding-window logic runs
vi.spyOn(cline, "getTokenUsage").mockReturnValue({ contextTokens: 100 } as any)
// Mock condense result to schedule the first post-condense call as stateless
const condenseMessages = [
{ role: "user", content: [{ type: "text", text: "Please continue from summary" }] },
{ role: "assistant", content: [{ type: "text", text: "Condensed summary" }], isSummary: true },
] as any
vi.spyOn(slidingWindowModule, "truncateConversationIfNeeded").mockResolvedValue({
messages: condenseMessages,
summary: "Condensed summary",
cost: 0,
newContextTokens: 50,
prevContextTokens: 100,
} as any)
// Spy on provider call and return a simple stream
const cmSpy = vi.spyOn(cline.api, "createMessage").mockReturnValue(makeSimpleStream("done"))
// Fire two triggers for the "first turn after condense"
const it1 = cline.attemptApiRequest(0)
await it1.next() // enters request, sets in-flight guard
const it2 = cline.attemptApiRequest(0)
await it2.next() // should no-op due to re-entrancy guard
// Exactly one provider invocation
expect(cmSpy).toHaveBeenCalledTimes(1)
})
})

View file

@ -125,6 +125,18 @@ export const webviewMessageHandler = async (
// Initialize with history item after deletion
await provider.initClineWithHistoryItem(historyItem)
// Invalidate GPT5 continuity for the newly initialized task so the next call does NOT
// send previous_response_id (prevents mismatched lineage after delete/trim).
try {
const newCline = provider.getCurrentCline()
if (newCline) {
// Call overwriteClineMessages with the same array to trigger the one-turn suppression flag.
await newCline.overwriteClineMessages(newCline.clineMessages)
}
} catch (e) {
console.error("Failed to invalidate continuity after delete:", e)
}
} catch (error) {
console.error("Error in delete message:", error)
vscode.window.showErrorMessage(
@ -345,9 +357,27 @@ export const webviewMessageHandler = async (
await updateGlobalState("alwaysAllowUpdateTodoList", message.bool)
await provider.postStateToWebview()
break
case "askResponse":
case "askResponse": {
const cline = provider.getCurrentCline?.()
// Optional single-flight guard: if the special first post-condense turn is in-flight,
// suppress duplicate UI-triggered sends to avoid racing a scheduled stateless call.
if (
cline &&
typeof (cline as any).isPostCondenseFirstCallScheduled === "boolean" &&
typeof (cline as any).isPostCondenseFirstCallInFlight === "boolean" &&
(cline as any).isPostCondenseFirstCallScheduled &&
(cline as any).isPostCondenseFirstCallInFlight
) {
try {
provider.log?.(
`[webview] askResponse suppressed during post-condense first-turn in-flight for task ${(cline as any).taskId}`,
)
} catch {}
break
}
provider.getCurrentCline()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
break
}
case "autoCondenseContext":
await updateGlobalState("autoCondenseContext", message.bool)
await provider.postStateToWebview()

View file

@ -14,6 +14,20 @@ export type ApiHandlerOptions = Omit<ProviderSettings, "apiProvider"> & {
* Defaults to true; set to false to disable summaries.
*/
enableGpt5ReasoningSummary?: boolean
/**
* Controls statefulness for Responses API.
* When false, treat interactions as stateless and avoid using previous_response_id.
* The provider will include encrypted reasoning content to allow passing it back explicitly.
* Defaults to true (stateful) if not provided.
*/
store?: boolean
/**
* Optional default cache key for OpenAI Responses API prompt bucketing.
* Per-call metadata.promptCacheKey takes precedence when provided.
*/
promptCacheKey?: string
}
// RouterName

View file

@ -4,4 +4,5 @@ export const GlobalFileNames = {
mcpSettings: "mcp_settings.json",
customModes: "custom_modes.yaml",
taskMetadata: "task_metadata.json",
openAiNativeState: "openai_native_state.json",
}

View file

@ -115,7 +115,8 @@ export const ChatRowContent = ({
}: ChatRowContentProps) => {
const { t } = useTranslation()
const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode } = useExtensionState()
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
const [reasoningCollapsed, setReasoningCollapsed] = useState<boolean>(true)
const [isDiffErrorExpanded, setIsDiffErrorExpanded] = useState(false)
const [showCopySuccess, setShowCopySuccess] = useState(false)
const [isEditing, setIsEditing] = useState(false)

View file

@ -57,6 +57,8 @@ export const ReasoningBlock = ({ content, elapsed, isCollapsed = false, onToggle
processNextTransition()
})
// Update the preview line only when there's a meaningful delta
// Restore previous thresholded behavior to keep collapsed header UX (counter) stable.
useEffect(() => {
if (content.length - cursorRef.current > 160) {
setThought("... " + content.slice(cursorRef.current))

View file

@ -648,11 +648,14 @@ const ApiOptions = ({
fuzzyMatchThreshold={apiConfiguration.fuzzyMatchThreshold}
onChange={(field, value) => setApiConfigurationField(field, value)}
/>
<TemperatureControl
value={apiConfiguration.modelTemperature}
onChange={handleInputChange("modelTemperature", noTransform)}
maxValue={2}
/>
{/* Hide temperature UI when the selected model does not support temperature */}
{selectedModelInfo?.supportsTemperature !== false && (
<TemperatureControl
value={apiConfiguration.modelTemperature}
onChange={handleInputChange("modelTemperature", noTransform)}
maxValue={2}
/>
)}
<RateLimitSecondsControl
value={apiConfiguration.rateLimitSeconds || 0}
onChange={(value) => setApiConfigurationField("rateLimitSeconds", value)}

View file

@ -36,12 +36,16 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
// Only show minimal for OpenAI Native provider GPT-5 models
const isOpenAiNativeProvider = apiConfiguration.apiProvider === "openai-native"
const isGpt5Model = isOpenAiNativeProvider && selectedModelId && selectedModelId.startsWith("gpt-5")
// Add "minimal" option for GPT-5 models
// Spread to convert readonly tuple into a mutable array, then expose as readonly for safety
// Build list of efforts:
// - If GPT5 (OpenAI Native), include "minimal"
// - Otherwise hide "minimal"
// Also dedupe in case the source list already includes "minimal"
const baseEfforts = [...reasoningEfforts] as ReasoningEffortWithMinimal[]
const withMinimal = Array.from(new Set<ReasoningEffortWithMinimal>(["minimal", ...baseEfforts]))
const withoutMinimal = baseEfforts.filter((v) => v !== "minimal") as ReasoningEffortWithMinimal[]
const availableReasoningEfforts: ReadonlyArray<ReasoningEffortWithMinimal> = isGpt5Model
? (["minimal", ...baseEfforts] as ReasoningEffortWithMinimal[])
: baseEfforts
? withMinimal
: withoutMinimal
// Default reasoning effort - use model's default if available
// GPT-5 models have "medium" as their default in the model configuration