mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
5d334d7ec8
A third scope, home, next to trip and journey: threads that name neither a trip nor a journey (the CHECK now only forbids both), reached under /api/assistant by their owner only. A home turn reads the user's trips through list_trips into a short card (under way, next, later, behind, undated), offers every read (a trip's on the id the model names, checked by the tool with the user's rights) and only the writes that take no trip or journey (atlas, vacation planner, collections, tour library, settings), all held as change cards. open_trip_chat answers with a card that goes on in that trip's own chat with the question waiting in its composer. On the desktop the window opens on the home chat outside a trip; its head lists the trips to switch into and a switched trip chat has the way back. On the phone the home chat opens full screen from the dashboard head and the dock's More list, and the card opens the trip with its chat. Both run on useAssistantHome.
111 lines
6.1 KiB
TypeScript
111 lines
6.1 KiB
TypeScript
/**
|
|
* The home assistant's controller: the AI addon before auth and nothing else
|
|
* to open, the managed marker, the routes with their statuses, and that every
|
|
* handler hands the caller's own id and the home scope to the same service
|
|
* the trip's and the journey's controllers use.
|
|
*/
|
|
import 'reflect-metadata';
|
|
|
|
import { AddonGuard } from '../../../../src/nest/addons/addon.guard';
|
|
import { REQUIRE_ADDON } from '../../../../src/nest/addons/require-addon.decorator';
|
|
import type { AssistantService, TurnChannel } from '../../../../src/nest/assistant/assistant.service';
|
|
import { HomeAssistantController } from '../../../../src/nest/assistant/home-assistant.controller';
|
|
import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard';
|
|
import { DomainError } from '../../../../src/nest/common/domain-error';
|
|
import { MANAGED_FORBIDDEN } from '../../../../src/nest/common/managed';
|
|
import { MANAGED_ROUTE_ALLOW_LIST } from '../../../../src/nest/common/validate-managed-routes';
|
|
import type { User } from '../../../../src/types';
|
|
import { RequestMethod } from '@nestjs/common';
|
|
import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
|
|
|
import type { Response } from 'express';
|
|
import { EventEmitter } from 'node:events';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const user = { id: 7, role: 'user' } as User;
|
|
const HOME = { kind: 'home' };
|
|
|
|
function make() {
|
|
const service = {
|
|
listThreads: vi.fn(async () => ({ threads: [] })),
|
|
getThread: vi.fn(async () => ({ thread: { id: 1, title: 't', lastMessageAt: 'x' }, messages: [] })),
|
|
deleteThread: vi.fn(async () => undefined),
|
|
chat: vi.fn(async (_u: number, _s: unknown, _b: unknown, _c: TurnChannel) => undefined),
|
|
decide: vi.fn(async (_u: number, _s: unknown, _th: number, _b: unknown, _c: TurnChannel) => undefined),
|
|
undo: vi.fn(async () => undefined),
|
|
stop: vi.fn(async () => undefined),
|
|
};
|
|
const controller = new HomeAssistantController(service as unknown as AssistantService);
|
|
return { service, controller };
|
|
}
|
|
|
|
/** Enough of an Express response for a turn to hand to the service. */
|
|
class FakeResponse extends EventEmitter {
|
|
writableEnded = false;
|
|
destroyed = false;
|
|
}
|
|
|
|
function route(name: string) {
|
|
const handler = (HomeAssistantController.prototype as unknown as Record<string, object>)[name];
|
|
return {
|
|
method: Reflect.getMetadata(METHOD_METADATA, handler) as RequestMethod,
|
|
path: Reflect.getMetadata(PATH_METADATA, handler) as string,
|
|
httpCode: Reflect.getMetadata(HTTP_CODE_METADATA, handler) as number | undefined,
|
|
};
|
|
}
|
|
|
|
describe('HomeAssistantController', () => {
|
|
it('ASTHCTL-001: sits behind the AI addon and auth, and is withheld on a managed install', () => {
|
|
expect(Reflect.getMetadata(PATH_METADATA, HomeAssistantController)).toBe('api/assistant');
|
|
expect(Reflect.getMetadata(GUARDS_METADATA, HomeAssistantController)).toEqual([AddonGuard, JwtAuthGuard]);
|
|
expect(Reflect.getMetadata(REQUIRE_ADDON, HomeAssistantController)).toMatchObject({ addonId: 'ai_assistant' });
|
|
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, HomeAssistantController)?.reason).toBeTruthy();
|
|
for (const name of ['chat', 'decide', 'deleteThread', 'stop', 'thread', 'threads', 'undo']) {
|
|
expect(MANAGED_ROUTE_ALLOW_LIST, name).toContain(`HomeAssistantController.${name}`);
|
|
}
|
|
});
|
|
|
|
it('ASTHCTL-002: declares the conversation routes, with their statuses, and none that belongs to a trip', () => {
|
|
expect(route('threads')).toMatchObject({ method: RequestMethod.GET, path: 'threads' });
|
|
expect(route('thread')).toMatchObject({ method: RequestMethod.GET, path: 'threads/:threadId' });
|
|
expect(route('deleteThread')).toEqual({ method: RequestMethod.DELETE, path: 'threads/:threadId', httpCode: 200 });
|
|
expect(route('chat')).toMatchObject({ method: RequestMethod.POST, path: 'chat' });
|
|
expect(route('decide')).toMatchObject({ method: RequestMethod.POST, path: 'threads/:threadId/decisions' });
|
|
expect(route('undo')).toEqual({ method: RequestMethod.POST, path: 'tool-calls/:toolCallId/undo', httpCode: 200 });
|
|
expect(route('stop')).toEqual({ method: RequestMethod.POST, path: 'stop', httpCode: 200 });
|
|
const handlers = HomeAssistantController.prototype as unknown as Record<string, unknown>;
|
|
for (const name of ['addFoundPlace', 'takeProposal', 'share', 'attach', 'upload']) {
|
|
expect(handlers[name], name).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('ASTHCTL-003: hands the caller and the home scope to the service, and answers the contract bodies', async () => {
|
|
const { service, controller } = make();
|
|
await controller.threads(user);
|
|
expect(service.listThreads).toHaveBeenCalledWith(7, HOME);
|
|
await controller.thread(user, '9');
|
|
expect(service.getThread).toHaveBeenCalledWith(7, HOME, 9);
|
|
expect(await controller.deleteThread(user, '9')).toEqual({ success: true });
|
|
expect(service.deleteThread).toHaveBeenCalledWith(7, HOME, 9);
|
|
expect(await controller.undo(user, 'tc-1', {})).toEqual({ undone: true });
|
|
expect(service.undo).toHaveBeenCalledWith(7, HOME, 'tc-1');
|
|
expect(await controller.stop(user, {})).toEqual({ success: true });
|
|
expect(service.stop).toHaveBeenCalledWith(7);
|
|
expect(() => controller.thread(user, 'abc')).toThrow(DomainError);
|
|
await expect(controller.deleteThread(user, '0')).rejects.toThrow(DomainError);
|
|
});
|
|
|
|
it('ASTHCTL-004: a turn and a decision go to the service at home, with the stream of the response', async () => {
|
|
const { service, controller } = make();
|
|
const chat = { locale: 'en', timeZone: 'UTC', threadId: null, text: 'Which trip is next?' };
|
|
await controller.chat(user, chat, new FakeResponse() as unknown as Response);
|
|
expect(service.chat.mock.calls[0].slice(0, 3)).toEqual([7, HOME, chat]);
|
|
const decisions = { locale: 'en', timeZone: 'UTC', decisions: [] };
|
|
const gone = new FakeResponse();
|
|
await controller.decide(user, '9', decisions, gone as unknown as Response);
|
|
expect(service.decide.mock.calls[0].slice(0, 3)).toEqual([7, HOME, 9]);
|
|
gone.emit('close');
|
|
expect(service.decide.mock.calls[0][4].signal.aborted).toBe(true);
|
|
});
|
|
});
|