Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions frontend/src/app/ai/gemini-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,12 +284,13 @@ describe('toGeminiSchema', () => {
'get_budget_status',
'get_spend_summary',
'generate_report',
'fetch_categories',
'approve_expense',
]);
for (const declaration of declarations) {
expect(declaration.description.length).toBeGreaterThan(0);
// Tools with inputs have parameters; `get_spend_summary` has none.
if (declaration.name === 'get_spend_summary') {
// Tools with inputs have parameters; `get_spend_summary` and `fetch_categories` have none.
if (declaration.name === 'get_spend_summary' || declaration.name === 'fetch_categories') {
expect(declaration.parameters).toBeUndefined();
} else {
expect(declaration.parameters?.type).toBe('OBJECT');
Expand Down
42 changes: 39 additions & 3 deletions frontend/src/app/pages/add-expense/add-expense.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,18 @@ import { AddExpense } from './add-expense.js';

describe('AddExpense (declarative WebMCP surface)', () => {
let fixture: ComponentFixture<AddExpense>;
let api: { post: ReturnType<typeof vi.fn> };
let api: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn> };
let audit: { record: ReturnType<typeof vi.fn> };
let session: { refreshPendingApprovals: ReturnType<typeof vi.fn> };

beforeEach(() => {
api = { post: vi.fn().mockResolvedValue({ id: 'exp-1', amount: 450, currency: 'INR', merchant: 'Barista', status: 'draft' }) };
api = {
get: vi.fn().mockResolvedValue([
{ id: 'cat-1', orgId: 'org-1', name: 'Travel', icon: 'plane', isDefault: true },
{ id: 'cat-2', orgId: 'org-1', name: 'Meals', icon: 'utensils', isDefault: true },
]),
post: vi.fn().mockResolvedValue({ id: 'exp-1', amount: 450, currency: 'INR', merchant: 'Barista', status: 'draft' }),
};
audit = { record: vi.fn() };
session = { refreshPendingApprovals: vi.fn().mockResolvedValue(0) };
TestBed.resetTestingModule();
Expand Down Expand Up @@ -56,7 +62,7 @@ describe('AddExpense (declarative WebMCP surface)', () => {
});

it('describes every parameter an agent has to fill', () => {
for (const name of ['amount', 'currency', 'merchant', 'expenseDate', 'note']) {
for (const name of ['amount', 'currency', 'categoryId', 'merchant', 'expenseDate', 'note']) {
const control = form().querySelector(`[name="${name}"]`);
expect(control, `missing control: ${name}`).not.toBeNull();
expect(
Expand Down Expand Up @@ -134,6 +140,36 @@ describe('AddExpense (declarative WebMCP surface)', () => {
expect(fixture.nativeElement.textContent).toContain('Amount must be greater than 0');
});

it('renders a category dropdown populated from the API', async () => {
await fixture.whenStable();
fixture.detectChanges();

const select = form().querySelector('[name="categoryId"]') as HTMLSelectElement;
expect(select).not.toBeNull();
// "None" + 2 categories from the mock
expect(select.options.length).toBe(3);
expect(select.options[1].textContent?.trim()).toBe('Travel');
expect(select.options[1].value).toBe('cat-1');
});

it('includes categoryId in the POST payload', async () => {
await fixture.whenStable();
fixture.detectChanges();

const amount = form().querySelector('[name="amount"]') as HTMLInputElement;
amount.value = '450';
const select = form().querySelector('[name="categoryId"]') as HTMLSelectElement;
select.value = 'cat-1';

form().dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
await fixture.whenStable();

expect(api.post).toHaveBeenCalledWith(
'/expenses',
expect.objectContaining({ categoryId: 'cat-1' }),
);
});

/**
* This form is the only tool call a person can make — every other tool goes
* through `ToolRegistry`, which is always an agent. Without this row the
Expand Down
37 changes: 35 additions & 2 deletions frontend/src/app/pages/add-expense/add-expense.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CURRENCIES, type Expense } from '@actuo/shared';
import { ChangeDetectionStrategy, Component, PLATFORM_ID, inject, signal } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { CURRENCIES, type Category, type Expense } from '@actuo/shared';
import { ApiClient } from '../../core/api/api-client.js';
import { Session } from '../../core/session/session.js';
import { ToolCallAudit } from '../../webmcp/tool-call-audit.js';
Expand Down Expand Up @@ -86,6 +87,21 @@ import { ToolCallAudit } from '../../webmcp/tool-call-audit.js';
/>
</div>

<div>
<label class="mb-1 block text-sm font-medium" for="categoryId">Category</label>
<select
id="categoryId"
name="categoryId"
toolparamdescription="Category UUID. Call fetch_categories first to see valid options."
class="min-h-11 w-full rounded-md border border-line bg-card px-3 text-body"
>
<option value="">None</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ cat.name }}</option>
}
</select>
</div>

<div>
<label class="mb-1 block text-sm font-medium" for="expenseDate">Date</label>
<input
Expand Down Expand Up @@ -136,12 +152,28 @@ export class AddExpense {
private readonly api = inject(ApiClient);
private readonly session = inject(Session);
private readonly audit = inject(ToolCallAudit);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

protected readonly currencies = CURRENCIES;
protected readonly today = new Date().toISOString().slice(0, 10);
protected readonly saving = signal(false);
protected readonly message = signal<string | null>(null);
protected readonly failed = signal(false);
protected readonly categories = signal<Category[]>([]);

constructor() {
if (this.isBrowser) void this.loadCategories();
}

private async loadCategories(): Promise<void> {
try {
this.categories.set(
await this.api.get<Category[]>('/orgs/current/categories'),
);
} catch {
// The form still works without categories — the field stays empty.
}
}

protected onSubmit(event: Event): void {
event.preventDefault();
Expand All @@ -151,6 +183,7 @@ export class AddExpense {
const payload = {
amount: Number(data.get('amount')),
currency: String(data.get('currency') ?? 'INR'),
categoryId: (data.get('categoryId') as string) || null,
merchant: (data.get('merchant') as string) || null,
note: (data.get('note') as string) || null,
expenseDate: String(data.get('expenseDate') ?? this.today),
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/app/tools/expense-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ describe('ExpenseTools', () => {
expect(api.post.mock.calls[1][0]).toBe('/expenses/exp-1/submit');
expect(result).toMatchObject({ status: 'submitted' });
});

it('passes categoryId to the create call', async () => {
api.post
.mockResolvedValueOnce(expense({ status: 'draft' }))
.mockResolvedValueOnce(expense({ status: 'submitted' }));

await tools.submitExpense().execute(
{ amount: 450, currency: 'INR', categoryId: 'cat-uuid-1' },
{ signal: new AbortController().signal },
);

const [, createBody] = api.post.mock.calls[0];
expect(createBody.categoryId).toBe('cat-uuid-1');
expect(createBody).not.toHaveProperty('category');
});
});

describe('get_budget_status', () => {
Expand Down Expand Up @@ -225,6 +240,36 @@ describe('ExpenseTools', () => {
});
});

describe('fetch_categories', () => {
it('is annotated read-only and needs no confirmation', () => {
const tool = tools.fetchCategories();
expect(tool.contract.annotations.readOnlyHint).toBe(true);
expect(tool.contract.requiresConfirmation).toBe(false);
});

it('returns categories with id, name, and icon', async () => {
api.get.mockResolvedValue([
{ id: 'cat-1', orgId: 'org-1', name: 'Travel', icon: 'plane', isDefault: true },
{ id: 'cat-2', orgId: 'org-1', name: 'Meals', icon: 'utensils', isDefault: true },
]);

const result = await tools.fetchCategories().execute(
{},
{ signal: new AbortController().signal },
) as Array<Record<string, unknown>>;

expect(api.get).toHaveBeenCalledWith(
'/orgs/current/categories',
undefined,
expect.anything(),
);
expect(result).toEqual([
{ id: 'cat-1', name: 'Travel', icon: 'plane' },
{ id: 'cat-2', name: 'Meals', icon: 'utensils' },
]);
});
});

describe('approve_expense', () => {
it('routes to approve or reject based on the decision', async () => {
api.post.mockResolvedValue(expense({ status: 'approved' }));
Expand Down Expand Up @@ -253,6 +298,7 @@ describe('ExpenseTools', () => {
'get_budget_status',
'get_spend_summary',
'generate_report',
'fetch_categories',
]);
});
});
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/app/tools/expense-tools.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, inject } from '@angular/core';
import {
FETCH_CATEGORIES,
GENERATE_REPORT,
GET_BUDGET_STATUS,
GET_SPEND_SUMMARY,
Expand All @@ -8,6 +9,7 @@ import {
APPROVE_EXPENSE,
type AnalyticsSummary,
type BudgetStatus,
type Category,
type Expense,
type ExpensePage,
} from '@actuo/shared';
Expand Down Expand Up @@ -53,7 +55,7 @@ export class ExpenseTools {
amount: number;
currency: string;
merchant?: string;
category?: string;
categoryId?: string;
note?: string;
expenseDate?: string;
}> {
Expand Down Expand Up @@ -176,13 +178,33 @@ export class ExpenseTools {
};
}

/** Read-only lookup for category IDs (PRD §7). */
fetchCategories(): ActuoTool<Record<string, never>> {
return {
contract: FETCH_CATEGORIES,
execute: async (_args, { signal }) => {
const categories = await this.api.get<Category[]>(
'/orgs/current/categories',
undefined,
signal,
);
return categories.map((c) => ({
id: c.id,
name: c.name,
icon: c.icon,
}));
},
};
}

all(): ActuoTool<never>[] {
return [
this.searchExpenses(),
this.submitExpense(),
this.getBudgetStatus(),
this.getSpendSummary(),
this.generateReport(),
this.fetchCategories(),
] as unknown as ActuoTool<never>[];
}

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/app/webmcp/tool-session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ describe('ToolSession (state-gated approve_expense)', () => {
registry = TestBed.inject(ToolRegistry);
});

it('publishes the five always-on tools on start', async () => {
it('publishes the always-on tools on start', async () => {
await session.start();
expect([...registry.registeredNames()].sort()).toEqual([
'fetch_categories',
'generate_report',
'get_budget_status',
'get_spend_summary',
Expand Down
23 changes: 21 additions & 2 deletions shared/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,14 @@ export const SUBMIT_EXPENSE: ActuoToolContract = {
name: 'submit_expense',
title: 'Submit an expense',
description:
'Create an expense and submit it for approval. Use when the user describes a purchase they want recorded.',
'Create an expense and submit it for approval. Use when the user describes a purchase they want recorded. If the user mentions a category, call fetch_categories first to get the categoryId.',
inputSchema: {
type: 'object',
properties: {
amount: { type: 'number', exclusiveMinimum: 0, description: 'Amount in the given currency.' },
currency: { type: 'string', enum: [...CURRENCIES], default: 'INR' },
merchant: { type: 'string', description: 'Where the money was spent.' },
category: { type: 'string', description: 'Category name, e.g. "Travel" or "Dining".' },
categoryId: { type: 'string', description: 'Category UUID from fetch_categories.' },
note: { type: 'string' },
expenseDate: { type: 'string', format: 'date', description: 'YYYY-MM-DD. Defaults to today.' },
},
Expand Down Expand Up @@ -163,6 +163,24 @@ export const GENERATE_REPORT: ActuoToolContract = {
requiresConfirmation: false,
};

/**
* Read-only lookup so the LLM can resolve human-friendly category names to
* the UUIDs that `submit_expense` and `search_expenses` need.
*/
export const FETCH_CATEGORIES: ActuoToolContract = {
name: 'fetch_categories',
title: 'List expense categories',
description:
'Returns the organization\'s expense categories with their IDs. Call this before submit_expense when the user mentions a category, so you can pass the correct categoryId.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
annotations: { readOnlyHint: true },
requiresConfirmation: false,
};

/**
* State-gated (PRD §7): only registered when the signed-in user is an
* admin/owner AND at least one expense is awaiting approval. Registration and
Expand Down Expand Up @@ -203,6 +221,7 @@ export const ALWAYS_ON_TOOLS: readonly ActuoToolContract[] = [
GET_BUDGET_STATUS,
GET_SPEND_SUMMARY,
GENERATE_REPORT,
FETCH_CATEGORIES,
] as const;

export const ALL_TOOL_CONTRACTS: readonly ActuoToolContract[] = [
Expand Down
Loading