diff --git a/packages/core/package.json b/packages/core/package.json index 22cff18..2fcc7fb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -48,6 +48,7 @@ "homepage": "https://alibaba.github.io/page-agent/", "scripts": { "build": "vite build", + "test": "vitest run", "dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"", "prepublishOnly": "node ../../scripts/pre-publish.js", "postpublish": "node ../../scripts/post-publish.js" diff --git a/packages/core/src/PageAgentCore.test.ts b/packages/core/src/PageAgentCore.test.ts new file mode 100644 index 0000000..1739026 --- /dev/null +++ b/packages/core/src/PageAgentCore.test.ts @@ -0,0 +1,275 @@ +import type { BrowserState, PageController } from '@page-agent/page-controller' +import { describe, expect, it, vi } from 'vitest' +import * as z from 'zod/v4' + +import { PageAgentCore, tool } from './PageAgentCore' +import type { ExecutionResult } from './types' + +type TestFetch = (...args: Parameters) => Promise + +function agentResponse(args: unknown): Response { + return new Response( + JSON.stringify({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + tool_calls: [ + { + function: { + name: 'AgentOutput', + arguments: JSON.stringify(args), + }, + }, + ], + }, + }, + ], + usage: {}, + }) + ) +} + +function createPageController(): PageController { + const browserState: BrowserState = { + url: 'https://example.test/', + title: 'Test page', + header: '', + content: '', + footer: '', + } + + return { + showMask: vi.fn(async () => {}), + hideMask: vi.fn(), + cleanUpHighlights: vi.fn(), + getLastUpdateTime: vi.fn(() => Date.now()), + getBrowserState: vi.fn(async () => browserState), + dispose: vi.fn(), + } as unknown as PageController +} + +function createAgent( + customFetch: TestFetch, + options: Partial[0]> = {} +): PageAgentCore { + return new PageAgentCore({ + baseURL: 'https://llm.test', + model: 'test-model', + maxRetries: 0, + stepDelay: 0, + customFetch, + customSystemPrompt: 'test', + pageController: createPageController(), + ...options, + }) +} + +function createFetchMock() { + return vi.fn() +} + +function onceActivity( + agent: PageAgentCore, + predicate: (detail: unknown) => boolean +): Promise { + return new Promise((resolve) => { + const onActivity = (event: Event) => { + if (!predicate((event as CustomEvent).detail)) return + agent.removeEventListener('activity', onActivity) + resolve() + } + + agent.addEventListener('activity', onActivity) + }) +} + +function isExecutingTool(detail: unknown, toolName: string): boolean { + return ( + typeof detail === 'object' && + detail !== null && + 'type' in detail && + 'tool' in detail && + detail.type === 'executing' && + detail.tool === toolName + ) +} + +function doneResponse(text: string, success = true): Response { + return agentResponse({ action: { done: { text, success } } }) +} + +function waitResponse(seconds = 10): Response { + return agentResponse({ action: { wait: { seconds } } }) +} + +/** + * Start a task that blocks on `wait`, returning once the tool is executing. + * The running promise is wrapped so awaiting this helper does not await the task. + */ +async function startBlockedTask( + agent: PageAgentCore, + task = 'first' +): Promise<{ result: Promise }> { + const waitStarted = onceActivity(agent, (detail) => isExecutingTool(detail, 'wait')) + const result = agent.execute(task) + await waitStarted + return { result } +} + +describe.concurrent('PageAgentCore lifecycle', () => { + describe('normal execution', () => { + it('runs a task to natural completion', async () => { + const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done')) + const agent = createAgent(fetchMock) + + const result = await agent.execute('do something') + + expect(result).toMatchObject({ success: true, data: 'all done' }) + expect(agent.status).toBe('completed') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('throws when a task is already running', async () => { + const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse()) + const agent = createAgent(fetchMock) + const { result } = await startBlockedTask(agent) + + await expect(agent.execute('second')).rejects.toThrow('A task is already running.') + + agent.stop() + await result + }) + }) + + describe('stop', () => { + it('aborts the running task and keeps the agent reusable', async () => { + const fetchMock = createFetchMock() + .mockResolvedValueOnce(waitResponse()) + .mockResolvedValueOnce(doneResponse('second task')) + const agent = createAgent(fetchMock) + const { result: firstTask } = await startBlockedTask(agent) + + agent.stop() + await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' }) + + const secondTask = await agent.execute('second') + expect(secondTask).toMatchObject({ success: true, data: 'second task' }) + }) + + it('is a no-op when no task is running', () => { + const agent = createAgent(createFetchMock()) + + expect(() => { + agent.stop() + agent.stop() + }).not.toThrow() + expect(agent.status).toBe('idle') + }) + }) + + describe('dispose', () => { + it('aborts the running task and blocks further execution', async () => { + const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse()) + const agent = createAgent(fetchMock) + const { result: task } = await startBlockedTask(agent) + + agent.dispose() + await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' }) + + expect(agent.disposed).toBe(true) + await expect(agent.execute('again')).rejects.toThrow('has been disposed') + }) + + it('is idempotent', () => { + const agent = createAgent(createFetchMock()) + + expect(() => { + agent.dispose() + agent.dispose() + }).not.toThrow() + expect(agent.disposed).toBe(true) + }) + }) + + describe('error handling', () => { + it('fails the task when the network request rejects', async () => { + const fetchMock = createFetchMock().mockRejectedValue(new Error('network down')) + const agent = createAgent(fetchMock) + + const result = await agent.execute('do something') + + expect(result.success).toBe(false) + expect(agent.status).toBe('error') + }) + + it('fails the task when a tool throws', async () => { + const fetchMock = createFetchMock().mockResolvedValue(agentResponse({ action: { boom: {} } })) + const agent = createAgent(fetchMock, { + customTools: { + boom: tool({ + description: 'Always throws.', + inputSchema: z.object({}), + execute: async () => { + throw new Error('tool exploded') + }, + }), + }, + }) + + const result = await agent.execute('trigger tool error') + + expect(result.success).toBe(false) + expect(agent.status).toBe('error') + }) + }) + + describe('cancellation edge cases', () => { + it('rejects a new task while a stopped task is settling', async () => { + const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse()) + const agent = createAgent(fetchMock) + const { result: firstTask } = await startBlockedTask(agent) + + agent.stop() + + await expect(agent.execute('too early')).rejects.toThrow('A task is already running.') + await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('discards a custom tool result that resolves after stop', async () => { + let resolveTool!: () => void + let notifyToolStarted!: () => void + const toolFinished = new Promise((resolve) => { + resolveTool = resolve + }) + const toolStarted = new Promise((resolve) => { + notifyToolStarted = resolve + }) + const fetchMock = createFetchMock().mockResolvedValue( + agentResponse({ action: { slow_tool: {} } }) + ) + const agent = createAgent(fetchMock, { + customTools: { + slow_tool: tool({ + description: 'A tool that deliberately ignores cancellation.', + inputSchema: z.object({}), + execute: async () => { + notifyToolStarted() + await toolFinished + return 'ignored stop' + }, + }), + }, + }) + + const task = agent.execute('run slow tool') + await toolStarted + + agent.stop() + resolveTool() + + await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' }) + }) + }) +}) diff --git a/packages/core/vitest.config.js b/packages/core/vitest.config.js new file mode 100644 index 0000000..323e218 --- /dev/null +++ b/packages/core/vitest.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: 'core', + include: ['src/**/*.test.ts'], + silent: 'passed-only', + }, +}) diff --git a/packages/llms/tsconfig.json b/packages/llms/tsconfig.json index fb30544..d45abe9 100644 --- a/packages/llms/tsconfig.json +++ b/packages/llms/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo" }, - "include": ["**/*.ts"], + "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules"] } diff --git a/packages/llms/vitest.config.ts b/packages/llms/vitest.config.js similarity index 100% rename from packages/llms/vitest.config.ts rename to packages/llms/vitest.config.js