|
|
@@ -9,9 +9,26 @@ import {
|
|
|
|
|
|
// ACCEPTANCE GAP: config() hook behaviour is not covered by CI — verify live.
|
|
|
|
|
|
-type ForegroundFallbackClient = ConstructorParameters<
|
|
|
- typeof ForegroundFallbackManager
|
|
|
->[0];
|
|
|
+// Shared session reference so our mock.module for getClient returns the
|
|
|
+// current test's mock session without relying on this.input (which is
|
|
|
+// undefined in tests — always set in production).
|
|
|
+let currentMockSession: Record<string, unknown> | null = null;
|
|
|
+
|
|
|
+// Override manager.test.ts's global mock.module for getClient. Called
|
|
|
+// at module load AND from createMockClient so it takes effect regardless of
|
|
|
+// test file load order.
|
|
|
+function installGetClientMock(): void {
|
|
|
+ mock.module('../../utils/opencode-client', () => ({
|
|
|
+ getClient: () => ({
|
|
|
+ session: currentMockSession ?? {
|
|
|
+ abort: mock(() => Promise.resolve()),
|
|
|
+ messages: mock(() => Promise.resolve({ data: [] })),
|
|
|
+ promptAsync: mock(() => Promise.resolve()),
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ }));
|
|
|
+}
|
|
|
+installGetClientMock();
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
// Helpers
|
|
|
@@ -44,10 +61,16 @@ function createMockClient(overrides?: {
|
|
|
session.promptAsync = promptAsync;
|
|
|
}
|
|
|
|
|
|
+ // Store for getClient mock
|
|
|
+ currentMockSession = session;
|
|
|
+ // Re-register the mock.module at test time so it survives any
|
|
|
+ // overwrite from other test files loaded in the same process.
|
|
|
+ installGetClientMock();
|
|
|
+
|
|
|
return {
|
|
|
client: {
|
|
|
session,
|
|
|
- } as unknown as ForegroundFallbackClient,
|
|
|
+ } as never,
|
|
|
mocks: { promptAsync, abort, messages },
|
|
|
};
|
|
|
}
|
|
|
@@ -180,7 +203,7 @@ describe('isFailoverError', () => {
|
|
|
describe('ForegroundFallbackManager (disabled)', () => {
|
|
|
test('does nothing when enabled=false', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), false);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), false, { directory: '/test' } as any);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'session.error',
|
|
|
@@ -205,7 +228,7 @@ describe('ForegroundFallbackManager session.error', () => {
|
|
|
|
|
|
beforeEach(() => {
|
|
|
({ client, mocks } = createMockClient());
|
|
|
- mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
});
|
|
|
|
|
|
test('triggers fallback on rate-limit session.error', async () => {
|
|
|
@@ -236,14 +259,14 @@ describe('ForegroundFallbackManager session.error', () => {
|
|
|
|
|
|
const call = mocks.promptAsync.mock.calls[0] as [
|
|
|
{
|
|
|
- path: { id: string };
|
|
|
- body: { model: { providerID: string; modelID: string } };
|
|
|
+ sessionID: string;
|
|
|
+ model: { providerID: string; modelID: string };
|
|
|
},
|
|
|
];
|
|
|
- expect(call[0].path.id).toBe('sess-1');
|
|
|
+ expect(call[0].sessionID).toBe('sess-1');
|
|
|
// Should have picked the next model after anthropic/claude-opus-4-5
|
|
|
- expect(call[0].body.model.providerID).toBe('openai');
|
|
|
- expect(call[0].body.model.modelID).toBe('gpt-4o');
|
|
|
+ expect(call[0].model.providerID).toBe('openai');
|
|
|
+ expect(call[0].model.modelID).toBe('gpt-4o');
|
|
|
});
|
|
|
|
|
|
test('marks the replayed user prompt as an internal initiator', async () => {
|
|
|
@@ -268,9 +291,9 @@ describe('ForegroundFallbackManager session.error', () => {
|
|
|
});
|
|
|
|
|
|
const call = mocks.promptAsync.mock.calls[0] as [
|
|
|
- { body: { parts: unknown[] } },
|
|
|
+ { parts: unknown[] },
|
|
|
];
|
|
|
- expect(call[0].body.parts.some(isInternalInitiatorPart)).toBe(true);
|
|
|
+ expect(call[0].parts.some(isInternalInitiatorPart)).toBe(true);
|
|
|
});
|
|
|
|
|
|
test('skips malformed messages without info when locating the last user message', async () => {
|
|
|
@@ -288,7 +311,7 @@ describe('ForegroundFallbackManager session.error', () => {
|
|
|
},
|
|
|
],
|
|
|
}));
|
|
|
- mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -312,9 +335,9 @@ describe('ForegroundFallbackManager session.error', () => {
|
|
|
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
const call = mocks.promptAsync.mock.calls[0] as [
|
|
|
- { body: { parts: Array<{ text?: string }> } },
|
|
|
+ { parts: Array<{ text?: string }> },
|
|
|
];
|
|
|
- expect(call[0].body.parts[0]?.text).toBe('real prompt');
|
|
|
+ expect(call[0].parts[0]?.text).toBe('real prompt');
|
|
|
});
|
|
|
|
|
|
test('does nothing when error is not a rate limit', async () => {
|
|
|
@@ -330,7 +353,7 @@ describe('ForegroundFallbackManager session.error', () => {
|
|
|
});
|
|
|
|
|
|
test('does nothing when no chain configured for session', async () => {
|
|
|
- const emptyMgr = new ForegroundFallbackManager(client, {}, true);
|
|
|
+ const emptyMgr = new ForegroundFallbackManager({}, true, { directory: '/test' } as any);
|
|
|
await emptyMgr.handleEvent({
|
|
|
type: 'session.error',
|
|
|
properties: {
|
|
|
@@ -345,7 +368,7 @@ describe('ForegroundFallbackManager session.error', () => {
|
|
|
|
|
|
test('does not abort when promptAsync is unavailable', async () => {
|
|
|
const { client, mocks } = createMockClient({ includePromptAsync: false });
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'session.error',
|
|
|
@@ -368,7 +391,7 @@ describe('ForegroundFallbackManager session.error', () => {
|
|
|
// abort succeeds on first call
|
|
|
},
|
|
|
});
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'session.error',
|
|
|
@@ -391,7 +414,7 @@ describe('ForegroundFallbackManager session.error', () => {
|
|
|
describe('ForegroundFallbackManager message.updated', () => {
|
|
|
test('tracks model from message.updated and falls back on error', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -408,16 +431,16 @@ describe('ForegroundFallbackManager message.updated', () => {
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
const call = mocks.promptAsync.mock.calls[0] as [
|
|
|
{
|
|
|
- body: { model: { providerID: string; modelID: string } };
|
|
|
+ model: { providerID: string; modelID: string };
|
|
|
},
|
|
|
];
|
|
|
- expect(call[0].body.model.providerID).toBe('openai');
|
|
|
- expect(call[0].body.model.modelID).toBe('gpt-4o');
|
|
|
+ expect(call[0].model.providerID).toBe('openai');
|
|
|
+ expect(call[0].model.modelID).toBe('gpt-4o');
|
|
|
});
|
|
|
|
|
|
test('uses agent name from message.updated to select correct chain', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
// explorer message with its model
|
|
|
await mgr.handleEvent({
|
|
|
@@ -436,13 +459,13 @@ describe('ForegroundFallbackManager message.updated', () => {
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
const call = mocks.promptAsync.mock.calls[0] as [
|
|
|
{
|
|
|
- body: { model: { providerID: string; modelID: string } };
|
|
|
+ model: { providerID: string; modelID: string };
|
|
|
},
|
|
|
];
|
|
|
// explorer chain: ['openai/gpt-4o-mini', 'anthropic/claude-haiku']
|
|
|
// current=gpt-4o-mini is tried → next = claude-haiku
|
|
|
- expect(call[0].body.model.providerID).toBe('anthropic');
|
|
|
- expect(call[0].body.model.modelID).toBe('claude-haiku');
|
|
|
+ expect(call[0].model.providerID).toBe('anthropic');
|
|
|
+ expect(call[0].model.modelID).toBe('claude-haiku');
|
|
|
});
|
|
|
});
|
|
|
|
|
|
@@ -462,7 +485,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
return {};
|
|
|
},
|
|
|
});
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -495,11 +518,11 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
test('keeps registered child agent identity sticky for retry fallback chain', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(
|
|
|
- client,
|
|
|
makeChains({
|
|
|
oracle: ['anthropic/claude-sonnet-4-5', 'openai/o3'],
|
|
|
}),
|
|
|
true,
|
|
|
+ { directory: '/test' } as any,
|
|
|
1,
|
|
|
);
|
|
|
|
|
|
@@ -526,19 +549,19 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
const call = mocks.promptAsync.mock.calls[0] as [
|
|
|
- { body: { model: { providerID: string; modelID: string } } },
|
|
|
+ { model: { providerID: string; modelID: string } },
|
|
|
];
|
|
|
- expect(call[0].body.model).toEqual({ providerID: 'openai', modelID: 'o3' });
|
|
|
+ expect(call[0].model).toEqual({ providerID: 'openai', modelID: 'o3' });
|
|
|
});
|
|
|
|
|
|
test('includes the sticky child agent in fallback promptAsync body', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(
|
|
|
- client,
|
|
|
makeChains({
|
|
|
oracle: ['anthropic/claude-sonnet-4-5', 'openai/o3'],
|
|
|
}),
|
|
|
true,
|
|
|
+ { directory: '/test' } as any,
|
|
|
1,
|
|
|
);
|
|
|
|
|
|
@@ -565,19 +588,17 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
const call = mocks.promptAsync.mock.calls[0] as [
|
|
|
{
|
|
|
- body: {
|
|
|
- agent?: string;
|
|
|
- model: { providerID: string; modelID: string };
|
|
|
- };
|
|
|
+ agent?: string;
|
|
|
+ model: { providerID: string; modelID: string };
|
|
|
},
|
|
|
];
|
|
|
- expect(call[0].body.agent).toBe('oracle');
|
|
|
- expect(call[0].body.model).toEqual({ providerID: 'openai', modelID: 'o3' });
|
|
|
+ expect(call[0].agent).toBe('oracle');
|
|
|
+ expect(call[0].model).toEqual({ providerID: 'openai', modelID: 'o3' });
|
|
|
});
|
|
|
|
|
|
test('triggers fallback on retry status with rate limit message', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 1);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -603,7 +624,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
|
|
|
test('triggers fallback on retry status with insufficient balance message', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 1);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -629,7 +650,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
|
|
|
test('ignores session.status with non-rate-limit retry message', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'session.status',
|
|
|
@@ -644,7 +665,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
|
|
|
test('does not abort or switch after retries without a failover reason', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -673,7 +694,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
|
|
|
test('triggers immediate fallback on first failover retry', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -702,7 +723,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
|
|
|
test('switches to fallback model on first failover retry', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -731,7 +752,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
|
|
|
test('triggers fallback when rate-limit text is in props.error instead of status.message', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 1);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -758,7 +779,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
|
|
|
test('triggers fallback when props.error is a plain string', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 1);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -785,7 +806,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
|
|
|
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);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -838,7 +859,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
return {};
|
|
|
},
|
|
|
});
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
|
|
|
|
|
|
// Seed session with model A (anthropic/claude-opus-4-5)
|
|
|
await mgr.handleEvent({
|
|
|
@@ -868,9 +889,9 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
expect(mocks.abort).toHaveBeenCalledTimes(1);
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
const firstCall = mocks.promptAsync.mock.calls[0] as [
|
|
|
- { body: { model: { providerID: string; modelID: string } } },
|
|
|
+ { model: { providerID: string; modelID: string } },
|
|
|
];
|
|
|
- expect(firstCall[0].body.model).toEqual({
|
|
|
+ expect(firstCall[0].model).toEqual({
|
|
|
providerID: 'openai',
|
|
|
modelID: 'gpt-4o',
|
|
|
});
|
|
|
@@ -909,7 +930,7 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
return {};
|
|
|
},
|
|
|
});
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1); // maxRetries=1 for immediate fallback
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 1); // maxRetries=1 for immediate fallback
|
|
|
|
|
|
// Seed session with model A
|
|
|
await mgr.handleEvent({
|
|
|
@@ -939,9 +960,9 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
expect(mocks.abort).toHaveBeenCalledTimes(1);
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
const firstCall = mocks.promptAsync.mock.calls[0] as [
|
|
|
- { body: { model: { providerID: string; modelID: string } } },
|
|
|
+ { model: { providerID: string; modelID: string } },
|
|
|
];
|
|
|
- expect(firstCall[0].body.model).toEqual({
|
|
|
+ expect(firstCall[0].model).toEqual({
|
|
|
providerID: 'openai',
|
|
|
modelID: 'gpt-4o',
|
|
|
});
|
|
|
@@ -965,9 +986,9 @@ describe('ForegroundFallbackManager session.status', () => {
|
|
|
expect(mocks.abort).toHaveBeenCalledTimes(2);
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
|
|
|
const secondCall = mocks.promptAsync.mock.calls[1] as [
|
|
|
- { body: { model: { providerID: string; modelID: string } } },
|
|
|
+ { model: { providerID: string; modelID: string } },
|
|
|
];
|
|
|
- expect(secondCall[0].body.model).toEqual({
|
|
|
+ expect(secondCall[0].model).toEqual({
|
|
|
providerID: 'google',
|
|
|
modelID: 'gemini-2.5-pro',
|
|
|
});
|
|
|
@@ -984,9 +1005,9 @@ describe('ForegroundFallbackManager chain exhaustion', () => {
|
|
|
// tryFallback adds 'openai/gpt-b' to tried → chain.find() returns undefined → exhausted.
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(
|
|
|
- client,
|
|
|
{ orchestrator: ['openai/gpt-b'] },
|
|
|
true,
|
|
|
+ { directory: '/test' } as any,
|
|
|
);
|
|
|
|
|
|
// Seed current model as the only chain entry
|
|
|
@@ -1021,9 +1042,9 @@ describe('ForegroundFallbackManager chain exhaustion', () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const chain = ['openai/model-x', 'openai/model-y'];
|
|
|
const mgr = new ForegroundFallbackManager(
|
|
|
- client,
|
|
|
{ orchestrator: chain },
|
|
|
true,
|
|
|
+ { directory: '/test' } as any,
|
|
|
);
|
|
|
|
|
|
// Session A: current model is model-x, which IS in the chain → picks model-y ✓
|
|
|
@@ -1046,9 +1067,9 @@ describe('ForegroundFallbackManager chain exhaustion', () => {
|
|
|
// → abort called to stop the freeze
|
|
|
const { client: client2, mocks: mocks2 } = createMockClient();
|
|
|
const mgr2 = new ForegroundFallbackManager(
|
|
|
- client2,
|
|
|
{ orchestrator: ['openai/model-y'] }, // single-entry chain already in use
|
|
|
true,
|
|
|
+ { directory: '/test' } as any,
|
|
|
);
|
|
|
await mgr2.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -1074,7 +1095,7 @@ describe('ForegroundFallbackManager chain exhaustion', () => {
|
|
|
describe('ForegroundFallbackManager deduplication', () => {
|
|
|
test('ignores a second trigger within dedup window for same session', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
const event = {
|
|
|
type: 'session.error',
|
|
|
@@ -1092,7 +1113,7 @@ describe('ForegroundFallbackManager deduplication', () => {
|
|
|
|
|
|
test('different sessions are not deduplicated against each other', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'session.error',
|
|
|
@@ -1108,7 +1129,7 @@ describe('ForegroundFallbackManager deduplication', () => {
|
|
|
|
|
|
test('cascade continues when second error arrives within dedup window after model switch', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
// Seed session: current model is first entry in orchestrator chain
|
|
|
await mgr.handleEvent({
|
|
|
@@ -1134,9 +1155,7 @@ describe('ForegroundFallbackManager deduplication', () => {
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
expect(mocks.promptAsync.mock.calls[0][0]).toEqual(
|
|
|
expect.objectContaining({
|
|
|
- body: expect.objectContaining({
|
|
|
- model: { providerID: 'openai', modelID: 'gpt-4o' },
|
|
|
- }),
|
|
|
+ model: { providerID: 'openai', modelID: 'gpt-4o' },
|
|
|
}),
|
|
|
);
|
|
|
|
|
|
@@ -1156,9 +1175,7 @@ describe('ForegroundFallbackManager deduplication', () => {
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
|
|
|
expect(mocks.promptAsync.mock.calls[1][0]).toEqual(
|
|
|
expect.objectContaining({
|
|
|
- body: expect.objectContaining({
|
|
|
- model: { providerID: 'google', modelID: 'gemini-2.5-pro' },
|
|
|
- }),
|
|
|
+ model: { providerID: 'google', modelID: 'gemini-2.5-pro' },
|
|
|
}),
|
|
|
);
|
|
|
});
|
|
|
@@ -1171,7 +1188,7 @@ describe('ForegroundFallbackManager deduplication', () => {
|
|
|
describe('ForegroundFallbackManager subagent.session.created', () => {
|
|
|
test('records agent name from subagent.session.created and falls back correctly', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
// Register the session as 'explorer' via subagent creation event
|
|
|
await mgr.handleEvent({
|
|
|
@@ -1188,14 +1205,14 @@ describe('ForegroundFallbackManager subagent.session.created', () => {
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
const call = mocks.promptAsync.mock.calls[0] as [
|
|
|
{
|
|
|
- body: { model: { providerID: string; modelID: string } };
|
|
|
+ model: { providerID: string; modelID: string };
|
|
|
},
|
|
|
];
|
|
|
// explorer chain: ['openai/gpt-4o-mini', 'anthropic/claude-haiku']
|
|
|
// agentName known → currentModel inferred as chain[0] (primary)
|
|
|
// primary is tried → fallback picks claude-haiku
|
|
|
- expect(call[0].body.model.providerID).toBe('anthropic');
|
|
|
- expect(call[0].body.model.modelID).toBe('claude-haiku');
|
|
|
+ expect(call[0].model.providerID).toBe('anthropic');
|
|
|
+ expect(call[0].model.modelID).toBe('claude-haiku');
|
|
|
});
|
|
|
});
|
|
|
|
|
|
@@ -1208,9 +1225,9 @@ describe('ForegroundFallbackManager session.deleted', () => {
|
|
|
const coordinator = new SessionLifecycle(() => {});
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(
|
|
|
- client,
|
|
|
makeChains(),
|
|
|
true,
|
|
|
+ { directory: '/test' } as any,
|
|
|
3,
|
|
|
coordinator,
|
|
|
);
|
|
|
@@ -1245,17 +1262,17 @@ describe('ForegroundFallbackManager session.deleted', () => {
|
|
|
// and should pick the first chain model (no current model seed after deletion)
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
const call = mocks.promptAsync.mock.calls[0] as [
|
|
|
- { body: { model: { providerID: string; modelID: string } } },
|
|
|
+ { model: { providerID: string; modelID: string } },
|
|
|
];
|
|
|
// orchestrator chain: ['anthropic/claude-opus-4-5', 'openai/gpt-4o', 'google/gemini-2.5-pro']
|
|
|
// no current model → first untried = anthropic/claude-opus-4-5
|
|
|
- expect(call[0].body.model.providerID).toBe('anthropic');
|
|
|
- expect(call[0].body.model.modelID).toBe('claude-opus-4-5');
|
|
|
+ expect(call[0].model.providerID).toBe('anthropic');
|
|
|
+ expect(call[0].model.modelID).toBe('claude-opus-4-5');
|
|
|
});
|
|
|
|
|
|
test('ignores session.deleted with no sessionID', async () => {
|
|
|
const { client } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
// Should not throw
|
|
|
await expect(
|
|
|
mgr.handleEvent({ type: 'session.deleted', properties: {} }),
|
|
|
@@ -1266,9 +1283,9 @@ describe('ForegroundFallbackManager session.deleted', () => {
|
|
|
const coordinator = new SessionLifecycle(() => {});
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(
|
|
|
- client,
|
|
|
makeChains(),
|
|
|
true,
|
|
|
+ { directory: '/test' } as any,
|
|
|
3,
|
|
|
coordinator,
|
|
|
);
|
|
|
@@ -1306,9 +1323,9 @@ describe('ForegroundFallbackManager session.deleted', () => {
|
|
|
const coordinator = new SessionLifecycle(() => {});
|
|
|
const { client } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(
|
|
|
- client,
|
|
|
makeChains(),
|
|
|
true,
|
|
|
+ { directory: '/test' } as any,
|
|
|
3,
|
|
|
coordinator,
|
|
|
);
|
|
|
@@ -1357,12 +1374,12 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
|
|
|
// orchestrator's chain - re-prompting oracle with an orchestrator model.
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(
|
|
|
- client,
|
|
|
{
|
|
|
// oracle intentionally absent - no chain configured
|
|
|
orchestrator: ['openai/gpt-4o', 'google/gemini-2.5-pro'],
|
|
|
},
|
|
|
true,
|
|
|
+ { directory: '/test' } as any,
|
|
|
);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
@@ -1387,9 +1404,9 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
|
|
|
// chain, the last-resort flattened chain is acceptable.
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(
|
|
|
- client,
|
|
|
{ orchestrator: ['openai/gpt-4o'] },
|
|
|
true,
|
|
|
+ { directory: '/test' } as any,
|
|
|
);
|
|
|
|
|
|
// No agent name tracked, no model tracked - triggers session.error
|
|
|
@@ -1404,10 +1421,10 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
|
|
|
// Falls through to last-resort → picks first model from any chain
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
const call = mocks.promptAsync.mock.calls[0] as [
|
|
|
- { body: { model: { providerID: string; modelID: string } } },
|
|
|
+ { model: { providerID: string; modelID: string } },
|
|
|
];
|
|
|
- expect(call[0].body.model.providerID).toBe('openai');
|
|
|
- expect(call[0].body.model.modelID).toBe('gpt-4o');
|
|
|
+ expect(call[0].model.providerID).toBe('openai');
|
|
|
+ expect(call[0].model.modelID).toBe('gpt-4o');
|
|
|
});
|
|
|
|
|
|
test('does NOT bleed into other agent chains for non-omos agents without a chain', async () => {
|
|
|
@@ -1416,9 +1433,9 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
|
|
|
// chain — that would switch the session from Build to Orchestrator.
|
|
|
const { client, mocks } = createMockClient();
|
|
|
const mgr = new ForegroundFallbackManager(
|
|
|
- client,
|
|
|
{ orchestrator: ['openai/gpt-5.6', 'new-api/glm-5.2'] },
|
|
|
true,
|
|
|
+ { directory: '/test' } as any,
|
|
|
);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
@@ -1449,7 +1466,7 @@ describe('ForegroundFallbackManager no-chain sessions', () => {
|
|
|
// FG must not abort or re-prompt — that races the council lifecycle and
|
|
|
// previously produced "[foreground-fallback] no chain configured" noise.
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -1481,7 +1498,7 @@ describe('ForegroundFallbackManager no-chain sessions', () => {
|
|
|
|
|
|
test('councillor session.error: no abort and no re-prompt', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
type: 'message.updated',
|
|
|
@@ -1509,7 +1526,7 @@ describe('ForegroundFallbackManager no-chain sessions', () => {
|
|
|
|
|
|
test('disableChain agent on session.status: no abort (not just no re-prompt)', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any, 3);
|
|
|
mgr.disableChain('orchestrator');
|
|
|
|
|
|
await mgr.handleEvent({
|
|
|
@@ -1548,7 +1565,7 @@ describe('ForegroundFallbackManager no-chain sessions', () => {
|
|
|
describe('ForegroundFallbackManager disableChain', () => {
|
|
|
test('after disableChain, rate-limit error surfaces instead of falling back', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
mgr.disableChain('orchestrator');
|
|
|
|
|
|
@@ -1573,7 +1590,7 @@ describe('ForegroundFallbackManager disableChain', () => {
|
|
|
|
|
|
test('other agents chains are unaffected by disableChain', async () => {
|
|
|
const { client, mocks } = createMockClient();
|
|
|
- const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
|
+ const mgr = new ForegroundFallbackManager(makeChains(), true, { directory: '/test' } as any);
|
|
|
|
|
|
mgr.disableChain('orchestrator');
|
|
|
|
|
|
@@ -1593,11 +1610,11 @@ describe('ForegroundFallbackManager disableChain', () => {
|
|
|
|
|
|
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
|
const call = mocks.promptAsync.mock.calls[0] as [
|
|
|
- { body: { model: { providerID: string; modelID: string } } },
|
|
|
+ { model: { providerID: string; modelID: string } },
|
|
|
];
|
|
|
// explorer chain: ['openai/gpt-4o-mini', 'anthropic/claude-haiku']
|
|
|
// current = gpt-4o-mini is tried → next = claude-haiku
|
|
|
- expect(call[0].body.model.providerID).toBe('anthropic');
|
|
|
- expect(call[0].body.model.modelID).toBe('claude-haiku');
|
|
|
+ expect(call[0].model.providerID).toBe('anthropic');
|
|
|
+ expect(call[0].model.modelID).toBe('claude-haiku');
|
|
|
});
|
|
|
});
|