Skip to content

Commit 74bc708

Browse files
committed
test: test global rate limiting for subtasks
1 parent 413577b commit 74bc708

File tree

2 files changed

+363
-0
lines changed

2 files changed

+363
-0
lines changed

src/core/task/Task.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ export class Task extends EventEmitter<ClineEvents> {
144144
private lastApiRequestTime?: number
145145
private consecutiveAutoApprovedRequestsCount: number = 0
146146

147+
/**
148+
* Reset the global API request timestamp. This should only be used for testing.
149+
* @internal
150+
*/
151+
static resetGlobalApiRequestTime(): void {
152+
Task.lastGlobalApiRequestTime = undefined
153+
}
154+
147155
toolRepetitionDetector: ToolRepetitionDetector
148156
rooIgnoreController?: RooIgnoreController
149157
rooProtectedController?: RooProtectedController

src/core/task/__tests__/Task.spec.ts

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-sear
1818
import { MultiFileSearchReplaceDiffStrategy } from "../../diff/strategies/multi-file-search-replace"
1919
import { EXPERIMENT_IDS } from "../../../shared/experiments"
2020

21+
// Mock delay before any imports that might use it
22+
vi.mock("delay", () => ({
23+
__esModule: true,
24+
default: vi.fn().mockResolvedValue(undefined),
25+
}))
26+
27+
import delay from "delay"
28+
2129
vi.mock("execa", () => ({
2230
execa: vi.fn(),
2331
}))
@@ -869,6 +877,353 @@ describe("Cline", () => {
869877
})
870878
})
871879

