fix(assistant): a book page card draws the page after as the server's trial made it

The Studio drew the page after a held edit or cover change by running the
card's operations on its own book, which knows nothing of the journey's
photos, words and figures, so a caption from a photo, a text from an entry
or a map from the route came out differently or not at all. The card now
carries the page the server's trial made with the full context, read with
the book's schema and within the card's size, and the Studio draws it while
its book stands at the version the trial ran on. A book that moved on, a
card from before and a page too heavy to carry still run the operations on
the book on screen, the book the change will run on. The receipt leaves the
page out, since an applied change is drawn from the book itself.
This commit is contained in:
Maurice
2026-10-23 02:29:04 +00:00
parent 7e28b8ce6d
commit 6d841f98e0
16 changed files with 424 additions and 63 deletions
@@ -1,3 +1,4 @@
import { logWarn } from '../../../audit/audit-log.logger';
import type { CardReader, Described, ToolArgs } from '../card-types';
import { recordOf, type Row } from '../card-values';
import { Rows, text } from '../preview-format';
@@ -8,6 +9,8 @@ import {
bookCardFindingSchema,
bookCardPageSchema,
bookPageOpSchema,
bookSpreadSchema,
MAX_CARD_CHARS,
setBookCoverArgs,
setBookCoverRequestSchema,
type AssistantChangePreview,
@@ -22,9 +25,11 @@ import {
* The change card of a held book write and its receipt. Both are built from
* the trial the turn's gate made of the call (book-trial.ts): the pages the
* change works on with their place in the book, what it lays them out on and
* brings onto them, and the findings on the pages it leaves. The card carries
* the operations, so the Studio draws the page after with the same engine.
* Once applied, the pages come from the tool's answer.
* brings onto them, and the findings on the pages it leaves. The card of an
* edit or the cover carries the page the trial made, so the Studio draws the
* page after as the tool would write it while its book is at the trial's
* version, and the operations for a book that moved on since or a card that
* cannot carry that page. Once applied, the pages come from the tool's answer.
*/
/** What describing a held book write looked up: the gate's trial of it, null when there was none. */
@@ -44,11 +49,15 @@ const CHANGE_OF: Readonly<Record<string, BookPageCard['change']>> = {
/**
* Operations past this weight are not carried: twenty texts of two thousand
* characters would push the card past MAX_CARD_CHARS and lose it whole, so
* such a card shows its facts and no page after.
* characters would push the card past MAX_CARD_CHARS and lose it whole. Such
* a card shows its facts, and the page after only where it carries the page
* the trial made.
*/
const MAX_OPS_CHARS = 12_000;
/** The tools whose card the Studio draws the page after for: an edit and the cover. */
const DRAWS_AFTER: ReadonlySet<string> = new Set(['edit_book_page', 'set_book_cover']);
/** The trial of `tool` with these arguments, through the turn's reader; null without one or for a refused call. */
export async function loadBook(tool: string, args: ToolArgs, read: CardReader): Promise<BookLoaded> {
const trial = trialFrom(await read(BOOK_TRIAL_READ, { tool, args }));
@@ -115,6 +124,45 @@ function findingsOf(findings: readonly BookCardFinding[]): Pick<BookPageCard, 'f
};
}
/**
* Whether a card stays within MAX_CARD_CHARS. Its page is already read as the
* union reads it and every other part is built in the union's shape, so the
* registry's parse adds nothing and this is the weight it measures, or more:
* carrying the page never costs the card itself.
*/
function fitsCard(card: BookPageCard): boolean {
return JSON.stringify(card).length <= MAX_CARD_CHARS;
}
/**
* The card with the page an edit or the cover leaves, as the trial made it
* with the journey's photos, words and figures (a caption from its photo's
* caption, a text from its entry, a map from the route): the Studio draws the
* page after from it rather than running the operations on its own book,
* which knows none of that. A page the book's schema refuses is never
* carried (logged), so the card goes out whole with its operations. A page
* that fits only without the operations goes without them: the Studio runs
* them only once its book moved on from the trial's version, and such a card
* then draws the page before alone. One too heavy even then (long texts, a
* long route) is left out, and the Studio runs the operations as for a card
* from before.
*/
function withAfter(tool: string, card: BookPageCard, trial: BookTrialRan): BookPageCard {
const spreadId = card.pages[0]?.spreadId;
const made = DRAWS_AFTER.has(tool) ? trial.spreads.find((spread) => spread.id === spreadId) : undefined;
if (!made) return card;
const parsed = bookSpreadSchema.safeParse(made);
if (!parsed.success) {
const issue = parsed.error.issues[0];
logWarn(`[assistant] the page after of ${tool} does not fit the book: ${issue?.path.join('.')} ${issue?.message}`);
return card;
}
const full = { ...card, after: parsed.data };
if (fitsCard(full)) return full;
const bare = { ...full, ops: [] };
return fitsCard(bare) ? bare : card;
}
/** How many pages a change adds (a new book: all of them) or deletes; none for any other change. */
function countOf(change: BookPageCard['change'], trial: BookTrialRan): number {
if (change === 'new') return trial.after.length;
@@ -124,14 +172,15 @@ function countOf(change: BookPageCard['change'], trial: BookTrialRan): number {
/**
* The card under a held book write: the pages it works on as they stand (the
* page edited, the cover, the page moved, the pages deleted, the page new ones
* follow), what it brings and what the check found. Null without a trial: a
* card needs the journey and the book as the server read them.
* follow), what it brings, what the check found and the page an edit or the
* cover leaves. Null without a trial: a card needs the journey and the book as
* the server read them.
*/
export function bookChangeCard(tool: string, args: ToolArgs, loaded: BookLoaded | null): BookPageCard | null {
const trial = loaded?.trial ?? null;
const change = CHANGE_OF[tool];
if (!trial || !change) return null;
return {
const card: BookPageCard = {
kind: 'bookPage',
journeyId: trial.journeyId,
change,
@@ -145,6 +194,7 @@ export function bookChangeCard(tool: string, args: ToolArgs, loaded: BookLoaded
photosAdded: trial.photosAdded,
...findingsOf(trial.findings),
};
return withAfter(tool, card, trial);
}
/** A page of a write's answer (`get_book`'s row) as a card names it, numbered as the held card knew it. */
@@ -212,7 +262,8 @@ function receiptFindings(value: unknown, held: BookPageCard): Pick<BookPageCard,
/**
* The receipt's card: the held card with the pages as the change left them
* (a deleted page as it was), the book's version after it and the findings
* its answer carries. Null when the held change had no card.
* its answer carries, without the page the trial made: once applied, the
* Studio draws the page as it is. Null when the held change had no card.
*/
export function bookAppliedCard(result: unknown, preview: AssistantChangePreview | null): BookPageCard | null {
const held = preview?.card?.kind === 'bookPage' ? preview.card : null;
@@ -222,8 +273,9 @@ export function bookAppliedCard(result: unknown, preview: AssistantChangePreview
const number = numberer(held, rows);
const answered = rows.flatMap((row) => answeredPage(row, number) ?? []).slice(0, BOOK_OP_LIMITS.pagesPerCall);
const kept = held.change === 'willDelete' || answered.length === 0;
const { after: _trialPage, ...facts } = held;
return {
...held,
...facts,
baseVersion: typeof answer.version === 'number' ? answer.version : held.baseVersion,
pages: kept ? held.pages : answered,
...receiptFindings(answer.findings, held),
@@ -51,6 +51,12 @@ export interface BookTrialRan {
before: BookCardPage[];
/** The pages it leaves, as they would stand. */
after: BookCardPage[];
/**
* The same pages as the engine made them, with the journey's photos, words
* and figures: what the change card draws after an edit or the cover, as
* the tool would write it.
*/
spreads: BookSpread[];
/** The findings on the pages it leaves, the most severe first. */
findings: BookFinding[];
/**
@@ -235,6 +241,7 @@ function ranOf(
version: input.version,
before: pagesOf(before, targets),
after: pagesOf(made.doc, ids),
spreads: made.doc.spreads.filter((spread) => ids.includes(spread.id)),
findings,
fresh,
photosAdded,
@@ -279,7 +286,8 @@ export function tryBookChange(tool: string, args: ToolArgs, input: BookTrialInpu
/** A trial as the card's lookup gets it back from the reader; null for anything else. */
export function trialFrom(value: unknown): BookTrial | null {
const record = recordOf(value);
if (record.ok === true && Array.isArray(record.after) && Array.isArray(record.findings)) return value as BookTrialRan;
const ran = record.ok === true && Array.isArray(record.after) && Array.isArray(record.spreads);
if (ran && Array.isArray(record.findings)) return value as BookTrialRan;
if (record.ok === false && typeof record.refusal === 'string') return value as BookTrialRefused;
return null;
}
@@ -6,12 +6,13 @@ import type { AssistantChangeVerbKey } from '@trek/shared';
/**
* The photo book in the Studio's chat. A held book write shows the pages it
* works on, before and after, drawn in the Studio from the operations the
* card carries, with what it brings and what the server's check of the result
* found; once applied, the pages as it left them. Edits, the cover and
* deleted pages are taken back with `restore_book_page`, new pages deleted
* again, a moved page moved back: an edit while its page is as the change
* left it, the others while nothing was saved to the book since.
* works on, before and after, drawn in the Studio from the page the server's
* trial made (or the operations the card carries, for a card without that
* page or a book moved on since), with what it brings and what the server's
* check of the result found; once applied, the pages as it left them. Edits,
* the cover and deleted pages are taken back with `restore_book_page`, new
* pages deleted again, a moved page moved back: an edit while its page is as
* the change left it, the others while nothing was saved to the book since.
*
* The check itself is the turn's gate (assistant-book-gate.ts): it tries the
* call on the stored book, refuses what the engine refuses and sends a change
@@ -6,16 +6,16 @@
*
* Covered: a page edit that would leave empty frames goes back to the model
* once, the same call made again is held with its page card (pages, the
* operations, the findings), applied with a receipt card, and taken back in
* one undo while nothing was saved since; an operation the engine refuses
* never reaches the user; new pages are not taken back once the book moved on,
* and neither is an edited page once that page was saved since (the restore
* checks the page as the change left it). A save to another page does not
* hold an edit's undo back, so of two changes to one page applied together
* "undo all" takes back the newer, then the older; changes apply in the order
* the decisions name them. A page is previewed with the ops of an edit before
* it is proposed, nothing saved; on this text-only model the preview is the
* page's description alone.
* operations, the findings, the page after as the trial made it), applied
* with a receipt card, and taken back in one undo while nothing was saved
* since; an operation the engine refuses never reaches the user; new pages
* are not taken back once the book moved on, and neither is an edited page
* once that page was saved since (the restore checks the page as the change
* left it). A save to another page does not hold an edit's undo back, so of
* two changes to one page applied together "undo all" takes back the newer,
* then the older; changes apply in the order the decisions name them. A page
* is previewed with the ops of an edit before it is proposed, nothing saved;
* on this text-only model the preview is the page's description alone.
*/
import { buildApp } from '../../src/bootstrap';
import { withRequestContext } from '../../src/nest/database/request-context';
@@ -240,6 +240,10 @@ describe('Studio book cards e2e', () => {
code: 'empty_frame',
severity: 'severe',
});
// The page after rides on the card as the server's trial made it: the layout's two frames, both empty.
const trialPage = change?.data.card?.kind === 'bookPage' ? change.data.card.after : undefined;
expect(trialPage?.id).toBe(j.inner);
expect(trialPage?.elements.filter((element) => element.kind === 'photo')).toHaveLength(2);
const threadId = ofType(turn, 'data-trek-thread')[0]?.data.threadId ?? 0;
const decided = await approve(j.base, j.userId, threadId, held?.toolCallId ?? '');
@@ -248,6 +252,8 @@ describe('Studio book cards e2e', () => {
toolCallId: held?.toolCallId,
card: { kind: 'bookPage', baseVersion: 2, pages: [{ spreadId: j.inner }] },
});
// Once applied the Studio draws the page as it is: the receipt carries no page of the trial.
expect(receipt?.data.card).not.toHaveProperty('after');
// The pages the answer kept for the undo never reach the model or the chat.
expect(JSON.stringify(ofType(decided, 'tool-output-available'))).not.toContain('previous_spreads');
expect(JSON.stringify(ofType(decided, 'tool-output-available'))).not.toContain('page_digests');
@@ -1,14 +1,16 @@
/**
* ASTCARD-BKPG-001 to ASTCARD-BKPG-011
* ASTCARD-BKPG-001 to ASTCARD-BKPG-014
*
* The photo book's cards in the Studio's chat: a held book write tried on the
* stored book with the shared engine (the pages before and after, numbered
* among the double pages, the photos it brings, the findings it leaves and the
* new severe ones among them, the engine's refusal in the tool's words), the
* change card built from that trial and checked against the shared union, the
* receipt from the tool's answer, the change row's words, and the way back
* from every book write but a new book. Cards go through the registry's guard,
* as a turn builds them.
* page an edit or the cover leaves as the trial made it with the journey's
* context (within the card's cap, in place of the operations if it must, and
* never one the book's schema refuses), the receipt from the tool's answer,
* the change row's words, and the way back from every book write but a new
* book. Cards go through the registry's guard, as a turn builds them.
*/
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
import { toolOutputForProvider } from '../../../../../../src/nest/assistant/assistant-output';
@@ -42,11 +44,13 @@ import {
applyBookOps,
blankBook,
bookPageCardSchema,
bookSpreadSchema,
MAX_CARD_CHARS,
type AssistantChangePreview,
type BookDocument,
type BookEditResult,
type BookPageCard,
type BookTextElement,
} from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -227,6 +231,8 @@ describe('the change card', () => {
const heavy = { spreadId: 'sp-3', ops: words };
const light = cardOf(changeCardOf('edit_book_page', ctx, heavy, loaded(ran('edit_book_page', heavy))));
expect(light.ops).toEqual([]);
// The page they leave is light (one body text, rewritten twenty times), so it still rides for the Studio to draw.
expect(light.after?.id).toBe('sp-3');
expect(JSON.stringify(light).length).toBeLessThan(MAX_CARD_CHARS);
});
@@ -444,3 +450,116 @@ describe('the receipt and the way back', () => {
expect(trialFrom('text')).toBeNull();
});
});
describe('the page after', () => {
/** sp-3 holding photo 81, and a gallery that keeps the photo's caption: the context the server's trial has. */
function captionable() {
const place = { op: 'place_photo', photoId: 81, target: 'new' };
const doc = edited(applyBookOps(book(), { spreadId: 'sp-3', ops: [place] }));
const frame = doc.spreads[2]?.elements.find((element) => element.kind === 'photo')?.id ?? '';
const base = input(doc);
const photos = [{ photoId: 81, caption: 'Tram 28 at dawn' }, { photoId: 82 }];
return { doc, frame, from: { ...base, ctx: { ...base.ctx, photos } } };
}
/** A trial whose page holds `count` texts of `chars` characters each: a page as heavy as a test needs. */
function weighed(trial: BookTrialRan, count: number, chars: number): BookTrialRan {
const page = trial.spreads[0];
const words = page?.elements.find((element): element is BookTextElement => element.kind === 'text');
if (!page || !words) throw new Error('the fixture page has no text');
const elements = Array.from({ length: count }, (_, i) => ({ ...words, id: `t-w${i}`, text: 'w'.repeat(chars) }));
return { ...trial, spreads: [{ ...page, elements }] };
}
it('ASTCARD-BKPG-012: an edit and the cover carry their page as the trial made it, a caption read from the gallery', () => {
const { doc, frame, from } = captionable();
const args = { spreadId: 'sp-3', ops: [{ op: 'caption', photoElementId: frame }] };
const trial = ran('edit_book_page', args, from);
expect(trial.spreads.map((spread) => spread.id)).toEqual(['sp-3']);
const card = cardOf(changeCardOf('edit_book_page', ctx, args, loaded(trial)));
// The page itself, as the shared schema reads it: no second run of the operations, here or in the Studio.
expect(card.after).toEqual(bookSpreadSchema.parse(trial.spreads[0]));
expect(card.after?.id).toBe('sp-3');
// The caption's words are the photo's in the journey, which only the server's context knows.
expect(card.after?.elements).toContainEqual(
expect.objectContaining({
kind: 'text',
text: 'Tram 28 at dawn',
binding: expect.objectContaining({ source: 'photo.caption', photoId: 81 }),
}),
);
// Run without that context, as the Studio runs a card's operations, the same edit is refused.
expect(applyBookOps(doc, args)).toMatchObject({ ok: false, error: { code: 'caption_missing' } });
expect(card.ops).toHaveLength(1);
const cover = { template: 'cover-full', photoId: 82, title: 'Portugal' };
const covered = ran('set_book_cover', cover);
const coverCard = cardOf(changeCardOf('set_book_cover', ctx, cover, loaded(covered)));
expect(coverCard.after).toEqual(bookSpreadSchema.parse(covered.spreads[0]));
expect(coverCard.after?.id).toBe('sp-1');
expect(coverCard.after?.elements).toContainEqual(expect.objectContaining({ kind: 'photo', photoId: 82 }));
expect(coverCard.after?.elements).toContainEqual(expect.objectContaining({ kind: 'text', text: 'Portugal' }));
// Once applied the Studio draws the page as it is: the receipt carries no page of the trial.
const answer = { version: 4, pages: [{ index: 2, id: 'sp-3', role: 'inner', photoIds: [81] }], findings: [] };
const preview: AssistantChangePreview = {
toolCallId: 'c1',
verbKey: 'editBookPage',
subject: '',
rows: [],
dayId: null,
placeIds: [],
undoable: true,
card,
};
const receipt = cardOf(appliedCardOf('edit_book_page', { args, result: answer, preview }));
expect(receipt).toMatchObject({ baseVersion: 4, ops: card.ops });
expect(receipt.after).toBeUndefined();
});
it('ASTCARD-BKPG-013: the page rides within the cap, in place of the operations if it must; other writes carry none', () => {
const texts = Array.from({ length: 5 }, () => ({ op: 'text', role: 'body', text: 'w'.repeat(2000) }));
const args = { spreadId: 'sp-3', ops: texts };
const trial = ran('edit_book_page', args);
// Two texts of 7,000 characters and the operations' 10,000 pass the cap together, not apart: the page goes
// without the operations, since the Studio draws the page from it.
const roomy = cardOf(changeCardOf('edit_book_page', ctx, args, loaded(weighed(trial, 2, 7000))));
expect(roomy.ops).toEqual([]);
expect(roomy.after?.elements).toHaveLength(2);
expect(JSON.stringify(roomy).length).toBeLessThan(MAX_CARD_CHARS);
// Too heavy even then: the card keeps its operations, for the Studio to run, and no page.
const heavy = cardOf(changeCardOf('edit_book_page', ctx, args, loaded(weighed(trial, 3, 8000))));
expect(heavy.ops).toHaveLength(5);
expect(heavy.after).toBeUndefined();
expect(JSON.stringify(heavy).length).toBeLessThan(MAX_CARD_CHARS);
// A page that fits rides with the operations.
expect(cardOf(changeCardOf('edit_book_page', ctx, args, loaded(trial)))).toMatchObject({ after: { id: 'sp-3' } });
expect(cardOf(changeCardOf('edit_book_page', ctx, args, loaded(trial))).ops).toHaveLength(5);
// New pages, a move and a delete draw no page after: their cards carry none.
const add = { afterSpreadId: 'sp-3', count: 2 };
expect(cardOf(changeCardOf('add_book_pages', ctx, add, loaded(ran('add_book_pages', add)))).after).toBeUndefined();
const move = { spreadId: 'sp-3', toIndex: 4 };
const moved = loaded(ran('move_book_page', move, input(threeSpreads())));
expect(cardOf(changeCardOf('move_book_page', ctx, move, moved)).after).toBeUndefined();
const gone = { spreadIds: ['sp-3'] };
const deleting = loaded(ran('delete_book_pages', gone));
expect(cardOf(changeCardOf('delete_book_pages', ctx, gone, deleting)).after).toBeUndefined();
// A trial without its pages is no trial the card reads.
expect(trialFrom({ ok: true, after: [], findings: [] })).toBeNull();
});
it('ASTCARD-BKPG-014: a page the book schema refuses never rides: the card goes out whole with its operations, logged', () => {
const args = { spreadId: 'sp-3', ops: [TWO_UP] };
const trial = ran('edit_book_page', args);
const unreadable = { ...trial, spreads: trial.spreads.map((spread) => ({ ...spread, background: 'white' })) };
const card = cardOf(changeCardOf('edit_book_page', ctx, args, loaded(unreadable)));
expect(card).toMatchObject({ kind: 'bookPage', pages: [{ spreadId: 'sp-3' }], ops: [TWO_UP] });
expect(card.after).toBeUndefined();
expect(logMock.logWarn).toHaveBeenCalledWith(expect.stringContaining('the page after of edit_book_page'));
// The same trial with a page it reads carries that page, and logs nothing.
vi.clearAllMocks();
expect(cardOf(changeCardOf('edit_book_page', ctx, args, loaded(trial))).after?.id).toBe('sp-3');
expect(logMock.logWarn).not.toHaveBeenCalled();
});
});