mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(assistant): chat, note and poll cards drawn like the Collab tab
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import type { AnswerInput, CardReader, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, clipped, recordOf, type Row } from '../card-values';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { asRow, excerptOf, fitted, listed, personFrom, rowById, stampOf, viewerOf } from './collab-values';
|
||||
import {
|
||||
COLLAB_MESSAGE_ORIGIN_ASSISTANT,
|
||||
type AssistantChangePreview,
|
||||
type CollabBubbleCard,
|
||||
type CollabChatCard,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The trip chat in the assistant chat. A read shows the last messages it
|
||||
* returned as the chat draws them, the older ones counted; a held send shows
|
||||
* the bubble it will post (its text as the model wrote it, unmasked, since
|
||||
* that is what goes out), a held delete or reaction the message it touches
|
||||
* as it will look afterwards. A deleted message never shows, and nothing of a
|
||||
* file but its id travels.
|
||||
*/
|
||||
|
||||
/** Messages a read shows; the older ones are counted. */
|
||||
const SHOWN = 5;
|
||||
/** What a listed message shows of its text; the rest is in the chat. */
|
||||
const EXCERPT = 500;
|
||||
/** What a sent bubble holds; a longer message is cut and marked. */
|
||||
const SENT = 4000;
|
||||
|
||||
type Change = CollabBubbleCard['change'];
|
||||
|
||||
function isDeleted(row: Row): boolean {
|
||||
return !!row.deleted;
|
||||
}
|
||||
|
||||
function reactionsOf(row: Row, viewerId: number): CollabBubbleCard['reactions'] {
|
||||
return rowsOf(row.reactions)
|
||||
.flatMap((reaction) => {
|
||||
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
|
||||
const users = rowsOf(reaction.users);
|
||||
if (emoji === '' || emoji.length > 16 || users.length === 0) return [];
|
||||
const names = users.map((user) => clipped(user.username, 80)).filter((name): name is string => name !== null);
|
||||
return [
|
||||
{
|
||||
emoji,
|
||||
count: Math.min(users.length, 999),
|
||||
mine: users.some((user) => user.user_id === viewerId),
|
||||
names: names.slice(0, 6),
|
||||
},
|
||||
];
|
||||
})
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function filesOf(row: Row): CollabBubbleCard['files'] {
|
||||
return rowsOf(row.attachments).flatMap((file) => {
|
||||
const fileId = argId(file, 'id');
|
||||
if (fileId === null) return [];
|
||||
const mime = typeof file.mime_type === 'string' ? file.mime_type : '';
|
||||
return [{ fileId, image: mime.startsWith('image/'), name: clipped(file.original_name, 120) }];
|
||||
});
|
||||
}
|
||||
|
||||
/** The message a row answers, quoted; none when it was deleted or its author is unknown. */
|
||||
function replyOf(row: Row): CollabBubbleCard['reply'] {
|
||||
const name = clipped(row.reply_username, 80);
|
||||
const text = clipped(row.reply_text, 120);
|
||||
return name && text ? { name, text } : null;
|
||||
}
|
||||
|
||||
/** A listed message as a bubble: its text cut to `max` and masked, its reactions read for the viewer. */
|
||||
function bubbleOf(row: Row, viewerId: number, change: Change, max = EXCERPT): CollabBubbleCard | null {
|
||||
const id = argId(row, 'id');
|
||||
const author = personFrom(row);
|
||||
if (id === null || author === null || isDeleted(row)) return null;
|
||||
const excerpt = excerptOf(row.text, max, true) ?? { text: '', cut: false };
|
||||
const files = filesOf(row);
|
||||
if (excerpt.text === '' && files.length === 0) return null;
|
||||
return {
|
||||
id,
|
||||
author,
|
||||
text: excerpt.text,
|
||||
cut: excerpt.cut,
|
||||
at: stampOf(row.created_at),
|
||||
reply: replyOf(row),
|
||||
reactions: reactionsOf(row, viewerId),
|
||||
files: files.slice(0, 4),
|
||||
fromAssistant: row.origin === COLLAB_MESSAGE_ORIGIN_ASSISTANT,
|
||||
change,
|
||||
reactEmoji: null,
|
||||
};
|
||||
}
|
||||
|
||||
function chatOf(messages: CollabBubbleCard[], earlier = 0): CollabChatCard | null {
|
||||
return messages.length === 0 ? null : { kind: 'collabChat', messages, earlier: Math.min(earlier, 10_000) };
|
||||
}
|
||||
|
||||
/** `list_collab_messages`: the last few messages that are still there, oldest first, the older ones counted. */
|
||||
export function chatAnswer({ value, card }: AnswerInput): CollabChatCard | null {
|
||||
const kept = listed(value, 'messages')
|
||||
.map((row) => bubbleOf(row, card.viewerId, 'none'))
|
||||
.filter((bubble): bubble is CollabBubbleCard => bubble !== null);
|
||||
return fitted(kept.slice(-SHOWN), (shown) => chatOf(shown, kept.length - shown.length), 'first');
|
||||
}
|
||||
|
||||
/** The chat's last hundred messages, as a held write's lookup reads them. */
|
||||
export function loadMessages(_args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return read('list_collab_messages');
|
||||
}
|
||||
|
||||
/** A send reads the chat only to quote the message it answers. */
|
||||
export function loadForReply(args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return argId(args, 'replyTo') === null ? Promise.resolve(null) : read('list_collab_messages');
|
||||
}
|
||||
|
||||
/** The listed message a held call names, when it is still there. */
|
||||
function target(loaded: unknown, args: ToolArgs): Row | null {
|
||||
const row = rowById(listed(loaded, 'messages'), argId(args, 'messageId'));
|
||||
return row && !isDeleted(row) ? row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `send_collab_message`: the bubble as it will be posted, by the user, on
|
||||
* their side of the chat. The quoted message comes from the lookup; one that
|
||||
* is gone quotes nothing (the server refuses the reply then).
|
||||
*/
|
||||
export function sendChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabChatCard | null {
|
||||
const author = viewerOf(ctx.card);
|
||||
const excerpt = excerptOf(args.text, SENT, false);
|
||||
if (!author || !excerpt) return null;
|
||||
const quoted = rowById(listed(loaded, 'messages'), argId(args, 'replyTo'));
|
||||
const reply =
|
||||
quoted && !isDeleted(quoted) ? replyOf({ reply_username: quoted.username, reply_text: quoted.text }) : null;
|
||||
return chatOf([
|
||||
{
|
||||
id: null,
|
||||
author,
|
||||
text: excerpt.text,
|
||||
cut: excerpt.cut,
|
||||
at: null,
|
||||
reply,
|
||||
reactions: [],
|
||||
files: [],
|
||||
// A message sent through the tool is posted as typed by the user, so it carries no mark.
|
||||
fromAssistant: false,
|
||||
change: 'send',
|
||||
reactEmoji: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** `delete_collab_message`: the user's own message, struck; none for another's, which the server refuses. */
|
||||
export function removeChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabChatCard | null {
|
||||
const row = target(loaded, args);
|
||||
if (!row || row.user_id !== ctx.card.viewerId) return null;
|
||||
const bubble = bubbleOf(row, ctx.card.viewerId, 'remove');
|
||||
return bubble ? chatOf([bubble]) : null;
|
||||
}
|
||||
|
||||
/** The reactions of a row after the viewer toggled `emoji` on it, as the chat groups them. */
|
||||
function toggled(row: Row, emoji: string, viewer: Row): Row {
|
||||
const reactions = rowsOf(row.reactions);
|
||||
const own = (user: Row) => user.user_id === viewer.user_id;
|
||||
const found = reactions.find((reaction) => reaction.emoji === emoji);
|
||||
if (!found) return { ...row, reactions: [...reactions, { emoji, users: [viewer] }] };
|
||||
const users = rowsOf(found.users);
|
||||
const next = users.some(own) ? users.filter((user) => !own(user)) : [...users, viewer];
|
||||
return {
|
||||
...row,
|
||||
reactions: reactions
|
||||
.map((reaction) => (reaction === found ? { ...reaction, users: next } : reaction))
|
||||
.filter((reaction) => rowsOf(reaction.users).length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** `react_collab_message`: the message with its reactions after the toggle, the toggled one marked. */
|
||||
export function reactChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabChatCard | null {
|
||||
const row = target(loaded, args);
|
||||
const viewer = viewerOf(ctx.card);
|
||||
// Taken as sent, untrimmed: the server stores the string it gets, so " 👍" is a reaction of its own.
|
||||
const emoji = typeof args.emoji === 'string' ? args.emoji : '';
|
||||
if (!row || !viewer || emoji.trim() === '' || emoji.length > 16) return null;
|
||||
const bubble = bubbleOf(toggled(row, emoji, asRow(viewer)), ctx.card.viewerId, 'react');
|
||||
return bubble ? chatOf([{ ...bubble, reactEmoji: emoji }]) : null;
|
||||
}
|
||||
|
||||
/** The bubble a stored preview drew, for an applied send to keep what only the preview knew. */
|
||||
function previewBubble(preview: AssistantChangePreview | null): CollabBubbleCard | null {
|
||||
const card = preview?.card;
|
||||
return card?.kind === 'collabChat' ? (card.messages[0] ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `send_collab_message` once applied: the posted message with its id, time,
|
||||
* origin and files read from the answer. Its text stays the one the held card
|
||||
* showed, unmasked, since it is the user's own message: an address in it must
|
||||
* not turn into "[hidden]" the moment it is sent.
|
||||
*/
|
||||
export function sendApplied(input: { result: unknown; preview: AssistantChangePreview | null }): CollabChatCard | null {
|
||||
const row = recordOf(recordOf(input.result).message);
|
||||
const authorId = argId(row, 'user_id');
|
||||
if (authorId === null) return null;
|
||||
const held = previewBubble(input.preview);
|
||||
const bubble = bubbleOf(row, authorId, held?.change ?? 'send', SENT);
|
||||
if (!bubble) return null;
|
||||
return chatOf([held ? { ...bubble, text: held.text, cut: held.cut } : bubble]);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Described, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, clipped, type Row } from '../card-values';
|
||||
import { Rows, text } from '../preview-format';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { lineOf, listed, rowById } from './collab-values';
|
||||
|
||||
/**
|
||||
* The words of the trip chat's change cards. Each keeps the generic verb of
|
||||
* what it does (add, change, remove) and names its tool, so the chat labels
|
||||
* it "Start a poll" or "Vote in the poll"; its subject is the message, the
|
||||
* question or the note's title, which the card itself shows and the receipt
|
||||
* and the share text still read. Rows only where the card is not enough on
|
||||
* its own: a note's title, category and pin before and after, and the option
|
||||
* a vote is for.
|
||||
*/
|
||||
|
||||
/** How long a subject taken from a message runs: its first line, cut. */
|
||||
const SUBJECT = 60;
|
||||
|
||||
type Describer = (ctx: PreviewContext, args: ToolArgs, loaded: unknown) => Described;
|
||||
|
||||
function described(tool: string, subject: string, rows = new Rows()): Described {
|
||||
return { subject, rows, dayId: null, placeIds: [], tool, domain: 'collab' };
|
||||
}
|
||||
|
||||
/** The first line of a text, cut to a subject; masked when it comes from an answer. */
|
||||
function firstLine(value: unknown, mask: boolean): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const line = value.trim().split('\n')[0] ?? '';
|
||||
return (mask ? clipped(line, SUBJECT) : lineOf(line, SUBJECT)) ?? '';
|
||||
}
|
||||
|
||||
function heldRow(loaded: unknown, key: string, args: ToolArgs, idKey: string): Row | null {
|
||||
return rowById(listed(loaded, key), argId(args, idKey));
|
||||
}
|
||||
|
||||
function pinMark(value: unknown): string {
|
||||
return typeof value === 'boolean' ? text(value) : '';
|
||||
}
|
||||
|
||||
/** A note change's rows: what its title, category and pin go from and to. */
|
||||
function noteRows(args: ToolArgs, note: Row | null): Rows {
|
||||
const rows = new Rows();
|
||||
if (lineOf(args.title, 200) !== null) rows.change('name', clipped(note?.title, 200), text(args.title));
|
||||
if (lineOf(args.category, 100) !== null) rows.change('category', clipped(note?.category, 100), text(args.category));
|
||||
if (typeof args.pinned === 'boolean') {
|
||||
rows.rows.push({
|
||||
op: 'change',
|
||||
labelKey: 'field',
|
||||
field: 'pinned',
|
||||
before: note ? pinMark(!!note.pinned) : null,
|
||||
after: pinMark(args.pinned),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const COLLAB_DESCRIBERS = {
|
||||
send_collab_message: (_ctx, args) => described('send_collab_message', firstLine(args.text, false)),
|
||||
delete_collab_message: (_ctx, args, loaded) => {
|
||||
const message = heldRow(loaded, 'messages', args, 'messageId');
|
||||
return described('delete_collab_message', firstLine(message?.text, true));
|
||||
},
|
||||
react_collab_message: (_ctx, args, loaded) => {
|
||||
const message = heldRow(loaded, 'messages', args, 'messageId');
|
||||
const emoji = lineOf(args.emoji, 16) ?? '';
|
||||
return described('react_collab_message', [emoji, firstLine(message?.text, true)].filter(Boolean).join(' '));
|
||||
},
|
||||
create_collab_poll: (_ctx, args) => described('create_collab_poll', firstLine(args.question, false)),
|
||||
vote_collab_poll: (_ctx, args, loaded) => {
|
||||
const poll = heldRow(loaded, 'polls', args, 'pollId');
|
||||
const index = typeof args.optionIndex === 'number' ? args.optionIndex : -1;
|
||||
const option = rowsOf(poll?.options)[index];
|
||||
const rows = new Rows().add('text', clipped(option?.text ?? option?.label, 160) ?? '');
|
||||
return described('vote_collab_poll', firstLine(poll?.question, true), rows);
|
||||
},
|
||||
close_collab_poll: (_ctx, args, loaded) =>
|
||||
described('close_collab_poll', firstLine(heldRow(loaded, 'polls', args, 'pollId')?.question, true)),
|
||||
delete_collab_poll: (_ctx, args, loaded) =>
|
||||
described('delete_collab_poll', firstLine(heldRow(loaded, 'polls', args, 'pollId')?.question, true)),
|
||||
create_collab_note: (_ctx, args) => {
|
||||
const rows = new Rows().add('category', lineOf(args.category, 100) ?? '');
|
||||
return described('create_collab_note', lineOf(args.title, 200) ?? '', rows);
|
||||
},
|
||||
update_collab_note: (_ctx, args, loaded) => {
|
||||
const note = heldRow(loaded, 'notes', args, 'noteId');
|
||||
const subject = clipped(note?.title, 200) ?? lineOf(args.title, 200) ?? '';
|
||||
return described('update_collab_note', subject, noteRows(args, note));
|
||||
},
|
||||
delete_collab_note: (_ctx, args, loaded) =>
|
||||
described('delete_collab_note', clipped(heldRow(loaded, 'notes', args, 'noteId')?.title, 200) ?? ''),
|
||||
} satisfies Readonly<Record<string, Describer>>;
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { AnswerInput, CardReader, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, blockText, clipped, hexOrNull, recordOf, type Row } from '../card-values';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { hostOf, lineOf, listed, personFrom, rowById, viewerOf } from './collab-values';
|
||||
import type { AssistantChangePreview, CollabNoteCard, CollabNotesCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The shared notes of the trip in the assistant chat, as the Collab tab
|
||||
* draws them. A note takes its category's colour the way the tab gives it
|
||||
* (the last note of the category in list order wins), a pinned note comes
|
||||
* first. A held write shows the one note it adds, changes or deletes, after
|
||||
* the change, with what it changes listed.
|
||||
*/
|
||||
|
||||
/** Notes a read shows; the rest are counted. */
|
||||
const SHOWN = 4;
|
||||
/** What a card shows of a note's text: about three lines. */
|
||||
const BODY = 600;
|
||||
/** The category and colour the server gives a note created without them. */
|
||||
const DEFAULT_CATEGORY = 'General';
|
||||
const DEFAULT_COLOR = '#6366f1';
|
||||
|
||||
type Change = CollabNoteCard['change'];
|
||||
type Field = CollabNoteCard['changed'][number];
|
||||
|
||||
/** The colour each category takes in the tab: the last note of it in list order. */
|
||||
function categoryColors(rows: readonly Row[]): Map<string, string> {
|
||||
const colors = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const color = hexOrNull(row.color);
|
||||
if (typeof row.category === 'string' && row.category !== '' && color) colors.set(row.category, color);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
/** A listed note as its card. Its text comes from an answer, so it is masked. */
|
||||
function noteOf(row: Row, colors: ReadonlyMap<string, string>, change: Change): CollabNoteCard | null {
|
||||
const noteId = argId(row, 'id');
|
||||
const title = clipped(row.title, 200);
|
||||
if (noteId === null || title === null) return null;
|
||||
const category = typeof row.category === 'string' ? row.category : null;
|
||||
return {
|
||||
noteId,
|
||||
title,
|
||||
body: blockText(row.content, BODY, { mask: true }),
|
||||
category: clipped(category, 100),
|
||||
color: (category ? colors.get(category) : undefined) ?? hexOrNull(row.color),
|
||||
pinned: !!row.pinned,
|
||||
websiteHost: hostOf(row.website),
|
||||
files: Math.min(rowsOf(row.attachments).length, 999),
|
||||
author: personFrom(row),
|
||||
change,
|
||||
changed: [],
|
||||
};
|
||||
}
|
||||
|
||||
function notesOf(notes: CollabNoteCard[], more = 0): CollabNotesCard | null {
|
||||
return notes.length === 0 ? null : { kind: 'collabNotes', notes, more: Math.min(more, 1_000_000) };
|
||||
}
|
||||
|
||||
/** When a note was last touched, for the tab's order. */
|
||||
function touchedAt(row: Row): number {
|
||||
const stamp = typeof row.updated_at === 'string' && row.updated_at ? row.updated_at : row.created_at;
|
||||
const ms = typeof stamp === 'string' ? Date.parse(stamp.includes('T') ? stamp : `${stamp.replace(' ', 'T')}Z`) : NaN;
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
/** `list_collab_notes`: up to four notes, pinned first, then the latest touched, as the tab sorts them. */
|
||||
export function notesAnswer({ value }: AnswerInput): CollabNotesCard | null {
|
||||
const rows = listed(value, 'notes');
|
||||
const colors = categoryColors(rows);
|
||||
const ordered = [...rows].sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned) || touchedAt(b) - touchedAt(a));
|
||||
const notes = ordered
|
||||
.map((row) => noteOf(row, colors, 'none'))
|
||||
.filter((note): note is CollabNoteCard => note !== null);
|
||||
const shown = notes.slice(0, SHOWN);
|
||||
return notesOf(shown, notes.length - shown.length);
|
||||
}
|
||||
|
||||
/** The trip's notes, as a held note write reads them. */
|
||||
export function loadNotes(_args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return read('list_collab_notes');
|
||||
}
|
||||
|
||||
/**
|
||||
* `create_collab_note`: the new note as the server will store it, its text
|
||||
* as the model wrote it. Without a category it lands in "General"; without a
|
||||
* colour it takes its category's, as the tab draws it.
|
||||
*/
|
||||
export function createNoteChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabNotesCard | null {
|
||||
const author = viewerOf(ctx.card);
|
||||
const title = lineOf(args.title, 200);
|
||||
if (!author || title === null) return null;
|
||||
const category = lineOf(args.category, 100) ?? DEFAULT_CATEGORY;
|
||||
const color = hexOrNull(args.color) ?? categoryColors(listed(loaded, 'notes')).get(category) ?? DEFAULT_COLOR;
|
||||
return notesOf([
|
||||
{
|
||||
noteId: null,
|
||||
title,
|
||||
body: blockText(args.content, BODY, { mask: false }),
|
||||
category,
|
||||
color,
|
||||
pinned: args.pinned === true,
|
||||
websiteHost: hostOf(args.website),
|
||||
files: 0,
|
||||
author,
|
||||
change: 'create',
|
||||
changed: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** The listed note a held call names, as its card. */
|
||||
function heldNote(loaded: unknown, args: ToolArgs, change: Change): CollabNoteCard | null {
|
||||
const rows = listed(loaded, 'notes');
|
||||
const row = rowById(rows, argId(args, 'noteId'));
|
||||
return row ? noteOf(row, categoryColors(rows), change) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_collab_note`: the note with the arguments laid over it as the
|
||||
* server applies them (an empty title, category or colour keeps the old one,
|
||||
* a link of null removes it), and which of its parts change.
|
||||
*/
|
||||
export function updateNoteChange(_ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabNotesCard | null {
|
||||
const before = heldNote(loaded, args, 'update');
|
||||
if (!before) return null;
|
||||
const row = rowById(listed(loaded, 'notes'), argId(args, 'noteId'));
|
||||
const after: CollabNoteCard = {
|
||||
...before,
|
||||
title: lineOf(args.title, 200) ?? before.title,
|
||||
body: args.content === undefined ? before.body : blockText(args.content, BODY, { mask: false }),
|
||||
category: lineOf(args.category, 100) ?? before.category,
|
||||
color: hexOrNull(args.color) ?? before.color,
|
||||
pinned: typeof args.pinned === 'boolean' ? args.pinned : before.pinned,
|
||||
websiteHost: args.website === undefined ? before.websiteHost : hostOf(args.website),
|
||||
};
|
||||
// Compared with the stored note, not the masked card, so an address in it reads as unchanged.
|
||||
const stored: Row = row ?? {};
|
||||
const set = (value: string | null, was: unknown) => value !== null && value !== lineOf(was, 200);
|
||||
const differs: Record<Field, boolean> = {
|
||||
title: set(lineOf(args.title, 200), stored.title),
|
||||
body: args.content !== undefined && (args.content ?? '') !== (stored.content ?? ''),
|
||||
category: set(lineOf(args.category, 100), stored.category),
|
||||
color: set(hexOrNull(args.color), stored.color),
|
||||
website: args.website !== undefined && (args.website ?? null) !== (stored.website ?? null),
|
||||
pinned: typeof args.pinned === 'boolean' && args.pinned !== !!stored.pinned,
|
||||
};
|
||||
const changed = (Object.keys(differs) as Field[]).filter((field) => differs[field]);
|
||||
return notesOf([{ ...after, changed }]);
|
||||
}
|
||||
|
||||
/** `delete_collab_note`: the note as it is, struck. */
|
||||
export function removeNoteChange(_ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabNotesCard | null {
|
||||
const note = heldNote(loaded, args, 'remove');
|
||||
return note ? notesOf([note]) : null;
|
||||
}
|
||||
|
||||
/** The note a stored preview drew. */
|
||||
function previewNote(preview: AssistantChangePreview | null): CollabNoteCard | null {
|
||||
const card = preview?.card;
|
||||
return card?.kind === 'collabNotes' ? (card.notes[0] ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A note write once applied: the note as the server answered it, with its
|
||||
* id. Its colour is the one the preview showed, which knew the category's.
|
||||
*/
|
||||
export function noteApplied(
|
||||
fallback: Change,
|
||||
input: { result: unknown; preview: AssistantChangePreview | null },
|
||||
): CollabNotesCard | null {
|
||||
const shown = previewNote(input.preview);
|
||||
const row = recordOf(recordOf(input.result).note);
|
||||
const colors = new Map<string, string>();
|
||||
if (shown?.category && shown.color) colors.set(shown.category, shown.color);
|
||||
const note = noteOf(row, colors, shown?.change ?? fallback);
|
||||
return note ? notesOf([{ ...note, changed: shown?.changed ?? [] }]) : null;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { AnswerInput, CardReader, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, blockText, clipped, recordOf, type Row } from '../card-values';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { asRow, deadlineOf, fitted, hasPassed, lineOf, listed, personFrom, rowById, viewerOf } from './collab-values';
|
||||
import type { AssistantChangePreview, CollabPollCard, CollabPollsCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The polls of the trip in the assistant chat, as the Collab tab draws them.
|
||||
* A read shows up to four polls, the open ones first; a held write shows the
|
||||
* one poll it starts, votes in, closes or deletes, after the change. A vote
|
||||
* is worked out the way the server casts it: the same option again takes it
|
||||
* back, and a single choice poll moves the vote. Who voted for what is read
|
||||
* for the signed-in user (`mine`), which the client cannot tell from the
|
||||
* three faces a card carries.
|
||||
*/
|
||||
|
||||
/** Polls a read shows; the rest are counted. */
|
||||
const SHOWN = 4;
|
||||
/** Options a poll shows; the rest are counted. */
|
||||
const OPTIONS = 12;
|
||||
|
||||
type Change = CollabPollCard['change'];
|
||||
|
||||
/** Whether `viewerId` voted for an option, given its voters and where it stands. */
|
||||
type MineOf = (voters: readonly Row[], index: number) => boolean;
|
||||
|
||||
const byViewer =
|
||||
(viewerId: number): MineOf =>
|
||||
(voters) =>
|
||||
voters.some((voter) => voter.user_id === viewerId);
|
||||
|
||||
function isClosed(row: Row): boolean {
|
||||
return row.is_closed === true || (row.is_closed === undefined && !!row.closed);
|
||||
}
|
||||
|
||||
function isMultiple(row: Row): boolean {
|
||||
return row.multiple_choice === true || (row.multiple_choice === undefined && !!row.multiple);
|
||||
}
|
||||
|
||||
/** A listed poll as its card, the options' votes read for the viewer. */
|
||||
function pollOf(row: Row, mineOf: MineOf, change: Change): CollabPollCard | null {
|
||||
const pollId = argId(row, 'id');
|
||||
const question = blockText(row.question, 300, { mask: true });
|
||||
const all = rowsOf(row.options);
|
||||
if (pollId === null || question === null || all.length === 0) return null;
|
||||
return {
|
||||
pollId,
|
||||
question,
|
||||
options: all.slice(0, OPTIONS).map((option, index) => {
|
||||
const voters = rowsOf(option.voters);
|
||||
return {
|
||||
text: clipped(option.text ?? option.label, 160) ?? `#${index + 1}`,
|
||||
votes: Math.min(voters.length, 9999),
|
||||
mine: mineOf(voters, index),
|
||||
voters: voters.slice(0, 3).flatMap((voter) => {
|
||||
const person = personFrom(voter);
|
||||
return person ? [person] : [];
|
||||
}),
|
||||
};
|
||||
}),
|
||||
moreOptions: Math.min(Math.max(all.length - OPTIONS, 0), 500),
|
||||
multiple: isMultiple(row),
|
||||
closed: isClosed(row),
|
||||
deadline: deadlineOf(row.deadline),
|
||||
author: personFrom(row),
|
||||
change,
|
||||
votedIndex: null,
|
||||
votedAdds: false,
|
||||
warning: null,
|
||||
};
|
||||
}
|
||||
|
||||
function pollsOf(polls: CollabPollCard[], more = 0): CollabPollsCard | null {
|
||||
return polls.length === 0 ? null : { kind: 'collabPolls', polls, more: Math.min(more, 1_000_000) };
|
||||
}
|
||||
|
||||
/** `list_collab_polls`: up to four polls, the open ones first as the Collab tab lists them. */
|
||||
export function pollsAnswer({ value, card }: AnswerInput, now = Date.now()): CollabPollsCard | null {
|
||||
const open = (row: Row) => !isClosed(row) && !hasPassed(deadlineOf(row.deadline), now);
|
||||
const rows = listed(value, 'polls');
|
||||
const ordered = [...rows.filter(open), ...rows.filter((row) => !open(row))];
|
||||
const polls = ordered
|
||||
.map((row) => pollOf(row, byViewer(card.viewerId), 'none'))
|
||||
.filter((poll): poll is CollabPollCard => poll !== null);
|
||||
return fitted(polls.slice(0, SHOWN), (shown) => pollsOf(shown, polls.length - shown.length), 'last');
|
||||
}
|
||||
|
||||
/** The trip's polls, as a held vote, close or delete reads them. */
|
||||
export function loadPolls(_args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return read('list_collab_polls');
|
||||
}
|
||||
|
||||
function heldPoll(loaded: unknown, args: ToolArgs): Row | null {
|
||||
return rowById(listed(loaded, 'polls'), argId(args, 'pollId'));
|
||||
}
|
||||
|
||||
/** `create_collab_poll`: the new poll, by the user, with its options as the model wrote them and no votes. */
|
||||
export function createPollChange(ctx: PreviewContext, args: ToolArgs): CollabPollsCard | null {
|
||||
const author = viewerOf(ctx.card);
|
||||
const question = blockText(args.question, 300, { mask: false });
|
||||
const texts = Array.isArray(args.options) ? args.options : [];
|
||||
// A blank option keeps its place as "#n", as a listed poll shows it: the server stores
|
||||
// every option and votes go by index, so dropping one would shift the rest.
|
||||
const options = texts.map((text, index) => lineOf(text, 160) ?? `#${index + 1}`);
|
||||
if (!author || question === null || options.length === 0) return null;
|
||||
return pollsOf([
|
||||
{
|
||||
pollId: null,
|
||||
question,
|
||||
options: options.slice(0, OPTIONS).map((text) => ({ text, votes: 0, mine: false, voters: [] })),
|
||||
moreOptions: Math.min(Math.max(options.length - OPTIONS, 0), 500),
|
||||
multiple: args.multiple === true,
|
||||
closed: false,
|
||||
deadline: deadlineOf(args.deadline),
|
||||
author,
|
||||
change: 'create',
|
||||
votedIndex: null,
|
||||
votedAdds: false,
|
||||
warning: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The options of a poll after the viewer's vote on `index`, as the server
|
||||
* casts it: a vote already there is taken back; otherwise a single choice
|
||||
* poll first takes the viewer off every option.
|
||||
*/
|
||||
function voted(options: readonly Row[], index: number, viewer: Row, multiple: boolean) {
|
||||
const own = (voter: Row) => voter.user_id === viewer.user_id;
|
||||
const adds = !rowsOf(options[index]?.voters).some(own);
|
||||
const after = options.map((option, at) => {
|
||||
const voters = rowsOf(option.voters);
|
||||
if (at === index) return { ...option, voters: adds ? [...voters, viewer] : voters.filter((v) => !own(v)) };
|
||||
return adds && !multiple ? { ...option, voters: voters.filter((v) => !own(v)) } : option;
|
||||
});
|
||||
return { after, adds };
|
||||
}
|
||||
|
||||
/**
|
||||
* `vote_collab_poll`: the poll after the vote, the toggled option marked. A
|
||||
* vote the server refuses (a closed poll, an option it does not have) shows
|
||||
* the poll as it is, with the reason.
|
||||
*/
|
||||
export function voteChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabPollsCard | null {
|
||||
const row = heldPoll(loaded, args);
|
||||
const viewer = viewerOf(ctx.card);
|
||||
if (!row || !viewer) return null;
|
||||
const options = rowsOf(row.options);
|
||||
const index = typeof args.optionIndex === 'number' && Number.isInteger(args.optionIndex) ? args.optionIndex : -1;
|
||||
const mine = byViewer(viewer.id);
|
||||
const known = index >= 0 && index < options.length;
|
||||
if (!known || isClosed(row)) {
|
||||
const poll = pollOf(row, mine, 'vote');
|
||||
const marked = known && index < OPTIONS ? index : null;
|
||||
return poll && pollsOf([{ ...poll, votedIndex: marked, warning: known ? 'closed' : 'unknownOption' }]);
|
||||
}
|
||||
const { after, adds } = voted(options, index, asRow(viewer), isMultiple(row));
|
||||
const poll = pollOf({ ...row, options: after }, mine, 'vote');
|
||||
return poll && pollsOf([{ ...poll, votedIndex: index < OPTIONS ? index : null, votedAdds: adds }]);
|
||||
}
|
||||
|
||||
/** `close_collab_poll`: the poll, closed; the client marks its leader. */
|
||||
export function closeChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabPollsCard | null {
|
||||
const row = heldPoll(loaded, args);
|
||||
const poll = row && pollOf({ ...row, is_closed: true }, byViewer(ctx.card.viewerId), 'close');
|
||||
return poll ? pollsOf([poll]) : null;
|
||||
}
|
||||
|
||||
/** `delete_collab_poll`: the poll as it is, struck. */
|
||||
export function removePollChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabPollsCard | null {
|
||||
const row = heldPoll(loaded, args);
|
||||
const poll = row && pollOf(row, byViewer(ctx.card.viewerId), 'remove');
|
||||
return poll ? pollsOf([poll]) : null;
|
||||
}
|
||||
|
||||
/** The poll a stored preview drew. */
|
||||
function previewPoll(preview: AssistantChangePreview | null): CollabPollCard | null {
|
||||
const card = preview?.card;
|
||||
return card?.kind === 'collabPolls' ? (card.polls[0] ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A poll write once applied: the poll as the server answered it, with its id
|
||||
* and the votes everyone cast by then. The applied card has no trip card, so
|
||||
* which options are the user's comes from the preview's after-state.
|
||||
*/
|
||||
export function pollApplied(
|
||||
fallback: Change,
|
||||
input: { result: unknown; preview: AssistantChangePreview | null },
|
||||
): CollabPollsCard | null {
|
||||
const shown = previewPoll(input.preview);
|
||||
const mine: MineOf = (_voters, index) => shown?.options[index]?.mine ?? false;
|
||||
const poll = pollOf(recordOf(recordOf(input.result).poll), mine, shown?.change ?? fallback);
|
||||
if (!poll) return null;
|
||||
return pollsOf([{ ...poll, votedIndex: shown?.votedIndex ?? null, votedAdds: shown?.votedAdds ?? false }]);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ToolCards, UndoSnapshot } from '../card-types';
|
||||
import { idIn, readKey, recordOf, type Row } from '../card-values';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { listed, rowById } from './collab-values';
|
||||
|
||||
/**
|
||||
* The way back from a note or a poll the assistant added: deleting it, as
|
||||
* long as nobody built on it since. A note must still read as it was created
|
||||
* (title, text, last change) and hold no file, since a new note starts with
|
||||
* none and any file is a teammate's; a poll must have no votes and still be
|
||||
* open. A sent message has no way back: deleting it leaves "deleted a
|
||||
* message" in everyone's chat.
|
||||
*/
|
||||
|
||||
type Undo = NonNullable<ToolCards['undo']>;
|
||||
|
||||
const NOTES_READ = { tool: 'list_collab_notes', args: {} };
|
||||
const POLLS_READ = { tool: 'list_collab_polls', args: {} };
|
||||
|
||||
/** The row a list read of the undo holds under `key` with the created row's id. */
|
||||
function current(snapshot: UndoSnapshot, read: { tool: string; args: Row }, key: string, created: Row): Row | null {
|
||||
const id = typeof created.id === 'number' ? created.id : null;
|
||||
return rowById(listed(snapshot.reads.get(readKey(read.tool, read.args)), key), id);
|
||||
}
|
||||
|
||||
/** A created note, still as it was created and without a file someone added. */
|
||||
function noteUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
|
||||
const created = recordOf(recordOf(result).note);
|
||||
const now = current(snapshot, NOTES_READ, 'notes', created);
|
||||
return (
|
||||
now !== null &&
|
||||
now.title === created.title &&
|
||||
(now.content ?? null) === (created.content ?? null) &&
|
||||
now.updated_at === created.updated_at &&
|
||||
rowsOf(now.attachments).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
/** A created poll nobody voted in and nobody closed. */
|
||||
function pollUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
|
||||
const created = recordOf(recordOf(result).poll);
|
||||
const now = current(snapshot, POLLS_READ, 'polls', created);
|
||||
if (now === null || now.question !== created.question || now.is_closed === true) return false;
|
||||
return rowsOf(now.options).every((option) => rowsOf(option.voters).length === 0);
|
||||
}
|
||||
|
||||
export const DELETE_CREATED_NOTE: Undo = {
|
||||
tool: 'delete_collab_note',
|
||||
args: (_args, result) => {
|
||||
const noteId = idIn(result, 'note');
|
||||
return noteId ? { noteId } : null;
|
||||
},
|
||||
reads: () => [NOTES_READ],
|
||||
unchanged: noteUnchanged,
|
||||
};
|
||||
|
||||
export const DELETE_CREATED_POLL: Undo = {
|
||||
tool: 'delete_collab_poll',
|
||||
args: (_args, result) => {
|
||||
const pollId = idIn(result, 'poll');
|
||||
return pollId ? { pollId } : null;
|
||||
},
|
||||
reads: () => [POLLS_READ],
|
||||
unchanged: pollUnchanged,
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { TripCard } from '../../assistant-context';
|
||||
import type { ToolArgs } from '../card-types';
|
||||
import { argId, blockText, clipped, fileKeyOf, idIn, instantOf, personOf, recordOf, type Row } from '../card-values';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { MAX_CARD_CHARS, type CardPerson } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The readers the trip chat's cards share: people as a collab row names them
|
||||
* (its author, a voter), the moments the chat stores, a text with its line
|
||||
* breaks and whether it was cut, and a link read as its host. Text from a
|
||||
* tool's answer is masked, text from a held call's arguments only cut: the
|
||||
* card shows exactly what will be posted.
|
||||
*/
|
||||
|
||||
/** Upload paths of TREK's own avatars; a picture on another host has no key. */
|
||||
const AVATAR_PATH = '/uploads/avatars/';
|
||||
|
||||
/** The person a collab row names: `idKey` and `nameKey` of the row, its `avatar_url` as a stored file name. */
|
||||
export function personFrom(row: Row, idKey = 'user_id', nameKey = 'username'): CardPerson | null {
|
||||
const id = argId(row, idKey);
|
||||
if (id === null) return null;
|
||||
return { id, name: clipped(row[nameKey], 80) ?? `#${id}`, avatarKey: fileKeyOf(row.avatar_url) };
|
||||
}
|
||||
|
||||
/** The signed-in user as the trip card names them; null when the card does not list them. */
|
||||
export function viewerOf(card: TripCard): CardPerson | null {
|
||||
return personOf(card, card.viewerId);
|
||||
}
|
||||
|
||||
/** A person as a collab row would name them, so a vote or a reaction the card adds reads like the others. */
|
||||
export function asRow(person: CardPerson): Row {
|
||||
return {
|
||||
user_id: person.id,
|
||||
username: person.name,
|
||||
avatar_url: person.avatarKey ? `${AVATAR_PATH}${person.avatarKey}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
const SQLITE_UTC = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::(\d{2}))?(?:\.\d+)?$/;
|
||||
|
||||
/**
|
||||
* A moment as the cards carry it (`YYYY-MM-DDTHH:MM:SSZ`): one that names its
|
||||
* zone as it is, and one without, as SQLite stores its stamps (a message's
|
||||
* `created_at`), read as UTC the way the chat reads those. A date alone is
|
||||
* its midnight in UTC, as a browser parses it.
|
||||
*/
|
||||
export function stampOf(value: unknown): string | null {
|
||||
const zoned = instantOf(value);
|
||||
if (zoned || typeof value !== 'string') return zoned;
|
||||
const text = value.trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return `${text}T00:00:00Z`;
|
||||
const match = SQLITE_UTC.exec(text);
|
||||
return match ? instantOf(`${match[1]}T${match[2]}:${match[3] ?? '00'}Z`) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A poll's deadline as the cards carry it. The deadline is stored as it was
|
||||
* sent and the Collab tab reads it with `new Date()`, so a date and time
|
||||
* without a zone is the viewer's own local time there: a different moment for
|
||||
* each viewer, which no instant on a card can match. Such a deadline gives
|
||||
* null (the card shows none rather than a countdown off by the viewer's
|
||||
* offset); one with a zone, and a date alone (midnight UTC in a browser too),
|
||||
* read as `stampOf` reads them. The app writes `toISOString()`, so only a
|
||||
* hand-made deadline lacks a zone.
|
||||
*/
|
||||
export function deadlineOf(value: unknown): string | null {
|
||||
if (typeof value === 'string' && SQLITE_UTC.test(value.trim())) return null;
|
||||
return stampOf(value);
|
||||
}
|
||||
|
||||
/** Whether a deadline lies behind `now`; a poll without one never ends by itself. */
|
||||
export function hasPassed(deadline: string | null, now: number): boolean {
|
||||
return deadline !== null && Date.parse(deadline) <= now;
|
||||
}
|
||||
|
||||
/** A text with its line breaks kept, cut to `max`, and whether anything was cut away. */
|
||||
export function excerptOf(value: unknown, max: number, mask: boolean): { text: string; cut: boolean } | null {
|
||||
const whole = blockText(value, Number.MAX_SAFE_INTEGER, { mask });
|
||||
if (whole === null) return null;
|
||||
return { text: blockText(whole, max, { mask: false }) ?? '', cut: whole.length > max };
|
||||
}
|
||||
|
||||
/** One line of a held call's arguments, cut but not masked. */
|
||||
export function lineOf(value: unknown, max: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const line = value.replace(/\s+/g, ' ').trim();
|
||||
if (line === '') return null;
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host of a note's link, without "www.", for a web address only; any
|
||||
* other scheme, a bare word or a broken address gives null.
|
||||
*/
|
||||
export function hostOf(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.trim() === '') return null;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
const host = url.hostname.replace(/^www\./, '');
|
||||
return host !== '' && host.length <= 253 ? host : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The rows a list read answered under `key` (`{ messages: [...] }`). */
|
||||
export function listed(value: unknown, key: string): Row[] {
|
||||
return rowsOf(recordOf(value)[key]);
|
||||
}
|
||||
|
||||
/** The row of `rows` with the id an argument names. */
|
||||
export function rowById(rows: readonly Row[], id: number | null): Row | null {
|
||||
return id === null ? null : (rows.find((row) => row.id === id) ?? null);
|
||||
}
|
||||
|
||||
/** The receipt's ids from the row an answer names under `key` (`{ poll: { id } }`). */
|
||||
export function answeredId(key: string): (args: ToolArgs, result: unknown) => number[] {
|
||||
return (_args, result) => {
|
||||
const id = idIn(result, key);
|
||||
return id === null ? [] : [id];
|
||||
};
|
||||
}
|
||||
|
||||
/** The receipt's ids from the argument `key`, for a write whose answer names nothing. */
|
||||
export function namedId(key: string): (args: ToolArgs) => number[] {
|
||||
return (args) => {
|
||||
const id = argId(args, key);
|
||||
return id === null ? [] : [id];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The most of `items` a card holds within MAX_CARD_CHARS: one item less at a
|
||||
* time, from the end `drop` names, while the card is too heavy and more than
|
||||
* one is left. A read of long messages with many reactions shows fewer of
|
||||
* them rather than no card.
|
||||
*/
|
||||
export function fitted<T, C>(items: readonly T[], build: (shown: T[]) => C, drop: 'first' | 'last'): C {
|
||||
let shown = [...items];
|
||||
let card = build(shown);
|
||||
while (shown.length > 1 && JSON.stringify(card).length > MAX_CARD_CHARS) {
|
||||
shown = drop === 'first' ? shown.slice(1) : shown.slice(0, -1);
|
||||
card = build(shown);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
@@ -1,4 +1,123 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
import {
|
||||
chatAnswer,
|
||||
loadForReply,
|
||||
loadMessages,
|
||||
reactChange,
|
||||
removeChange,
|
||||
sendApplied,
|
||||
sendChange,
|
||||
} from './collab-chat';
|
||||
import { COLLAB_DESCRIBERS } from './collab-describe';
|
||||
import {
|
||||
createNoteChange,
|
||||
loadNotes,
|
||||
noteApplied,
|
||||
notesAnswer,
|
||||
removeNoteChange,
|
||||
updateNoteChange,
|
||||
} from './collab-notes';
|
||||
import {
|
||||
closeChange,
|
||||
createPollChange,
|
||||
loadPolls,
|
||||
pollApplied,
|
||||
pollsAnswer,
|
||||
removePollChange,
|
||||
voteChange,
|
||||
} from './collab-polls';
|
||||
import { DELETE_CREATED_NOTE, DELETE_CREATED_POLL } from './collab-undo';
|
||||
import { answeredId, namedId } from './collab-values';
|
||||
|
||||
/** The cards of the trip chat: messages, polls and shared notes. None yet: this package fills them in. */
|
||||
export const COLLAB_CARDS: DomainCards = { id: 'collab', tools: {} };
|
||||
/**
|
||||
* The cards of the trip chat: messages, polls and shared notes, drawn as the
|
||||
* Collab tab draws them. The three reads show what they found; every write
|
||||
* shows what it will post, change or remove, and the ones that create keep
|
||||
* the answer's id once applied. A new note or poll can be taken back while
|
||||
* nobody built on it; a sent message cannot.
|
||||
*/
|
||||
export const COLLAB_CARDS: DomainCards = {
|
||||
id: 'collab',
|
||||
tools: {
|
||||
list_collab_messages: { answer: chatAnswer },
|
||||
list_collab_polls: { answer: pollsAnswer },
|
||||
list_collab_notes: { answer: notesAnswer },
|
||||
send_collab_message: {
|
||||
write: { verbKey: 'genericCreate', entity: 'collab_message' },
|
||||
describe: COLLAB_DESCRIBERS.send_collab_message,
|
||||
load: loadForReply,
|
||||
change: sendChange,
|
||||
applied: sendApplied,
|
||||
appliedIds: answeredId('message'),
|
||||
},
|
||||
delete_collab_message: {
|
||||
write: { verbKey: 'genericRemove', entity: 'collab_message' },
|
||||
describe: COLLAB_DESCRIBERS.delete_collab_message,
|
||||
load: loadMessages,
|
||||
change: removeChange,
|
||||
appliedIds: namedId('messageId'),
|
||||
},
|
||||
react_collab_message: {
|
||||
write: { verbKey: 'genericUpdate', entity: 'collab_message' },
|
||||
describe: COLLAB_DESCRIBERS.react_collab_message,
|
||||
load: loadMessages,
|
||||
change: reactChange,
|
||||
appliedIds: namedId('messageId'),
|
||||
},
|
||||
create_collab_poll: {
|
||||
write: { verbKey: 'genericCreate', entity: 'collab_poll' },
|
||||
describe: COLLAB_DESCRIBERS.create_collab_poll,
|
||||
change: createPollChange,
|
||||
applied: (input) => pollApplied('create', input),
|
||||
undo: DELETE_CREATED_POLL,
|
||||
appliedIds: answeredId('poll'),
|
||||
},
|
||||
vote_collab_poll: {
|
||||
write: { verbKey: 'genericUpdate', entity: 'collab_poll' },
|
||||
describe: COLLAB_DESCRIBERS.vote_collab_poll,
|
||||
load: loadPolls,
|
||||
change: voteChange,
|
||||
applied: (input) => pollApplied('vote', input),
|
||||
appliedIds: answeredId('poll'),
|
||||
},
|
||||
close_collab_poll: {
|
||||
write: { verbKey: 'genericUpdate', entity: 'collab_poll' },
|
||||
describe: COLLAB_DESCRIBERS.close_collab_poll,
|
||||
load: loadPolls,
|
||||
change: closeChange,
|
||||
applied: (input) => pollApplied('close', input),
|
||||
appliedIds: answeredId('poll'),
|
||||
},
|
||||
delete_collab_poll: {
|
||||
write: { verbKey: 'genericRemove', entity: 'collab_poll' },
|
||||
describe: COLLAB_DESCRIBERS.delete_collab_poll,
|
||||
load: loadPolls,
|
||||
change: removePollChange,
|
||||
appliedIds: namedId('pollId'),
|
||||
},
|
||||
create_collab_note: {
|
||||
write: { verbKey: 'genericCreate', entity: 'collab_note' },
|
||||
describe: COLLAB_DESCRIBERS.create_collab_note,
|
||||
load: loadNotes,
|
||||
change: createNoteChange,
|
||||
applied: (input) => noteApplied('create', input),
|
||||
undo: DELETE_CREATED_NOTE,
|
||||
appliedIds: answeredId('note'),
|
||||
},
|
||||
update_collab_note: {
|
||||
write: { verbKey: 'genericUpdate', entity: 'collab_note' },
|
||||
describe: COLLAB_DESCRIBERS.update_collab_note,
|
||||
load: loadNotes,
|
||||
change: updateNoteChange,
|
||||
applied: (input) => noteApplied('update', input),
|
||||
appliedIds: answeredId('note'),
|
||||
},
|
||||
delete_collab_note: {
|
||||
write: { verbKey: 'genericRemove', entity: 'collab_note' },
|
||||
describe: COLLAB_DESCRIBERS.delete_collab_note,
|
||||
load: loadNotes,
|
||||
change: removeNoteChange,
|
||||
appliedIds: namedId('noteId'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -70,7 +70,8 @@ function jsonContent(uri: string, data: unknown) {
|
||||
* vote_collab_poll gained the demo-user gate the legacy registrar was missing,
|
||||
* matching every other collab write tool. The two note write tools have since
|
||||
* gained `website`, which the REST route has always accepted and the note card
|
||||
* renders as a link preview.
|
||||
* renders as a link preview. list_collab_notes came later, for the assistant's
|
||||
* note cards: the notes resource as a tool, under the same gates.
|
||||
*/
|
||||
@McpController()
|
||||
export class CollabMcp {
|
||||
@@ -214,6 +215,22 @@ export class CollabMcp {
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_collab_notes',
|
||||
description: 'List the shared notes of a trip, pinned first, with their category, colour, link and files.',
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: collabNotesOn,
|
||||
access: { group: 'collab', mode: 'read' },
|
||||
})
|
||||
async listCollabNotes({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
const notes = await this.collab.listNotes(tripId);
|
||||
return ok({ notes });
|
||||
}
|
||||
|
||||
// --- COLLAB POLLS & CHAT ---
|
||||
|
||||
@Tool({
|
||||
@@ -240,7 +257,10 @@ export class CollabMcp {
|
||||
question: z.string().min(1),
|
||||
options: z.array(z.string()).min(2).describe('Poll answer options (at least 2)'),
|
||||
multiple: z.boolean().optional().describe('Allow multiple choice'),
|
||||
deadline: z.string().optional().describe('ISO date string for poll deadline'),
|
||||
deadline: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Poll deadline as an ISO date-time with its zone, e.g. 2026-10-24T18:00:00+02:00 or ...Z'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: collabPollsOn,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Collab e2e for the notes as an MCP client reads them: list_collab_notes
|
||||
* called through a real MCP session on the whole app (buildApp, the real
|
||||
* registry with its addon and sub-feature gates, scopes and error mapper,
|
||||
* the real CollabService and a migrated temp SQLite), the way the
|
||||
* assistant's note cards read them.
|
||||
*/
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { makeCollabNote } from '../helpers/factories/collab';
|
||||
import { setAddonEnabled, setCollabFeature } from '../helpers/factories/settings';
|
||||
import { makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
let app: INestApplication;
|
||||
let orm: MikroORM;
|
||||
|
||||
/** The tools `userId` is offered, and one call of `name` as that user with every scope: its JSON, or its error. */
|
||||
async function session(userId: number, name: string, args: Record<string, unknown>) {
|
||||
const server = createLocalMcpServer('TREK collab e2e');
|
||||
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
|
||||
await withRequestContext(orm, () =>
|
||||
app
|
||||
.get(McpRegistryService)
|
||||
.attach(server, { userId, scopes: null, isStaticToken: false }, { include: (kind) => kind === 'tool', around }),
|
||||
);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: 'collab-e2e', version: '1.0.0' });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
const offered = (await client.listTools()).tools.map((tool) => tool.name);
|
||||
if (!offered.includes(name)) return { offered, answer: null };
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
|
||||
const text = content[0]?.text ?? '';
|
||||
return { offered, answer: result.isError === true ? { error: text } : (JSON.parse(text) as unknown) };
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
orm = app.get(MikroORM);
|
||||
await setAddonEnabled(orm, 'collab', true);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
describe('the shared notes over MCP', () => {
|
||||
it('COLLAB-MCP-E2E-001: lists the notes of the trip, pinned first, with their category, colour and link', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { title: 'Rome' });
|
||||
await makeCollabNote(orm, trip.id, user.id, { title: 'Packing ideas', category: 'Ideas' });
|
||||
const pinned = await makeCollabNote(orm, trip.id, user.id, {
|
||||
title: 'Restaurants',
|
||||
category: 'Food',
|
||||
color: '#10b981',
|
||||
website: 'https://www.luigis.it/menu',
|
||||
pinned: 1,
|
||||
});
|
||||
const { answer } = await session(user.id, 'list_collab_notes', { tripId: trip.id });
|
||||
const notes = (answer as { notes: Array<Record<string, unknown>> }).notes;
|
||||
expect(notes.map((note) => note.title)).toEqual(['Restaurants', 'Packing ideas']);
|
||||
expect(notes[0]).toMatchObject({
|
||||
id: pinned.id,
|
||||
category: 'Food',
|
||||
color: '#10b981',
|
||||
website: 'https://www.luigis.it/menu',
|
||||
pinned: 1,
|
||||
username: user.username,
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('COLLAB-MCP-E2E-002: refuses a trip the user is not part of', async () => {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const { user: stranger } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, owner.id);
|
||||
await makeCollabNote(orm, trip.id, owner.id, { title: 'Private' });
|
||||
const { answer } = await session(stranger.id, 'list_collab_notes', { tripId: trip.id });
|
||||
expect(answer).toEqual({ error: expect.any(String) });
|
||||
});
|
||||
|
||||
it('COLLAB-MCP-E2E-003: is gone while the notes are switched off, and back with them', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id);
|
||||
await setCollabFeature(orm, 'notes', false);
|
||||
try {
|
||||
const { offered } = await session(user.id, 'list_collab_notes', { tripId: trip.id });
|
||||
expect(offered).not.toContain('list_collab_notes');
|
||||
expect(offered).toContain('list_collab_polls');
|
||||
} finally {
|
||||
await setCollabFeature(orm, 'notes', true);
|
||||
}
|
||||
expect((await session(user.id, 'list_collab_notes', { tripId: trip.id })).answer).toEqual({ notes: [] });
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import { countRows, findRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { FakeRealtimeService } from '../../helpers/fake-realtime';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { bootTestApp } from '../../helpers/test-app';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { resetTestDb, setCollabFeature } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
@@ -825,3 +825,64 @@ describe('create_collab_note, as the Collab tab posts it', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: list_collab_notes', () => {
|
||||
interface ListedNote {
|
||||
id: number;
|
||||
title: string;
|
||||
username: string;
|
||||
pinned: number | null;
|
||||
attachments: unknown[];
|
||||
}
|
||||
|
||||
async function listNotes(userId: number, tripId: number) {
|
||||
let listed: { isError?: unknown; data: unknown } = { data: null };
|
||||
await withHarness(userId, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_collab_notes', arguments: { tripId } });
|
||||
listed = { isError: result.isError, data: result.isError ? null : parseToolResult(result) };
|
||||
});
|
||||
return listed;
|
||||
}
|
||||
|
||||
it('MCP-COLLAB-010: lists the notes of the trip, pinned first, with their author and files', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createCollabNote(testDb, trip.id, user.id, { title: 'Ideas' });
|
||||
const pinned = createCollabNote(testDb, trip.id, user.id, { title: 'Restaurants' });
|
||||
await updateRows(orm, CollabNotes, { id: pinned.id }, { pinned: 1 });
|
||||
const { isError, data } = await listNotes(user.id, trip.id);
|
||||
expect(isError).not.toBe(true);
|
||||
const notes = (data as { notes: ListedNote[] }).notes;
|
||||
expect(notes.map((note) => note.title)).toEqual(['Restaurants', 'Ideas']);
|
||||
expect(notes[0]).toMatchObject({ id: pinned.id, pinned: 1, username: user.username, attachments: [] });
|
||||
});
|
||||
|
||||
it('MCP-COLLAB-011: refuses a trip the user is not part of, and reads nothing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
createCollabNote(testDb, trip.id, owner.id, { title: 'Private' });
|
||||
const { isError, data } = await listNotes(user.id, trip.id);
|
||||
expect(isError).toBe(true);
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it('MCP-COLLAB-012: is a read, offered only while the notes are switched on', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = async () => {
|
||||
const h = await createMcpHarness({ realtime, userId: user.id, withResources: false });
|
||||
try {
|
||||
return (await h.client.listTools()).tools.map((tool) => [tool.name, tool.annotations?.readOnlyHint]);
|
||||
} finally {
|
||||
await h.cleanup();
|
||||
}
|
||||
};
|
||||
expect(await names()).toContainEqual(['list_collab_notes', true]);
|
||||
setCollabFeature(testDb, 'notes', false);
|
||||
try {
|
||||
expect((await names()).map(([name]) => name)).not.toContain('list_collab_notes');
|
||||
} finally {
|
||||
setCollabFeature(testDb, 'notes', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* The trip chat's cards in the assistant chat: messages, polls and shared
|
||||
* notes as a read answered them and as a held write will leave them, built
|
||||
* from the tools' raw answers and checked against the card union through the
|
||||
* registry's guard (a card that does not fit its kind comes back as null).
|
||||
* Text from an answer is masked, text the model wrote is not; no address of
|
||||
* a file or a picture travels; a vote is worked out as the server casts it.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
|
||||
import { loadFor } from '../../../../../../src/nest/assistant/cards/card-context';
|
||||
import type { AnswerInput, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
|
||||
import { deadlineOf, stampOf } from '../../../../../../src/nest/assistant/cards/collab/collab-values';
|
||||
import {
|
||||
answerCardOf,
|
||||
appliedCardOf,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
changeCardOf,
|
||||
undoReadsOf,
|
||||
} from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { appliedChange, describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import {
|
||||
assistantChangePreviewSchema,
|
||||
MAX_CARD_CHARS,
|
||||
type CollabChatCard,
|
||||
type CollabNotesCard,
|
||||
type CollabPollsCard,
|
||||
type TrekCard,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** Anna (7) asks; Jon (8) signed in with a provider's picture. */
|
||||
const summary = {
|
||||
trip: { title: 'Rome' },
|
||||
members: {
|
||||
owner: { id: 7, username: 'anna', avatar_url: '/uploads/avatars/anna.png' },
|
||||
collaborators: [{ id: 8, username: 'jon', avatar_url: 'https://idp.test/jon.png' }],
|
||||
},
|
||||
};
|
||||
const card = buildTripCard(1, 7, summary, null, null);
|
||||
const ctx: PreviewContext = { card, categories: new Map(), locale: 'en', today: '2026-10-21', airport: () => null };
|
||||
const ANNA = { id: 7, name: 'anna', avatarKey: 'anna.png' };
|
||||
const JON = { id: 8, name: 'jon', avatarKey: null };
|
||||
/** Anna as a voter row names her: without a picture. */
|
||||
const ANNA_VOTER = { ...ANNA, avatarKey: null };
|
||||
const ANNA_ROW = { user_id: 7, username: 'anna', avatar_url: '/uploads/avatars/anna.png' };
|
||||
|
||||
const answer = (tool: string, value: unknown): TrekCard | null =>
|
||||
answerCardOf(tool, { toolCallId: 'call_1', args: {}, value, card, today: ctx.today } satisfies AnswerInput);
|
||||
|
||||
const message = (id: number, over: Row = {}): Row => ({
|
||||
id,
|
||||
trip_id: 1,
|
||||
user_id: 8,
|
||||
username: 'jon',
|
||||
avatar_url: 'https://idp.test/jon.png',
|
||||
text: `Message ${id}`,
|
||||
created_at: '2026-10-21 09:12:00',
|
||||
deleted: 0,
|
||||
origin: null,
|
||||
reply_to: null,
|
||||
reply_text: null,
|
||||
reply_username: null,
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const voter = (id: number, name: string) => ({ id, user_id: id, username: name, avatar: null, avatar_url: null });
|
||||
|
||||
const poll = (id: number, over: Row = {}): Row => ({
|
||||
id,
|
||||
trip_id: 1,
|
||||
user_id: 8,
|
||||
username: 'jon',
|
||||
avatar_url: null,
|
||||
question: `Question ${id}?`,
|
||||
options: [
|
||||
{ text: 'Trattoria', label: 'Trattoria', voters: [voter(8, 'jon')] },
|
||||
{ text: 'Market', label: 'Market', voters: [voter(7, 'anna')] },
|
||||
{ text: 'Noodles', label: 'Noodles', voters: [] },
|
||||
],
|
||||
is_closed: false,
|
||||
multiple_choice: false,
|
||||
deadline: null,
|
||||
created_at: '2026-10-20 10:00:00',
|
||||
...over,
|
||||
});
|
||||
|
||||
const note = (id: number, over: Row = {}): Row => ({
|
||||
id,
|
||||
trip_id: 1,
|
||||
user_id: 7,
|
||||
username: 'anna',
|
||||
avatar_url: '/uploads/avatars/anna.png',
|
||||
title: `Note ${id}`,
|
||||
content: null,
|
||||
category: 'General',
|
||||
color: '#6366f1',
|
||||
pinned: 0,
|
||||
website: null,
|
||||
created_at: '2026-10-20 10:00:00',
|
||||
updated_at: '2026-10-20 10:00:00',
|
||||
attachments: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const chatOf = (built: TrekCard | null): CollabChatCard => {
|
||||
if (built?.kind !== 'collabChat') throw new Error(`no chat card: ${JSON.stringify(built)}`);
|
||||
return built;
|
||||
};
|
||||
const pollsOf = (built: TrekCard | null): CollabPollsCard => {
|
||||
if (built?.kind !== 'collabPolls') throw new Error(`no poll card: ${JSON.stringify(built)}`);
|
||||
return built;
|
||||
};
|
||||
const notesOf = (built: TrekCard | null): CollabNotesCard => {
|
||||
if (built?.kind !== 'collabNotes') throw new Error(`no note card: ${JSON.stringify(built)}`);
|
||||
return built;
|
||||
};
|
||||
|
||||
describe('the chat card of a read', () => {
|
||||
it('ASTCOLLAB-001: shows the last five messages still there, oldest first, and counts the older ones', () => {
|
||||
const rows = [1, 2, 3, 4, 5, 6, 7, 8].map((id) => message(id, id === 7 ? { deleted: 1, text: '' } : {}));
|
||||
const chat = chatOf(answer('list_collab_messages', { messages: rows }));
|
||||
expect(chat.messages.map((bubble) => bubble.id)).toEqual([3, 4, 5, 6, 8]);
|
||||
expect(chat.earlier).toBe(2);
|
||||
expect(chat.messages[0]).toMatchObject({ author: JON, at: '2026-10-21T09:12:00Z', change: 'none', cut: false });
|
||||
expect(answer('list_collab_messages', { messages: [message(1, { deleted: 1 })] })).toBeNull();
|
||||
expect(answer('list_collab_messages', { messages: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-002: reads reactions for the user, files by id, the assistant mark, and never an address', () => {
|
||||
const row = message(4, {
|
||||
origin: 'assistant',
|
||||
reactions: [
|
||||
{ emoji: '❤️', count: 2, users: [{ user_id: 7, username: 'anna' }, { user_id: 8, username: 'jon' }] },
|
||||
{ emoji: '👍', count: 1, users: [{ user_id: 8, username: 'jon' }] },
|
||||
],
|
||||
attachments: [
|
||||
{ id: 31, original_name: 'ticket.jpg', mime_type: 'image/jpeg', url: '/api/trips/1/files/31/download' },
|
||||
{ id: 32, original_name: 'plan.pdf', mime_type: 'application/pdf', url: '/api/trips/1/files/32/download' },
|
||||
],
|
||||
});
|
||||
const [bubble] = chatOf(answer('list_collab_messages', { messages: [row] })).messages;
|
||||
expect(bubble?.reactions).toEqual([
|
||||
{ emoji: '❤️', count: 2, mine: true, names: ['anna', 'jon'] },
|
||||
{ emoji: '👍', count: 1, mine: false, names: ['jon'] },
|
||||
]);
|
||||
expect(bubble?.files).toEqual([
|
||||
{ fileId: 31, image: true, name: 'ticket.jpg' },
|
||||
{ fileId: 32, image: false, name: 'plan.pdf' },
|
||||
]);
|
||||
expect(bubble?.fromAssistant).toBe(true);
|
||||
const json = JSON.stringify(bubble);
|
||||
expect(json).not.toContain('/api/');
|
||||
expect(json).not.toContain('http');
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-003: keeps line breaks, folds blank runs, masks an address and cuts a long message', () => {
|
||||
const text = 'Train at 7:40\n\n\n\nwrite to jon@example.test\nok';
|
||||
const [bubble] = chatOf(answer('list_collab_messages', { messages: [message(1, { text })] })).messages;
|
||||
expect(bubble?.text).toBe('Train at 7:40\n\nwrite to [hidden]\nok');
|
||||
const longText = { messages: [message(2, { text: 'x'.repeat(900) })] };
|
||||
const [long] = chatOf(answer('list_collab_messages', longText)).messages;
|
||||
expect(long?.cut).toBe(true);
|
||||
expect(long?.text).toHaveLength(500);
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-004: quotes the message a bubble answers, and nothing for a deleted one', () => {
|
||||
const reply = message(3, { reply_to: 2, reply_username: 'anna', reply_text: 'Shall we?' });
|
||||
const gone = message(4, { reply_to: 1, reply_username: 'anna', reply_text: '' });
|
||||
const chat = chatOf(answer('list_collab_messages', { messages: [reply, gone] }));
|
||||
expect(chat.messages.map((bubble) => bubble.reply)).toEqual([{ name: 'anna', text: 'Shall we?' }, null]);
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-005: a face is a stored file name: a provider picture or a way up gives none', () => {
|
||||
const rows = [
|
||||
message(1, { user_id: 7, username: 'anna', avatar_url: '/uploads/avatars/anna.png' }),
|
||||
message(2, { avatar_url: 'https://idp.test/jon.png' }),
|
||||
message(3, { avatar_url: '/uploads/avatars/../secret.png' }),
|
||||
];
|
||||
const chat = chatOf(answer('list_collab_messages', { messages: rows }));
|
||||
expect(chat.messages.map((bubble) => bubble.author.avatarKey)).toEqual(['anna.png', null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the chat card of a held write', () => {
|
||||
const listing = { messages: [message(1), message(2, ANNA_ROW), message(3, { deleted: 1 })] };
|
||||
|
||||
it('ASTCOLLAB-010: a send shows the bubble as it will be posted, unmasked, by the user', () => {
|
||||
const text = 'Mail me at anna@example.test\nthanks';
|
||||
const chat = chatOf(changeCardOf('send_collab_message', ctx, { text }, null));
|
||||
expect(chat.messages).toEqual([
|
||||
{
|
||||
id: null,
|
||||
author: ANNA,
|
||||
text,
|
||||
cut: false,
|
||||
at: null,
|
||||
reply: null,
|
||||
reactions: [],
|
||||
files: [],
|
||||
fromAssistant: false,
|
||||
change: 'send',
|
||||
reactEmoji: null,
|
||||
},
|
||||
]);
|
||||
const long = chatOf(changeCardOf('send_collab_message', ctx, { text: 'y'.repeat(4200) }, null));
|
||||
expect(long.messages[0]).toMatchObject({ cut: true });
|
||||
expect(long.messages[0]?.text).toHaveLength(4000);
|
||||
expect(changeCardOf('send_collab_message', ctx, {}, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-011: a reply quotes the message it answers from the lookup, nothing when it is gone', async () => {
|
||||
const read = vi.fn().mockResolvedValue(listing);
|
||||
expect(await loadFor('send_collab_message', { text: 'Hi' }, read)).toBeNull();
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(await loadFor('send_collab_message', { text: 'Yes', replyTo: 1 }, read)).toBe(listing);
|
||||
expect(read).toHaveBeenCalledWith('list_collab_messages', undefined);
|
||||
const quoted = chatOf(changeCardOf('send_collab_message', ctx, { text: 'Yes', replyTo: 1 }, listing));
|
||||
expect(quoted.messages[0]?.reply).toEqual({ name: 'jon', text: 'Message 1' });
|
||||
for (const replyTo of [3, 99]) {
|
||||
const none = chatOf(changeCardOf('send_collab_message', ctx, { text: 'Yes', replyTo }, listing));
|
||||
expect(none.messages[0]?.reply).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("ASTCOLLAB-012: a delete shows the user's own message struck, and no card for another's or a missing one", () => {
|
||||
const own = chatOf(changeCardOf('delete_collab_message', ctx, { messageId: 2 }, listing));
|
||||
expect(own.messages[0]).toMatchObject({ id: 2, change: 'remove', author: ANNA });
|
||||
for (const messageId of [1, 3, 99]) {
|
||||
expect(changeCardOf('delete_collab_message', ctx, { messageId }, listing)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-013: a reaction shows the message after the toggle, the toggled emoji marked', () => {
|
||||
const reacted = {
|
||||
messages: [
|
||||
message(1, {
|
||||
reactions: [
|
||||
{ emoji: '❤️', count: 1, users: [{ user_id: 7, username: 'anna' }] },
|
||||
{ emoji: '👍', count: 1, users: [{ user_id: 8, username: 'jon' }] },
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
const off = chatOf(changeCardOf('react_collab_message', ctx, { messageId: 1, emoji: '❤️' }, reacted));
|
||||
expect(off.messages[0]?.reactions).toEqual([{ emoji: '👍', count: 1, mine: false, names: ['jon'] }]);
|
||||
expect(off.messages[0]).toMatchObject({ change: 'react', reactEmoji: '❤️' });
|
||||
const more = chatOf(changeCardOf('react_collab_message', ctx, { messageId: 1, emoji: '👍' }, reacted));
|
||||
expect(more.messages[0]?.reactions[1]).toEqual({ emoji: '👍', count: 2, mine: true, names: ['jon', 'anna'] });
|
||||
const fresh = chatOf(changeCardOf('react_collab_message', ctx, { messageId: 1, emoji: '🎉' }, reacted));
|
||||
expect(fresh.messages[0]?.reactions[2]).toEqual({ emoji: '🎉', count: 1, mine: true, names: ['anna'] });
|
||||
expect(changeCardOf('react_collab_message', ctx, { messageId: 99, emoji: '🎉' }, reacted)).toBeNull();
|
||||
// Taken as sent: the server stores " 👍" as it gets it, a reaction of its own beside 👍.
|
||||
const spaced = chatOf(changeCardOf('react_collab_message', ctx, { messageId: 1, emoji: ' 👍' }, reacted));
|
||||
expect(spaced.messages[0]?.reactions.map((reaction) => reaction.emoji)).toEqual(['❤️', '👍', ' 👍']);
|
||||
expect(spaced.messages[0]?.reactEmoji).toBe(' 👍');
|
||||
expect(changeCardOf('react_collab_message', ctx, { messageId: 1, emoji: ' ' }, reacted)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-014: an applied send carries the posted message with its id and time', () => {
|
||||
const preview = describeToolCall('send_collab_message', 'call_1', { text: 'Hi' }, ctx, null);
|
||||
const result = { message: message(40, { ...ANNA_ROW, text: 'Hi', created_at: '2026-10-21 10:00:05' }) };
|
||||
const chat = chatOf(appliedCardOf('send_collab_message', { args: { text: 'Hi' }, result, preview }));
|
||||
expect(chat.messages[0]).toMatchObject({ id: 40, at: '2026-10-21T10:00:05Z', change: 'send', text: 'Hi' });
|
||||
const refused = { args: {}, result: 'Reply target message not found', preview };
|
||||
expect(appliedCardOf('send_collab_message', refused)).toBeNull();
|
||||
// The user's own words stay as the held card showed them, an address included.
|
||||
const text = 'Mail me at anna@example.test';
|
||||
const mailPreview = describeToolCall('send_collab_message', 'call_2', { text }, ctx, null);
|
||||
const mailed = { message: message(41, { ...ANNA_ROW, text }) };
|
||||
const sent = chatOf(appliedCardOf('send_collab_message', { args: { text }, result: mailed, preview: mailPreview }));
|
||||
expect(sent.messages[0]).toMatchObject({ id: 41, text, cut: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the poll cards', () => {
|
||||
const now = Date.parse('2026-10-21T12:00:00Z');
|
||||
|
||||
it('ASTCOLLAB-020: a read shows up to four polls, the open ones first, the votes read for the user', () => {
|
||||
vi.useFakeTimers({ now });
|
||||
try {
|
||||
const rows = [
|
||||
poll(1, { is_closed: true }),
|
||||
poll(2, { deadline: '2026-10-20T00:00:00.000Z' }),
|
||||
poll(3),
|
||||
poll(4),
|
||||
poll(5),
|
||||
poll(6),
|
||||
];
|
||||
const polls = pollsOf(answer('list_collab_polls', { polls: rows }));
|
||||
expect(polls.polls.map((p) => p.pollId)).toEqual([3, 4, 5, 6]);
|
||||
expect(polls.more).toBe(2);
|
||||
expect(polls.polls[0]?.options).toEqual([
|
||||
{ text: 'Trattoria', votes: 1, mine: false, voters: [JON] },
|
||||
{ text: 'Market', votes: 1, mine: true, voters: [ANNA_VOTER] },
|
||||
{ text: 'Noodles', votes: 0, mine: false, voters: [] },
|
||||
]);
|
||||
expect(polls.polls[0]).toMatchObject({ multiple: false, closed: false, author: JON, change: 'none' });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-021: a new poll lists twelve options, counts the rest, and has no votes', () => {
|
||||
const options = Array.from({ length: 14 }, (_, index) => `Option ${index + 1}`);
|
||||
const args = { question: 'Where on Friday?', options, multiple: true, deadline: '2026-10-24T18:00:00+02:00' };
|
||||
const [created] = pollsOf(changeCardOf('create_collab_poll', ctx, args, null)).polls;
|
||||
expect(created?.options).toHaveLength(12);
|
||||
expect(created).toMatchObject({
|
||||
pollId: null,
|
||||
moreOptions: 2,
|
||||
multiple: true,
|
||||
deadline: '2026-10-24T16:00:00Z',
|
||||
author: ANNA,
|
||||
change: 'create',
|
||||
});
|
||||
expect(created?.options.every((option) => option.votes === 0 && !option.mine)).toBe(true);
|
||||
// A blank option keeps its place, since votes go by index; a deadline without a zone is no moment.
|
||||
const blank = { question: 'Which?', options: ['A', ' ', 'C'], deadline: '2026-10-24T18:00' };
|
||||
const [kept] = pollsOf(changeCardOf('create_collab_poll', ctx, blank, null)).polls;
|
||||
expect(kept?.options.map((option) => option.text)).toEqual(['A', '#2', 'C']);
|
||||
expect(kept?.deadline).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-022: a vote moves a single choice, adds to a multiple choice and takes a repeat back', () => {
|
||||
const listing = { polls: [poll(1), poll(2, { multiple_choice: true })] };
|
||||
const vote = (pollId: number, optionIndex: number) =>
|
||||
pollsOf(changeCardOf('vote_collab_poll', ctx, { pollId, optionIndex }, listing)).polls[0];
|
||||
const moved = vote(1, 2);
|
||||
expect(moved?.options.map((option) => [option.votes, option.mine])).toEqual([
|
||||
[1, false],
|
||||
[0, false],
|
||||
[1, true],
|
||||
]);
|
||||
expect(moved).toMatchObject({ votedIndex: 2, votedAdds: true, warning: null, change: 'vote' });
|
||||
const added = vote(2, 0);
|
||||
expect(added?.options.map((option) => option.mine)).toEqual([true, true, false]);
|
||||
const back = vote(1, 1);
|
||||
expect(back?.options[1]).toMatchObject({ votes: 0, mine: false });
|
||||
expect(back).toMatchObject({ votedIndex: 1, votedAdds: false });
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-023: a vote the server refuses says why, and no poll gives no card', () => {
|
||||
const listing = { polls: [poll(1, { is_closed: true }), poll(2)] };
|
||||
const closed = pollsOf(changeCardOf('vote_collab_poll', ctx, { pollId: 1, optionIndex: 0 }, listing)).polls[0];
|
||||
expect(closed).toMatchObject({ warning: 'closed', votedIndex: 0, votedAdds: false });
|
||||
expect(closed?.options[0]?.votes).toBe(1);
|
||||
const unknown = pollsOf(changeCardOf('vote_collab_poll', ctx, { pollId: 2, optionIndex: 9 }, listing)).polls[0];
|
||||
expect(unknown).toMatchObject({ warning: 'unknownOption', votedIndex: null });
|
||||
expect(changeCardOf('vote_collab_poll', ctx, { pollId: 3, optionIndex: 0 }, listing)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-024: closing marks the poll closed, deleting shows it as it is', () => {
|
||||
const listing = { polls: [poll(1)] };
|
||||
expect(pollsOf(changeCardOf('close_collab_poll', ctx, { pollId: 1 }, listing)).polls[0]).toMatchObject({
|
||||
closed: true,
|
||||
change: 'close',
|
||||
});
|
||||
expect(pollsOf(changeCardOf('delete_collab_poll', ctx, { pollId: 1 }, listing)).polls[0]).toMatchObject({
|
||||
closed: false,
|
||||
change: 'remove',
|
||||
});
|
||||
expect(changeCardOf('close_collab_poll', ctx, { pollId: 2 }, listing)).toBeNull();
|
||||
});
|
||||
|
||||
it("ASTCOLLAB-025: an applied vote has the server's counts and keeps the user's options from the preview", () => {
|
||||
const listing = { polls: [poll(1)] };
|
||||
const preview = describeToolCall('vote_collab_poll', 'call_1', { pollId: 1, optionIndex: 2 }, ctx, listing);
|
||||
const result = {
|
||||
poll: poll(1, {
|
||||
options: [
|
||||
{ text: 'Trattoria', voters: [voter(8, 'jon'), voter(9, 'mia')] },
|
||||
{ text: 'Market', voters: [] },
|
||||
{ text: 'Noodles', voters: [voter(7, 'anna')] },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const [applied] = pollsOf(appliedCardOf('vote_collab_poll', { args: {}, result, preview })).polls;
|
||||
expect(applied?.options.map((option) => [option.votes, option.mine])).toEqual([
|
||||
[2, false],
|
||||
[0, false],
|
||||
[1, true],
|
||||
]);
|
||||
expect(applied).toMatchObject({ pollId: 1, change: 'vote', votedIndex: 2, votedAdds: true });
|
||||
const created = appliedCardOf('create_collab_poll', { args: {}, result: { poll: poll(5) }, preview: null });
|
||||
expect(pollsOf(created).polls[0]).toMatchObject({ pollId: 5, change: 'create' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the note cards', () => {
|
||||
it('ASTCOLLAB-030: a read shows pinned notes first, then the latest changed, in their category colour', () => {
|
||||
const rows = [
|
||||
note(1, { category: 'Ideas', color: '#ef4444', updated_at: '2026-10-20 08:00:00' }),
|
||||
note(2, { category: 'Ideas', color: '#10b981', updated_at: '2026-10-21 08:00:00' }),
|
||||
note(3, { pinned: 1, website: 'https://www.luigis.it/menu', attachments: [{ id: 1 }, { id: 2 }] }),
|
||||
note(4, { website: 'javascript:alert(1)', content: 'Mail jon@example.test' }),
|
||||
note(5, { updated_at: '2026-10-01 08:00:00' }),
|
||||
];
|
||||
const notes = notesOf(answer('list_collab_notes', { notes: rows }));
|
||||
expect(notes.notes.map((n) => n.noteId)).toEqual([3, 2, 4, 1]);
|
||||
expect(notes.more).toBe(1);
|
||||
expect(notes.notes[0]).toMatchObject({ pinned: true, websiteHost: 'luigis.it', files: 2, author: ANNA });
|
||||
expect(notes.notes[1]?.color).toBe('#10b981');
|
||||
expect(notes.notes[2]).toMatchObject({ websiteHost: null, body: 'Mail [hidden]', color: '#6366f1' });
|
||||
expect(notes.notes[3]?.color).toBe('#10b981');
|
||||
const few = notesOf(answer('list_collab_notes', { notes: [note(1), note(2)] }));
|
||||
expect(few.notes).toHaveLength(2);
|
||||
expect(few.more).toBe(0);
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-031: a new note lands in General without a category, in its category colour without a colour', () => {
|
||||
const listing = { notes: [note(1, { category: 'Food', color: '#f59e0b' })] };
|
||||
const plain = notesOf(changeCardOf('create_collab_note', ctx, { title: 'Ideas' }, listing)).notes[0];
|
||||
expect(plain).toMatchObject({ category: 'General', color: '#6366f1', change: 'create', noteId: null });
|
||||
expect(plain?.author).toEqual(ANNA);
|
||||
const pizza = { title: 'Pizza', category: 'Food' };
|
||||
const food = notesOf(changeCardOf('create_collab_note', ctx, pizza, listing)).notes[0];
|
||||
expect(food?.color).toBe('#f59e0b');
|
||||
const args = { title: 'Rome', content: 'Ask at anna@example.test', color: '#3b82f6', website: 'http://r.test/x' };
|
||||
const own = notesOf(changeCardOf('create_collab_note', ctx, args, listing)).notes[0];
|
||||
expect(own).toMatchObject({ color: '#3b82f6', body: 'Ask at anna@example.test', websiteHost: 'r.test' });
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-032: a change lists only the parts that differ, a link of null removes it', () => {
|
||||
const listing = { notes: [note(1, { title: 'Food', website: 'https://a.test', pinned: 0 })] };
|
||||
const args = { noteId: 1, title: 'Food', category: 'Eat', website: null, pinned: true };
|
||||
const changed = notesOf(changeCardOf('update_collab_note', ctx, args, listing)).notes[0];
|
||||
expect(changed?.changed).toEqual(['category', 'website', 'pinned']);
|
||||
expect(changed).toMatchObject({ category: 'Eat', websiteHost: null, pinned: true, change: 'update', noteId: 1 });
|
||||
expect(changeCardOf('update_collab_note', ctx, { noteId: 2, title: 'x' }, listing)).toBeNull();
|
||||
const removed = notesOf(changeCardOf('delete_collab_note', ctx, { noteId: 1 }, listing)).notes[0];
|
||||
expect(removed).toMatchObject({ change: 'remove', title: 'Food' });
|
||||
});
|
||||
|
||||
it("ASTCOLLAB-033: an applied note carries the server's id and keeps the preview's colour and changes", () => {
|
||||
const listing = { notes: [note(1, { category: 'Food', color: '#f59e0b' })] };
|
||||
const args = { title: 'Pizza', category: 'Food' };
|
||||
const preview = describeToolCall('create_collab_note', 'call_1', args, ctx, listing);
|
||||
const result = { note: note(9, { title: 'Pizza', category: 'Food', color: '#6366f1' }) };
|
||||
const applied = notesOf(appliedCardOf('create_collab_note', { args: {}, result, preview })).notes[0];
|
||||
expect(applied).toMatchObject({ noteId: 9, color: '#f59e0b', change: 'create' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the words, the receipts and the way back', () => {
|
||||
const messages = { messages: [message(2, { user_id: 7, username: 'anna', text: 'See you\nat nine' })] };
|
||||
const polls = { polls: [poll(1)] };
|
||||
const notes = { notes: [note(1, { title: 'Food', category: 'Ideas', pinned: 0 })] };
|
||||
|
||||
it('ASTCOLLAB-040: every write names its tool in the trip chat, with its message, question or title', () => {
|
||||
const cases: Array<[string, Row, unknown, string]> = [
|
||||
['send_collab_message', { text: 'Hello all\nsecond line' }, null, 'Hello all'],
|
||||
['delete_collab_message', { messageId: 2 }, messages, 'See you'],
|
||||
['react_collab_message', { messageId: 2, emoji: '👍' }, messages, '👍 See you'],
|
||||
['create_collab_poll', { question: 'Where?', options: ['A', 'B'] }, null, 'Where?'],
|
||||
['vote_collab_poll', { pollId: 1, optionIndex: 1 }, polls, 'Question 1?'],
|
||||
['close_collab_poll', { pollId: 1 }, polls, 'Question 1?'],
|
||||
['delete_collab_poll', { pollId: 1 }, polls, 'Question 1?'],
|
||||
['create_collab_note', { title: 'Pizza' }, null, 'Pizza'],
|
||||
['update_collab_note', { noteId: 1, pinned: true }, notes, 'Food'],
|
||||
['delete_collab_note', { noteId: 1 }, notes, 'Food'],
|
||||
];
|
||||
for (const [tool, args, loaded, subject] of cases) {
|
||||
const preview = describeToolCall(tool, 'call_1', args, ctx, loaded);
|
||||
expect(assistantChangePreviewSchema.safeParse(preview).success, tool).toBe(true);
|
||||
const verbKey = ASSISTANT_WRITE_TOOLS[tool]?.verbKey;
|
||||
expect(preview, tool).toMatchObject({ subject, tool, domain: 'collab', verbKey });
|
||||
expect(preview.card, tool).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-041: a vote names its option, a note change its title, category and pin before and after', () => {
|
||||
expect(describeToolCall('vote_collab_poll', 'c', { pollId: 1, optionIndex: 1 }, ctx, polls).rows).toEqual([
|
||||
{ op: 'add', labelKey: 'text', before: null, after: 'Market' },
|
||||
]);
|
||||
const args = { noteId: 1, title: 'Eat', category: 'Food', pinned: true };
|
||||
expect(describeToolCall('update_collab_note', 'c', args, ctx, notes).rows).toEqual([
|
||||
{ op: 'change', labelKey: 'name', before: 'Food', after: 'Eat' },
|
||||
{ op: 'change', labelKey: 'category', before: 'Ideas', after: 'Food' },
|
||||
{ op: 'change', labelKey: 'field', field: 'pinned', before: '✗', after: '✓' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ASTCOLLAB-042: a note id is the shared note's, never a day note of the same number", () => {
|
||||
const withDayNote = buildTripCard(
|
||||
1,
|
||||
7,
|
||||
{ ...summary, days: [{ id: 31, day_number: 1, notes: [{ id: 1, text: 'Day note' }] }] },
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(withDayNote.notes.get(1)?.text).toBe('Day note');
|
||||
const preview = describeToolCall('delete_collab_note', 'c', { noteId: 1 }, { ...ctx, card: withDayNote }, notes);
|
||||
expect(preview.subject).toBe('Food');
|
||||
expect(describeToolCall('delete_collab_note', 'c', { noteId: 1 }, ctx, null).subject).toBe('');
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-043: a receipt names the message, poll or note it touched', () => {
|
||||
const cases: Array<[string, Row, unknown, string, number[]]> = [
|
||||
['send_collab_message', { text: 'x' }, { message: { id: 40 } }, 'collab_message', [40]],
|
||||
['delete_collab_message', { messageId: 2 }, { success: true }, 'collab_message', [2]],
|
||||
['react_collab_message', { messageId: 2, emoji: '👍' }, { reactions: [] }, 'collab_message', [2]],
|
||||
['create_collab_poll', {}, { poll: { id: 5 } }, 'collab_poll', [5]],
|
||||
['vote_collab_poll', { pollId: 5, optionIndex: 0 }, { poll: { id: 5 } }, 'collab_poll', [5]],
|
||||
['close_collab_poll', { pollId: 5 }, { poll: { id: 5 } }, 'collab_poll', [5]],
|
||||
['delete_collab_poll', { pollId: 5 }, { success: true }, 'collab_poll', [5]],
|
||||
['create_collab_note', {}, { note: { id: 9 } }, 'collab_note', [9]],
|
||||
['update_collab_note', { noteId: 9 }, { note: { id: 9 } }, 'collab_note', [9]],
|
||||
['delete_collab_note', { noteId: 9 }, { success: true }, 'collab_note', [9]],
|
||||
];
|
||||
for (const [tool, args, result, entity, ids] of cases) {
|
||||
expect(appliedChange(tool, 'c', args, result, null, null), tool).toMatchObject({ entity, ids });
|
||||
}
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-044: a new note or poll is taken back while nobody built on it; a message has no way back', () => {
|
||||
const createdNote = { note: note(9, { title: 'Pizza' }) };
|
||||
const createdPoll = { poll: poll(5, { options: [{ text: 'A', voters: [] }] }) };
|
||||
expect(ASSISTANT_UNDO_MAP.create_collab_note?.args({}, createdNote, null)).toEqual({ noteId: 9 });
|
||||
expect(ASSISTANT_UNDO_MAP.create_collab_poll?.args({}, createdPoll, null)).toEqual({ pollId: 5 });
|
||||
expect(ASSISTANT_UNDO_MAP.send_collab_message).toBeUndefined();
|
||||
expect(undoReadsOf('create_collab_note', {}, createdNote)).toEqual([{ tool: 'list_collab_notes', args: {} }]);
|
||||
const snapshot = (tool: string, value: unknown) => ({
|
||||
summary: {},
|
||||
places: {},
|
||||
reads: new Map([[readKey(tool, {}), value]]),
|
||||
});
|
||||
const noteKept = (rows: Row[]) =>
|
||||
unchangedSinceApplied('create_collab_note', createdNote, snapshot('list_collab_notes', { notes: rows }));
|
||||
const pollKept = (rows: Row[]) =>
|
||||
unchangedSinceApplied('create_collab_poll', createdPoll, snapshot('list_collab_polls', { polls: rows }));
|
||||
expect(noteKept([createdNote.note])).toBe(true);
|
||||
expect(noteKept([{ ...createdNote.note, updated_at: '2026-10-21 11:00:00' }])).toBe(false);
|
||||
// A file a teammate attached keeps the note, even when its last change stayed the same.
|
||||
expect(noteKept([{ ...createdNote.note, attachments: [{ id: 3, original_name: 'menu.pdf' }] }])).toBe(false);
|
||||
expect(pollKept([createdPoll.poll])).toBe(true);
|
||||
expect(pollKept([{ ...createdPoll.poll, options: [{ text: 'A', voters: [voter(8, 'jon')] }] }])).toBe(false);
|
||||
expect(pollKept([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the bounds', () => {
|
||||
it('ASTCOLLAB-050: a moment is read as UTC to the second; a deadline without a zone is none', () => {
|
||||
expect(stampOf('2026-10-21 09:12:00')).toBe('2026-10-21T09:12:00Z');
|
||||
expect(stampOf('2026-10-21T09:12:00.000Z')).toBe('2026-10-21T09:12:00Z');
|
||||
expect(stampOf('2026-10-21T11:12:00+02:00')).toBe('2026-10-21T09:12:00Z');
|
||||
expect(stampOf('2026-10-24')).toBe('2026-10-24T00:00:00Z');
|
||||
expect(stampOf('Friday')).toBeNull();
|
||||
expect(stampOf(null)).toBeNull();
|
||||
expect(deadlineOf('2026-10-24T18:00:00+02:00')).toBe('2026-10-24T16:00:00Z');
|
||||
expect(deadlineOf('2026-10-24T18:00:00.000Z')).toBe('2026-10-24T18:00:00Z');
|
||||
expect(deadlineOf('2026-10-24')).toBe('2026-10-24T00:00:00Z');
|
||||
expect(deadlineOf('2026-10-24T18:00')).toBeNull();
|
||||
expect(deadlineOf('2026-10-24 18:00:00')).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-051: the largest chat, poll and note cards fit the card size', () => {
|
||||
const words = (n: number) => 'word '.repeat(n);
|
||||
const reactions = ['1', '2', '3', '4', '5', '6', '7', '8'].map((emoji) => ({
|
||||
emoji,
|
||||
users: [1, 2, 3, 4, 5, 6].map((id) => ({ user_id: id, username: 'n'.repeat(80) })),
|
||||
}));
|
||||
const files = [1, 2, 3, 4].map((id) => ({ id, original_name: 'f'.repeat(120), mime_type: 'image/png' }));
|
||||
const reply = { reply_username: 'r'.repeat(80), reply_text: words(40) };
|
||||
const heavy = { text: words(200), ...reply, reactions, attachments: files };
|
||||
const chat = chatOf(answer('list_collab_messages', { messages: [1, 2, 3, 4, 5].map((id) => message(id, heavy)) }));
|
||||
expect(chat.messages.length + chat.earlier).toBe(5);
|
||||
expect(chat.messages.at(-1)?.id).toBe(5);
|
||||
const options = Array.from({ length: 20 }, (_, index) => ({
|
||||
text: `${index} ${words(40)}`,
|
||||
voters: [1, 2, 3].map((id) => voter(id, 'v'.repeat(80))),
|
||||
}));
|
||||
const bigPolls = pollsOf(answer('list_collab_polls', { polls: [1, 2, 3, 4].map((id) => poll(id, { options })) }));
|
||||
expect(bigPolls.polls.length + bigPolls.more).toBe(4);
|
||||
expect(bigPolls.polls[0]?.pollId).toBe(1);
|
||||
const bigNote = { title: words(50), content: words(200), category: 'c'.repeat(100) };
|
||||
const bigNotes = notesOf(answer('list_collab_notes', { notes: [1, 2, 3, 4].map((id) => note(id, bigNote)) }));
|
||||
for (const built of [chat, bigPolls, bigNotes]) {
|
||||
expect(JSON.stringify(built).length).toBeLessThanOrEqual(MAX_CARD_CHARS);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user