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
14 changes: 11 additions & 3 deletions Progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ day's rate; a row on a weekend names the preceding working day.
| Spend by category | 1 | ✅ | `GET /api/analytics/summary` returns `byCategory` with per-category spend and share. Also `get_spend_summary` WebMCP tool |
| Month-over-month deltas | 1 | ✅ | `monthOverMonthDelta` in `AnalyticsSummary`, percentage change vs prior month (null when no prior data). Exposed in `get_spend_summary` tool output |
| Team vs individual | 2 | ⬜ | — |
| CSV export | 0 | ✅ | Chunked, cancellable, complete across pages |
| CSV export | 0 | ✅ | Chunked, cancellable, complete across pages. The file is fetched with the session bearer and saved via `saveBlob` (`core/reports/report-download.ts`) — `generate_report` returns a job id and a bounded preview, never the download URL, which is an authenticated route no chat link can reach. Two ways to get the file: the Download button on the tool call card, and the `download_report` tool for agents |
| **PDF export** | 2 | ⬜ | `pdf` is now **rejected** rather than silently answered with CSV. `shared/src/report-format-contract.spec.ts` pins the tool schema and the backend DTO to the same list |
| `/api/analytics/*` | 1 | ✅ | `backend/src/analytics/` module with `GET /api/analytics/summary`. Returns current/prior month totals, MoM delta, per-category breakdown, excluded counts. 7 unit tests |

Expand Down Expand Up @@ -235,18 +235,26 @@ Cross-origin is live as of 2026-08-29; only the standalone-script packaging (Pha
| Aspect | Status | Notes |
|---|---|---|
| Declarative API (annotated form) | ✅ | Add Expense: `toolname`/`tooldescription`/`toolparamdescription`/`toolautosubmit`, `agentInvoked` + `respondWith`, **no JS registration** |
| Imperative `registerTool` | ✅ | Five tools, per-tool `AbortController` lifetime |
| Imperative `registerTool` | ✅ | Six tools, per-tool `AbortController` lifetime |
| JSON Schema inputs | ✅ | One definition in `shared/src/tools.ts`, used by client and server |
| **Dynamic / state-gated tools** | ✅ | The shell polls on sign-in and after every mutating call. Verified live: `approve_expense` present in `getTools()` as owner with 3 pending, absent as member, and every tool retired on sign-out |
| Cancellation (`AbortSignal`) | ✅ | Client aborts, polls stop, server abandons the job mid-fetch and mid-format |
| Cross-origin tools | ✅ | See §6.8. Needs a genuinely second origin — same-origin descriptors are filtered out, which is what made the earlier in-repo page unprovable. It is now a separately built, independently deployed app Actuo does not own, and it is proven from the deploy itself, not only from localhost |
| Security annotations | ✅ | `readOnlyHint` on all five, driving the shell's re-poll and the `/agent` panel. `untrustedContentHint` on `search_expenses` and `approve_expense` — the two that surface *another person's* free text — and on the converter's tools, whose results carry third-party rate data; shown as a badge on the tool-call card |
| Security annotations | ✅ | `readOnlyHint` on the reads, driving the shell's re-poll and the `/agent` panel. `untrustedContentHint` on `search_expenses` and `approve_expense` — the two that surface *another person's* free text — and on the converter's tools, whose results carry third-party rate data; shown as a badge on the tool-call card |
| `getTools()` discovery | ✅ | Drives the cross-origin path and the `/agent` panel; re-runs on `toolchange`. The Copilot still reads its own registry for local tools, deliberately — see "the tool registry decision" |
| `executeTool()` + manual debug panel | 🟡 | `executeTool()` done, and `/agent` renders `Copilot.crossOriginTools` and the registry's `invocationLog()`. Still read-only: there is no form to invoke a tool by hand with arbitrary arguments |

> Open question: `generate_report` is annotated `readOnlyHint: true` but creates a
> server-side job. Defensible, but decide it deliberately.

`download_report` is the companion tool that saves a finished report to disk. It
exists because a file has no other route out for a client that can only call
tools: the download route needs the session bearer header, so no URL — in chat or
handed to an agent — can fetch it. A tool's `execute()` runs *inside the page*, in
the authenticated session, so the page does the fetch and the browser save on the
agent's behalf. `readOnlyHint: false` (a file lands on the user's machine) with no
confirmation (asking to download is the confirmation).

