mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-23 04:17:45 +00:00
Co-authored-by: Douglas <douglas.ym.lai@gmail.com> Co-authored-by: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com>
306 lines
9.5 KiB
TypeScript
306 lines
9.5 KiB
TypeScript
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
import {
|
|
reconcileToolkitState,
|
|
TOOLKIT_MIN_DISPLAY_MS,
|
|
} from '@/components/Session/SidePanelSections/AgentPoolSection';
|
|
import { AgentStatusValue } from '@/types/constants';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
type State = Parameters<typeof reconcileToolkitState>[0];
|
|
|
|
function makeState(): State {
|
|
return { entries: new Map(), timers: new Map(), retired: new Set() };
|
|
}
|
|
|
|
function makeScheduler() {
|
|
const scheduled: Array<{
|
|
id: string;
|
|
delay: number;
|
|
cancelled: boolean;
|
|
handle: number;
|
|
}> = [];
|
|
let nextHandle = 1;
|
|
const schedule = (id: string, delay: number) => {
|
|
const handle = nextHandle++;
|
|
const record = { id, delay, cancelled: false, handle };
|
|
scheduled.push(record);
|
|
return handle as unknown as ReturnType<typeof setTimeout>;
|
|
};
|
|
const cancel = (h: ReturnType<typeof setTimeout>) => {
|
|
const record = scheduled.find(
|
|
(r) => r.handle === (h as unknown as number) && !r.cancelled
|
|
);
|
|
if (record) record.cancelled = true;
|
|
};
|
|
return { scheduled, schedule, cancel };
|
|
}
|
|
|
|
function runExpired(
|
|
state: State,
|
|
_scheduled: ReturnType<typeof makeScheduler>['scheduled'],
|
|
now: number
|
|
) {
|
|
for (const entry of [...state.entries.values()]) {
|
|
if (entry.expireAt !== null && entry.expireAt <= now) {
|
|
state.entries.delete(entry.id);
|
|
state.timers.delete(entry.id);
|
|
state.retired.add(entry.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('reconcileToolkitState', () => {
|
|
it('shows a RUNNING toolkit immediately', () => {
|
|
const state = makeState();
|
|
const { schedule, cancel } = makeScheduler();
|
|
const names = reconcileToolkitState(
|
|
state,
|
|
[{ id: 'a1', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
|
|
{ now: 0, minDisplayMs: TOOLKIT_MIN_DISPLAY_MS, schedule, cancel }
|
|
);
|
|
expect(names).toEqual(['Browser Toolkit']);
|
|
});
|
|
|
|
it('hides a RUNNING toolkit when it completes — but only after minDisplayMs', () => {
|
|
const state = makeState();
|
|
const { scheduled, schedule, cancel } = makeScheduler();
|
|
const minDisplayMs = 1500;
|
|
|
|
// t=0 → ACTIVATE
|
|
let names = reconcileToolkitState(
|
|
state,
|
|
[{ id: 'a1', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
|
|
{ now: 0, minDisplayMs, schedule, cancel }
|
|
);
|
|
expect(names).toEqual(['Browser Toolkit']);
|
|
|
|
// t=50 → DEACTIVATE — still in min-display window
|
|
names = reconcileToolkitState(
|
|
state,
|
|
[
|
|
{
|
|
id: 'a1',
|
|
name: 'Browser Toolkit',
|
|
status: AgentStatusValue.COMPLETED,
|
|
},
|
|
],
|
|
{ now: 50, minDisplayMs, schedule, cancel }
|
|
);
|
|
expect(names).toEqual(['Browser Toolkit']);
|
|
expect(scheduled).toHaveLength(1);
|
|
expect(scheduled[0]?.delay).toBeGreaterThanOrEqual(1400);
|
|
|
|
// t=1500 → timer fires, entry evicted
|
|
runExpired(state, scheduled, 1500);
|
|
names = reconcileToolkitState(state, [], {
|
|
now: 1500,
|
|
minDisplayMs,
|
|
schedule,
|
|
cancel,
|
|
});
|
|
expect(names).toEqual([]);
|
|
});
|
|
|
|
it('surfaces a fast toolkit that ACTIVATEs and DEACTIVATEs within a single render pass', () => {
|
|
// This mirrors the user report: a browser_agent fires Browser Toolkit,
|
|
// Search Toolkit, and Screenshot Toolkit. Search/Screenshot can flip
|
|
// RUNNING → COMPLETED very quickly. All three must still appear.
|
|
const state = makeState();
|
|
const { schedule, cancel } = makeScheduler();
|
|
const minDisplayMs = 1500;
|
|
|
|
const names = reconcileToolkitState(
|
|
state,
|
|
[
|
|
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
|
|
{
|
|
id: 's',
|
|
name: 'Search Toolkit',
|
|
status: AgentStatusValue.COMPLETED,
|
|
},
|
|
{
|
|
id: 'p',
|
|
name: 'Screenshot Toolkit',
|
|
status: AgentStatusValue.COMPLETED,
|
|
},
|
|
],
|
|
{ now: 100, minDisplayMs, schedule, cancel }
|
|
);
|
|
|
|
expect(names).toEqual([
|
|
'Browser Toolkit',
|
|
'Search Toolkit',
|
|
'Screenshot Toolkit',
|
|
]);
|
|
});
|
|
|
|
it('re-arming: a toolkit that flips back to RUNNING cancels its pending removal', () => {
|
|
const state = makeState();
|
|
const { scheduled, schedule, cancel } = makeScheduler();
|
|
const minDisplayMs = 1500;
|
|
|
|
reconcileToolkitState(
|
|
state,
|
|
[{ id: 'a', name: 'Search Toolkit', status: AgentStatusValue.RUNNING }],
|
|
{ now: 0, minDisplayMs, schedule, cancel }
|
|
);
|
|
reconcileToolkitState(
|
|
state,
|
|
[{ id: 'a', name: 'Search Toolkit', status: AgentStatusValue.COMPLETED }],
|
|
{ now: 100, minDisplayMs, schedule, cancel }
|
|
);
|
|
expect(scheduled.filter((s) => !s.cancelled)).toHaveLength(1);
|
|
|
|
reconcileToolkitState(
|
|
state,
|
|
[{ id: 'a', name: 'Search Toolkit', status: AgentStatusValue.RUNNING }],
|
|
{ now: 120, minDisplayMs, schedule, cancel }
|
|
);
|
|
expect(scheduled.filter((s) => !s.cancelled)).toHaveLength(0);
|
|
const entry = state.entries.get('a');
|
|
expect(entry?.expireAt).toBeNull();
|
|
});
|
|
|
|
it('dedupes by name while preserving first-seen order', () => {
|
|
// Each ACTIVATE of the same toolkit name gets a unique id from the store,
|
|
// but users should only see one tag per name.
|
|
const state = makeState();
|
|
const { schedule, cancel } = makeScheduler();
|
|
const minDisplayMs = 1500;
|
|
|
|
const names = reconcileToolkitState(
|
|
state,
|
|
[
|
|
{
|
|
id: '1',
|
|
name: 'Browser Toolkit',
|
|
status: AgentStatusValue.RUNNING,
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Search Toolkit',
|
|
status: AgentStatusValue.RUNNING,
|
|
},
|
|
{
|
|
id: '3',
|
|
name: 'Browser Toolkit',
|
|
status: AgentStatusValue.RUNNING,
|
|
},
|
|
],
|
|
{ now: 0, minDisplayMs, schedule, cancel }
|
|
);
|
|
expect(names).toEqual(['Browser Toolkit', 'Search Toolkit']);
|
|
});
|
|
|
|
it('ignores the "notice" placeholder', () => {
|
|
// Consumers filter `toolkitName === "notice"` before passing events in.
|
|
// This test asserts reconcile behaves correctly when callers hand it only
|
|
// real toolkit events (the helper itself is caller-filtered).
|
|
const state = makeState();
|
|
const { schedule, cancel } = makeScheduler();
|
|
const names = reconcileToolkitState(
|
|
state,
|
|
[{ id: '1', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
|
|
{ now: 0, minDisplayMs: 1500, schedule, cancel }
|
|
);
|
|
expect(names).toEqual(['Browser Toolkit']);
|
|
});
|
|
|
|
it('full sequence: browser stays, search and screenshot come and go', () => {
|
|
const state = makeState();
|
|
const { scheduled, schedule, cancel } = makeScheduler();
|
|
const minDisplayMs = 1500;
|
|
|
|
// t=0: browser starts
|
|
let names = reconcileToolkitState(
|
|
state,
|
|
[{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
|
|
{ now: 0, minDisplayMs, schedule, cancel }
|
|
);
|
|
expect(names).toEqual(['Browser Toolkit']);
|
|
|
|
// t=200: search starts (while browser still running)
|
|
names = reconcileToolkitState(
|
|
state,
|
|
[
|
|
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
|
|
{ id: 's1', name: 'Search Toolkit', status: AgentStatusValue.RUNNING },
|
|
],
|
|
{ now: 200, minDisplayMs, schedule, cancel }
|
|
);
|
|
expect(names).toEqual(['Browser Toolkit', 'Search Toolkit']);
|
|
|
|
// t=250: search finishes fast
|
|
names = reconcileToolkitState(
|
|
state,
|
|
[
|
|
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
|
|
{
|
|
id: 's1',
|
|
name: 'Search Toolkit',
|
|
status: AgentStatusValue.COMPLETED,
|
|
},
|
|
],
|
|
{ now: 250, minDisplayMs, schedule, cancel }
|
|
);
|
|
expect(names).toEqual(['Browser Toolkit', 'Search Toolkit']);
|
|
|
|
// t=400: screenshot starts
|
|
names = reconcileToolkitState(
|
|
state,
|
|
[
|
|
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
|
|
{
|
|
id: 's1',
|
|
name: 'Search Toolkit',
|
|
status: AgentStatusValue.COMPLETED,
|
|
},
|
|
{
|
|
id: 'p1',
|
|
name: 'Screenshot Toolkit',
|
|
status: AgentStatusValue.RUNNING,
|
|
},
|
|
],
|
|
{ now: 400, minDisplayMs, schedule, cancel }
|
|
);
|
|
expect(names).toEqual([
|
|
'Browser Toolkit',
|
|
'Search Toolkit',
|
|
'Screenshot Toolkit',
|
|
]);
|
|
|
|
// t=1700: search's min-display elapsed (firstSeen=200 + 1500) → evicted
|
|
runExpired(state, scheduled, 1700);
|
|
names = reconcileToolkitState(
|
|
state,
|
|
[
|
|
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
|
|
{
|
|
id: 's1',
|
|
name: 'Search Toolkit',
|
|
status: AgentStatusValue.COMPLETED,
|
|
},
|
|
{
|
|
id: 'p1',
|
|
name: 'Screenshot Toolkit',
|
|
status: AgentStatusValue.RUNNING,
|
|
},
|
|
],
|
|
{ now: 1700, minDisplayMs, schedule, cancel }
|
|
);
|
|
expect(names).toEqual(['Browser Toolkit', 'Screenshot Toolkit']);
|
|
});
|
|
});
|