|
|
@@ -405,7 +405,7 @@ describe('ForegroundFallbackManager message.updated', () => {
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
describe('ForegroundFallbackManager session.status', () => {
|
|
|
- test('aborts active retry-budget-exhausted session before fallback re-prompt', async () => {
|
|
|
+ test('aborts session before fallback re-prompt on first failover retry', async () => {
|
|
|
const calls: string[] = [];
|
|
|
const { client, mocks } = createMockClient({
|
|
|
abortImpl: async () => {
|
|
|
@@ -429,19 +429,17 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- for (const attempt of [1, 2, 3]) {
|
|
|
- await mgr.handleEvent({
|
|
|
- type: 'session.status',
|
|
|
- properties: {
|
|
|
- sessionID: 'sess-retry-abort-before-prompt',
|
|
|
- status: {
|
|
|
- type: 'retry',
|
|
|
- attempt,
|
|
|
- message: 'rate limit, retrying...',
|
|
|
- },
|
|
|
+ await mgr.handleEvent({
|
|
|
+ type: 'session.status',
|
|
|
+ properties: {
|
|
|
+ sessionID: 'sess-retry-abort-before-prompt',
|
|
|
+ status: {
|
|
|
+ type: 'retry',
|
|
|
+ attempt: 1,
|
|
|
+ message: 'rate limit, retrying...',
|
|
|
},
|
|
|
- });
|
|
|
- }
|
|
|
+ },
|
|
|
+ });
|
|
|
|
|
|
expect(mocks.abort).toHaveBeenCalledTimes(1);
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
@@ -627,7 +625,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
expect(mocks.promptAsync).not.toHaveBeenCalled();
|
|
|
});
|
|
|
|
|
|
- test('absorbs failover retries until the retry budget is exhausted', async () => {
|
|
|
+ test('triggers immediate fallback on first failover retry', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
|
|
|
|
|
|
@@ -642,7 +640,6 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- // The first retry is absorbed; only exhaustion triggers a failover.
|
|
|
await mgr.handleEvent({
|
|
|
type: 'session.status',
|
|
|
properties: {
|
|
|
@@ -654,10 +651,10 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
},
|
|
|
},
|
|
|
});
|
|
|
- expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
|
|
|
+ expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
});
|
|
|
|
|
|
- test('switches models after three failover retries', async () => {
|
|
|
+ test('switches to fallback model on first failover retry', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
|
|
|
|
|
|
@@ -672,7 +669,6 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- // First retry is absorbed.
|
|
|
await mgr.handleEvent({
|
|
|
type: 'session.status',
|
|
|
properties: {
|
|
|
@@ -684,31 +680,6 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
},
|
|
|
},
|
|
|
});
|
|
|
- expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
|
|
|
-
|
|
|
- // Second retry is also absorbed; the third exhausts the budget.
|
|
|
- await mgr.handleEvent({
|
|
|
- type: 'session.status',
|
|
|
- properties: {
|
|
|
- sessionID: 'sess-retry2',
|
|
|
- status: {
|
|
|
- type: 'retry',
|
|
|
- attempt: 2,
|
|
|
- message: 'rate limit, retrying...',
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
- await mgr.handleEvent({
|
|
|
- type: 'session.status',
|
|
|
- properties: {
|
|
|
- sessionID: 'sess-retry2',
|
|
|
- status: {
|
|
|
- type: 'retry',
|
|
|
- attempt: 3,
|
|
|
- message: 'rate limit, retrying...',
|
|
|
- },
|
|
|
- },
|
|
|
- });
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
});
|
|
|
|
|
|
@@ -766,7 +737,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
});
|
|
|
|
|
|
- test('non-rate-limit status does not clear retries (no infinite loop from abort side effects)', async () => {
|
|
|
+ test('non-rate-limit retry does not trigger fallback but rate-limit does', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
|
|
|
|
|
|
@@ -781,7 +752,17 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- // First rate-limit is absorbed.
|
|
|
+ // Non-rate-limit retry (e.g. abort side effect): must NOT trigger fallback.
|
|
|
+ await mgr.handleEvent({
|
|
|
+ type: 'session.status',
|
|
|
+ properties: {
|
|
|
+ sessionID: 'sess-nonrl',
|
|
|
+ status: { type: 'retry', attempt: 1, message: 'aborted' },
|
|
|
+ },
|
|
|
+ });
|
|
|
+ expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
|
|
|
+
|
|
|
+ // Genuine rate-limit retry triggers immediate fallback.
|
|
|
await mgr.handleEvent({
|
|
|
type: 'session.status',
|
|
|
properties: {
|
|
|
@@ -793,25 +774,67 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
},
|
|
|
},
|
|
|
});
|
|
|
- expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
|
|
|
+ expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('ignores stale retry event from original model after fallback switches models', async () => {
|
|
|
+ // greptile-apps race condition: after a fallback succeeds and the manager
|
|
|
+ // switches to model B, a delayed retry event from model A's original retry
|
|
|
+ // loop (already in-flight when the abort happened) should NOT trigger a
|
|
|
+ // second fallback — it carries the old model's error, not model B's.
|
|
|
+ const calls: string[] = [];
|
|
|
+ const { client, mocks } = createMockClient({
|
|
|
+ abortImpl: async () => {
|
|
|
+ calls.push('abort');
|
|
|
+ },
|
|
|
+ promptAsyncImpl: async () => {
|
|
|
+ calls.push('promptAsync');
|
|
|
+ return {};
|
|
|
+ },
|
|
|
+ });
|
|
|
+ const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
|
|
|
+
|
|
|
+ // Seed session with model A (anthropic/claude-opus-4-5)
|
|
|
+ await mgr.handleEvent({
|
|
|
+ type: 'message.updated',
|
|
|
+ properties: {
|
|
|
+ info: {
|
|
|
+ sessionID: 'sess-stale',
|
|
|
+ providerID: 'anthropic',
|
|
|
+ modelID: 'claude-opus-4-5',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ });
|
|
|
|
|
|
- // Non-rate-limit status (e.g. abort side effect): must NOT reset retries.
|
|
|
- // If it did, the next rate-limit would see tried=0 and trigger immediate
|
|
|
- // fallback again — the infinite loop.
|
|
|
+ // First retry event: model A rate-limited → triggers fallback to model B
|
|
|
await mgr.handleEvent({
|
|
|
type: 'session.status',
|
|
|
properties: {
|
|
|
- sessionID: 'sess-nonrl',
|
|
|
- status: { type: 'retry', attempt: 1, message: 'aborted' },
|
|
|
+ sessionID: 'sess-stale',
|
|
|
+ status: {
|
|
|
+ type: 'retry',
|
|
|
+ attempt: 1,
|
|
|
+ message: 'rate limit, retrying...',
|
|
|
+ },
|
|
|
},
|
|
|
});
|
|
|
- expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
|
|
|
|
|
|
- // Second rate-limit remains within the budget.
|
|
|
+ expect(mocks.abort).toHaveBeenCalledTimes(1);
|
|
|
+ expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
+ const firstCall = mocks.promptAsync.mock.calls[0] as [
|
|
|
+ { body: { model: { providerID: string; modelID: string } } },
|
|
|
+ ];
|
|
|
+ expect(firstCall[0].body.model).toEqual({
|
|
|
+ providerID: 'openai',
|
|
|
+ modelID: 'gpt-4o',
|
|
|
+ });
|
|
|
+
|
|
|
+ // Stale retry event from the ORIGINAL model A arrives after the switch.
|
|
|
+ // The session model is now openai/gpt-4o, so this event should be ignored.
|
|
|
await mgr.handleEvent({
|
|
|
type: 'session.status',
|
|
|
properties: {
|
|
|
- sessionID: 'sess-nonrl',
|
|
|
+ sessionID: 'sess-stale',
|
|
|
status: {
|
|
|
type: 'retry',
|
|
|
attempt: 2,
|
|
|
@@ -819,21 +842,89 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
},
|
|
|
},
|
|
|
});
|
|
|
- expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
|
|
|
|
|
|
- // Third rate-limit exhausts the budget.
|
|
|
+ // Should NOT trigger another fallback — the event is stale
|
|
|
+ expect(mocks.abort).toHaveBeenCalledTimes(1);
|
|
|
+ expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('does NOT ignore genuine retry from fallback model within dedup window', async () => {
|
|
|
+ // greptile-apps issue #2: a genuine retry from the fallback model (model B)
|
|
|
+ // arriving within the dedup window should trigger a fallback, not be ignored.
|
|
|
+ // The previous fix used lastTriggerModel which still held model A, causing
|
|
|
+ // model B's genuine retry to be mistaken for a stale retry from model A.
|
|
|
+ const calls: string[] = [];
|
|
|
+ const { client, mocks } = createMockClient({
|
|
|
+ abortImpl: async () => {
|
|
|
+ calls.push('abort');
|
|
|
+ },
|
|
|
+ promptAsyncImpl: async () => {
|
|
|
+ calls.push('promptAsync');
|
|
|
+ return {};
|
|
|
+ },
|
|
|
+ });
|
|
|
+ const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1); // maxRetries=1 for immediate fallback
|
|
|
+
|
|
|
+ // Seed session with model A
|
|
|
+ await mgr.handleEvent({
|
|
|
+ type: 'message.updated',
|
|
|
+ properties: {
|
|
|
+ info: {
|
|
|
+ sessionID: 'sess-genuine-retry',
|
|
|
+ providerID: 'anthropic',
|
|
|
+ modelID: 'claude-opus-4-5',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // First retry event: model A rate-limited → triggers fallback to model B
|
|
|
await mgr.handleEvent({
|
|
|
type: 'session.status',
|
|
|
properties: {
|
|
|
- sessionID: 'sess-nonrl',
|
|
|
+ sessionID: 'sess-genuine-retry',
|
|
|
status: {
|
|
|
type: 'retry',
|
|
|
- attempt: 3,
|
|
|
+ attempt: 1,
|
|
|
message: 'rate limit, retrying...',
|
|
|
},
|
|
|
},
|
|
|
});
|
|
|
+
|
|
|
+ expect(mocks.abort).toHaveBeenCalledTimes(1);
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
+ const firstCall = mocks.promptAsync.mock.calls[0] as [
|
|
|
+ { body: { model: { providerID: string; modelID: string } } },
|
|
|
+ ];
|
|
|
+ expect(firstCall[0].body.model).toEqual({
|
|
|
+ providerID: 'openai',
|
|
|
+ modelID: 'gpt-4o',
|
|
|
+ });
|
|
|
+
|
|
|
+ // Now model B (openai/gpt-4o) is active. A GENUINE retry from model B
|
|
|
+ // arrives within the dedup window (immediately after). This should trigger
|
|
|
+ // another fallback to model C (google/gemini-2.5-pro), NOT be ignored.
|
|
|
+ await mgr.handleEvent({
|
|
|
+ type: 'session.status',
|
|
|
+ properties: {
|
|
|
+ sessionID: 'sess-genuine-retry',
|
|
|
+ status: {
|
|
|
+ type: 'retry',
|
|
|
+ attempt: 1, // attempt resets for new model
|
|
|
+ message: 'rate limit, retrying...',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // Should trigger a second fallback to model C
|
|
|
+ expect(mocks.abort).toHaveBeenCalledTimes(2);
|
|
|
+ expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
|
|
|
+ const secondCall = mocks.promptAsync.mock.calls[1] as [
|
|
|
+ { body: { model: { providerID: string; modelID: string } } },
|
|
|
+ ];
|
|
|
+ expect(secondCall[0].body.model).toEqual({
|
|
|
+ providerID: 'google',
|
|
|
+ modelID: 'gemini-2.5-pro',
|
|
|
+ });
|
|
|
});
|
|
|
});
|
|
|
|