Files
TREK/server/tests/unit/nest/assistant/assistant-attachment-tools.test.ts
T
Maurice 89c33ac3f4 feat(assistant): an attached file can be kept in Files, on its own or with a booking or an expense
The chips offered to keep an attached file in Files, but nothing could do it:
the assistant's upload_trip_file stays denied, and the file never reached
Files. A new server write, keep_attached_file, is offered to the model where
the thread holds an attachment and held as a change card naming the file and
the booking, expense or place it goes with.

Applied, it runs the checks of a Files upload with the user's rights (the
demo block, the trip, file_upload and file_edit for a link, the instance's
file types, every linked id on the same trip), copies the bytes to a trip
file of their own, and writes the trip file, its expense link and the
attachment's pointer to it in one transaction; the members' planners get
file:created after it. A second keep of the same attachment answers the file
kept the first time. FilesService gains storeBytes and discardBytes so the
storage write stays out of that transaction.

The receipt opens the Files tab; the card's verb, its "Attached to" row and
the receipt's open label are new keys in all 27 locales. Undo is not offered:
no tool takes a trip file back yet.
2026-10-24 22:32:48 +02:00

128 lines
5.9 KiB
TypeScript

/**
* read_attached_file as the turn offers it: only where the thread holds a file
* with text, held as the consent card until the user let the model read that
* file, then a read; any id the conversation does not hold answered the same.
*/
import type { AssistantAttachmentSummary } from '../../../../src/db/repositories/AssistantAttachments.repository';
import type { AgentToolPort } from '../../../../src/nest/assistant/assistant-agent';
import {
attachedPort,
NO_SUCH_ATTACHMENT,
KEEP_ATTACHED_FILE_SPEC,
NO_TEXT,
READ_ATTACHED_FILE_SPEC,
} from '../../../../src/nest/assistant/assistant-attachment-tools';
import { systemPrompt } from '../../../../src/nest/assistant/assistant.prompt';
import { describe, expect, it, vi } from 'vitest';
const OWN = '0b7c2f0e-6f6d-4a8e-9b1a-3d2c1e0f9a87';
const SHARED = '1c8d3f1f-7a7e-4b9f-8c2b-4e3d2f1a0b98';
const SCAN = '2d9e4a2a-8b8f-4cab-9d3c-5f4e3a2b1c09';
const ELSEWHERE = '3eaf5b3b-9c9a-4dbc-8e4d-6a5f4b3c2d10';
function row(id: string, extra: Partial<AssistantAttachmentSummary> = {}): AssistantAttachmentSummary {
return {
id,
thread_id: 4,
user_id: 7,
trip_id: 9,
name: `${id.slice(0, 4)}.pdf`,
mime: 'application/pdf',
size: 10,
sha256: 'x',
storage_key: `assistant/${id}.pdf`,
text_status: 'ok',
shared_at: null,
file_id: null,
created_at: '2026-10-24 08:00:00',
...extra,
};
}
function base(): AgentToolPort {
return {
specs: () => [{ name: 'get_trip_summary', description: '', parameters: {} }],
kindOf: vi.fn(() => 'read' as const),
pinArgs: vi.fn((_name: string, args: Record<string, unknown>) => ({ args })),
call: vi.fn(async () => ({ ok: true, value: 'trip' })),
};
}
const ATTACHED = [row(OWN), row(SHARED, { shared_at: '2026-10-24 08:01:00' }), row(SCAN, { text_status: 'scanned' })];
describe('attachedPort', () => {
it('ASTATL-001: offers keep_attached_file where the thread holds a file, read_attached_file where one has text', () => {
const port = base();
expect(attachedPort(port, null)).toBe(port);
expect(attachedPort(port, { attached: [], sentTo: 'm', read: vi.fn() })).toBe(port);
const scanOnly = attachedPort(port, {
attached: [row(SCAN, { text_status: 'scanned' })],
sentTo: 'm',
read: vi.fn(),
});
expect(scanOnly.specs().map((spec) => spec.name)).toEqual(['get_trip_summary', KEEP_ATTACHED_FILE_SPEC.name]);
expect(scanOnly.pinArgs('read_attached_file', { attachmentId: SCAN })).toEqual({
args: { attachmentId: SCAN },
});
const offered = attachedPort(port, { attached: ATTACHED, sentTo: 'm', read: vi.fn() });
expect(offered.specs().map((spec) => spec.name)).toEqual([
'get_trip_summary',
READ_ATTACHED_FILE_SPEC.name,
KEEP_ATTACHED_FILE_SPEC.name,
]);
});
it('ASTATL-002: the first read of a file is held for the user, a shared one runs at once', () => {
const port = attachedPort(base(), { attached: ATTACHED, sentTo: 'm', read: vi.fn() });
expect(port.kindOf('read_attached_file', { attachmentId: OWN })).toBe('write');
expect(port.kindOf('read_attached_file', { attachmentId: SHARED })).toBe('read');
expect(port.kindOf('read_attached_file', { attachmentId: ELSEWHERE })).toBe('write');
expect(port.kindOf('get_trip_summary', {})).toBe('read');
});
it('ASTATL-003: the held call names the file and where its text goes, never more; anything else is refused alike', () => {
const port = attachedPort(base(), { attached: ATTACHED, sentTo: 'gpt-5 (api.openai.com)', read: vi.fn() });
expect(port.pinArgs('read_attached_file', { attachmentId: OWN, sentTo: 'nowhere' })).toEqual({
args: { attachmentId: OWN, name: '0b7c.pdf', sentTo: 'gpt-5 (api.openai.com)' },
});
// Another thread's file, another user's, a made-up id and no id at all read the same.
for (const args of [{ attachmentId: ELSEWHERE }, { attachmentId: 'f00' }, {}]) {
expect(port.pinArgs('read_attached_file', args)).toEqual({ error: NO_SUCH_ATTACHMENT });
}
expect(port.pinArgs('read_attached_file', { attachmentId: SCAN })).toEqual({ error: NO_TEXT });
});
it('ASTATL-004: a read runs through the turn, and only for a file of the thread', async () => {
const read = vi.fn(async () => ({ ok: true, value: { name: 'x', read: true }, modelText: 'text' }));
const inner = base();
const port = attachedPort(inner, { attached: ATTACHED, sentTo: 'm', read });
expect(await port.call('read_attached_file', { attachmentId: SHARED })).toMatchObject({ modelText: 'text' });
expect(read).toHaveBeenCalledWith(SHARED);
expect(await port.call('read_attached_file', { attachmentId: ELSEWHERE })).toEqual({
ok: false,
value: NO_SUCH_ATTACHMENT,
});
await port.call('get_trip_summary', {});
expect(inner.call).toHaveBeenCalledWith('get_trip_summary', {});
});
it('ASTATL-005: the standing instructions say how an attached file is read and booked', () => {
const prompt = systemPrompt('English');
expect(prompt).toContain('read it with read_attached_file');
expect(prompt).toContain('proposed with create_reservation');
});
it('ASTATL-006: a keep is always held, checked, and names the file; an attachment of no one here is refused alike', () => {
const port = attachedPort(base(), { attached: ATTACHED, sentTo: 'm', read: vi.fn() });
expect(port.kindOf('keep_attached_file', { attachmentId: SCAN })).toBe('write');
expect(port.pinArgs('keep_attached_file', { attachmentId: SCAN, reservation_id: 5, name: 'forged.exe' })).toEqual({
args: { attachmentId: SCAN, reservation_id: 5, name: '2d9e.pdf' },
});
expect(port.pinArgs('keep_attached_file', { attachmentId: ELSEWHERE })).toEqual({ error: NO_SUCH_ATTACHMENT });
expect(port.pinArgs('keep_attached_file', {})).toEqual({ error: NO_SUCH_ATTACHMENT });
const bad = port.pinArgs('keep_attached_file', { attachmentId: OWN, budget_item_id: 'seven' });
expect(bad).toEqual({ error: expect.stringContaining('Invalid arguments') });
});
});