## §8.4 PWA — ✅

`@angular/service-worker` installed and enabled on the production build only.
Expand Down
88 changes: 88 additions & 0 deletions backend/src/reports/reports.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { NotFoundException } from '@nestjs/common';
import type { Response } from 'express';
import { describe, expect, it, vi } from 'vitest';
import type { AuthenticatedUser } from '../auth/auth.types.js';
import { ReportsController } from './reports.controller.js';
import type { ReportJob, ReportsService } from './reports.service.js';

const USER: AuthenticatedUser = {
userId: 'user-1',
orgId: 'org-1',
email: 'priya@actuo.demo',
role: 'owner',
};

const READY: ReportJob = {
id: 'job-1',
orgId: 'org-1',
status: 'ready',
format: 'csv',
rows: 2,
url: '/api/reports/job-1/download',
filename: 'actuo-expenses-2026-08-01_2026-08-31.csv',
content: 'date,merchant,amount,currency,status\n2026-08-10,Barista,450,INR,approved',
preview: 'date,merchant,amount,currency,status\n2026-08-10,Barista,450,INR,approved',
previewTruncated: false,
createdAt: Date.now(),
};

function createController(job: ReportJob) {
const reports = { get: vi.fn().mockReturnValue(job) } as unknown as ReportsService;
const res = { setHeader: vi.fn() } as unknown as Response;
return { controller: new ReportsController(reports), res };
}

