/**
* TaskManager Tests
* Comprehensive coverage for A2A task routing, lifecycle, and error paths.
* Mocks all tool functions and the getDeps factory to isolate TaskManager logic.
*/
// ─── Mocks (vi.hoisted ensures these are available before vi.mock hoisting) ───
const {
mockScoutInventory,
mockManageCart,
mockNegotiateTerms,
mockExecuteCheckout,
mockTrackOrder,
mockGetDeps,
} = vi.hoisted(() => ({
mockScoutInventory: vi.fn(),
mockManageCart: vi.fn(),
mockNegotiateTerms: vi.fn(),
mockExecuteCheckout: vi.fn(),
mockTrackOrder: vi.fn(),
mockGetDeps: vi.fn(() => ({
config: { shopify: {}, ap2: {}, gateway: { feeRate: 0.005, feeWalletAddress: '' }, dynamodb: {} },
sessionManager: {},
mandateStore: {},
feeCollector: {},
verifier: {},
guardrail: {},
storefrontAPI: null,
adminAPI: null,
})),
}));
vi.mock('../../src/tools/scout-inventory.js', () => ({
scoutInventory: mockScoutInventory,
}));
vi.mock('../../src/tools/manage-cart.js', () => ({
manageCart: mockManageCart,
}));
vi.mock('../../src/tools/negotiate-terms.js', () => ({
negotiateTerms: mockNegotiateTerms,
}));
vi.mock('../../src/tools/execute-checkout.js', () => ({
executeCheckout: mockExecuteCheckout,
}));
vi.mock('../../src/tools/track-order.js', () => ({
trackOrder: mockTrackOrder,
}));
vi.mock('../../src/dynamo/factory.js', () => ({
getDeps: mockGetDeps,
}));
import { TaskManager } from '../../src/a2a/task-manager.js';
import type { Message } from '../../src/a2a/types.js';
// ─── Helpers ───
function makeDataMessage(skill: string, params: Record<string, unknown> = {}): Message {
return {
role: 'user',
parts: [{ type: 'data', data: { skill, params } }],
};
}
function makeTextJsonMessage(skill: string, params: Record<string, unknown> = {}): Message {
return {
role: 'user',
parts: [{ type: 'text', text: JSON.stringify({ skill, params }) }],
};
}
function makeInvalidTextMessage(text: string): Message {
return {
role: 'user',
parts: [{ type: 'text', text }],
};
}
function makeEmptyMessage(): Message {
return {
role: 'user',
parts: [],
};
}
// ─── Tests ───
describe('TaskManager', () => {
let manager: TaskManager;
beforeEach(() => {
manager = new TaskManager();
vi.clearAllMocks();
});
// ─── Routing via DataPart ───
describe('processMessage — routing via DataPart', () => {
it('routes "scout_inventory" to scoutInventory tool', async () => {
mockScoutInventory.mockResolvedValue({ products: [{ id: 'p1', title: 'Shirt' }] });
const task = await manager.processMessage(makeDataMessage('scout_inventory', { query: 'shirt' }));
expect(task.status.state).toBe('completed');
expect(task.status.message?.parts[0]).toEqual(
expect.objectContaining({ type: 'text', text: expect.stringContaining('scout_inventory') }),
);
expect(task.artifacts).toBeDefined();
expect(task.artifacts).toHaveLength(1);
expect(task.artifacts![0].parts[0]).toEqual(
expect.objectContaining({ type: 'data', data: { products: [{ id: 'p1', title: 'Shirt' }] } }),
);
expect(mockScoutInventory).toHaveBeenCalledWith({ query: 'shirt' });
});
it('routes "negotiate_terms" to negotiateTerms tool with config', async () => {
mockNegotiateTerms.mockResolvedValue({ accepted: true });
const task = await manager.processMessage(
makeDataMessage('negotiate_terms', { agent_profile: { capabilities: [] } }),
);
expect(task.status.state).toBe('completed');
expect(mockNegotiateTerms).toHaveBeenCalledTimes(1);
// negotiateTerms receives (params, config)
expect(mockNegotiateTerms).toHaveBeenCalledWith(
{ agent_profile: { capabilities: [] } },
expect.objectContaining({ shopify: expect.anything() }),
);
});
it('routes "execute_checkout" to executeCheckout tool with deps', async () => {
mockExecuteCheckout.mockResolvedValue({ order_id: 'ord-1', status: 'confirmed' });
const task = await manager.processMessage(
makeDataMessage('execute_checkout', { checkout_id: 'ck-1' }),
);
expect(task.status.state).toBe('completed');
expect(mockExecuteCheckout).toHaveBeenCalledTimes(1);
// executeCheckout receives (params, deps)
expect(mockExecuteCheckout).toHaveBeenCalledWith(
{ checkout_id: 'ck-1' },
expect.objectContaining({
sessionManager: expect.anything(),
verifier: expect.anything(),
mandateStore: expect.anything(),
guardrail: expect.anything(),
feeCollector: expect.anything(),
storefrontAPI: null,
}),
);
});
it('routes "manage_cart" to manageCart tool', async () => {
mockManageCart.mockResolvedValue({ cart_id: 'cart-1', items: [] });
const task = await manager.processMessage(
makeDataMessage('manage_cart', { action: 'create' }),
);
expect(task.status.state).toBe('completed');
expect(mockManageCart).toHaveBeenCalledWith({ action: 'create' });
expect(task.artifacts).toBeDefined();
expect(task.artifacts![0].parts[0]).toEqual(
expect.objectContaining({ type: 'data', data: { cart_id: 'cart-1', items: [] } }),
);
});
it('routes "track_order" to trackOrder tool', async () => {
mockTrackOrder.mockResolvedValue({ order_id: 'order-1', status: 'shipped' });
const task = await manager.processMessage(
makeDataMessage('track_order', { order_id: 'order-1' }),
);
expect(task.status.state).toBe('completed');
expect(mockTrackOrder).toHaveBeenCalledWith({ order_id: 'order-1' });
expect(task.artifacts![0].parts[0]).toEqual(
expect.objectContaining({ type: 'data', data: { order_id: 'order-1', status: 'shipped' } }),
);
});
});
// ─── Routing via TextPart (JSON.parse fallback) ───
describe('processMessage — routing via TextPart JSON', () => {
it('parses JSON text part and routes to the correct skill', async () => {
mockScoutInventory.mockResolvedValue({ products: [] });
const task = await manager.processMessage(
makeTextJsonMessage('scout_inventory', { query: 'hat' }),
);
expect(task.status.state).toBe('completed');
expect(mockScoutInventory).toHaveBeenCalledWith({ query: 'hat' });
});
it('handles text part with skill but no params (defaults to {})', async () => {
mockManageCart.mockResolvedValue({ cart_id: 'c-new' });
const message: Message = {
role: 'user',
parts: [{ type: 'text', text: JSON.stringify({ skill: 'manage_cart' }) }],
};
const task = await manager.processMessage(message);
expect(task.status.state).toBe('completed');
expect(mockManageCart).toHaveBeenCalledWith({});
});
});
// ─── Unknown / Invalid Skill ───
describe('processMessage — unknown skill', () => {
it('returns failed task for unknown skill name', async () => {
const task = await manager.processMessage(
makeDataMessage('nonexistent_skill', {}),
);
expect(task.status.state).toBe('failed');
expect(task.status.message?.parts[0]).toEqual(
expect.objectContaining({
type: 'text',
text: expect.stringContaining('Unknown skill'),
}),
);
expect(task.status.message?.parts[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining('nonexistent_skill'),
}),
);
expect(task.artifacts).toBeUndefined();
});
it('lists available skills in the error message', async () => {
const task = await manager.processMessage(
makeDataMessage('invalid', {}),
);
const text = (task.status.message?.parts[0] as { type: string; text: string }).text;
expect(text).toContain('scout_inventory');
expect(text).toContain('manage_cart');
expect(text).toContain('negotiate_terms');
expect(text).toContain('execute_checkout');
expect(text).toContain('track_order');
});
});
// ─── Invalid Input (no structured data) ───
describe('processMessage — invalid input extraction', () => {
it('returns failed task when message has no parseable parts', async () => {
const task = await manager.processMessage(
makeInvalidTextMessage('just some plain text, not json'),
);
expect(task.status.state).toBe('failed');
expect(task.status.message?.parts[0]).toEqual(
expect.objectContaining({
type: 'text',
text: expect.stringContaining('Could not extract structured input'),
}),
);
expect(task.artifacts).toBeUndefined();
});
it('returns failed task when message has empty parts array', async () => {
const task = await manager.processMessage(makeEmptyMessage());
expect(task.status.state).toBe('failed');
expect(task.status.message?.parts[0]).toEqual(
expect.objectContaining({
type: 'text',
text: expect.stringContaining('Could not extract structured input'),
}),
);
});
it('returns failed task when DataPart data has no skill field', async () => {
const message: Message = {
role: 'user',
parts: [{ type: 'data', data: { foo: 'bar' } }],
};
const task = await manager.processMessage(message);
expect(task.status.state).toBe('failed');
expect(task.status.message?.parts[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining('Could not extract structured input'),
}),
);
});
it('returns failed task when TextPart has valid JSON but no skill field', async () => {
const message: Message = {
role: 'user',
parts: [{ type: 'text', text: JSON.stringify({ action: 'search' }) }],
};
const task = await manager.processMessage(message);
expect(task.status.state).toBe('failed');
});
});
// ─── Tool Execution Errors ───
describe('processMessage — tool execution errors', () => {
it('returns failed task when tool throws an Error', async () => {
mockScoutInventory.mockRejectedValue(new Error('Shopify API timeout'));
const task = await manager.processMessage(
makeDataMessage('scout_inventory', { query: 'jacket' }),
);
expect(task.status.state).toBe('failed');
expect(task.status.message?.parts[0]).toEqual(
expect.objectContaining({
type: 'text',
text: expect.stringContaining('Shopify API timeout'),
}),
);
expect(task.artifacts).toBeUndefined();
});
it('returns failed task when tool throws a non-Error value', async () => {
mockManageCart.mockRejectedValue('string error value');
const task = await manager.processMessage(
makeDataMessage('manage_cart', { action: 'get' }),
);
expect(task.status.state).toBe('failed');
expect(task.status.message?.parts[0]).toEqual(
expect.objectContaining({
type: 'text',
text: expect.stringContaining('string error value'),
}),
);
});
it('includes skill name in the error message', async () => {
mockTrackOrder.mockRejectedValue(new Error('Order not found'));
const task = await manager.processMessage(
makeDataMessage('track_order', { order_id: 'x' }),
);
expect(task.status.state).toBe('failed');
const text = (task.status.message?.parts[0] as { type: string; text: string }).text;
expect(text).toContain('track_order');
expect(text).toContain('Order not found');
});
});
// ─── Task Structure ───
describe('task structure', () => {
it('completed task has id, status with state/timestamp/message, and artifacts', async () => {
mockScoutInventory.mockResolvedValue({ data: 'result' });
const task = await manager.processMessage(
makeDataMessage('scout_inventory', {}),
);
// Required fields
expect(typeof task.id).toBe('string');
expect(task.id.length).toBeGreaterThan(0);
expect(task.status).toBeDefined();
expect(task.status.state).toBe('completed');
expect(typeof task.status.timestamp).toBe('string');
// Timestamp should be an ISO date string
expect(new Date(task.status.timestamp).toISOString()).toBe(task.status.timestamp);
// Status message
expect(task.status.message).toBeDefined();
expect(task.status.message!.role).toBe('agent');
expect(task.status.message!.parts).toHaveLength(1);
expect(task.status.message!.parts[0].type).toBe('text');
// Artifacts
expect(task.artifacts).toBeDefined();
expect(task.artifacts).toHaveLength(1);
expect(task.artifacts![0].name).toBe('result');
expect(task.artifacts![0].parts).toHaveLength(1);
expect(task.artifacts![0].parts[0].type).toBe('data');
});
it('failed task has no artifacts', async () => {
const task = await manager.processMessage(
makeDataMessage('unknown_skill', {}),
);
expect(task.status.state).toBe('failed');
expect(task.artifacts).toBeUndefined();
});
it('each call generates a unique task ID', async () => {
mockScoutInventory.mockResolvedValue({});
const task1 = await manager.processMessage(makeDataMessage('scout_inventory', {}));
const task2 = await manager.processMessage(makeDataMessage('scout_inventory', {}));
expect(task1.id).not.toBe(task2.id);
});
});
// ─── DataPart priority over TextPart ───
describe('processMessage — extraction priority', () => {
it('prefers DataPart over TextPart when both are present', async () => {
mockScoutInventory.mockResolvedValue({ source: 'data-part' });
mockManageCart.mockResolvedValue({ source: 'text-part' });
const message: Message = {
role: 'user',
parts: [
{ type: 'text', text: JSON.stringify({ skill: 'manage_cart', params: {} }) },
{ type: 'data', data: { skill: 'scout_inventory', params: { query: 'shoes' } } },
],
};
const task = await manager.processMessage(message);
expect(task.status.state).toBe('completed');
// DataPart should win: scout_inventory, not manage_cart
expect(mockScoutInventory).toHaveBeenCalledWith({ query: 'shoes' });
expect(mockManageCart).not.toHaveBeenCalled();
});
});
// ─── Tool returns null data ───
describe('processMessage — null result data', () => {
it('completed task with null tool result has no artifacts', async () => {
mockScoutInventory.mockResolvedValue(null);
const task = await manager.processMessage(
makeDataMessage('scout_inventory', {}),
);
// The tool returned null, which is falsy. buildTask checks `if (data)` so no artifacts.
expect(task.status.state).toBe('completed');
expect(task.artifacts).toBeUndefined();
});
});
});