diff --git a/packages/evals/src/db/migrations/0005_strong_skrulls.sql b/packages/evals/src/db/migrations/0005_strong_skrulls.sql new file mode 100644 index 00000000000..8b1397c1cbb --- /dev/null +++ b/packages/evals/src/db/migrations/0005_strong_skrulls.sql @@ -0,0 +1,12 @@ +ALTER TABLE "tasks" DROP CONSTRAINT "tasks_run_id_runs_id_fk"; +--> statement-breakpoint +ALTER TABLE "tasks" DROP CONSTRAINT "tasks_task_metrics_id_taskMetrics_id_fk"; +--> statement-breakpoint +ALTER TABLE "toolErrors" DROP CONSTRAINT "toolErrors_run_id_runs_id_fk"; +--> statement-breakpoint +ALTER TABLE "toolErrors" DROP CONSTRAINT "toolErrors_task_id_tasks_id_fk"; +--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_task_metrics_id_taskMetrics_id_fk" FOREIGN KEY ("task_metrics_id") REFERENCES "public"."taskMetrics"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "toolErrors" ADD CONSTRAINT "toolErrors_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "toolErrors" ADD CONSTRAINT "toolErrors_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/evals/src/db/migrations/meta/0005_snapshot.json b/packages/evals/src/db/migrations/meta/0005_snapshot.json new file mode 100644 index 00000000000..4a4b6ee3b4b --- /dev/null +++ b/packages/evals/src/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,472 @@ +{ + "id": "71b54967-86df-42ec-a200-bfd8dad85069", + "prevId": "9caa4487-e146-4084-907d-fbf9cc3e03b9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.runs": { + "name": "runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "task_metrics_id": { + "name": "task_metrics_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contextWindow": { + "name": "contextWindow", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "inputPrice": { + "name": "inputPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "outputPrice": { + "name": "outputPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cacheWritesPrice": { + "name": "cacheWritesPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cacheReadsPrice": { + "name": "cacheReadsPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "jobToken": { + "name": "jobToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socket_path": { + "name": "socket_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "concurrency": { + "name": "concurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "passed": { + "name": "passed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed": { + "name": "failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "runs_task_metrics_id_taskMetrics_id_fk": { + "name": "runs_task_metrics_id_taskMetrics_id_fk", + "tableFrom": "runs", + "tableTo": "taskMetrics", + "columnsFrom": ["task_metrics_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.taskMetrics": { + "name": "taskMetrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "taskMetrics_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tokens_context": { + "name": "tokens_context", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cache_writes": { + "name": "cache_writes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cache_reads": { + "name": "cache_reads", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tool_usage": { + "name": "tool_usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tasks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_metrics_id": { + "name": "task_metrics_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exercise": { + "name": "exercise", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tasks_language_exercise_iteration_idx": { + "name": "tasks_language_exercise_iteration_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "exercise", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "iteration", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_run_id_runs_id_fk": { + "name": "tasks_run_id_runs_id_fk", + "tableFrom": "tasks", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_task_metrics_id_taskMetrics_id_fk": { + "name": "tasks_task_metrics_id_taskMetrics_id_fk", + "tableFrom": "tasks", + "tableTo": "taskMetrics", + "columnsFrom": ["task_metrics_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.toolErrors": { + "name": "toolErrors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "toolErrors_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "toolErrors_run_id_runs_id_fk": { + "name": "toolErrors_run_id_runs_id_fk", + "tableFrom": "toolErrors", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "toolErrors_task_id_tasks_id_fk": { + "name": "toolErrors_task_id_tasks_id_fk", + "tableFrom": "toolErrors", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/evals/src/db/migrations/meta/_journal.json b/packages/evals/src/db/migrations/meta/_journal.json index 813667c6375..fbdfcd79bfe 100644 --- a/packages/evals/src/db/migrations/meta/_journal.json +++ b/packages/evals/src/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1764201678953, "tag": "0004_sloppy_black_knight", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1765167049182, + "tag": "0005_strong_skrulls", + "breakpoints": true } ] } diff --git a/packages/evals/src/db/schema.ts b/packages/evals/src/db/schema.ts index 638aae0eeeb..5094e64f206 100644 --- a/packages/evals/src/db/schema.ts +++ b/packages/evals/src/db/schema.ts @@ -50,9 +50,9 @@ export const tasks = pgTable( { id: integer().primaryKey().generatedAlwaysAsIdentity(), runId: integer("run_id") - .references(() => runs.id) + .references(() => runs.id, { onDelete: "cascade" }) .notNull(), - taskMetricsId: integer("task_metrics_id").references(() => taskMetrics.id), + taskMetricsId: integer("task_metrics_id").references(() => taskMetrics.id, { onDelete: "set null" }), language: text().notNull().$type(), exercise: text().notNull(), iteration: integer().default(1).notNull(), @@ -111,8 +111,8 @@ export type UpdateTaskMetrics = Partial> export const toolErrors = pgTable("toolErrors", { id: integer().primaryKey().generatedAlwaysAsIdentity(), - runId: integer("run_id").references(() => runs.id), - taskId: integer("task_id").references(() => tasks.id), + runId: integer("run_id").references(() => runs.id, { onDelete: "cascade" }), + taskId: integer("task_id").references(() => tasks.id, { onDelete: "cascade" }), toolName: text("tool_name").notNull().$type(), error: text().notNull(), createdAt: timestamp("created_at").notNull(), diff --git a/packages/types/src/__tests__/context-management.test.ts b/packages/types/src/__tests__/context-management.test.ts new file mode 100644 index 00000000000..a2e91f8346b --- /dev/null +++ b/packages/types/src/__tests__/context-management.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest" +import { CONTEXT_MANAGEMENT_EVENTS, isContextManagementEvent } from "../context-management.js" + +describe("context-management", () => { + describe("CONTEXT_MANAGEMENT_EVENTS", () => { + it("should contain all expected event types", () => { + expect(CONTEXT_MANAGEMENT_EVENTS).toContain("condense_context") + expect(CONTEXT_MANAGEMENT_EVENTS).toContain("condense_context_error") + expect(CONTEXT_MANAGEMENT_EVENTS).toContain("sliding_window_truncation") + expect(CONTEXT_MANAGEMENT_EVENTS).toHaveLength(3) + }) + }) + + describe("isContextManagementEvent", () => { + it("should return true for valid context management events", () => { + expect(isContextManagementEvent("condense_context")).toBe(true) + expect(isContextManagementEvent("condense_context_error")).toBe(true) + expect(isContextManagementEvent("sliding_window_truncation")).toBe(true) + }) + + it("should return false for non-context-management events", () => { + expect(isContextManagementEvent("text")).toBe(false) + expect(isContextManagementEvent("error")).toBe(false) + expect(isContextManagementEvent(null)).toBe(false) + expect(isContextManagementEvent(undefined)).toBe(false) + }) + }) +}) diff --git a/packages/types/src/context-management.ts b/packages/types/src/context-management.ts new file mode 100644 index 00000000000..aba06a22db5 --- /dev/null +++ b/packages/types/src/context-management.ts @@ -0,0 +1,34 @@ +/** + * Context Management Types + * + * This module provides type definitions for context management events. + * These events are used to handle different strategies for managing conversation context + * when approaching token limits. + * + * Event Types: + * - `condense_context`: Context was condensed using AI summarization + * - `condense_context_error`: An error occurred during context condensation + * - `sliding_window_truncation`: Context was truncated using sliding window strategy + */ + +/** + * Array of all context management event types. + * Used for runtime type checking. + */ +export const CONTEXT_MANAGEMENT_EVENTS = [ + "condense_context", + "condense_context_error", + "sliding_window_truncation", +] as const + +/** + * Union type representing all possible context management event types. + */ +export type ContextManagementEvent = (typeof CONTEXT_MANAGEMENT_EVENTS)[number] + +/** + * Type guard function to check if a value is a valid context management event. + */ +export function isContextManagementEvent(value: unknown): value is ContextManagementEvent { + return typeof value === "string" && (CONTEXT_MANAGEMENT_EVENTS as readonly string[]).includes(value) +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 32505ede7bc..4ab60899df8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,6 +1,7 @@ export * from "./api.js" export * from "./cloud.js" export * from "./codebase-index.js" +export * from "./context-management.js" export * from "./cookie-consent.js" export * from "./events.js" export * from "./experiment.js" diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index a857aacd3a4..47a39120531 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -197,8 +197,17 @@ export type ToolProgressStatus = z.infer /** * ContextCondense + * + * Data associated with a successful context condensation event. + * This is attached to messages with `say: "condense_context"` when + * the condensation operation completes successfully. + * + * @property cost - The API cost incurred for the condensation operation + * @property prevContextTokens - Token count before condensation + * @property newContextTokens - Token count after condensation + * @property summary - The condensed summary that replaced the original context + * @property condenseId - Optional unique identifier for this condensation operation */ - export const contextCondenseSchema = z.object({ cost: z.number(), prevContextTokens: z.number(), @@ -212,21 +221,39 @@ export type ContextCondense = z.infer /** * ContextTruncation * - * Used to track sliding window truncation events for the UI. + * Data associated with a sliding window truncation event. + * This is attached to messages with `say: "sliding_window_truncation"` when + * messages are removed from the conversation history to stay within token limits. + * + * Unlike condensation, truncation simply removes older messages without + * summarizing them. This is a faster but less context-preserving approach. + * + * @property truncationId - Unique identifier for this truncation operation + * @property messagesRemoved - Number of conversation messages that were removed + * @property prevContextTokens - Token count before truncation occurred + * @property newContextTokens - Token count after truncation occurred */ - export const contextTruncationSchema = z.object({ truncationId: z.string(), messagesRemoved: z.number(), prevContextTokens: z.number(), + newContextTokens: z.number(), }) export type ContextTruncation = z.infer /** * ClineMessage + * + * The main message type used for communication between the extension and webview. + * Messages can either be "ask" (requiring user response) or "say" (informational). + * + * Context Management Fields: + * - `contextCondense`: Present when `say: "condense_context"` and condensation succeeded + * - `contextTruncation`: Present when `say: "sliding_window_truncation"` and truncation occurred + * + * Note: These fields are mutually exclusive - a message will have at most one of them. */ - export const clineMessageSchema = z.object({ ts: z.number(), type: z.union([z.literal("ask"), z.literal("say")]), @@ -240,7 +267,15 @@ export const clineMessageSchema = z.object({ conversationHistoryIndex: z.number().optional(), checkpoint: z.record(z.string(), z.unknown()).optional(), progressStatus: toolProgressStatusSchema.optional(), + /** + * Data for successful context condensation. + * Present when `say: "condense_context"` and `partial: false`. + */ contextCondense: contextCondenseSchema.optional(), + /** + * Data for sliding window truncation. + * Present when `say: "sliding_window_truncation"`. + */ contextTruncation: contextTruncationSchema.optional(), isProtected: z.boolean().optional(), apiProtocol: z.union([z.literal("openai"), z.literal("anthropic")]).optional(), diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index 674cbeb176f..0ed8f94ed05 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -9,7 +9,13 @@ import { BaseProvider } from "../../../api/providers/base-provider" import { ApiMessage } from "../../task-persistence/apiMessages" import * as condenseModule from "../../condense" -import { TOKEN_BUFFER_PERCENTAGE, estimateTokenCount, truncateConversation, manageContext } from "../index" +import { + TOKEN_BUFFER_PERCENTAGE, + estimateTokenCount, + truncateConversation, + manageContext, + willManageContext, +} from "../index" // Create a mock ApiHandler for testing class MockApiHandler extends BaseProvider { @@ -1280,4 +1286,125 @@ describe("Context Management", () => { expect(result2.truncationId).toBeDefined() }) }) + + /** + * Tests for the willManageContext helper function + */ + describe("willManageContext", () => { + it("should return true when context percent exceeds threshold", () => { + const result = willManageContext({ + totalTokens: 60000, + contextWindow: 100000, // 60% of context window + maxTokens: 30000, + autoCondenseContext: true, + autoCondenseContextPercent: 50, // 50% threshold + profileThresholds: {}, + currentProfileId: "default", + lastMessageTokens: 0, + }) + expect(result).toBe(true) + }) + + it("should return false when context percent is below threshold", () => { + const result = willManageContext({ + totalTokens: 40000, + contextWindow: 100000, // 40% of context window + maxTokens: 30000, + autoCondenseContext: true, + autoCondenseContextPercent: 50, // 50% threshold + profileThresholds: {}, + currentProfileId: "default", + lastMessageTokens: 0, + }) + expect(result).toBe(false) + }) + + it("should return true when tokens exceed allowedTokens even if autoCondenseContext is false", () => { + // allowedTokens = contextWindow * (1 - 0.1) - reservedTokens = 100000 * 0.9 - 30000 = 60000 + const result = willManageContext({ + totalTokens: 60001, // Exceeds allowedTokens + contextWindow: 100000, + maxTokens: 30000, + autoCondenseContext: false, // Even with auto-condense disabled + autoCondenseContextPercent: 50, + profileThresholds: {}, + currentProfileId: "default", + lastMessageTokens: 0, + }) + expect(result).toBe(true) + }) + + it("should return false when autoCondenseContext is false and tokens are below allowedTokens", () => { + // allowedTokens = contextWindow * (1 - 0.1) - reservedTokens = 100000 * 0.9 - 30000 = 60000 + const result = willManageContext({ + totalTokens: 59999, // Below allowedTokens + contextWindow: 100000, + maxTokens: 30000, + autoCondenseContext: false, + autoCondenseContextPercent: 50, // This shouldn't matter since autoCondenseContext is false + profileThresholds: {}, + currentProfileId: "default", + lastMessageTokens: 0, + }) + expect(result).toBe(false) + }) + + it("should use profile-specific threshold when available", () => { + const result = willManageContext({ + totalTokens: 55000, + contextWindow: 100000, // 55% of context window + maxTokens: 30000, + autoCondenseContext: true, + autoCondenseContextPercent: 80, // Global threshold 80% + profileThresholds: { "test-profile": 50 }, // Profile threshold 50% + currentProfileId: "test-profile", + lastMessageTokens: 0, + }) + // Should trigger because 55% > 50% (profile threshold) + expect(result).toBe(true) + }) + + it("should fall back to global threshold when profile threshold is -1", () => { + const result = willManageContext({ + totalTokens: 55000, + contextWindow: 100000, // 55% of context window + maxTokens: 30000, + autoCondenseContext: true, + autoCondenseContextPercent: 80, // Global threshold 80% + profileThresholds: { "test-profile": -1 }, // Profile uses global + currentProfileId: "test-profile", + lastMessageTokens: 0, + }) + // Should NOT trigger because 55% < 80% (global threshold) + expect(result).toBe(false) + }) + + it("should include lastMessageTokens in the calculation", () => { + // Without lastMessageTokens: 49000 tokens = 49% + // With lastMessageTokens: 49000 + 2000 = 51000 tokens = 51% + const resultWithoutLastMessage = willManageContext({ + totalTokens: 49000, + contextWindow: 100000, + maxTokens: 30000, + autoCondenseContext: true, + autoCondenseContextPercent: 50, // 50% threshold + profileThresholds: {}, + currentProfileId: "default", + lastMessageTokens: 0, + }) + expect(resultWithoutLastMessage).toBe(false) + + const resultWithLastMessage = willManageContext({ + totalTokens: 49000, + contextWindow: 100000, + maxTokens: 30000, + autoCondenseContext: true, + autoCondenseContextPercent: 50, // 50% threshold + profileThresholds: {}, + currentProfileId: "default", + lastMessageTokens: 2000, // Pushes total to 51% + }) + expect(resultWithLastMessage).toBe(true) + }) + }) }) diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index ff92cb3ca56..993c69a3657 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -133,6 +133,68 @@ export function truncateConversation(messages: ApiMessage[], fracToRemove: numbe } } +/** + * Options for checking if context management will likely run. + * A subset of ContextManagementOptions with only the fields needed for threshold calculation. + */ +export type WillManageContextOptions = { + totalTokens: number + contextWindow: number + maxTokens?: number | null + autoCondenseContext: boolean + autoCondenseContextPercent: number + profileThresholds: Record + currentProfileId: string + lastMessageTokens: number +} + +/** + * Checks whether context management (condensation or truncation) will likely run based on current token usage. + * + * This is useful for showing UI indicators before `manageContext` is actually called, + * without duplicating the threshold calculation logic. + * + * @param {WillManageContextOptions} options - The options for threshold calculation + * @returns {boolean} True if context management will likely run, false otherwise + */ +export function willManageContext({ + totalTokens, + contextWindow, + maxTokens, + autoCondenseContext, + autoCondenseContextPercent, + profileThresholds, + currentProfileId, + lastMessageTokens, +}: WillManageContextOptions): boolean { + if (!autoCondenseContext) { + // When auto-condense is disabled, only truncation can occur + const reservedTokens = maxTokens || ANTHROPIC_DEFAULT_MAX_TOKENS + const prevContextTokens = totalTokens + lastMessageTokens + const allowedTokens = contextWindow * (1 - TOKEN_BUFFER_PERCENTAGE) - reservedTokens + return prevContextTokens > allowedTokens + } + + const reservedTokens = maxTokens || ANTHROPIC_DEFAULT_MAX_TOKENS + const prevContextTokens = totalTokens + lastMessageTokens + const allowedTokens = contextWindow * (1 - TOKEN_BUFFER_PERCENTAGE) - reservedTokens + + // Determine the effective threshold to use + let effectiveThreshold = autoCondenseContextPercent + const profileThreshold = profileThresholds[currentProfileId] + if (profileThreshold !== undefined) { + if (profileThreshold === -1) { + effectiveThreshold = autoCondenseContextPercent + } else if (profileThreshold >= MIN_CONDENSE_THRESHOLD && profileThreshold <= MAX_CONDENSE_THRESHOLD) { + effectiveThreshold = profileThreshold + } + // Invalid values fall back to global setting (effectiveThreshold already set) + } + + const contextPercent = (100 * prevContextTokens) / contextWindow + return contextPercent >= effectiveThreshold || prevContextTokens > allowedTokens +} + /** * Context Management: Conditionally manages the conversation context when approaching limits. * @@ -164,6 +226,7 @@ export type ContextManagementResult = SummarizeResponse & { prevContextTokens: number truncationId?: string messagesRemoved?: number + newContextTokensAfterTruncation?: number } /** @@ -254,6 +317,25 @@ export async function manageContext({ // Fall back to sliding window truncation if needed if (prevContextTokens > allowedTokens) { const truncationResult = truncateConversation(messages, 0.5, taskId) + + // Calculate new context tokens after truncation by counting non-truncated messages + // Messages with truncationParent are hidden, so we count only those without it + const effectiveMessages = truncationResult.messages.filter( + (msg) => !msg.truncationParent && !msg.isTruncationMarker, + ) + let newContextTokensAfterTruncation = 0 + for (const msg of effectiveMessages) { + const content = msg.content + if (Array.isArray(content)) { + newContextTokensAfterTruncation += await estimateTokenCount(content, apiHandler) + } else if (typeof content === "string") { + newContextTokensAfterTruncation += await estimateTokenCount( + [{ type: "text", text: content }], + apiHandler, + ) + } + } + return { messages: truncationResult.messages, prevContextTokens, @@ -262,6 +344,7 @@ export async function manageContext({ error, truncationId: truncationResult.truncationId, messagesRemoved: truncationResult.messagesRemoved, + newContextTokensAfterTruncation, } } // No truncation or condensation needed diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4914e80ea28..1df90f13444 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -100,7 +100,7 @@ import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { AssistantMessageParser } from "../assistant-message/AssistantMessageParser" import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" -import { manageContext } from "../context-management" +import { manageContext, willManageContext } from "../context-management" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" @@ -3520,6 +3520,9 @@ export class Task extends EventEmitter implements TaskLike { const protocol = resolveToolProtocol(this.apiConfiguration, modelInfo) const useNativeTools = isNativeProtocol(protocol) + // Send condenseTaskContextStarted to show in-progress indicator + await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) + // Force aggressive truncation by keeping only 75% of the conversation history const truncateResult = await manageContext({ messages: this.apiConversationHistory, @@ -3559,6 +3562,7 @@ export class Task extends EventEmitter implements TaskLike { truncationId: truncateResult.truncationId, messagesRemoved: truncateResult.messagesRemoved ?? 0, prevContextTokens: truncateResult.prevContextTokens, + newContextTokens: truncateResult.newContextTokensAfterTruncation ?? 0, } await this.say( "sliding_window_truncation", @@ -3572,6 +3576,9 @@ export class Task extends EventEmitter implements TaskLike { contextTruncation, ) } + + // Notify webview that context management is complete (removes in-progress spinner) + await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId }) } public async *attemptApiRequest(retryAttempt: number = 0): ApiStream { @@ -3660,6 +3667,38 @@ export class Task extends EventEmitter implements TaskLike { const protocol = resolveToolProtocol(this.apiConfiguration, modelInfoForProtocol) const useNativeTools = isNativeProtocol(protocol) + // Check if context management will likely run (threshold check) + // This allows us to show an in-progress indicator to the user + // We use the centralized willManageContext helper to avoid duplicating threshold logic + const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1] + const lastMessageContent = lastMessage?.content + let lastMessageTokens = 0 + if (lastMessageContent) { + lastMessageTokens = Array.isArray(lastMessageContent) + ? await this.api.countTokens(lastMessageContent) + : await this.api.countTokens([{ type: "text", text: lastMessageContent as string }]) + } + + const contextManagementWillRun = willManageContext({ + totalTokens: contextTokens, + contextWindow, + maxTokens, + autoCondenseContext, + autoCondenseContextPercent, + profileThresholds, + currentProfileId, + lastMessageTokens, + }) + + // Send condenseTaskContextStarted BEFORE manageContext to show in-progress indicator + // This notification must be sent here (not earlier) because the early check uses stale token count + // (before user message is added to history), which could incorrectly skip showing the indicator + if (contextManagementWillRun && autoCondenseContext) { + await this.providerRef + .deref() + ?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) + } + const truncateResult = await manageContext({ messages: this.apiConversationHistory, totalTokens: contextTokens, @@ -3706,6 +3745,7 @@ export class Task extends EventEmitter implements TaskLike { truncationId: truncateResult.truncationId, messagesRemoved: truncateResult.messagesRemoved ?? 0, prevContextTokens: truncateResult.prevContextTokens, + newContextTokens: truncateResult.newContextTokensAfterTruncation ?? 0, } await this.say( "sliding_window_truncation", @@ -3719,6 +3759,14 @@ export class Task extends EventEmitter implements TaskLike { contextTruncation, ) } + + // Notify webview that context management is complete (sets isCondensing = false) + // This removes the in-progress spinner and allows the completed result to show + if (contextManagementWillRun && autoCondenseContext) { + await this.providerRef + .deref() + ?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId }) + } } // Get the effective API history by filtering out condensed messages diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index ce0571d16fb..62f77d17f44 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -124,6 +124,7 @@ export interface ExtensionMessage { | "mcpExecutionStatus" | "vsCodeSetting" | "authenticatedUser" + | "condenseTaskContextStarted" | "condenseTaskContextResponse" | "singleRouterModelFetchResponse" | "rooCreditBalance" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index eb4aea0e249..a8897482e3a 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -41,7 +41,7 @@ import { Markdown } from "./Markdown" import { CommandExecution } from "./CommandExecution" import { CommandExecutionError } from "./CommandExecutionError" import { AutoApprovedRequestLimitWarning } from "./AutoApprovedRequestLimitWarning" -import { CondensingContextRow, ContextCondenseRow } from "./ContextCondenseRow" +import { InProgressRow, CondensationResultRow, CondensationErrorRow, TruncationResultRow } from "./context-management" import CodebaseSearchResultsDisplay from "./CodebaseSearchResultsDisplay" import { appendImages } from "@src/utils/imageUtils" import { McpExecution } from "./McpExecution" @@ -1508,19 +1508,35 @@ export const ChatRowContent = ({ /> ) case "condense_context": + // In-progress state if (message.partial) { - return + return } - return message.contextCondense ? : null + // Completed state + if (message.contextCondense) { + return + } + return null case "condense_context_error": - return ( - - ) + // return ( + // + // ) + return + case "sliding_window_truncation": + // In-progress state + if (message.partial) { + return + } + // Completed state + if (message.contextTruncation) { + return + } + return null case "codebase_search_result": let parsed: { content: { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 329772e3739..38c37baef8e 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -867,8 +867,21 @@ const ChatViewComponent: React.ForwardRefRenderFunction ({ default: () => null, })) +// Mock react-virtuoso to render items directly without virtualization +// This allows tests to verify items rendered in the chat list +vi.mock("react-virtuoso", () => ({ + Virtuoso: function MockVirtuoso({ + data, + itemContent, + }: { + data: ClineMessage[] + itemContent: (index: number, item: ClineMessage) => React.ReactNode + }) { + return ( +
+ {data.map((item, index) => ( +
+ {itemContent(index, item)} +
+ ))} +
+ ) + }, +})) + // Mock VersionIndicator - returns null by default to prevent rendering in tests vi.mock("../../common/VersionIndicator", () => ({ default: vi.fn(() => null), @@ -467,6 +489,11 @@ describe("ChatView - Focus Grabbing Tests", () => { expect(getByTestId("chat-textarea")).toBeInTheDocument() }) + // Wait for the debounced focus effect to fire (50ms debounce + buffer for CI variability) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + // Clear any initial calls after state has settled mockFocus.mockClear() diff --git a/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx b/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx new file mode 100644 index 00000000000..62d2d8fd101 --- /dev/null +++ b/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx @@ -0,0 +1,25 @@ +import { useTranslation } from "react-i18next" + +interface CondensationErrorRowProps { + errorText?: string +} + +/** + * Displays an error message when context condensation fails. + * Shows a warning icon with the error header and optional error details. + */ +export function CondensationErrorRow({ errorText }: CondensationErrorRowProps) { + const { t } = useTranslation() + + return ( +
+
+ + + {t("chat:contextManagement.condensation.errorHeader")} + +
+ {errorText && {errorText}} +
+ ) +} diff --git a/webview-ui/src/components/chat/ContextCondenseRow.tsx b/webview-ui/src/components/chat/context-management/CondensationResultRow.tsx similarity index 54% rename from webview-ui/src/components/chat/ContextCondenseRow.tsx rename to webview-ui/src/components/chat/context-management/CondensationResultRow.tsx index c495927215e..99343acdaa6 100644 --- a/webview-ui/src/components/chat/ContextCondenseRow.tsx +++ b/webview-ui/src/components/chat/context-management/CondensationResultRow.tsx @@ -1,16 +1,26 @@ import { useState } from "react" import { useTranslation } from "react-i18next" import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react" +import { FoldVertical } from "lucide-react" import type { ContextCondense } from "@roo-code/types" -import { Markdown } from "./Markdown" -import { ProgressIndicator } from "./ProgressIndicator" +import { Markdown } from "../Markdown" -export const ContextCondenseRow = ({ cost, prevContextTokens, newContextTokens, summary }: ContextCondense) => { +interface CondensationResultRowProps { + data: ContextCondense +} + +/** + * Displays the result of a successful context condensation operation. + * Shows token reduction, cost, and an expandable summary section. + */ +export function CondensationResultRow({ data }: CondensationResultRowProps) { const { t } = useTranslation() const [isExpanded, setIsExpanded] = useState(false) + const { cost, prevContextTokens, newContextTokens, summary } = data + // Handle null/undefined token values to prevent crashes const prevTokens = prevContextTokens ?? 0 const newTokens = newContextTokens ?? 0 @@ -21,24 +31,14 @@ export const ContextCondenseRow = ({ cost, prevContextTokens, newContextTokens,
setIsExpanded(!isExpanded)}> -
- -
- - {t("chat:contextCondense.title")} + + + {t("chat:contextManagement.condensation.title")} + - {prevTokens.toLocaleString()} → {newTokens.toLocaleString()} {t("tokens")} + {prevTokens.toLocaleString()} → {newTokens.toLocaleString()}{" "} + {t("chat:contextManagement.tokens")} 0 ? "opacity-100" : "opacity-0"}> ${displayCost.toFixed(2)} @@ -55,14 +55,3 @@ export const ContextCondenseRow = ({ cost, prevContextTokens, newContextTokens,
) } - -export const CondensingContextRow = () => { - const { t } = useTranslation() - return ( -
- - - {t("chat:contextCondense.condensing")} -
- ) -} diff --git a/webview-ui/src/components/chat/context-management/InProgressRow.tsx b/webview-ui/src/components/chat/context-management/InProgressRow.tsx new file mode 100644 index 00000000000..fbe260d9b62 --- /dev/null +++ b/webview-ui/src/components/chat/context-management/InProgressRow.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next" + +import { ProgressIndicator } from "../ProgressIndicator" + +interface InProgressRowProps { + eventType: "condense_context" | "sliding_window_truncation" +} + +/** + * Displays an in-progress indicator for context management operations. + * Shows a spinner with operation-specific text based on the event type. + */ +export function InProgressRow({ eventType }: InProgressRowProps) { + const { t } = useTranslation() + + const progressText = + eventType === "condense_context" + ? t("chat:contextManagement.condensation.inProgress") + : t("chat:contextManagement.truncation.inProgress") + + return ( +
+ + {progressText} +
+ ) +} diff --git a/webview-ui/src/components/chat/context-management/TruncationResultRow.tsx b/webview-ui/src/components/chat/context-management/TruncationResultRow.tsx new file mode 100644 index 00000000000..f31a6752c1b --- /dev/null +++ b/webview-ui/src/components/chat/context-management/TruncationResultRow.tsx @@ -0,0 +1,64 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { FoldVertical } from "lucide-react" + +import type { ContextTruncation } from "@roo-code/types" + +interface TruncationResultRowProps { + data: ContextTruncation +} + +/** + * Displays the result of a sliding window truncation operation. + * Shows information about how many messages were removed and the + * token count before and after truncation. + * + * This component provides visual feedback for truncation events which + * were previously silent, addressing a gap in the context management UI. + */ +export function TruncationResultRow({ data }: TruncationResultRowProps) { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + + const { messagesRemoved, prevContextTokens, newContextTokens } = data + + // Handle null/undefined values to prevent crashes + const removedCount = messagesRemoved ?? 0 + const prevTokens = prevContextTokens ?? 0 + const newTokens = newContextTokens ?? 0 + + return ( +
+
setIsExpanded(!isExpanded)}> +
+ + + {t("chat:contextManagement.truncation.title")} + + + {prevTokens.toLocaleString()} → {newTokens.toLocaleString()}{" "} + {t("chat:contextManagement.tokens")} + +
+ +
+ + {isExpanded && ( +
+
+
+ + {t("chat:contextManagement.truncation.messagesRemoved", { count: removedCount })} + +
+

+ {t("chat:contextManagement.truncation.description")} +

+
+
+ )} +
+ ) +} diff --git a/webview-ui/src/components/chat/context-management/index.ts b/webview-ui/src/components/chat/context-management/index.ts new file mode 100644 index 00000000000..4150a58d97f --- /dev/null +++ b/webview-ui/src/components/chat/context-management/index.ts @@ -0,0 +1,13 @@ +/** + * Context Management UI Components + * + * Components for displaying context management events in the ChatView: + * - Context Condensation: AI-powered summarization to reduce token usage + * - Context Truncation: Sliding window removal of older messages + * - Error States: When context management operations fail + */ + +export { InProgressRow } from "./InProgressRow" +export { CondensationResultRow } from "./CondensationResultRow" +export { CondensationErrorRow } from "./CondensationErrorRow" +export { TruncationResultRow } from "./TruncationResultRow" diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 632cb4089d8..f4ae84bfe34 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -177,11 +177,20 @@ }, "current": "Current" }, - "contextCondense": { - "title": "Context Condensed", - "condensing": "Condensing context...", - "errorHeader": "Failed to condense context", - "tokens": "tokens" + "contextManagement": { + "tokens": "tokens", + "condensation": { + "title": "Context Condensed", + "inProgress": "Condensing context...", + "errorHeader": "Failed to condense context" + }, + "truncation": { + "title": "Context Truncated", + "inProgress": "Truncating context...", + "messagesRemoved": "{{count}} message removed", + "messagesRemoved_other": "{{count}} messages removed", + "description": "Older messages were removed from the conversation to stay within the context window limit. This is a fast but less context-preserving approach compared to condensation." + } }, "instructions": { "wantsToFetch": "Roo wants to fetch detailed instructions to assist with the current task" diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 0f64c6673ea..cfeac522956 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -289,11 +289,20 @@ "thinking": "思考", "seconds": "{{count}}秒" }, - "contextCondense": { - "title": "上下文已压缩", - "condensing": "正在压缩上下文...", - "errorHeader": "上下文压缩失败", - "tokens": "tokens" + "contextManagement": { + "tokens": "Token", + "condensation": { + "title": "上下文已压缩", + "inProgress": "正在压缩上下文...", + "errorHeader": "上下文压缩失败" + }, + "truncation": { + "title": "上下文已截断", + "inProgress": "正在截断上下文...", + "messagesRemoved": "已移除 {{count}} 条消息", + "messagesRemoved_other": "已移除 {{count}} 条消息", + "description": "为保持在上下文窗口限制内,已从对话中移除较旧的消息。与压缩相比,这是一种快速但上下文保留较少的方法。" + } }, "followUpSuggest": { "copyToInput": "复制到输入框(或按住Shift点击)", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 2bb1f69461c..a8b3552b8ce 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -177,11 +177,20 @@ }, "current": "目前" }, - "contextCondense": { - "title": "上下文已壓縮", - "condensing": "正在壓縮上下文...", - "errorHeader": "壓縮上下文失敗", - "tokens": "Token" + "contextManagement": { + "tokens": "Token", + "condensation": { + "title": "上下文已壓縮", + "inProgress": "正在壓縮上下文...", + "errorHeader": "壓縮上下文失敗" + }, + "truncation": { + "title": "上下文已截斷", + "inProgress": "正在截斷上下文...", + "messagesRemoved": "已移除 {{count}} 則訊息", + "messagesRemoved_other": "已移除 {{count}} 則訊息", + "description": "為保持在上下文視窗限制內,已從對話中移除較舊的訊息。與壓縮相比,這是一種快速但上下文保留較少的方法。" + } }, "instructions": { "wantsToFetch": "Roo 想要取得詳細指示以協助目前工作"