mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-10 22:41:14 +00:00
feat: OpenAI provider/types/UI updates; provider state persistence
This commit is contained in:
parent
bce579f4c1
commit
ddf30eb221
16 changed files with 1601 additions and 2616 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
24
packages/types/src/providers/__tests__/openai.models.spec.ts
Normal file
24
packages/types/src/providers/__tests__/openai.models.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
76
src/core/task-persistence/providerState.ts
Normal file
76
src/core/task-persistence/providerState.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 GPT‑5 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -125,6 +125,18 @@ export const webviewMessageHandler = async (
|
|||
|
||||
// Initialize with history item after deletion
|
||||
await provider.initClineWithHistoryItem(historyItem)
|
||||
|
||||
// Invalidate GPT‑5 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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ export const GlobalFileNames = {
|
|||
mcpSettings: "mcp_settings.json",
|
||||
customModes: "custom_modes.yaml",
|
||||
taskMetadata: "task_metadata.json",
|
||||
openAiNativeState: "openai_native_state.json",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 GPT‑5 (OpenAI Native), include "minimal"
|
||||
// - Otherwise hide "minimal"
|
||||
// Also de‑dupe 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue