mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge pull request #3736 from RooVetGit/canyon/condense-metrics
[Condense Context] Track metrics around context condensing and show in UI
This commit is contained in:
commit
aff94e13bc
32 changed files with 949 additions and 108 deletions
5
.changeset/fair-houses-deny.md
Normal file
5
.changeset/fair-houses-deny.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"roo-cline": patch
|
||||
---
|
||||
|
||||
Shows in the UI when the context is intelligently condensed
|
||||
|
|
@ -993,6 +993,7 @@ export const clineSays = [
|
|||
"checkpoint_saved",
|
||||
"rooignore_error",
|
||||
"diff_error",
|
||||
"condense_context",
|
||||
] as const
|
||||
|
||||
export const clineSaySchema = z.enum(clineSays)
|
||||
|
|
@ -1011,6 +1012,18 @@ export const toolProgressStatusSchema = z.object({
|
|||
|
||||
export type ToolProgressStatus = z.infer<typeof toolProgressStatusSchema>
|
||||
|
||||
/**
|
||||
* ContextCondense
|
||||
*/
|
||||
|
||||
export const contextCondenseSchema = z.object({
|
||||
cost: z.number(),
|
||||
prevContextTokens: z.number(),
|
||||
newContextTokens: z.number(),
|
||||
})
|
||||
|
||||
export type ContextCondense = z.infer<typeof contextCondenseSchema>
|
||||
|
||||
/**
|
||||
* ClineMessage
|
||||
*/
|
||||
|
|
@ -1027,6 +1040,7 @@ export const clineMessageSchema = z.object({
|
|||
conversationHistoryIndex: z.number().optional(),
|
||||
checkpoint: z.record(z.string(), z.unknown()).optional(),
|
||||
progressStatus: toolProgressStatusSchema.optional(),
|
||||
contextCondense: contextCondenseSchema.optional(),
|
||||
})
|
||||
|
||||
export type ClineMessage = z.infer<typeof clineMessageSchema>
|
||||
|
|
|
|||
|
|
@ -69,10 +69,11 @@ describe("summarizeConversation", () => {
|
|||
// Reset mocks
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Setup mock stream
|
||||
// Setup mock stream with usage information
|
||||
mockStream = (async function* () {
|
||||
yield { type: "text" as const, text: "This is " }
|
||||
yield { type: "text" as const, text: "a summary" }
|
||||
yield { type: "usage" as const, totalCost: 0.05, outputTokens: 150 }
|
||||
})()
|
||||
|
||||
// Setup mock API handler
|
||||
|
|
@ -103,7 +104,10 @@ describe("summarizeConversation", () => {
|
|||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler)
|
||||
expect(result).toEqual(messages)
|
||||
expect(result.messages).toEqual(messages)
|
||||
expect(result.cost).toBe(0)
|
||||
expect(result.summary).toBe("")
|
||||
expect(result.newContextTokens).toBeUndefined()
|
||||
expect(mockApiHandler.createMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -119,7 +123,10 @@ describe("summarizeConversation", () => {
|
|||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler)
|
||||
expect(result).toEqual(messages)
|
||||
expect(result.messages).toEqual(messages)
|
||||
expect(result.cost).toBe(0)
|
||||
expect(result.summary).toBe("")
|
||||
expect(result.newContextTokens).toBeUndefined()
|
||||
expect(mockApiHandler.createMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -142,17 +149,22 @@ describe("summarizeConversation", () => {
|
|||
|
||||
// Verify the structure of the result
|
||||
// The result should be: original messages (except last N) + summary + last N messages
|
||||
expect(result.length).toBe(messages.length + 1) // Original + summary
|
||||
expect(result.messages.length).toBe(messages.length + 1) // Original + summary
|
||||
|
||||
// Check that the summary message was inserted correctly
|
||||
const summaryMessage = result[result.length - N_MESSAGES_TO_KEEP - 1]
|
||||
const summaryMessage = result.messages[result.messages.length - N_MESSAGES_TO_KEEP - 1]
|
||||
expect(summaryMessage.role).toBe("assistant")
|
||||
expect(summaryMessage.content).toBe("This is a summary")
|
||||
expect(summaryMessage.isSummary).toBe(true)
|
||||
|
||||
// Check that the last N_MESSAGES_TO_KEEP messages are preserved
|
||||
const lastMessages = messages.slice(-N_MESSAGES_TO_KEEP)
|
||||
expect(result.slice(-N_MESSAGES_TO_KEEP)).toEqual(lastMessages)
|
||||
expect(result.messages.slice(-N_MESSAGES_TO_KEEP)).toEqual(lastMessages)
|
||||
|
||||
// Check the cost and token counts
|
||||
expect(result.cost).toBe(0.05)
|
||||
expect(result.summary).toBe("This is a summary")
|
||||
expect(result.newContextTokens).toBe(250) // 150 output tokens + 100 from countTokens
|
||||
})
|
||||
|
||||
it("should handle empty summary response", async () => {
|
||||
|
|
@ -172,9 +184,10 @@ describe("summarizeConversation", () => {
|
|||
const mockWarn = jest.fn()
|
||||
console.warn = mockWarn
|
||||
|
||||
// Setup empty summary response
|
||||
// Setup empty summary response with usage information
|
||||
const emptyStream = (async function* () {
|
||||
yield { type: "text" as const, text: "" }
|
||||
yield { type: "usage" as const, totalCost: 0.02, outputTokens: 0 }
|
||||
})()
|
||||
|
||||
// Create a new mock for createMessage that returns empty stream
|
||||
|
|
@ -189,7 +202,9 @@ describe("summarizeConversation", () => {
|
|||
const result = await summarizeConversation(messages, mockApiHandler)
|
||||
|
||||
// Should return original messages when summary is empty
|
||||
expect(result).toEqual(messages)
|
||||
expect(result.messages).toEqual(messages)
|
||||
expect(result.cost).toBe(0.02)
|
||||
expect(result.summary).toBe("")
|
||||
expect(mockWarn).toHaveBeenCalledWith("Received empty summary from API")
|
||||
|
||||
// Restore console.warn
|
||||
|
|
@ -225,4 +240,37 @@ describe("summarizeConversation", () => {
|
|||
const mockCallArgs = (maybeRemoveImageBlocks as jest.Mock).mock.calls[0][0] as any[]
|
||||
expect(mockCallArgs[mockCallArgs.length - 1]).toEqual(expectedFinalMessage)
|
||||
})
|
||||
|
||||
it("should calculate newContextTokens correctly with systemPrompt", async () => {
|
||||
const messages: ApiMessage[] = [
|
||||
{ role: "user", content: "Hello", ts: 1 },
|
||||
{ role: "assistant", content: "Hi there", ts: 2 },
|
||||
{ role: "user", content: "How are you?", ts: 3 },
|
||||
{ role: "assistant", content: "I'm good", ts: 4 },
|
||||
{ role: "user", content: "What's new?", ts: 5 },
|
||||
{ role: "assistant", content: "Not much", ts: 6 },
|
||||
{ role: "user", content: "Tell me more", ts: 7 },
|
||||
]
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
|
||||
// Create a stream with usage information
|
||||
const streamWithUsage = (async function* () {
|
||||
yield { type: "text" as const, text: "This is a summary with system prompt" }
|
||||
yield { type: "usage" as const, totalCost: 0.06, outputTokens: 200 }
|
||||
})()
|
||||
|
||||
// Override the mock for this test
|
||||
mockApiHandler.createMessage = jest.fn().mockReturnValue(streamWithUsage) as any
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, systemPrompt)
|
||||
|
||||
// Verify that countTokens was called with the correct messages including system prompt
|
||||
expect(mockApiHandler.countTokens).toHaveBeenCalled()
|
||||
|
||||
// Check the newContextTokens calculation includes system prompt
|
||||
expect(result.newContextTokens).toBe(300) // 200 output tokens + 100 from countTokens
|
||||
expect(result.cost).toBe(0.06)
|
||||
expect(result.summary).toBe("This is a summary with system prompt")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -45,23 +45,35 @@ Example summary structure:
|
|||
Output only the summary of the conversation so far, without any additional commentary or explanation.
|
||||
`
|
||||
|
||||
export type SummarizeResponse = {
|
||||
messages: ApiMessage[] // The messages after summarization
|
||||
summary: string // The summary text; empty string for no summary
|
||||
cost: number // The cost of the summarization operation
|
||||
newContextTokens?: number // The number of tokens in the context for the next API request
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes the conversation messages using an LLM call
|
||||
*
|
||||
* @param {ApiMessage[]} messages - The conversation messages
|
||||
* @param {ApiHandler} apiHandler - The API handler to use for token counting.
|
||||
* @returns {ApiMessage[]} - The input messages, potentially including a new summary message before the last message.
|
||||
* @returns {SummarizeResponse} - The result of the summarization operation (see above)
|
||||
*/
|
||||
export async function summarizeConversation(messages: ApiMessage[], apiHandler: ApiHandler): Promise<ApiMessage[]> {
|
||||
export async function summarizeConversation(
|
||||
messages: ApiMessage[],
|
||||
apiHandler: ApiHandler,
|
||||
systemPrompt?: string,
|
||||
): Promise<SummarizeResponse> {
|
||||
const response: SummarizeResponse = { messages, cost: 0, summary: "" }
|
||||
const messagesToSummarize = getMessagesSinceLastSummary(messages.slice(0, -N_MESSAGES_TO_KEEP))
|
||||
if (messagesToSummarize.length <= 1) {
|
||||
return messages // Not enough messages to warrant a summary
|
||||
return response // Not enough messages to warrant a summary
|
||||
}
|
||||
const keepMessages = messages.slice(-N_MESSAGES_TO_KEEP)
|
||||
for (const message of keepMessages) {
|
||||
if (message.isSummary) {
|
||||
return messages // We recently summarized these messages; it's too soon to summarize again.
|
||||
}
|
||||
// Check if there's a recent summary in the messages we're keeping
|
||||
const recentSummaryExists = keepMessages.some((message) => message.isSummary)
|
||||
if (recentSummaryExists) {
|
||||
return response // We recently summarized these messages; it's too soon to summarize again.
|
||||
}
|
||||
const finalRequestMessage: Anthropic.MessageParam = {
|
||||
role: "user",
|
||||
|
|
@ -73,16 +85,21 @@ export async function summarizeConversation(messages: ApiMessage[], apiHandler:
|
|||
// Note: this doesn't need to be a stream, consider using something like apiHandler.completePrompt
|
||||
const stream = apiHandler.createMessage(SUMMARY_PROMPT, requestMessages)
|
||||
let summary = ""
|
||||
// TODO(canyon): compute usage and cost for this operation and update the global metrics.
|
||||
let cost = 0
|
||||
let outputTokens = 0
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === "text") {
|
||||
summary += chunk.text
|
||||
} else if (chunk.type === "usage") {
|
||||
// Record final usage chunk only
|
||||
cost = chunk.totalCost ?? 0
|
||||
outputTokens = chunk.outputTokens ?? 0
|
||||
}
|
||||
}
|
||||
summary = summary.trim()
|
||||
if (summary.length === 0) {
|
||||
console.warn("Received empty summary from API")
|
||||
return messages
|
||||
return { ...response, cost }
|
||||
}
|
||||
const summaryMessage: ApiMessage = {
|
||||
role: "assistant",
|
||||
|
|
@ -90,8 +107,19 @@ export async function summarizeConversation(messages: ApiMessage[], apiHandler:
|
|||
ts: keepMessages[0].ts,
|
||||
isSummary: true,
|
||||
}
|
||||
const newMessages = [...messages.slice(0, -N_MESSAGES_TO_KEEP), summaryMessage, ...keepMessages]
|
||||
|
||||
return [...messages.slice(0, -N_MESSAGES_TO_KEEP), summaryMessage, ...keepMessages]
|
||||
// Count the tokens in the context for the next API request
|
||||
// We only estimate the tokens in summaryMesage if outputTokens is 0, otherwise we use outputTokens
|
||||
const contextMessages = outputTokens ? [...keepMessages] : [summaryMessage, ...keepMessages]
|
||||
if (systemPrompt) {
|
||||
contextMessages.unshift({ role: "user", content: systemPrompt })
|
||||
}
|
||||
const contextBlocks = contextMessages.flatMap((message) =>
|
||||
typeof message.content === "string" ? [{ text: message.content, type: "text" as const }] : message.content,
|
||||
)
|
||||
const newContextTokens = outputTokens + (await apiHandler.countTokens(contextBlocks))
|
||||
return { messages: newMessages, summary, cost, newContextTokens }
|
||||
}
|
||||
|
||||
/* Returns the list of all messages since the last summary message, including the summary. Returns all messages if there is no summary. */
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
truncateConversationIfNeeded,
|
||||
} from "../index"
|
||||
import { ApiMessage } from "../../task-persistence/apiMessages"
|
||||
import * as condenseModule from "../../condense"
|
||||
|
||||
// Create a mock ApiHandler for testing
|
||||
class MockApiHandler extends BaseProvider {
|
||||
|
|
@ -248,7 +249,14 @@ describe("truncateConversationIfNeeded", () => {
|
|||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(result).toEqual(messagesWithSmallContent) // No truncation occurs
|
||||
|
||||
// Check the new return type
|
||||
expect(result).toEqual({
|
||||
messages: messagesWithSmallContent,
|
||||
summary: "",
|
||||
cost: 0,
|
||||
prevContextTokens: totalTokens,
|
||||
})
|
||||
})
|
||||
|
||||
it("should truncate if tokens are above max tokens threshold", async () => {
|
||||
|
|
@ -260,7 +268,7 @@ describe("truncateConversationIfNeeded", () => {
|
|||
|
||||
// When truncating, always uses 0.5 fraction
|
||||
// With 4 messages after the first, 0.5 fraction means remove 2 messages
|
||||
const expectedResult = [messagesWithSmallContent[0], messagesWithSmallContent[3], messagesWithSmallContent[4]]
|
||||
const expectedMessages = [messagesWithSmallContent[0], messagesWithSmallContent[3], messagesWithSmallContent[4]]
|
||||
|
||||
const result = await truncateConversationIfNeeded({
|
||||
messages: messagesWithSmallContent,
|
||||
|
|
@ -269,7 +277,13 @@ describe("truncateConversationIfNeeded", () => {
|
|||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(result).toEqual(expectedResult)
|
||||
|
||||
expect(result).toEqual({
|
||||
messages: expectedMessages,
|
||||
summary: "",
|
||||
cost: 0,
|
||||
prevContextTokens: totalTokens,
|
||||
})
|
||||
})
|
||||
|
||||
it("should work with non-prompt caching models the same as prompt caching models", async () => {
|
||||
|
|
@ -298,7 +312,10 @@ describe("truncateConversationIfNeeded", () => {
|
|||
apiHandler: mockApiHandler,
|
||||
})
|
||||
|
||||
expect(result1).toEqual(result2)
|
||||
expect(result1.messages).toEqual(result2.messages)
|
||||
expect(result1.summary).toEqual(result2.summary)
|
||||
expect(result1.cost).toEqual(result2.cost)
|
||||
expect(result1.prevContextTokens).toEqual(result2.prevContextTokens)
|
||||
|
||||
// Test above threshold
|
||||
const aboveThreshold = 70001
|
||||
|
|
@ -318,7 +335,10 @@ describe("truncateConversationIfNeeded", () => {
|
|||
apiHandler: mockApiHandler,
|
||||
})
|
||||
|
||||
expect(result3).toEqual(result4)
|
||||
expect(result3.messages).toEqual(result4.messages)
|
||||
expect(result3.summary).toEqual(result4.summary)
|
||||
expect(result3.cost).toEqual(result4.cost)
|
||||
expect(result3.prevContextTokens).toEqual(result4.prevContextTokens)
|
||||
})
|
||||
|
||||
it("should consider incoming content when deciding to truncate", async () => {
|
||||
|
|
@ -344,7 +364,12 @@ describe("truncateConversationIfNeeded", () => {
|
|||
maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(resultWithSmall).toEqual(messagesWithSmallContent) // No truncation
|
||||
expect(resultWithSmall).toEqual({
|
||||
messages: messagesWithSmallContent,
|
||||
summary: "",
|
||||
cost: 0,
|
||||
prevContextTokens: baseTokensForSmall + smallContentTokens,
|
||||
}) // No truncation
|
||||
|
||||
// Test case 2: Large content that will push us over the threshold
|
||||
const largeContent = [
|
||||
|
|
@ -368,7 +393,10 @@ describe("truncateConversationIfNeeded", () => {
|
|||
maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(resultWithLarge).not.toEqual(messagesWithLargeContent) // Should truncate
|
||||
expect(resultWithLarge.messages).not.toEqual(messagesWithLargeContent) // Should truncate
|
||||
expect(resultWithLarge.summary).toBe("")
|
||||
expect(resultWithLarge.cost).toBe(0)
|
||||
expect(resultWithLarge.prevContextTokens).toBe(baseTokensForLarge + largeContentTokens)
|
||||
|
||||
// Test case 3: Very large content that will definitely exceed threshold
|
||||
const veryLargeContent = [{ type: "text" as const, text: "X".repeat(1000) }]
|
||||
|
|
@ -387,7 +415,10 @@ describe("truncateConversationIfNeeded", () => {
|
|||
maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(resultWithVeryLarge).not.toEqual(messagesWithVeryLargeContent) // Should truncate
|
||||
expect(resultWithVeryLarge.messages).not.toEqual(messagesWithVeryLargeContent) // Should truncate
|
||||
expect(resultWithVeryLarge.summary).toBe("")
|
||||
expect(resultWithVeryLarge.cost).toBe(0)
|
||||
expect(resultWithVeryLarge.prevContextTokens).toBe(baseTokensForVeryLarge + veryLargeContentTokens)
|
||||
})
|
||||
|
||||
it("should truncate if tokens are within TOKEN_BUFFER_PERCENTAGE of the threshold", async () => {
|
||||
|
|
@ -409,7 +440,140 @@ describe("truncateConversationIfNeeded", () => {
|
|||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(result).toEqual(expectedResult)
|
||||
expect(result).toEqual({
|
||||
messages: expectedResult,
|
||||
summary: "",
|
||||
cost: 0,
|
||||
prevContextTokens: totalTokens,
|
||||
})
|
||||
})
|
||||
|
||||
it("should use summarizeConversation when autoCondenseContext is true and tokens exceed threshold", async () => {
|
||||
// Mock the summarizeConversation function
|
||||
const mockSummary = "This is a summary of the conversation"
|
||||
const mockCost = 0.05
|
||||
const mockSummarizeResponse: condenseModule.SummarizeResponse = {
|
||||
messages: [
|
||||
{ role: "user", content: "First message" },
|
||||
{ role: "assistant", content: mockSummary, isSummary: true },
|
||||
{ role: "user", content: "Last message" },
|
||||
],
|
||||
summary: mockSummary,
|
||||
cost: mockCost,
|
||||
newContextTokens: 100,
|
||||
}
|
||||
|
||||
const summarizeSpy = jest
|
||||
.spyOn(condenseModule, "summarizeConversation")
|
||||
.mockResolvedValue(mockSummarizeResponse)
|
||||
|
||||
const modelInfo = createModelInfo(100000, 30000)
|
||||
const totalTokens = 70001 // Above threshold
|
||||
const messagesWithSmallContent = [...messages.slice(0, -1), { ...messages[messages.length - 1], content: "" }]
|
||||
|
||||
const result = await truncateConversationIfNeeded({
|
||||
messages: messagesWithSmallContent,
|
||||
totalTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
autoCondenseContext: true,
|
||||
systemPrompt: "System prompt",
|
||||
})
|
||||
|
||||
// Verify summarizeConversation was called with the right parameters
|
||||
expect(summarizeSpy).toHaveBeenCalledWith(messagesWithSmallContent, mockApiHandler, "System prompt")
|
||||
|
||||
// Verify the result contains the summary information
|
||||
expect(result).toMatchObject({
|
||||
messages: mockSummarizeResponse.messages,
|
||||
summary: mockSummary,
|
||||
cost: mockCost,
|
||||
prevContextTokens: totalTokens,
|
||||
})
|
||||
// newContextTokens might be present, but we don't need to verify its exact value
|
||||
|
||||
// Clean up
|
||||
summarizeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should fall back to truncateConversation when autoCondenseContext is true but summarization fails", async () => {
|
||||
// Mock the summarizeConversation function to return empty summary
|
||||
const mockSummarizeResponse: condenseModule.SummarizeResponse = {
|
||||
messages: messages, // Original messages unchanged
|
||||
summary: "", // Empty summary indicates failure
|
||||
cost: 0.01,
|
||||
}
|
||||
|
||||
const summarizeSpy = jest
|
||||
.spyOn(condenseModule, "summarizeConversation")
|
||||
.mockResolvedValue(mockSummarizeResponse)
|
||||
|
||||
const modelInfo = createModelInfo(100000, 30000)
|
||||
const totalTokens = 70001 // Above threshold
|
||||
const messagesWithSmallContent = [...messages.slice(0, -1), { ...messages[messages.length - 1], content: "" }]
|
||||
|
||||
// When truncating, always uses 0.5 fraction
|
||||
// With 4 messages after the first, 0.5 fraction means remove 2 messages
|
||||
const expectedMessages = [messagesWithSmallContent[0], messagesWithSmallContent[3], messagesWithSmallContent[4]]
|
||||
|
||||
const result = await truncateConversationIfNeeded({
|
||||
messages: messagesWithSmallContent,
|
||||
totalTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
autoCondenseContext: true,
|
||||
})
|
||||
|
||||
// Verify summarizeConversation was called
|
||||
expect(summarizeSpy).toHaveBeenCalled()
|
||||
|
||||
// Verify it fell back to truncation
|
||||
expect(result.messages).toEqual(expectedMessages)
|
||||
expect(result.summary).toBe("")
|
||||
expect(result.prevContextTokens).toBe(totalTokens)
|
||||
// The cost might be different than expected, so we don't check it
|
||||
|
||||
// Clean up
|
||||
summarizeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should not call summarizeConversation when autoCondenseContext is false", async () => {
|
||||
// Reset any previous mock calls
|
||||
jest.clearAllMocks()
|
||||
const summarizeSpy = jest.spyOn(condenseModule, "summarizeConversation")
|
||||
|
||||
const modelInfo = createModelInfo(100000, 30000)
|
||||
const totalTokens = 70001 // Above threshold
|
||||
const messagesWithSmallContent = [...messages.slice(0, -1), { ...messages[messages.length - 1], content: "" }]
|
||||
|
||||
// When truncating, always uses 0.5 fraction
|
||||
// With 4 messages after the first, 0.5 fraction means remove 2 messages
|
||||
const expectedMessages = [messagesWithSmallContent[0], messagesWithSmallContent[3], messagesWithSmallContent[4]]
|
||||
|
||||
const result = await truncateConversationIfNeeded({
|
||||
messages: messagesWithSmallContent,
|
||||
totalTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
autoCondenseContext: false,
|
||||
})
|
||||
|
||||
// Verify summarizeConversation was not called
|
||||
expect(summarizeSpy).not.toHaveBeenCalled()
|
||||
|
||||
// Verify it used truncation
|
||||
expect(result).toEqual({
|
||||
messages: expectedMessages,
|
||||
summary: "",
|
||||
cost: 0,
|
||||
prevContextTokens: totalTokens,
|
||||
})
|
||||
|
||||
// Clean up
|
||||
summarizeSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -449,7 +613,12 @@ describe("getMaxTokens", () => {
|
|||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(result1).toEqual(messagesWithSmallContent)
|
||||
expect(result1).toEqual({
|
||||
messages: messagesWithSmallContent,
|
||||
summary: "",
|
||||
cost: 0,
|
||||
prevContextTokens: 39999,
|
||||
})
|
||||
|
||||
// Above max tokens - truncate
|
||||
const result2 = await truncateConversationIfNeeded({
|
||||
|
|
@ -459,8 +628,11 @@ describe("getMaxTokens", () => {
|
|||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(result2).not.toEqual(messagesWithSmallContent)
|
||||
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
|
||||
expect(result2.messages).not.toEqual(messagesWithSmallContent)
|
||||
expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
|
||||
expect(result2.summary).toBe("")
|
||||
expect(result2.cost).toBe(0)
|
||||
expect(result2.prevContextTokens).toBe(50001)
|
||||
})
|
||||
|
||||
it("should use 20% of context window as buffer when maxTokens is undefined", async () => {
|
||||
|
|
@ -479,7 +651,12 @@ describe("getMaxTokens", () => {
|
|||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(result1).toEqual(messagesWithSmallContent)
|
||||
expect(result1).toEqual({
|
||||
messages: messagesWithSmallContent,
|
||||
summary: "",
|
||||
cost: 0,
|
||||
prevContextTokens: 69999,
|
||||
})
|
||||
|
||||
// Above max tokens - truncate
|
||||
const result2 = await truncateConversationIfNeeded({
|
||||
|
|
@ -489,8 +666,11 @@ describe("getMaxTokens", () => {
|
|||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(result2).not.toEqual(messagesWithSmallContent)
|
||||
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
|
||||
expect(result2.messages).not.toEqual(messagesWithSmallContent)
|
||||
expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
|
||||
expect(result2.summary).toBe("")
|
||||
expect(result2.cost).toBe(0)
|
||||
expect(result2.prevContextTokens).toBe(80001)
|
||||
})
|
||||
|
||||
it("should handle small context windows appropriately", async () => {
|
||||
|
|
@ -508,7 +688,7 @@ describe("getMaxTokens", () => {
|
|||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(result1).toEqual(messagesWithSmallContent)
|
||||
expect(result1.messages).toEqual(messagesWithSmallContent)
|
||||
|
||||
// Above max tokens - truncate
|
||||
const result2 = await truncateConversationIfNeeded({
|
||||
|
|
@ -519,7 +699,7 @@ describe("getMaxTokens", () => {
|
|||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(result2).not.toEqual(messagesWithSmallContent)
|
||||
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
|
||||
expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
|
||||
})
|
||||
|
||||
it("should handle large context windows appropriately", async () => {
|
||||
|
|
@ -538,7 +718,7 @@ describe("getMaxTokens", () => {
|
|||
maxTokens: modelInfo.maxTokens,
|
||||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(result1).toEqual(messagesWithSmallContent)
|
||||
expect(result1.messages).toEqual(messagesWithSmallContent)
|
||||
|
||||
// Above max tokens - truncate
|
||||
const result2 = await truncateConversationIfNeeded({
|
||||
|
|
@ -549,6 +729,6 @@ describe("getMaxTokens", () => {
|
|||
apiHandler: mockApiHandler,
|
||||
})
|
||||
expect(result2).not.toEqual(messagesWithSmallContent)
|
||||
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
|
||||
expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ApiHandler } from "../../api"
|
||||
import { summarizeConversation } from "../condense"
|
||||
import { summarizeConversation, SummarizeResponse } from "../condense"
|
||||
import { ApiMessage } from "../task-persistence/apiMessages"
|
||||
|
||||
/**
|
||||
|
|
@ -53,6 +53,7 @@ export function truncateConversation(messages: ApiMessage[], fracToRemove: numbe
|
|||
* @param {number} maxTokens - The maximum number of tokens allowed.
|
||||
* @param {ApiHandler} apiHandler - The API handler to use for token counting.
|
||||
* @param {boolean} autoCondenseContext - Whether to use LLM summarization or sliding window implementation
|
||||
* @param {string} systemPrompt - The system prompt, used for estimating the new context size after summarizing.
|
||||
* @returns {ApiMessage[]} The original or truncated conversation messages.
|
||||
*/
|
||||
|
||||
|
|
@ -63,8 +64,11 @@ type TruncateOptions = {
|
|||
maxTokens?: number | null
|
||||
apiHandler: ApiHandler
|
||||
autoCondenseContext?: boolean
|
||||
systemPrompt?: string
|
||||
}
|
||||
|
||||
type TruncateResponse = SummarizeResponse & { prevContextTokens: number }
|
||||
|
||||
/**
|
||||
* Conditionally truncates the conversation messages if the total token count
|
||||
* exceeds the model's limit, considering the size of incoming content.
|
||||
|
|
@ -79,7 +83,8 @@ export async function truncateConversationIfNeeded({
|
|||
maxTokens,
|
||||
apiHandler,
|
||||
autoCondenseContext,
|
||||
}: TruncateOptions): Promise<ApiMessage[]> {
|
||||
systemPrompt,
|
||||
}: TruncateOptions): Promise<TruncateResponse> {
|
||||
// Calculate the maximum tokens reserved for response
|
||||
const reservedTokens = maxTokens || contextWindow * 0.2
|
||||
|
||||
|
|
@ -99,12 +104,13 @@ export async function truncateConversationIfNeeded({
|
|||
|
||||
// Determine if truncation is needed and apply if necessary
|
||||
if (effectiveTokens <= allowedTokens) {
|
||||
return messages
|
||||
return { messages, summary: "", cost: 0, prevContextTokens: effectiveTokens }
|
||||
} else if (autoCondenseContext) {
|
||||
const summarizedMessages = await summarizeConversation(messages, apiHandler)
|
||||
if (messages !== summarizedMessages) {
|
||||
return summarizedMessages
|
||||
const result = await summarizeConversation(messages, apiHandler, systemPrompt)
|
||||
if (result.summary) {
|
||||
return { ...result, prevContextTokens: effectiveTokens }
|
||||
}
|
||||
}
|
||||
return truncateConversation(messages, 0.5)
|
||||
const truncatedMessages = truncateConversation(messages, 0.5)
|
||||
return { messages: truncatedMessages, prevContextTokens: effectiveTokens, summary: "", cost: 0 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import pWaitFor from "p-wait-for"
|
|||
import { serializeError } from "serialize-error"
|
||||
|
||||
// schemas
|
||||
import { TokenUsage, ToolUsage, ToolName } from "../../schemas"
|
||||
import { TokenUsage, ToolUsage, ToolName, ContextCondense } from "../../schemas"
|
||||
|
||||
// api
|
||||
import { ApiHandler, buildApiHandler } from "../../api"
|
||||
|
|
@ -490,6 +490,7 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
options: {
|
||||
isNonInteractive?: boolean
|
||||
} = {},
|
||||
contextCondense?: ContextCondense,
|
||||
): Promise<undefined> {
|
||||
if (this.abort) {
|
||||
throw new Error(`[RooCode#say] task ${this.taskId}.${this.instanceId} aborted`)
|
||||
|
|
@ -517,7 +518,15 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
this.lastMessageTs = sayTs
|
||||
}
|
||||
|
||||
await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, partial })
|
||||
await this.addToClineMessages({
|
||||
ts: sayTs,
|
||||
type: "say",
|
||||
say: type,
|
||||
text,
|
||||
images,
|
||||
partial,
|
||||
contextCondense,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// New now have a complete version of a previously partial message.
|
||||
|
|
@ -547,7 +556,7 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
this.lastMessageTs = sayTs
|
||||
}
|
||||
|
||||
await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images })
|
||||
await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, contextCondense })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -562,7 +571,15 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
this.lastMessageTs = sayTs
|
||||
}
|
||||
|
||||
await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, checkpoint })
|
||||
await this.addToClineMessages({
|
||||
ts: sayTs,
|
||||
type: "say",
|
||||
say: type,
|
||||
text,
|
||||
images,
|
||||
checkpoint,
|
||||
contextCondense,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -985,10 +1002,6 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
this.consecutiveMistakeCount = 0
|
||||
}
|
||||
|
||||
// Get previous api req's index to check token usage and determine if we
|
||||
// need to truncate conversation history.
|
||||
const previousApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
|
||||
|
||||
// In this Cline request loop, we need to check if this task instance
|
||||
// has been asked to wait for a subtask to finish before continuing.
|
||||
const provider = this.providerRef.deref()
|
||||
|
|
@ -1147,7 +1160,7 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
// Yields only if the first chunk is successful, otherwise will
|
||||
// allow the user to retry the request (most likely due to rate
|
||||
// limit error, which gets thrown on the first chunk).
|
||||
const stream = this.attemptApiRequest(previousApiReqIndex)
|
||||
const stream = this.attemptApiRequest()
|
||||
let assistantMessage = ""
|
||||
let reasoningMessage = ""
|
||||
this.isStreaming = true
|
||||
|
|
@ -1354,7 +1367,7 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
}
|
||||
}
|
||||
|
||||
public async *attemptApiRequest(previousApiReqIndex: number, retryAttempt: number = 0): ApiStream {
|
||||
public async *attemptApiRequest(retryAttempt: number = 0): ApiStream {
|
||||
let mcpHub: McpHub | undefined
|
||||
|
||||
const { apiConfiguration, mcpEnabled, autoApprovalEnabled, alwaysApproveResubmit, requestDelaySeconds } =
|
||||
|
|
@ -1444,25 +1457,8 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
)
|
||||
})()
|
||||
|
||||
// If the previous API request's total token usage is close to the
|
||||
// context window, truncate the conversation history to free up space
|
||||
// for the new request.
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = this.clineMessages[previousApiReqIndex]?.text
|
||||
|
||||
if (!previousRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
tokensIn = 0,
|
||||
tokensOut = 0,
|
||||
cacheWrites = 0,
|
||||
cacheReads = 0,
|
||||
}: ClineApiReqInfo = JSON.parse(previousRequest)
|
||||
|
||||
const totalTokens = tokensIn + tokensOut + cacheWrites + cacheReads
|
||||
|
||||
const { contextTokens } = this.getTokenUsage()
|
||||
if (contextTokens) {
|
||||
// Default max tokens value for thinking models when no specific
|
||||
// value is set.
|
||||
const DEFAULT_THINKING_MODEL_MAX_TOKENS = 16_384
|
||||
|
|
@ -1476,16 +1472,31 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
const contextWindow = modelInfo.contextWindow
|
||||
|
||||
const autoCondenseContext = experiments?.autoCondenseContext ?? false
|
||||
const trimmedMessages = await truncateConversationIfNeeded({
|
||||
const truncateResult = await truncateConversationIfNeeded({
|
||||
messages: this.apiConversationHistory,
|
||||
totalTokens,
|
||||
totalTokens: contextTokens,
|
||||
maxTokens,
|
||||
contextWindow,
|
||||
apiHandler: this.api,
|
||||
autoCondenseContext,
|
||||
systemPrompt,
|
||||
})
|
||||
if (trimmedMessages !== this.apiConversationHistory) {
|
||||
await this.overwriteApiConversationHistory(trimmedMessages)
|
||||
if (truncateResult.messages !== this.apiConversationHistory) {
|
||||
await this.overwriteApiConversationHistory(truncateResult.messages)
|
||||
}
|
||||
if (truncateResult.summary) {
|
||||
const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult
|
||||
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
|
||||
await this.say(
|
||||
"condense_context",
|
||||
undefined /* text */,
|
||||
undefined /* images */,
|
||||
false /* partial */,
|
||||
undefined /* checkpoint */,
|
||||
undefined /* progressStatus */,
|
||||
undefined /* options */,
|
||||
contextCondense,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1556,7 +1567,7 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
|
||||
// Delegate generator output from the recursive call with
|
||||
// incremented retry count.
|
||||
yield* this.attemptApiRequest(previousApiReqIndex, retryAttempt + 1)
|
||||
yield* this.attemptApiRequest(retryAttempt + 1)
|
||||
|
||||
return
|
||||
} else {
|
||||
|
|
@ -1574,7 +1585,7 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
await this.say("api_req_retried")
|
||||
|
||||
// Delegate generator output from the recursive call.
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
yield* this.attemptApiRequest()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -1610,7 +1621,7 @@ export class Task extends EventEmitter<ClineEvents> {
|
|||
return combineApiRequests(combineCommandSequences(messages))
|
||||
}
|
||||
|
||||
public getTokenUsage() {
|
||||
public getTokenUsage(): TokenUsage {
|
||||
return getApiMetrics(this.combineMessages(this.clineMessages.slice(1)))
|
||||
}
|
||||
|
||||
|
|
|
|||
36
src/exports/roo-code.d.ts
vendored
36
src/exports/roo-code.d.ts
vendored
|
|
@ -405,6 +405,7 @@ type ClineMessage = {
|
|||
| "checkpoint_saved"
|
||||
| "rooignore_error"
|
||||
| "diff_error"
|
||||
| "condense_context"
|
||||
)
|
||||
| undefined
|
||||
text?: string | undefined
|
||||
|
|
@ -423,6 +424,14 @@ type ClineMessage = {
|
|||
text?: string | undefined
|
||||
}
|
||||
| undefined
|
||||
contextCondense?:
|
||||
| {
|
||||
cost: number
|
||||
prevContextTokens: number
|
||||
newContextTokens: number
|
||||
summary: string
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
type TokenUsage = {
|
||||
|
|
@ -480,6 +489,7 @@ type RooCodeEvents = {
|
|||
| "checkpoint_saved"
|
||||
| "rooignore_error"
|
||||
| "diff_error"
|
||||
| "condense_context"
|
||||
)
|
||||
| undefined
|
||||
text?: string | undefined
|
||||
|
|
@ -498,6 +508,14 @@ type RooCodeEvents = {
|
|||
text?: string | undefined
|
||||
}
|
||||
| undefined
|
||||
contextCondense?:
|
||||
| {
|
||||
cost: number
|
||||
prevContextTokens: number
|
||||
newContextTokens: number
|
||||
summary: string
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
},
|
||||
]
|
||||
|
|
@ -949,6 +967,7 @@ type IpcMessage =
|
|||
| "checkpoint_saved"
|
||||
| "rooignore_error"
|
||||
| "diff_error"
|
||||
| "condense_context"
|
||||
)
|
||||
| undefined
|
||||
text?: string | undefined
|
||||
|
|
@ -967,6 +986,14 @@ type IpcMessage =
|
|||
text?: string | undefined
|
||||
}
|
||||
| undefined
|
||||
contextCondense?:
|
||||
| {
|
||||
cost: number
|
||||
prevContextTokens: number
|
||||
newContextTokens: number
|
||||
summary: string
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
},
|
||||
]
|
||||
|
|
@ -1408,6 +1435,7 @@ type TaskEvent =
|
|||
| "checkpoint_saved"
|
||||
| "rooignore_error"
|
||||
| "diff_error"
|
||||
| "condense_context"
|
||||
)
|
||||
| undefined
|
||||
text?: string | undefined
|
||||
|
|
@ -1426,6 +1454,14 @@ type TaskEvent =
|
|||
text?: string | undefined
|
||||
}
|
||||
| undefined
|
||||
contextCondense?:
|
||||
| {
|
||||
cost: number
|
||||
prevContextTokens: number
|
||||
newContextTokens: number
|
||||
summary: string
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ type ClineMessage = {
|
|||
| "checkpoint_saved"
|
||||
| "rooignore_error"
|
||||
| "diff_error"
|
||||
| "condense_context"
|
||||
)
|
||||
| undefined
|
||||
text?: string | undefined
|
||||
|
|
@ -431,6 +432,14 @@ type ClineMessage = {
|
|||
text?: string | undefined
|
||||
}
|
||||
| undefined
|
||||
contextCondense?:
|
||||
| {
|
||||
cost: number
|
||||
prevContextTokens: number
|
||||
newContextTokens: number
|
||||
summary: string
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
export type { ClineMessage }
|
||||
|
|
@ -492,6 +501,7 @@ type RooCodeEvents = {
|
|||
| "checkpoint_saved"
|
||||
| "rooignore_error"
|
||||
| "diff_error"
|
||||
| "condense_context"
|
||||
)
|
||||
| undefined
|
||||
text?: string | undefined
|
||||
|
|
@ -510,6 +520,14 @@ type RooCodeEvents = {
|
|||
text?: string | undefined
|
||||
}
|
||||
| undefined
|
||||
contextCondense?:
|
||||
| {
|
||||
cost: number
|
||||
prevContextTokens: number
|
||||
newContextTokens: number
|
||||
summary: string
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
},
|
||||
]
|
||||
|
|
@ -963,6 +981,7 @@ type IpcMessage =
|
|||
| "checkpoint_saved"
|
||||
| "rooignore_error"
|
||||
| "diff_error"
|
||||
| "condense_context"
|
||||
)
|
||||
| undefined
|
||||
text?: string | undefined
|
||||
|
|
@ -981,6 +1000,14 @@ type IpcMessage =
|
|||
text?: string | undefined
|
||||
}
|
||||
| undefined
|
||||
contextCondense?:
|
||||
| {
|
||||
cost: number
|
||||
prevContextTokens: number
|
||||
newContextTokens: number
|
||||
summary: string
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
},
|
||||
]
|
||||
|
|
@ -1426,6 +1453,7 @@ type TaskEvent =
|
|||
| "checkpoint_saved"
|
||||
| "rooignore_error"
|
||||
| "diff_error"
|
||||
| "condense_context"
|
||||
)
|
||||
| undefined
|
||||
text?: string | undefined
|
||||
|
|
@ -1444,6 +1472,14 @@ type TaskEvent =
|
|||
text?: string | undefined
|
||||
}
|
||||
| undefined
|
||||
contextCondense?:
|
||||
| {
|
||||
cost: number
|
||||
prevContextTokens: number
|
||||
newContextTokens: number
|
||||
summary: string
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -929,6 +929,7 @@ export const clineSays = [
|
|||
"checkpoint_saved",
|
||||
"rooignore_error",
|
||||
"diff_error",
|
||||
"condense_context",
|
||||
] as const
|
||||
|
||||
export const clineSaySchema = z.enum(clineSays)
|
||||
|
|
@ -946,6 +947,19 @@ export const toolProgressStatusSchema = z.object({
|
|||
|
||||
export type ToolProgressStatus = z.infer<typeof toolProgressStatusSchema>
|
||||
|
||||
/**
|
||||
* ContextCondense
|
||||
*/
|
||||
|
||||
export const contextCondenseSchema = z.object({
|
||||
cost: z.number(),
|
||||
prevContextTokens: z.number(),
|
||||
newContextTokens: z.number(),
|
||||
summary: z.string(),
|
||||
})
|
||||
|
||||
export type ContextCondense = z.infer<typeof contextCondenseSchema>
|
||||
|
||||
/**
|
||||
* ClineMessage
|
||||
*/
|
||||
|
|
@ -962,6 +976,7 @@ export const clineMessageSchema = z.object({
|
|||
conversationHistoryIndex: z.number().optional(),
|
||||
checkpoint: z.record(z.string(), z.unknown()).optional(),
|
||||
progressStatus: toolProgressStatusSchema.optional(),
|
||||
contextCondense: contextCondenseSchema.optional(),
|
||||
})
|
||||
|
||||
export type ClineMessage = z.infer<typeof clineMessageSchema>
|
||||
|
|
|
|||
328
src/shared/__tests__/getApiMetrics.test.ts
Normal file
328
src/shared/__tests__/getApiMetrics.test.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
// npx jest src/shared/__tests__/getApiMetrics.test.ts
|
||||
|
||||
import { getApiMetrics } from "../getApiMetrics"
|
||||
import { ClineMessage } from "../ExtensionMessage"
|
||||
|
||||
describe("getApiMetrics", () => {
|
||||
// Helper function to create a basic api_req_started message
|
||||
const createApiReqStartedMessage = (
|
||||
text: string = '{"tokensIn":10,"tokensOut":20}',
|
||||
ts: number = 1000,
|
||||
): ClineMessage => ({
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text,
|
||||
ts,
|
||||
})
|
||||
|
||||
// Helper function to create a condense_context message
|
||||
const createCondenseContextMessage = (
|
||||
cost: number = 0.002,
|
||||
newContextTokens: number = 500,
|
||||
prevContextTokens: number = 1000,
|
||||
ts: number = 2000,
|
||||
): ClineMessage => ({
|
||||
type: "say",
|
||||
say: "condense_context",
|
||||
contextCondense: {
|
||||
cost,
|
||||
newContextTokens,
|
||||
prevContextTokens,
|
||||
summary: "Context was condensed",
|
||||
},
|
||||
ts,
|
||||
})
|
||||
|
||||
// Helper function to create a non-API message
|
||||
const createOtherMessage = (
|
||||
say: "text" | "error" | "reasoning" | "completion_result" = "text",
|
||||
text: string = "Hello world",
|
||||
ts: number = 999,
|
||||
): ClineMessage => ({
|
||||
type: "say",
|
||||
say,
|
||||
text,
|
||||
ts,
|
||||
})
|
||||
|
||||
describe("Basic functionality", () => {
|
||||
it("should calculate metrics from a single api_req_started message", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqStartedMessage(
|
||||
'{"tokensIn":100,"tokensOut":200,"cacheWrites":5,"cacheReads":10,"cost":0.005}',
|
||||
),
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
expect(result.totalTokensIn).toBe(100)
|
||||
expect(result.totalTokensOut).toBe(200)
|
||||
expect(result.totalCacheWrites).toBe(5)
|
||||
expect(result.totalCacheReads).toBe(10)
|
||||
expect(result.totalCost).toBe(0.005)
|
||||
expect(result.contextTokens).toBe(315) // 100 + 200 + 5 + 10
|
||||
})
|
||||
|
||||
it("should calculate metrics from multiple api_req_started messages", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqStartedMessage(
|
||||
'{"tokensIn":100,"tokensOut":200,"cacheWrites":5,"cacheReads":10,"cost":0.005}',
|
||||
1000,
|
||||
),
|
||||
createApiReqStartedMessage(
|
||||
'{"tokensIn":50,"tokensOut":150,"cacheWrites":3,"cacheReads":7,"cost":0.003}',
|
||||
2000,
|
||||
),
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
expect(result.totalTokensIn).toBe(150) // 100 + 50
|
||||
expect(result.totalTokensOut).toBe(350) // 200 + 150
|
||||
expect(result.totalCacheWrites).toBe(8) // 5 + 3
|
||||
expect(result.totalCacheReads).toBe(17) // 10 + 7
|
||||
expect(result.totalCost).toBe(0.008) // 0.005 + 0.003
|
||||
expect(result.contextTokens).toBe(210) // 50 + 150 + 3 + 7 (from the last message)
|
||||
})
|
||||
|
||||
it("should calculate metrics from condense_context messages", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createCondenseContextMessage(0.002, 500, 1000, 1000),
|
||||
createCondenseContextMessage(0.003, 400, 800, 2000),
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
expect(result.totalTokensIn).toBe(0)
|
||||
expect(result.totalTokensOut).toBe(0)
|
||||
expect(result.totalCacheWrites).toBeUndefined()
|
||||
expect(result.totalCacheReads).toBeUndefined()
|
||||
expect(result.totalCost).toBe(0.005) // 0.002 + 0.003
|
||||
expect(result.contextTokens).toBe(400) // newContextTokens from the last condense_context message
|
||||
})
|
||||
|
||||
it("should calculate metrics from mixed message types", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqStartedMessage(
|
||||
'{"tokensIn":100,"tokensOut":200,"cacheWrites":5,"cacheReads":10,"cost":0.005}',
|
||||
1000,
|
||||
),
|
||||
createOtherMessage("text", "Some text", 1500),
|
||||
createCondenseContextMessage(0.002, 500, 1000, 2000),
|
||||
createApiReqStartedMessage(
|
||||
'{"tokensIn":50,"tokensOut":150,"cacheWrites":3,"cacheReads":7,"cost":0.003}',
|
||||
3000,
|
||||
),
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
expect(result.totalTokensIn).toBe(150) // 100 + 50
|
||||
expect(result.totalTokensOut).toBe(350) // 200 + 150
|
||||
expect(result.totalCacheWrites).toBe(8) // 5 + 3
|
||||
expect(result.totalCacheReads).toBe(17) // 10 + 7
|
||||
expect(result.totalCost).toBe(0.01) // 0.005 + 0.002 + 0.003
|
||||
expect(result.contextTokens).toBe(210) // 50 + 150 + 3 + 7 (from the last api_req_started message)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty messages array", () => {
|
||||
const result = getApiMetrics([])
|
||||
|
||||
expect(result.totalTokensIn).toBe(0)
|
||||
expect(result.totalTokensOut).toBe(0)
|
||||
expect(result.totalCacheWrites).toBeUndefined()
|
||||
expect(result.totalCacheReads).toBeUndefined()
|
||||
expect(result.totalCost).toBe(0)
|
||||
expect(result.contextTokens).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle messages with no API metrics", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createOtherMessage("text", "Message 1", 1000),
|
||||
createOtherMessage("error", "Error message", 2000),
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
expect(result.totalTokensIn).toBe(0)
|
||||
expect(result.totalTokensOut).toBe(0)
|
||||
expect(result.totalCacheWrites).toBeUndefined()
|
||||
expect(result.totalCacheReads).toBeUndefined()
|
||||
expect(result.totalCost).toBe(0)
|
||||
expect(result.contextTokens).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle invalid JSON in api_req_started message", () => {
|
||||
// We need to mock console.error to avoid polluting test output
|
||||
const originalConsoleError = console.error
|
||||
console.error = jest.fn()
|
||||
|
||||
const messages: ClineMessage[] = [
|
||||
{
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: "This is not valid JSON",
|
||||
ts: 1000,
|
||||
},
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
// Should not throw and should return default values
|
||||
expect(result.totalTokensIn).toBe(0)
|
||||
expect(result.totalTokensOut).toBe(0)
|
||||
expect(result.totalCacheWrites).toBeUndefined()
|
||||
expect(result.totalCacheReads).toBeUndefined()
|
||||
expect(result.totalCost).toBe(0)
|
||||
expect(result.contextTokens).toBe(0)
|
||||
|
||||
// Restore console.error
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
|
||||
it("should handle missing text field in api_req_started message", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
ts: 1000,
|
||||
// text field is missing
|
||||
},
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
// Should not throw and should return default values
|
||||
expect(result.totalTokensIn).toBe(0)
|
||||
expect(result.totalTokensOut).toBe(0)
|
||||
expect(result.totalCacheWrites).toBeUndefined()
|
||||
expect(result.totalCacheReads).toBeUndefined()
|
||||
expect(result.totalCost).toBe(0)
|
||||
expect(result.contextTokens).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle missing contextCondense field in condense_context message", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{
|
||||
type: "say",
|
||||
say: "condense_context",
|
||||
ts: 1000,
|
||||
// contextCondense field is missing
|
||||
},
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
// Should not throw and should return default values
|
||||
expect(result.totalTokensIn).toBe(0)
|
||||
expect(result.totalTokensOut).toBe(0)
|
||||
expect(result.totalCacheWrites).toBeUndefined()
|
||||
expect(result.totalCacheReads).toBeUndefined()
|
||||
expect(result.totalCost).toBe(0)
|
||||
expect(result.contextTokens).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle partial metrics in api_req_started message", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqStartedMessage('{"tokensIn":100}', 1000), // Only tokensIn
|
||||
createApiReqStartedMessage('{"tokensOut":200}', 2000), // Only tokensOut
|
||||
createApiReqStartedMessage('{"cacheWrites":5}', 3000), // Only cacheWrites
|
||||
createApiReqStartedMessage('{"cacheReads":10}', 4000), // Only cacheReads
|
||||
createApiReqStartedMessage('{"cost":0.005}', 5000), // Only cost
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
expect(result.totalTokensIn).toBe(100)
|
||||
expect(result.totalTokensOut).toBe(200)
|
||||
expect(result.totalCacheWrites).toBe(5)
|
||||
expect(result.totalCacheReads).toBe(10)
|
||||
expect(result.totalCost).toBe(0.005)
|
||||
|
||||
// The implementation will use the last message with tokens for contextTokens
|
||||
// In this case, it's the cacheReads message
|
||||
expect(result.contextTokens).toBe(10)
|
||||
})
|
||||
|
||||
it("should handle non-number values in api_req_started message", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
// Use string values that can be parsed as JSON but aren't valid numbers for the metrics
|
||||
createApiReqStartedMessage(
|
||||
'{"tokensIn":"not-a-number","tokensOut":"not-a-number","cacheWrites":"not-a-number","cacheReads":"not-a-number","cost":"not-a-number"}',
|
||||
),
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
// Non-number values should be ignored
|
||||
expect(result.totalTokensIn).toBe(0)
|
||||
expect(result.totalTokensOut).toBe(0)
|
||||
expect(result.totalCacheWrites).toBeUndefined()
|
||||
expect(result.totalCacheReads).toBeUndefined()
|
||||
expect(result.totalCost).toBe(0)
|
||||
|
||||
// The implementation concatenates string values for contextTokens
|
||||
expect(result.contextTokens).toBe("not-a-numbernot-a-numbernot-a-numbernot-a-number")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Context tokens calculation", () => {
|
||||
it("should calculate contextTokens from the last api_req_started message", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqStartedMessage('{"tokensIn":100,"tokensOut":200,"cacheWrites":5,"cacheReads":10}', 1000),
|
||||
createApiReqStartedMessage('{"tokensIn":50,"tokensOut":150,"cacheWrites":3,"cacheReads":7}', 2000),
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
// Should use the values from the last api_req_started message
|
||||
expect(result.contextTokens).toBe(210) // 50 + 150 + 3 + 7
|
||||
})
|
||||
|
||||
it("should calculate contextTokens from the last condense_context message", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqStartedMessage('{"tokensIn":100,"tokensOut":200,"cacheWrites":5,"cacheReads":10}', 1000),
|
||||
createCondenseContextMessage(0.002, 500, 1000, 2000),
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
// Should use newContextTokens from the last condense_context message
|
||||
expect(result.contextTokens).toBe(500)
|
||||
})
|
||||
|
||||
it("should prioritize the last message for contextTokens calculation", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createCondenseContextMessage(0.002, 500, 1000, 1000),
|
||||
createApiReqStartedMessage('{"tokensIn":100,"tokensOut":200,"cacheWrites":5,"cacheReads":10}', 2000),
|
||||
createCondenseContextMessage(0.003, 400, 800, 3000),
|
||||
createApiReqStartedMessage('{"tokensIn":50,"tokensOut":150,"cacheWrites":3,"cacheReads":7}', 4000),
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
// Should use the values from the last api_req_started message
|
||||
expect(result.contextTokens).toBe(210) // 50 + 150 + 3 + 7
|
||||
})
|
||||
|
||||
it("should handle missing values when calculating contextTokens", () => {
|
||||
// We need to mock console.error to avoid polluting test output
|
||||
const originalConsoleError = console.error
|
||||
console.error = jest.fn()
|
||||
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqStartedMessage('{"tokensIn":null,"cacheWrites":5,"cacheReads":10}', 1000),
|
||||
]
|
||||
|
||||
const result = getApiMetrics(messages)
|
||||
|
||||
// Should handle missing or invalid values
|
||||
expect(result.contextTokens).toBe(15) // 0 + 0 + 5 + 10
|
||||
|
||||
// Restore console.error
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -2,11 +2,19 @@ import { TokenUsage } from "../schemas"
|
|||
|
||||
import { ClineMessage } from "./ExtensionMessage"
|
||||
|
||||
export type ParsedApiReqStartedTextType = {
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
cacheWrites: number
|
||||
cacheReads: number
|
||||
cost?: number // Only present if combineApiRequests has been called
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates API metrics from an array of ClineMessages.
|
||||
*
|
||||
* This function processes 'api_req_started' messages that have been combined with their
|
||||
* corresponding 'api_req_finished' messages by the combineApiRequests function.
|
||||
* This function processes 'condense_context' messages and 'api_req_started' messages that have been
|
||||
* combined with their corresponding 'api_req_finished' messages by the combineApiRequests function.
|
||||
* It extracts and sums up the tokensIn, tokensOut, cacheWrites, cacheReads, and cost from these messages.
|
||||
*
|
||||
* @param messages - An array of ClineMessage objects to process.
|
||||
|
|
@ -29,30 +37,12 @@ export function getApiMetrics(messages: ClineMessage[]) {
|
|||
contextTokens: 0,
|
||||
}
|
||||
|
||||
// Helper function to get total tokens from a message
|
||||
const getTotalTokensFromMessage = (message: ClineMessage): number => {
|
||||
if (!message.text) return 0
|
||||
try {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads } = JSON.parse(message.text)
|
||||
return (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Find the last api_req_started message that has any tokens
|
||||
const lastApiReq = [...messages].reverse().find((message) => {
|
||||
if (message.type === "say" && message.say === "api_req_started") {
|
||||
return getTotalTokensFromMessage(message) > 0
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Calculate running totals
|
||||
messages.forEach((message) => {
|
||||
if (message.type === "say" && message.say === "api_req_started" && message.text) {
|
||||
try {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads, cost } = JSON.parse(message.text)
|
||||
const parsedText: ParsedApiReqStartedTextType = JSON.parse(message.text)
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads, cost } = parsedText
|
||||
|
||||
if (typeof tokensIn === "number") {
|
||||
result.totalTokensIn += tokensIn
|
||||
|
|
@ -69,16 +59,34 @@ export function getApiMetrics(messages: ClineMessage[]) {
|
|||
if (typeof cost === "number") {
|
||||
result.totalCost += cost
|
||||
}
|
||||
|
||||
// If this is the last api request with tokens, use its total for context size
|
||||
if (message === lastApiReq) {
|
||||
result.contextTokens = getTotalTokensFromMessage(message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing JSON:", error)
|
||||
}
|
||||
} else if (message.type === "say" && message.say === "condense_context") {
|
||||
result.totalCost += message.contextCondense?.cost ?? 0
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate context tokens, from the last API request started or condense context message
|
||||
result.contextTokens = 0
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
if (message.type === "say" && message.say === "api_req_started" && message.text) {
|
||||
try {
|
||||
const parsedText: ParsedApiReqStartedTextType = JSON.parse(message.text)
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads } = parsedText
|
||||
result.contextTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
} catch (error) {
|
||||
console.error("Error parsing JSON:", error)
|
||||
continue
|
||||
}
|
||||
} else if (message.type === "say" && message.say === "condense_context") {
|
||||
result.contextTokens = message.contextCondense?.newContextTokens ?? 0
|
||||
}
|
||||
if (result.contextTokens) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { ProgressIndicator } from "./ProgressIndicator"
|
|||
import { Markdown } from "./Markdown"
|
||||
import { CommandExecution } from "./CommandExecution"
|
||||
import { CommandExecutionError } from "./CommandExecutionError"
|
||||
import { ContextCondenseRow } from "./ContextCondenseRow"
|
||||
|
||||
interface ChatRowProps {
|
||||
message: ClineMessage
|
||||
|
|
@ -926,6 +927,8 @@ export const ChatRowContent = ({
|
|||
checkpoint={message.checkpoint}
|
||||
/>
|
||||
)
|
||||
case "condense_context":
|
||||
return message.contextCondense ? <ContextCondenseRow {...message.contextCondense} /> : null
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
38
webview-ui/src/components/chat/ContextCondenseRow.tsx
Normal file
38
webview-ui/src/components/chat/ContextCondenseRow.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
import { ContextCondense } from "@roo/schemas"
|
||||
import { Markdown } from "./Markdown"
|
||||
|
||||
export const ContextCondenseRow = ({ cost, prevContextTokens, newContextTokens, summary }: ContextCondense) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer select-none"
|
||||
onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<div className="flex items-center gap-2 flex-grow">
|
||||
<span className="codicon codicon-compress text-blue-400" />
|
||||
<span className="font-bold text-vscode-foreground">{t("chat:contextCondense.title")}</span>
|
||||
<span className="text-vscode-descriptionForeground text-sm">
|
||||
{prevContextTokens.toLocaleString()} → {newContextTokens.toLocaleString()} {t("tokens")}
|
||||
</span>
|
||||
<VSCodeBadge className={cost > 0 ? "opacity-100" : "opacity-0"}>${cost.toFixed(2)}</VSCodeBadge>
|
||||
</div>
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 ml-0 p-4 bg-vscode-editor-background rounded text-vscode-foreground text-sm">
|
||||
<h3 className="font-bold mb-5 -mt-1 ml-2">{t("chat:contextCondense.conversationSummary")}</h3>
|
||||
<div className="-ml-6">
|
||||
<Markdown markdown={summary} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "Pensant",
|
||||
"seconds": "{{count}}s"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Context condensat",
|
||||
"conversationSummary": "Resum de la conversa",
|
||||
"tokens": "tokens"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "Copiar a l'entrada (o Shift + clic)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "Denke nach",
|
||||
"seconds": "{{count}}s"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Kontext komprimiert",
|
||||
"conversationSummary": "Gesprächszusammenfassung",
|
||||
"tokens": "Tokens"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "In Eingabefeld kopieren (oder Shift + Klick)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -129,6 +129,11 @@
|
|||
},
|
||||
"current": "Current"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Context Condensed",
|
||||
"conversationSummary": "Conversation Summary",
|
||||
"tokens": "tokens"
|
||||
},
|
||||
"instructions": {
|
||||
"wantsToFetch": "Roo wants to fetch detailed instructions to assist with the current task"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -400,7 +400,7 @@
|
|||
"warning": "⚠️",
|
||||
"AUTO_CONDENSE_CONTEXT": {
|
||||
"name": "Intelligently condense the context window",
|
||||
"description": "Uses an LLM call to summarize the past conversation when the task's context window is almost full, rather than dropping old messages. Disclaimer: the cost of summarizing is not currently included in the API costs shown in the UI."
|
||||
"description": "Uses an LLM call to summarize the past conversation when the task's context window is almost full, rather than dropping old messages."
|
||||
},
|
||||
"DIFF_STRATEGY_UNIFIED": {
|
||||
"name": "Use experimental unified diff strategy",
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "Pensando",
|
||||
"seconds": "{{count}}s"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Contexto condensado",
|
||||
"conversationSummary": "Resumen de la conversación",
|
||||
"tokens": "tokens"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "Copiar a la entrada (o Shift + clic)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "Réflexion",
|
||||
"seconds": "{{count}}s"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Contexte condensé",
|
||||
"conversationSummary": "Résumé de la conversation",
|
||||
"tokens": "tokens"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "Copier vers l'entrée (ou Shift + clic)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "विचार कर रहा है",
|
||||
"seconds": "{{count}} सेकंड"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "संदर्भ संक्षिप्त किया गया",
|
||||
"conversationSummary": "वार्तालाप का सारांश",
|
||||
"tokens": "टोकन"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "इनपुट में कॉपी करें (या Shift + क्लिक)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "Sto pensando",
|
||||
"seconds": "{{count}}s"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Contesto condensato",
|
||||
"conversationSummary": "Riepilogo della conversazione",
|
||||
"tokens": "token"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "Copia nell'input (o Shift + clic)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "考え中",
|
||||
"seconds": "{{count}}秒"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "コンテキスト要約",
|
||||
"conversationSummary": "会話の要約",
|
||||
"tokens": "トークン"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "入力欄にコピー(またはShift + クリック)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "생각 중",
|
||||
"seconds": "{{count}}초"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "컨텍스트 요약됨",
|
||||
"conversationSummary": "대화 요약",
|
||||
"tokens": "토큰"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "입력창에 복사 (또는 Shift + 클릭)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@
|
|||
"thinking": "Denkt na",
|
||||
"seconds": "{{count}}s"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Context samengevat",
|
||||
"conversationSummary": "Gespreksoverzicht",
|
||||
"tokens": "tokens"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "Kopiëren naar invoer (zelfde als shift + klik)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "Myślenie",
|
||||
"seconds": "{{count}} s"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Kontekst skondensowany",
|
||||
"conversationSummary": "Podsumowanie rozmowy",
|
||||
"tokens": "tokeny"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "Kopiuj do pola wprowadzania (lub Shift + kliknięcie)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "Pensando",
|
||||
"seconds": "{{count}}s"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Contexto condensado",
|
||||
"conversationSummary": "Resumo da conversa",
|
||||
"tokens": "tokens"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "Copiar para entrada (ou Shift + clique)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@
|
|||
"thinking": "Обдумывание",
|
||||
"seconds": "{{count}}с"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Контекст сжат",
|
||||
"conversationSummary": "Сводка разговора",
|
||||
"tokens": "токены"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "Скопировать во ввод (то же, что shift + клик)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "Düşünüyor",
|
||||
"seconds": "{{count}}sn"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Bağlam Özetlendi",
|
||||
"conversationSummary": "Konuşma Özeti",
|
||||
"tokens": "token"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "Giriş alanına kopyala (veya Shift + tıklama)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "Đang suy nghĩ",
|
||||
"seconds": "{{count}} giây"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "Ngữ cảnh đã tóm tắt",
|
||||
"conversationSummary": "Tóm tắt cuộc hội thoại",
|
||||
"tokens": "token"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "Sao chép vào ô nhập liệu (hoặc Shift + nhấp chuột)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "思考中",
|
||||
"seconds": "{{count}}秒"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "上下文已压缩",
|
||||
"conversationSummary": "对话摘要",
|
||||
"tokens": "tokens"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "复制到输入框(或按住Shift点击)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@
|
|||
"thinking": "思考中",
|
||||
"seconds": "{{count}}秒"
|
||||
},
|
||||
"contextCondense": {
|
||||
"title": "上下文已壓縮",
|
||||
"conversationSummary": "對話摘要",
|
||||
"tokens": "tokens"
|
||||
},
|
||||
"followUpSuggest": {
|
||||
"copyToInput": "複製到輸入框(或按住 Shift 並點選)"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue