Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 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,
type ModelRecord,
} from "@roo-code/types"

Expand Down Expand Up @@ -127,6 +128,16 @@ export interface ApiHandlerCreateMessageMetadata {
* when the user clicks stop, preventing wasted API tokens/compute on the provider side.
*/
abortSignal?: AbortSignal
/**
* Per-request thinking effort override applied by rebuilding the handler.
* Providers resolve effort from the handler options (each provider reads
* `options.reasoningEffort` through its own model-params step), so passing the
* override in the configuration handed to `buildApiHandler` is what takes effect;
* it outranks the settings-derived `reasoningEffort` for the handler built from
* this object. Task-scoped and transient: it applies to the next request only
* (no mid-stream effect) and is never persisted to settings.
*/
reasoningEffort?: ReasoningEffortExtended
}

/**
Expand Down
98 changes: 98 additions & 0 deletions src/core/condense/__tests__/condense-effort-metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// npx vitest run src/core/condense/__tests__/condense-effort-metadata.spec.ts
//
// The summarizer boundary: Task hands the task-local thinking effort override to
// to summarizeConversation inside request metadata. This checks the other half of that hand-off:
// the real summarizer forwards the metadata to the handler's createMessage instead of dropping
// the effort. The Task-side tests cover Task-to-summarizer propagation only.

import { Anthropic } from "@anthropic-ai/sdk"
import type { ModelInfo, ReasoningEffortExtended } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"

import type { ApiHandlerCreateMessageMetadata } from "../../../api"
import { BaseProvider } from "../../../api/providers/base-provider"
import { ApiStream, ApiStreamChunk } from "../../../api/transform/stream"
import { ApiMessage } from "../../task-persistence/apiMessages"
import { summarizeConversation } from "../index"

const OVERRIDE_EFFORT: ReasoningEffortExtended = "xhigh"
const TASK_ID = "dte-condense-task"

class RecordingApiHandler extends BaseProvider {
/** The metadata of the last request this handler was asked to make. */
public requestedMetadata: ApiHandlerCreateMessageMetadata | undefined

override createMessage(
_systemPrompt: string,
_messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
this.requestedMetadata = metadata
return (async function* (): AsyncGenerator<ApiStreamChunk> {
yield { type: "text", text: "summary of the conversation" }
yield { type: "usage", inputTokens: 10, outputTokens: 5, totalCost: 0.01 }
})()
}

override getModel(): { id: string; info: ModelInfo } {
return {
id: "dte-condense-model",
info: { contextWindow: 100_000, maxTokens: 5_000, supportsPromptCache: false, supportsImages: false },
}
}
}

// Three messages with no summary in them: getMessagesSinceLastSummary returns all of them, so
// summarizeConversation gets past its "not enough messages" guard and issues the request.
function conversationHistory(): ApiMessage[] {
return [
{ role: "user", content: [{ type: "text", text: "read the failing test" }], ts: 1 },
{ role: "assistant", content: [{ type: "text", text: "the assertion drifted" }], ts: 2 },
{ role: "user", content: [{ type: "text", text: "fix it" }], ts: 3 },
]
}