880+
describe("Subtask Rate Limiting", () => {
881+
let mockProvider: any
882+
let mockApiConfig: any
883+
let mockDelay: ReturnType<typeof vi.fn>
884+
885+
beforeEach(() => {
886+
vi.clearAllMocks()
887+
// Reset the global timestamp before each test
888+
Task.resetGlobalApiRequestTime()
889+
890+
mockApiConfig = {
891+
apiProvider: "anthropic",
892+
apiKey: "test-key",
893+
rateLimitSeconds: 5,
894+
}
895+
896+
mockProvider = {
897+
context: {
898+
globalStorageUri: { fsPath: "/test/storage" },
899+
},
900+
getState: vi.fn().mockResolvedValue({
901+
apiConfiguration: mockApiConfig,
902+
}),
903+
say: vi.fn(),
904+
postStateToWebview: vi.fn().mockResolvedValue(undefined),
905+
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
906+
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
907+
}
908+
909+
// Get the mocked delay function
910+
mockDelay = delay as ReturnType<typeof vi.fn>
911+
mockDelay.mockClear()
912+
})
913+
914+
afterEach(() => {
915+
// Clean up the global state after each test
916+
Task.resetGlobalApiRequestTime()
917+
})
918+
919+
it("should enforce rate limiting across parent and subtask", async () => {
920+
// Add a spy to track getState calls
921+
const getStateSpy = vi.spyOn(mockProvider, "getState")
922+
923+
// Create parent task
924+
const parent = new Task({
925+
provider: mockProvider,
926+
apiConfiguration: mockApiConfig,
927+
task: "parent task",
928+
startTask: false,
929+
})
930+
931+
// Mock the API stream response
932+
const mockStream = {
933+
async *[Symbol.asyncIterator]() {
934+
yield { type: "text", text: "parent response" }
935+
},
936+
async next() {
937+
return { done: true, value: { type: "text", text: "parent response" } }
938+
},
939+
async return() {
940+
return { done: true, value: undefined }
941+
},
942+
async throw(e: any) {
943+
throw e
944+
},
945+
[Symbol.asyncDispose]: async () => {},
946+
} as AsyncGenerator<ApiStreamChunk>
947+
948+
vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
949+
950+
// Make an API request with the parent task
951+
const parentIterator = parent.attemptApiRequest(0)
952+
await parentIterator.next()
953+
954+
// Verify no delay was applied for the first request
955+
expect(mockDelay).not.toHaveBeenCalled()
956+
957+
// Create a subtask immediately after
958+
const child = new Task({
959+
provider: mockProvider,
960+
apiConfiguration: mockApiConfig,
961+
task: "child task",
962+
parentTask: parent,
963+
rootTask: parent,
964+
startTask: false,
965+
})
966+
967+
// Mock the child's API stream
968+
const childMockStream = {
969+
async *[Symbol.asyncIterator]() {
970+
yield { type: "text", text: "child response" }
971+
},
972+
async next() {
973+
return { done: true, value: { type: "text", text: "child response" } }
974+
},
975+
async return() {
976+
return { done: true, value: undefined }
977+
},
978+
async throw(e: any) {
979+
throw e
980+
},
981+
[Symbol.asyncDispose]: async () => {},
982+
} as AsyncGenerator<ApiStreamChunk>
983+
984+
vi.spyOn(child.api, "createMessage").mockReturnValue(childMockStream)
985+
986+
// Make an API request with the child task
987+
const childIterator = child.attemptApiRequest(0)
988+
await childIterator.next()
989+
990+
// Verify rate limiting was applied
991+
expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds)
992+
expect(mockDelay).toHaveBeenCalledWith(1000)
993+
}, 10000) // Increase timeout to 10 seconds
994+
995+
it("should not apply rate limiting if enough time has passed", async () => {
996+
// Create parent task
997+
const parent = new Task({
998+
provider: mockProvider,
999+
apiConfiguration: mockApiConfig,
1000+
task: "parent task",
1001+
startTask: false,
1002+
})
1003+
1004+
// Mock the API stream response
1005+
const mockStream = {
1006+
async *[Symbol.asyncIterator]() {
1007+
yield { type: "text", text: "response" }
1008+
},
1009+
async next() {
1010+
return { done: true, value: { type: "text", text: "response" } }
1011+
},
1012+
async return() {
1013+
return { done: true, value: undefined }
1014+
},
1015+
async throw(e: any) {
1016+
throw e
1017+
},
1018+
[Symbol.asyncDispose]: async () => {},
1019+
} as AsyncGenerator<ApiStreamChunk>
1020+
1021+
vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
1022+
1023+
// Make an API request with the parent task
1024+
const parentIterator = parent.attemptApiRequest(0)
1025+
await parentIterator.next()
1026+
1027+
// Simulate time passing (more than rate limit)
1028+
const originalDateNow = Date.now
1029+
const mockTime = Date.now() + (mockApiConfig.rateLimitSeconds + 1) * 1000
1030+
Date.now = vi.fn(() => mockTime)
1031+
1032+
// Create a subtask after time has passed
1033+
const child = new Task({
1034+
provider: mockProvider,
1035+
apiConfiguration: mockApiConfig,
1036+
task: "child task",
1037+
parentTask: parent,
1038+
rootTask: parent,
1039+
startTask: false,
1040+
})
1041+
1042+
vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream)
1043+
1044+
// Make an API request with the child task
1045+
const childIterator = child.attemptApiRequest(0)
1046+
await childIterator.next()
1047+
1048+
// Verify no rate limiting was applied
1049+
expect(mockDelay).not.toHaveBeenCalled()
1050+
1051+
// Restore Date.now
1052+
Date.now = originalDateNow
1053+
})
1054+
1055+
it("should share rate limiting across multiple subtasks", async () => {
1056+
// Create parent task
1057+
const parent = new Task({
1058+
provider: mockProvider,
1059+
apiConfiguration: mockApiConfig,
1060+
task: "parent task",
1061+
startTask: false,
1062+
})
1063+
1064+
// Mock the API stream response
1065+
const mockStream = {
1066+
async *[Symbol.asyncIterator]() {
1067+
yield { type: "text", text: "response" }
1068+
},
1069+
async next() {
1070+
return { done: true, value: { type: "text", text: "response" } }
1071+
},
1072+
async return() {
1073+
return { done: true, value: undefined }
1074+
},
1075+
async throw(e: any) {
1076+
throw e
1077+
},
1078+
[Symbol.asyncDispose]: async () => {},
1079+
} as AsyncGenerator<ApiStreamChunk>
1080+
1081+
vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
1082+
1083+
// Make an API request with the parent task
1084+
const parentIterator = parent.attemptApiRequest(0)
1085+
await parentIterator.next()
1086+
1087+
// Create first subtask
1088+
const child1 = new Task({
1089+
provider: mockProvider,
1090+
apiConfiguration: mockApiConfig,
1091+
task: "child task 1",
1092+
parentTask: parent,
1093+
rootTask: parent,
1094+
startTask: false,
1095+
})
1096+
1097+
vi.spyOn(child1.api, "createMessage").mockReturnValue(mockStream)
1098+
1099+
// Make an API request with the first child task
1100+
const child1Iterator = child1.attemptApiRequest(0)
1101+
await child1Iterator.next()
1102+
1103+
// Verify rate limiting was applied
1104+
const firstDelayCount = mockDelay.mock.calls.length
1105+
expect(firstDelayCount).toBe(mockApiConfig.rateLimitSeconds)
1106+
1107+
// Clear the mock to count new delays
1108+
mockDelay.mockClear()
1109+
1110+
// Create second subtask immediately after
1111+
const child2 = new Task({
1112+
provider: mockProvider,
1113+
apiConfiguration: mockApiConfig,
1114+
task: "child task 2",
1115+
parentTask: parent,
1116+
rootTask: parent,
1117+
startTask: false,
1118+
})
1119+
1120+
vi.spyOn(child2.api, "createMessage").mockReturnValue(mockStream)
1121+
1122+
// Make an API request with the second child task
1123+
const child2Iterator = child2.attemptApiRequest(0)
1124+
await child2Iterator.next()
1125+
1126+
// Verify rate limiting was applied again
1127+
expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds)
1128+
}, 15000) // Increase timeout to 15 seconds
1129+
1130+
it("should handle rate limiting with zero rate limit", async () => {
1131+
// Update config to have zero rate limit
1132+
mockApiConfig.rateLimitSeconds = 0
1133+
mockProvider.getState.mockResolvedValue({
1134+
apiConfiguration: mockApiConfig,
1135+
})
1136+
1137+
// Create parent task
1138+
const parent = new Task({
1139+
provider: mockProvider,
1140+
apiConfiguration: mockApiConfig,
1141+
task: "parent task",
1142+
startTask: false,
1143+
})
1144+
1145+
// Mock the API stream response
1146+
const mockStream = {
1147+
async *[Symbol.asyncIterator]() {
1148+
yield { type: "text", text: "response" }
1149+
},
1150+
async next() {
1151+
return { done: true, value: { type: "text", text: "response" } }
1152+
},
1153+
async return() {
1154+
return { done: true, value: undefined }
1155+
},
1156+
async throw(e: any) {
1157+
throw e
1158+
},
1159+
[Symbol.asyncDispose]: async () => {},
1160+
} as AsyncGenerator<ApiStreamChunk>
1161+
1162+
vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
1163+
1164+
// Make an API request with the parent task
1165+
const parentIterator = parent.attemptApiRequest(0)
1166+
await parentIterator.next()
1167+
1168+
// Create a subtask
1169+
const child = new Task({
1170+
provider: mockProvider,
1171+
apiConfiguration: mockApiConfig,
1172+
task: "child task",
1173+
parentTask: parent,
1174+
rootTask: parent,
1175+
startTask: false,
1176+
})
1177+
1178+
vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream)
1179+
1180+
// Make an API request with the child task
1181+
const childIterator = child.attemptApiRequest(0)
1182+
await childIterator.next()
1183+
1184+
// Verify no delay was applied
1185+
expect(mockDelay).not.toHaveBeenCalled()
1186+
})
1187+
1188+
it("should update global timestamp even when no rate limiting is needed", async () => {
1189+
// Create task
1190+
const task = new Task({
1191+
provider: mockProvider,
1192+
apiConfiguration: mockApiConfig,
1193+
task: "test task",
1194+
startTask: false,
1195+
})
1196+
1197+
// Mock the API stream response
1198+
const mockStream = {
1199+
async *[Symbol.asyncIterator]() {
1200+
yield { type: "text", text: "response" }
1201+
},
1202+
async next() {
1203+
return { done: true, value: { type: "text", text: "response" } }
1204+
},
1205+
async return() {
1206+
return { done: true, value: undefined }
1207+
},
1208+
async throw(e: any) {
1209+
throw e
1210+
},
1211+
[Symbol.asyncDispose]: async () => {},
1212+
} as AsyncGenerator<ApiStreamChunk>
1213+
1214+
vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream)
1215+
1216+
// Make an API request
1217+
const iterator = task.attemptApiRequest(0)
1218+
await iterator.next()
1219+
1220+
// Access the private static property via reflection for testing
1221+
const globalTimestamp = (Task as any).lastGlobalApiRequestTime
1222+
expect(globalTimestamp).toBeDefined()
1223+
expect(globalTimestamp).toBeGreaterThan(0)
1224+
})
1225+
})
1226+
8721227
describe("Dynamic Strategy Selection", () => {
8731228
let mockProvider: any
8741229
let mockApiConfig: any

0 commit comments

Comments
 (0)