Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
19788c0
feat(settings): dynamic thinking effort experimental toggle (DTE-1)
easonliang28 Sep 4, 2026
82358bd
feat(api): per-request thinking effort override and adaptive effort e…
easonliang28 Sep 4, 2026
4075c8a
feat(task): task-local runtime thinking effort state with per-request…
easonliang28 Sep 4, 2026
710bf61
feat(settings): dynamic thinking effort experimental toggle (DTE-1)
easonliang28 Sep 4, 2026
521e0e6
feat(settings): dynamic thinking effort experimental toggle (DTE-1)
easonliang28 Sep 4, 2026
e2f249a
Merge branch 'main' into feat/dte-v2-1-dynamic-thinking-effort
easonLiangWorldedtech Sep 4, 2026
53f22dc
Merge upstream main (0d937c05081) into feat/dte-v2-1-dynamic-thinking…
easonliang28 Sep 4, 2026
18f488f
Merge feat/dte-v2-1-dynamic-thinking-effort (53f22dc) into feat/dte-v…
easonliang28 Sep 4, 2026
39762bf
test(settings): assert false and unset dynamicThinkingEffort persiste…
easonliang28 Sep 4, 2026
f97f8d9
Merge U2 head (18f488fa5) into U3 (stack sync: U1 final CR fixes + U2…
easonliang28 Sep 4, 2026
efbd336
Merge U1 final head (39762bf81) into U2 (stack sync: U1 last CR fix —…
easonliang28 Sep 4, 2026
c5b48aa
Merge U2 head (efbd336e5) into U3 (stack sync: U1 final persistence t…
easonliang28 Sep 4, 2026
e89cced
Merge upstream main (0dbd5846f) into U1 (stack sync: main v3.82.0 - G…
easonliang28 Sep 5, 2026
069c34b
Merge U1 head (e89cceddd9a) into U2 (stack sync: U1 final head in - m…
easonliang28 Sep 5, 2026
e8c66cf
Merge U2 head (069c34b9a) into U3 (stack sync: main advances to 0dbd5…
easonliang28 Sep 5, 2026
1edd728
fix(task): clear DTE runtime-effort state on disposal and dispose abo…
easonliang28 Sep 5, 2026
1087fcb
fix(task): restore API configuration when clearing the DTE effort ove…
easonliang28 Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/types/src/__tests__/experiment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { experimentIds, experimentIdsSchema, experimentsSchema } from "../experiment.js"

describe("dynamicThinkingEffort experiment", () => {
it("is part of the experiment id enum", () => {
expect(experimentIds).toContain("dynamicThinkingEffort")
expect(experimentIdsSchema.safeParse("dynamicThinkingEffort").success).toBe(true)
})

it("parses enabled and disabled states", () => {
expect(experimentsSchema.parse({ dynamicThinkingEffort: true })).toEqual({ dynamicThinkingEffort: true })
expect(experimentsSchema.parse({ dynamicThinkingEffort: false })).toEqual({ dynamicThinkingEffort: false })
expect(experimentsSchema.parse({})).toEqual({})
})

it("rejects non-boolean values", () => {
expect(experimentsSchema.safeParse({ dynamicThinkingEffort: "yes" }).success).toBe(false)
expect(experimentIdsSchema.safeParse("dynamic-thinking-effort").success).toBe(false)
})
})
2 changes: 2 additions & 0 deletions packages/types/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const experimentIds = [
"runSlashCommand",
"customTools",
"parallelToolExecution",
"dynamicThinkingEffort",
] as const

export const experimentIdsSchema = z.enum(experimentIds)
Expand All @@ -28,6 +29,7 @@ export const experimentsSchema = z.object({
runSlashCommand: z.boolean().optional(),
customTools: z.boolean().optional(),
parallelToolExecution: z.boolean().optional(),
dynamicThinkingEffort: z.boolean().optional(),
})

export type Experiments = z.infer<typeof experimentsSchema>
Expand Down
10 changes: 8 additions & 2 deletions scripts/stryker-diff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,14 @@ export const PACKAGE_CONFIGS = [
root: "src",
sourceRoot: "src/",
vitestConfig: "vitest.config.ts",
vitestRelated: false,
discoverRelatedTests: true,
// Use plugin-side related-test discovery: with an explicit STRYKER_TEST_FILES
// list, stryker-js 10.0.0 plans static mutants with runtime activation
// (stryker-mutator/stryker-js#6144, #6209), which the vitest plugin can only
// apply after top-level code has run, producing false Survived results.
// Revert to vitestRelated: false / discoverRelatedTests: true once the
// upstream planner fix lands.
vitestRelated: true,
discoverRelatedTests: false,
excludedPaths: ["src/esbuild.mjs", "src/eslint.config.mjs", "src/utils/vitest-verbosity.ts"],
},
]
Expand Down
7 changes: 5 additions & 2 deletions scripts/stryker-diff.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,11 @@ describe("buildManifest", () => {
assert.equal(webview.runRoot, ".")
assert.equal(webview.discoverRelatedTests, true)
assert.equal(webview.vitestRelated, false)
assert.equal(extension.discoverRelatedTests, true)
assert.equal(extension.vitestRelated, false)
// The extension entry uses plugin-side related discovery while stryker-js 10.0.0
// plans static mutants with runtime activation when given explicit test files
// (stryker-mutator/stryker-js#6144, #6209); see the entry comment in stryker-diff.mjs.
assert.equal(extension.discoverRelatedTests, false)
assert.equal(extension.vitestRelated, true)
})

it("returns no packages for tests, barrels, unsupported packages, and type-only changes", () => {
Expand Down
9 changes: 9 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
retiredProviderIdentifiers,
type ProviderSettings,
type ModelInfo,
type ReasoningEffortExtended,
} from "@roo-code/types"

import { getRouterRemovalMessage } from "../core/config/routerRemoval"
Expand Down Expand Up @@ -115,6 +116,14 @@ export interface ApiHandlerCreateMessageMetadata {
* when the user clicks stop, preventing wasted API tokens/compute on the provider side.
*/
abortSignal?: AbortSignal
/**
* Per-request thinking effort override (DTE series 2/5).
* When defined, takes precedence over the settings-derived `reasoningEffort`
* wherever the effective effort is resolved (see `resolveEffectiveReasoningEffort`).
* Task-scoped and transient: it applies to this request only (the next request
* after being set — no mid-stream effect) and is never persisted to settings.
*/
reasoningEffort?: ReasoningEffortExtended
}

export interface ApiHandler {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// npx vitest run src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts

import { ADAPTIVE_OUTPUT_CONFIG_EFFORTS, resolveEffectiveReasoningEffort } from "../reasoning"

describe("DTE series 2/5 — resolveEffectiveReasoningEffort", () => {
const settingsEffort = "high"
const modelDefault = "medium"

it("returns the per-request override when present (strongest precedence)", () => {
expect(
resolveEffectiveReasoningEffort({
override: "xhigh",
settingsReasoningEffort: settingsEffort,
modelDefaultEffort: modelDefault,
}),
).toBe("xhigh")
})

it("lets the override win even when it is out-of-range for the adaptive envelope", () => {
// "minimal" is a valid override value but outside the adaptive envelope set;
// resolution still returns it — envelope gating is the caller's concern.
expect(
resolveEffectiveReasoningEffort({
override: "minimal",
settingsReasoningEffort: settingsEffort,
modelDefaultEffort: modelDefault,
}),
).toBe("minimal")
})

it("falls back to the settings value when no override is present", () => {
expect(
resolveEffectiveReasoningEffort({ settingsReasoningEffort: "low", modelDefaultEffort: modelDefault }),
).toBe("low")
})

it("preserves the settings 'disable' sentinel when no override is present", () => {
expect(
resolveEffectiveReasoningEffort({ settingsReasoningEffort: "disable", modelDefaultEffort: modelDefault }),
).toBe("disable")
})

it("an explicit override wins over a settings 'disable' sentinel", () => {
expect(resolveEffectiveReasoningEffort({ override: "low", settingsReasoningEffort: "disable" })).toBe("low")
})

it("falls back to the model default when neither override nor settings is set", () => {
expect(resolveEffectiveReasoningEffort({ modelDefaultEffort: "low" })).toBe("low")
})

it("returns undefined when nothing is set", () => {
expect(resolveEffectiveReasoningEffort({})).toBeUndefined()
})

it("exposes exactly the in-range adaptive envelope efforts", () => {
expect([...ADAPTIVE_OUTPUT_CONFIG_EFFORTS]).toEqual(["low", "medium", "high", "xhigh", "max"])
})
})
45 changes: 45 additions & 0 deletions src/api/transform/reasoning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,51 @@ export type AnthropicProviderReasoningParams = AnthropicReasoningParams | { type

export type OpenAiReasoningParams = { reasoning_effort: OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"] }

/**
* DTE series 2/5 — effort levels accepted by the Claude 4.7+ adaptive-thinking
* `output_config.effort` envelope. Efforts outside this set (e.g. "none",
* "minimal", "disable") omit the envelope so the API applies its own default.
*/
export const ADAPTIVE_OUTPUT_CONFIG_EFFORTS: readonly ReasoningEffortExtended[] = [
"low",
"medium",
"high",
"xhigh",
"max",
]

/**
* DTE series 2/5 — resolves the effective thinking effort for a single request.
*
* Resolution order (strongest first):
* 1. `override` — the per-request task-local effort
* (`ApiHandlerCreateMessageMetadata.reasoningEffort`),
* 2. `settingsReasoningEffort` — the settings-derived value,
* 3. `modelDefaultEffort` — the model's default effort.
*
* This is the single shared resolution point for the per-request override:
* providers that resolve the effective effort through it inherit the override
* without duplicating precedence logic. The override is transient (next request
* only) and never persisted to settings.
*/
export const resolveEffectiveReasoningEffort = ({
override,
settingsReasoningEffort,
modelDefaultEffort,
}: {
override?: ReasoningEffortExtended
settingsReasoningEffort?: ReasoningEffortExtended | "disable"
modelDefaultEffort?: ReasoningEffortExtended
}): ReasoningEffortExtended | "disable" | undefined => {
if (override !== undefined) {
return override
}
if (settingsReasoningEffort !== undefined) {
return settingsReasoningEffort
}
return modelDefaultEffort
}

// Valid Gemini thinking levels for effort-based reasoning
const GEMINI_THINKING_LEVELS = ["minimal", "low", "medium", "high"] as const

Expand Down
103 changes: 102 additions & 1 deletion src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type TaskMetadata,
type TaskEvents,
type ProviderSettings,
type ReasoningEffortExtended,
type TokenUsage,
type ToolUsage,
type ToolName,
Expand Down Expand Up @@ -318,6 +319,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// API
apiConfiguration: ProviderSettings
api: ApiHandler
// DTE series 2/5: task-local thinking effort override. Transient per-task state —
// never persisted to settings; cleared on dispose (see dispose()).
private runtimeThinkingEffort?: ReasoningEffortExtended
private runtimeThinkingEffortSource?: string
// Settings-derived effort captured when the override activates, so clearing
// (undefined) restores it in the in-memory apiConfiguration copy.
private preOverrideReasoningEffort?: ProviderSettings["reasoningEffort"]
private rateLimitClock: RateLimitClock
private autoApprovalHandler: AutoApprovalHandler

Expand Down Expand Up @@ -1633,14 +1641,89 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
* Updates the API configuration and rebuilds the API handler.
* There is no tool-protocol switching or tool parser swapping.
*
* DTE series 2/5: when a task-local thinking effort override is active
* (`setRuntimeThinkingEffort`), the incoming configuration's `reasoningEffort`
* becomes the new restore value and the override is re-applied on top of the
* fresh in-memory copy — clearing the override later restores the NEW profile's
* value, not a stale one.
*
* @param newApiConfiguration - The new API configuration to use
*/
public updateApiConfiguration(newApiConfiguration: ProviderSettings): void {
// Update the configuration and rebuild the API handler
this.apiConfiguration = newApiConfiguration
if (this.runtimeThinkingEffort !== undefined) {
// DTE series 2/5: a task-local override is active, so re-capture the
// incoming profile's value as the restore value and re-apply the
// override on top of the new in-memory copy — clearing the override
// must restore the NEW profile's value, not the stale one.
this.preOverrideReasoningEffort = newApiConfiguration.reasoningEffort
this.apiConfiguration = { ...newApiConfiguration, reasoningEffort: this.runtimeThinkingEffort }
} else {
this.apiConfiguration = newApiConfiguration
}
this.api = buildApiHandler(this.apiConfiguration)
}

/**
* DTE series 2/5: sets — or clears with `undefined` — the task-local thinking
* effort override.
*
* Resolution order for the affected requests (strongest first): this
* task-local override → settings `reasoningEffort` → model default. The
* override applies to the NEXT API request only (no mid-stream effect): it is
* passed per request as `metadata.reasoningEffort` and, while active, is
* merged into the in-memory `apiConfiguration` copy (profile-switch /
* `updateApiConfiguration` precedent) so the rebuilt handler reflects it too.
* `undefined` clears the override and restores the settings-derived value in
* the copy. Nothing is ever written to persisted settings.
*
* @param effort - The task-local effort, or `undefined` to clear.
* @param source - Optional provenance label (UI wiring lands in a later PR).
*/
public setRuntimeThinkingEffort(effort: ReasoningEffortExtended | undefined, source?: string): void {
const wasActive = this.runtimeThinkingEffort !== undefined
this.runtimeThinkingEffort = effort
this.runtimeThinkingEffortSource = effort === undefined ? undefined : source

if (effort !== undefined) {
// Capture the settings-derived value once so clearing can restore it.
if (!wasActive) {
this.preOverrideReasoningEffort = this.apiConfiguration.reasoningEffort
}
// Merge into the in-memory copy (never the persisted settings object).
this.apiConfiguration = { ...this.apiConfiguration, reasoningEffort: effort }
} else if (wasActive) {
// Restore the settings-derived value captured when the override activated.
this.apiConfiguration = { ...this.apiConfiguration, reasoningEffort: this.preOverrideReasoningEffort }
this.preOverrideReasoningEffort = undefined
} else {
// Already inactive: nothing to clear.
return
}

// Rebuild the handler from the updated copy so the next request uses it.
this.api = buildApiHandler(this.apiConfiguration)
}

/**
* DTE series 2/5: reads the current task-local thinking effort override.
*/
public getRuntimeThinkingEffort(): { effort?: ReasoningEffortExtended; source?: string } {
return {
effort: this.runtimeThinkingEffort,
source: this.runtimeThinkingEffortSource,
}
}

/**
* DTE series 2/5: metadata fragment carrying the active task-local effort
* override on a single request. Empty when no override is active, so the
* existing settings resolution applies unchanged.
*/
private getRuntimeThinkingEffortMetadata(): Pick<ApiHandlerCreateMessageMetadata, "reasoningEffort"> {
return this.runtimeThinkingEffort !== undefined ? { reasoningEffort: this.runtimeThinkingEffort } : {}
}

public async submitUserMessage(
text: string,
images?: string[],
Expand Down Expand Up @@ -1757,6 +1840,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
parallelToolCalls: true,
}
: {}),
// DTE series 2/5: carry the active task-local effort override.
...this.getRuntimeThinkingEffortMetadata(),
}
// Generate environment details to include in the condensed summary
const environmentDetails = await getEnvironmentDetails(this, true)
Expand Down Expand Up @@ -2525,6 +2610,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}

/**
* Centralized task teardown: releases task resources and resets transient
* task-local state.
*/
public dispose(): Promise<void> {
if (this.disposalPromise) {
return this.disposalPromise
Expand Down Expand Up @@ -2617,6 +2706,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
console.error("Error reverting diff changes:", error)
}

// Clear the DTE runtime-effort override via the standard clearing call so a
// retained disposed task never serves a stale override: the override also
// lives in apiConfiguration.reasoningEffort and the built api handler, and
// the clearing call restores both before clearing the runtime fields.
this.setRuntimeThinkingEffort(undefined)

await pendingCleanup
await this.diffReversionPromise
}
Expand Down Expand Up @@ -4200,6 +4295,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
parallelToolCalls: true,
}
: {}),
// DTE series 2/5: carry the active task-local effort override.
...this.getRuntimeThinkingEffortMetadata(),
}

try {
Expand Down Expand Up @@ -4426,6 +4523,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
parallelToolCalls: true,
}
: {}),
// DTE series 2/5: carry the active task-local effort override.
...this.getRuntimeThinkingEffortMetadata(),
}

// Only generate environment details when context management will actually run.
Expand Down Expand Up @@ -4591,6 +4690,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
taskId: this.taskId,
suppressPreviousResponseId: this.skipPrevResponseIdOnce,
abortSignal,
// DTE series 2/5: carry the active task-local effort override for this request.
...this.getRuntimeThinkingEffortMetadata(),
// Include tools whenever they are present.
...(shouldIncludeTools
? {
Expand Down
Loading
Loading