describe("summarizeConversation reasoning-effort forwarding", () => {
beforeEach(() => {
if (!TelemetryService.hasInstance()) {
TelemetryService.createInstance([])
}
})

it("forwards an active override to the handler request", async () => {
const handler = new RecordingApiHandler()

const result = await summarizeConversation({
messages: conversationHistory(),
apiHandler: handler,
systemPrompt: "system prompt",
taskId: TASK_ID,
isAutomaticTrigger: false,
metadata: { mode: "code", taskId: TASK_ID, reasoningEffort: OVERRIDE_EFFORT },
environmentDetails: "environment details",
cwd: "/workspace",
})

expect(result.error).toBeUndefined()
expect(result.summary).toBe("summary of the conversation")
expect(handler.requestedMetadata?.reasoningEffort).toBe(OVERRIDE_EFFORT)
})

it("omits the key when the caller hands no override", async () => {
const handler = new RecordingApiHandler()

const result = await summarizeConversation({
messages: conversationHistory(),
apiHandler: handler,
systemPrompt: "system prompt",
taskId: TASK_ID,
isAutomaticTrigger: false,
metadata: { mode: "code", taskId: TASK_ID },
environmentDetails: "environment details",
cwd: "/workspace",
})

expect(result.summary).toBe("summary of the conversation")
expect(handler.requestedMetadata).not.toHaveProperty("reasoningEffort")
})
})
129 changes: 121 additions & 8 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type TaskMetadata,
type TaskEvents,
type ProviderSettings,
type ReasoningEffortExtended,
type TokenUsage,
type ToolUsage,
type ToolName,
Expand Down Expand Up @@ -429,6 +430,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// API
apiConfiguration: ProviderSettings
api: ApiHandler
// Task-local thinking effort override. Transient per-task state —
// never persisted to settings; cleared on dispose (see dispose()).
private runtimeThinkingEffort?: ReasoningEffortExtended
private runtimeThinkingEffortSource?: string
/**
* Units of in-flight API work: a request from setup through its retries and
* background usage drain, or a manual condense. Work can hold any handler it
Expand Down Expand Up @@ -2555,16 +2560,39 @@ 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.
*
* 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. Handlers such as
// VS Code LM / GitHub Copilot own host listeners, so the old one is
// released — immediately when idle, or when the last request, retry,
// background drain, or condense that leased it settles.
// apiConfiguration stays the profile-derived projection;
// an active task-local override is applied to the rebuilt handler only, so
// a profile switch keeps the override live and clearing it later restores
// the NEW profile's value (it is the configuration itself now).
//
// Same disposal guard as setRuntimeThinkingEffort: this is the other path that rebuilds the
// handler, and once disposal has started its handler set is already collected, so a rebuild
// would strand the replacement and re-retire a disposed handler. The task is going away, so
// there is nothing left to re-project either.
if (this.disposalPromise !== undefined) {
return
}

const previousApi = this.api
this.apiConfiguration = newApiConfiguration
this.api = buildApiHandler(this.apiConfiguration)
this.api = buildApiHandler(
this.runtimeThinkingEffort !== undefined
? { ...newApiConfiguration, reasoningEffort: this.runtimeThinkingEffort }
: newApiConfiguration,
)
this.retireApi(previousApi)

// Refresh only the soft tool-repetition limit from the new profile so an
Expand All @@ -2581,6 +2609,76 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.toolRepetitionDetector.updateLimits(this.toolRepetitionSoftLimit, this.consecutiveMistakeLimit)
}

/**
* 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 applied to the
* rebuilt API handler. `apiConfiguration` itself stays the profile-derived
* projection — the override never enters it — so the webview state (which
* posts the active task's `apiConfiguration`) and any profile save built
* from it can never carry the task-local effort. `undefined` clears the
* override and rebuilds the handler from the projection.
*
* Not inherited: child, handoff, and resumed tasks construct their own Task and
* start with no override — it lives in this task's private fields and is never
* persisted.
* @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 {
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Resolved
// Disposal has started (or finished): `disposalPromise` is set once and never cleared, so it
// is the liveness signal. The override is dropped rather than applied — the task serves no
// further request, and the handler set that dispose collects was already snapshotted, so
// rebuilding here would produce a handler nobody owns (still holding its configuration
// listeners) and retire the handler disposal has just disposed.
if (this.disposalPromise !== undefined) {
return
}

const wasActive = this.runtimeThinkingEffort !== undefined
this.runtimeThinkingEffort = effort
this.runtimeThinkingEffortSource = effort === undefined ? undefined : source

if (effort === undefined && !wasActive) {
// Already inactive: nothing to clear.
return
}

// Rebuild the handler with the effective effort applied. The projection
// (this.apiConfiguration) is untouched, so clearing needs no restore: the
// handler is simply rebuilt from the profile-derived configuration. The
// replaced handler is retired like any other swap: handlers with host
// listeners must not leak when the effort changes.
const previousApi = this.api
this.api = buildApiHandler(
effort !== undefined ? { ...this.apiConfiguration, reasoningEffort: effort } : this.apiConfiguration,
)
this.retireApi(previousApi)
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Resolved
}

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

/**
* 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 } : {}
}

/**
* Marks the start of in-flight API work. The returned release is idempotent;
* the last release disposes every handler replaced while work was running.
Expand Down Expand Up @@ -2760,6 +2858,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
parallelToolCalls: true,
}
: {}),
// 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 @@ -3707,6 +3807,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 @@ -3826,6 +3930,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
console.error("Error reverting diff changes:", error)
}

// Clear the DTE runtime-effort override so a retained disposed task never
// reports a stale override. The fields are cleared directly instead of via
// setRuntimeThinkingEffort: that setter rebuilds the handler, and during
// disposal the rebuild would (a) create a replacement handler this pass has
// already collected and disposed, and (b) retire the just-disposed handler a
// second time. `apiConfiguration` never carries the override (it stays the
// profile-derived projection), so there is nothing to restore.
this.runtimeThinkingEffort = undefined
this.runtimeThinkingEffortSource = undefined

await pendingCleanup
await this.diffReversionPromise
}
Expand Down Expand Up @@ -4372,7 +4486,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// under the canonical name slip past a name comparison. The first call wins.
const idAlreadyUsed = this.assistantMessageContent.some(
(entry) =>
// Stryker disable next-line ConditionalExpression: equivalent mutant, only tool_use and mcp_tool_use entries carry an id, so the type check cannot change entry.id === event.id
(entry.type === "tool_use" || entry.type === "mcp_tool_use") &&
(entry as { id?: string }).id === event.id,
)
Expand Down Expand Up @@ -4425,7 +4538,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// keep the deprecated single-entry-per-ID lookup.
let streamingKey: string | undefined

// Stryker disable next-line ConditionalExpression: equivalent mutant, event.name is always a string here, so the legacy nameless branch is unreachable
if (event.name !== undefined) {
streamingKey = NativeToolCallParser.makeStreamingKey(event.id, event.name)
} else {
Expand All @@ -4442,7 +4554,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}

const partialToolUse =
// Stryker disable next-line ConditionalExpression: equivalent mutant, streamingKey is always a string here (makeStreamingKey), so the null side is unreachable
streamingKey === undefined
? null
: NativeToolCallParser.processStreamingChunk(
Expand All @@ -4451,7 +4562,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
nativeToolCallParserScope,
)

// Stryker disable next-line ConditionalExpression, EqualityOperator: equivalent mutants, streamingKey is always defined here, so both conditions reduce to the partialToolUse check
if (partialToolUse && streamingKey !== undefined) {
// Reuse the compound key built above: it is exactly the key the start event
// registered under, so the delta lookup needs no second encoding.
Expand Down Expand Up @@ -4870,7 +4980,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (event.type === "tool_call_end") {
// Create compound key for deduplication (same pattern as streaming handler).
// End events carry the tool name; fall back to the id for events that don't.
// Stryker disable next-line LogicalOperator: equivalent mutant, finalizeRawChunks emits end events with a string name, so the ?? event.id fallback is unreachable
let eventName = event.name ?? event.id
let dedupKey = NativeToolCallParser.makeStreamingKey(event.id, eventName)

Expand All @@ -4887,13 +4996,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// name (provider quirk), the compound-key lookup above misses and both the
// parser entry and the dedup tracking would stay stale. Resolve the tracked
// entry by id and target the real compound key for finalization and cleanup.
// Stryker disable next-line ConditionalExpression, LogicalOperator, EqualityOperator: equivalent mutants, the both-miss case is unreachable via the raw-chunk pipeline
if (finalToolUse === null && toolUseIndex === undefined) {
const resolved = NativeToolCallParser.getStreamingToolCallById(
event.id,
nativeToolCallParserScope,
)
// Stryker disable next-line ConditionalExpression: unreachable defensive region — getStreamingToolCallById can only resolve when the both-miss case above is reachable, which the raw-chunk pipeline cannot produce (see the directive there).
if (resolved) {
eventName = resolved.name
dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name)
Expand Down Expand Up @@ -5637,6 +5744,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
parallelToolCalls: true,
}
: {}),
// Carry the active task-local effort override.
...this.getRuntimeThinkingEffortMetadata(),
}

try {
Expand Down Expand Up @@ -5888,6 +5997,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
parallelToolCalls: true,
}
: {}),
// Carry the active task-local effort override.
...this.getRuntimeThinkingEffortMetadata(),
}

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