describe('ReportsController', () => {
it('exposes the preview and filename on the status route', () => {
const { controller } = createController(READY);

expect(controller.status(USER, 'job-1')).toEqual({
jobId: 'job-1',
status: 'ready',
rows: 2,
url: '/api/reports/job-1/download',
filename: 'actuo-expenses-2026-08-01_2026-08-31.csv',
preview: READY.preview,
previewTruncated: false,
error: undefined,
});
});

/**
* Without this the CSV renders in the tab instead of saving, which reads as
* a broken download even when the request itself succeeded.
*/
it('sends the CSV as an attachment named after the range', () => {
const { controller, res } = createController(READY);

const body = controller.download(USER, 'job-1', res);

expect(res.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="actuo-expenses-2026-08-01_2026-08-31.csv"',
);
expect(body).toBe(READY.content);
});

it('falls back to a job-id filename when the job carries none', () => {
const { controller, res } = createController({ ...READY, filename: undefined });

controller.download(USER, 'job-1', res);

expect(res.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="actuo-report-job-1.csv"',
);
});

it('404s a job that is not ready, and sets no attachment header', () => {
const { controller, res } = createController({
...READY,
status: 'pending',
content: undefined,
});

expect(() => controller.download(USER, 'job-1', res)).toThrow(NotFoundException);
expect(res.setHeader).not.toHaveBeenCalled();
});
});
50 changes: 39 additions & 11 deletions backend/src/reports/reports.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
import { Body, Controller, Get, Header, NotFoundException, Param, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
Header,
NotFoundException,
Param,
Post,
Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { Roles } from '../auth/roles.decorator.js';
import type { AuthenticatedUser } from '../auth/auth.types.js';
import { GenerateReportDto } from './dto/report.dto.js';
import { ReportsService, type ReportStatus } from './reports.service.js';
import { ReportsService, type ReportJob, type ReportStatus } from './reports.service.js';

interface ReportView {
jobId: string;
status: ReportStatus;
rows?: number;
url?: string;
filename?: string;
preview?: string;
previewTruncated?: boolean;
error?: string;
}

Expand Down Expand Up @@ -39,24 +52,39 @@ export class ReportsController {
return view(this.reports.cancel(user, id));
}

/**
* Authenticated like every other route: the caller sends a bearer header, so
* this is reached by `ApiClient.download()`, never by navigating the browser
* to the URL. Content-Disposition is set here rather than with `@Header`
* because the filename carries the job's own date range.
*/
@Roles('owner', 'admin', 'member')
@Get(':id/download')
@Header('Content-Type', 'text/csv; charset=utf-8')
download(@CurrentUser() user: AuthenticatedUser, @Param('id') id: string): string {
download(
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
@Res({ passthrough: true }) res: Response,
): string {
const job = this.reports.get(user, id);
if (job.status !== 'ready' || job.content === undefined) {
throw new NotFoundException('Report is not ready');
}
const filename = job.filename ?? `actuo-report-${job.id}.csv`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
return job.content;
}
}

function view(job: {
id: string;
status: ReportStatus;
rows?: number;
url?: string;
error?: string;
}): ReportView {
return { jobId: job.id, status: job.status, rows: job.rows, url: job.url, error: job.error };
function view(job: ReportJob): ReportView {
return {
jobId: job.id,
status: job.status,
rows: job.rows,
url: job.url,
filename: job.filename,
preview: job.preview,
previewTruncated: job.previewTruncated,
error: job.error,
};
}
39 changes: 39 additions & 0 deletions backend/src/reports/reports.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,45 @@ describe('ReportsService', () => {
expect(job.content?.split('\n')).toHaveLength(41); // header + 40 rows
});

it('names the file after the range it covers', async () => {
const { service } = createService();
const { jobId } = service.start(USER, { from: '2026-08-01', to: '2026-08-31' });

await vi.waitFor(() => expect(service.get(USER, jobId).status).toBe('ready'), { timeout: 5000 });

expect(service.get(USER, jobId).filename).toBe('actuo-expenses-2026-08-01_2026-08-31.csv');
});

/**
* The preview is what an agent with no UI gets instead of a download URL it
* cannot authenticate. Bounded, and it says so when it is bounded — a
* truncated report presented as a whole one is the same confident wrong
* answer the paging fix above exists to prevent.
*/
it('carries a bounded preview that admits when it is truncated', async () => {
const { service } = createService();
const { jobId } = service.start(USER, { from: '2026-08-01', to: '2026-08-31' });

await vi.waitFor(() => expect(service.get(USER, jobId).status).toBe('ready'), { timeout: 5000 });

const job = service.get(USER, jobId);
const lines = job.preview?.split('\n') ?? [];
expect(lines[0]).toBe('date,merchant,amount,currency,status');
expect(lines).toHaveLength(21); // header + 20 of the 40 rows
expect(job.previewTruncated).toBe(true);
});

it('does not claim truncation when every row fits the preview', async () => {
const { service } = createService(pagedList(5));
const { jobId } = service.start(USER, { from: '2026-08-01', to: '2026-08-31' });

await vi.waitFor(() => expect(service.get(USER, jobId).status).toBe('ready'), { timeout: 5000 });

const job = service.get(USER, jobId);
expect(job.previewTruncated).toBe(false);
expect(job.preview).toBe(job.content);
});

/**
* The point of the whole feature: Stop must actually stop the work, not just
* detach the client from a job that keeps running to completion.
Expand Down
33 changes: 33 additions & 0 deletions backend/src/reports/reports.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface ReportJob {
rows?: number;
url?: string;
content?: string;
/** What the browser should save the file as; reaches Content-Disposition. */
filename?: string;
/** Header plus the first PREVIEW_ROWS rows — see the note on `url` below. */
preview?: string;
previewTruncated?: boolean;
error?: string;
createdAt: number;
}
Expand Down Expand Up @@ -119,9 +124,23 @@ export class ReportsService {

if (this.jobs.get(job.id)?.status === 'cancelled') return;

/*
* These five move together, the way the FX lock's three do: a download
* URL with no filename, or a preview with no row count, describes a file
* that cannot be defended.
*
* `url` is the correct self-describing path for this resource and stays.
* It is NOT what the `generate_report` tool hands the model — that route
* needs a bearer header, so a link to it in chat 401s the moment anyone
* clicks it. `preview` exists so an agent with no UI still gets data
* rather than a URL it cannot authenticate. See expense-tools.ts.
*/
job.content = lines.join('\n');
job.rows = items.length;
job.url = `/api/reports/${job.id}/download`;
job.filename = reportFilename(dto.from, dto.to);
job.preview = lines.slice(0, PREVIEW_ROWS + 1).join('\n');
job.previewTruncated = items.length > PREVIEW_ROWS;
job.status = 'ready';
} catch (error) {
// A cancel mid-fetch is a normal outcome, not a failure.
Expand All @@ -137,6 +156,20 @@ class ReportCancelled extends Error {}

const CSV_HEADER = 'date,merchant,amount,currency,status';

/** How many data rows the tool result carries, so a headless agent gets data
* without the whole file landing in a model's context on every later turn. */
const PREVIEW_ROWS = 20;

/**
* The DTO already validates both dates as ISO8601, but this value is
* interpolated into a Content-Disposition header, so it is filtered here rather
* than trusted two layers away from the validator.
*/
function reportFilename(from: string, to: string): string {
const safe = (value: string) => value.replace(/[^\w.-]/g, '');
return `actuo-expenses-${safe(from)}_${safe(to)}.csv`;
}

function toCsvRow(expense: Expense): string {
return [
expense.expenseDate,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/ai/gemini-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ describe('toGeminiSchema', () => {
'get_budget_status',
'get_spend_summary',
'generate_report',
'download_report',
'fetch_categories',
'approve_expense',
]);
Expand Down
38 changes: 37 additions & 1 deletion frontend/src/app/copilot/copilot-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import {
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { ReportDownload } from '../core/reports/report-download';
import { ToolCallCard } from '../ui/tool-call-card';
import { Copilot } from './copilot';
import { Copilot, type CopilotEntry } from './copilot';

/**
* The Copilot: an idle aurora orb that opens into a conversation.
Expand Down Expand Up @@ -153,8 +154,12 @@ import { Copilot } from './copilot';
[mutates]="entry.mutates"
[untrusted]="entry.untrusted"
[cancellable]="entry.cancellable"
[downloadLabel]="downloadLabel(entry)"
[downloading]="downloads.isPending(reportJobId(entry))"
[downloadError]="downloads.errorFor(reportJobId(entry))"
(confirm)="copilot.respondToConfirmation(true)"
(cancel)="onCancel(entry.state)"
(download)="onDownload(entry)"
/>
}
}
Expand Down Expand Up @@ -185,6 +190,7 @@ import { Copilot } from './copilot';
})
export class CopilotPanel {
protected readonly copilot = inject(Copilot);
protected readonly downloads = inject(ReportDownload);
protected readonly draft = signal('');
private readonly scroller = viewChild<ElementRef<HTMLElement>>('scroller');

Expand Down Expand Up @@ -214,6 +220,34 @@ export class CopilotPanel {
void this.copilot.send(text);
}

/**
* Which completed tool call produced a file the user can keep.
*
* Report-specific knowledge sits in the Copilot's view rather than in
* `ToolCallCard`, which is the shared UI layer, or in `Copilot`, whose spec
* would then need an `ApiClient` fake. `copilot.ts` already names
* `generate_report` for `cancellable`, so this is the established seam.
*/
protected downloadLabel(entry: ToolEntry): string | undefined {
const jobId = this.reportJobId(entry);
if (!jobId) return undefined;
const rows = (entry.result as { rows?: number }).rows;
return rows === undefined ? 'Download CSV' : `Download CSV (${rows} rows)`;
}

protected reportJobId(entry: ToolEntry): string | undefined {
if (entry.name !== 'generate_report' || entry.state !== 'done') return undefined;
const result = entry.result;
if (!result || typeof result !== 'object') return undefined;
const { jobId } = result as { jobId?: unknown };
return typeof jobId === 'string' && jobId ? jobId : undefined;
}

protected onDownload(entry: ToolEntry): void {
const jobId = this.reportJobId(entry);
if (jobId) void this.downloads.download(jobId);
}

/**
* Cancel means two different things on a card: decline a proposed action, or
* stop work already running. Both are the same button to the user.
Expand All @@ -223,3 +257,5 @@ export class CopilotPanel {
else this.copilot.stop();
}
}

type ToolEntry = Extract<CopilotEntry, { kind: 'tool' }>;
Loading
Loading