feat(tour-announce): Implement AI-driven tour announcement system with adapter logic and profile integration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-03 23:30:04 -07:00
parent 6af60a85e3
commit 0cd73f0320
3 changed files with 528 additions and 0 deletions

View file

@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest';
import type {
AdapterContext,
AgentActionRef,
AgentActionWrite,
BlocklistEntry,
BlocklistKind,
} from '@cocottetech/surface-adapter-contracts';
import type {
TrystProfileDriver,
TrystProfileSession,
} from '@cocottetech/bookings-tryst-adapter';
import { descriptor } from './index.js';
const action = descriptor.action;
function entry(value: string, kind: BlocklistKind): BlocklistEntry {
return {
id: `bl_${value}`,
userId: 'user_1',
orgId: null,
kind,
value,
scope: 'global',
reason: null,
expiresAt: null,
createdAt: '2026-01-01T00:00:00.000Z',
createdBy: 'user',
};
}
function makeDriver(): { driver: TrystProfileDriver; calls: string[] } {
const calls: string[] = [];
let n = 0;
const driver: TrystProfileDriver = {
goto: async (p) => {
calls.push(`goto:${p}`);
},
setField: async (f, v) => {
calls.push(`setField:${f}=${v}`);
},
setChip: async (c, on) => {
calls.push(`setChip:${c}=${on}`);
},
submit: async (form) => {
calls.push(`submit:${form}`);
n += 1;
return { externalId: `tryst_tour_${n}` };
},
};
return { driver, calls };
}
function makeCtx(
entries: BlocklistEntry[],
driver: TrystProfileDriver,
record: (w: AgentActionWrite) => Promise<AgentActionRef>,
): AdapterContext {
const session: TrystProfileSession = {
surface: 'tryst',
authenticated: true,
driver,
};
return {
userId: 'user_1',
session,
platformApi: { get: vi.fn(), post: vi.fn() },
blocklist: {
list: async (kind?: BlocklistKind) =>
kind ? entries.filter((e) => e.kind === kind) : entries,
},
agentActions: { record },
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
};
}
const BERLIN = { city: 'Berlin', startDate: '2026-10-03', endDate: '2026-10-07' };
describe('tour-announce action', () => {
it('exposes a propose-only, non-auto-executable tryst descriptor', () => {
expect(descriptor.surface).toBe('tryst');
expect(descriptor.verb).toBe('tour-announce');
expect(descriptor.autoExecutable).toBe(false);
});
it('rejects a draft that names a blocklisted accommodation (K3f-2)', async () => {
const { driver } = makeDriver();
const record = vi.fn();
// Composed phrasing for "Hotel Adlon" only trips if the city string carries it;
// the gate scans the composed text, so put the hotel name in the city field.
const ctx = makeCtx([entry('Hotel Adlon', 'phrase')], driver, record);
const input = {
tier: 'standard' as const,
legs: [{ city: 'Berlin near Hotel Adlon', startDate: '2026-10-03', endDate: '2026-10-07' }],
};
const result = await action.precheck(input, ctx);
expect(result.ok).toBe(false);
expect(result.rejections.some((r) => r.gate === 'K3f-2')).toBe(true);
expect(record).not.toHaveBeenCalled();
});
it('rejects a draft referencing a govt-name (K3c-1)', async () => {
const { driver } = makeDriver();
const ctx = makeCtx([entry('John Doe', 'phrase')], driver, vi.fn());
const input = {
tier: 'standard' as const,
legs: [{ city: 'visiting John Doe in Berlin', startDate: '2026-10-03', endDate: '2026-10-07' }],
};
const result = await action.precheck(input, ctx);
expect(result.ok).toBe(false);
expect(result.rejections.some((r) => r.gate === 'K3c-1')).toBe(true);
});
it('rejects when legs exceed the tier tour-slot ceiling', async () => {
const { driver } = makeDriver();
const ctx = makeCtx([], driver, vi.fn());
// Basic tier = 1 slot; two legs exceed it.
const input = {
tier: 'basic' as const,
legs: [BERLIN, { city: 'Paris', startDate: '2026-10-08', endDate: '2026-10-10' }],
};
const result = await action.precheck(input, ctx);
expect(result.ok).toBe(false);
expect(result.rejections.some((r) => r.gate === 'R-tour-slots')).toBe(true);
});
it('passes precheck for a clean within-ceiling draft', async () => {
const { driver } = makeDriver();
const ctx = makeCtx([entry('Hotel Adlon', 'phrase')], driver, vi.fn());
const result = await action.precheck(
{ tier: 'standard' as const, legs: [BERLIN] },
ctx,
);
expect(result.ok).toBe(true);
});
it('publishes each leg and writes a non-auto agent_actions row', async () => {
const { driver, calls } = makeDriver();
const record = vi
.fn<(w: AgentActionWrite) => Promise<AgentActionRef>>()
.mockResolvedValue({ id: 'aa_t1', createdAt: '2026-06-03T00:00:00.000Z' });
const ctx = makeCtx([], driver, record);
const out = await action.execute(
{
tier: 'standard' as const,
legs: [BERLIN, { city: 'Paris', startDate: '2026-10-08', endDate: '2026-10-10' }],
},
ctx,
);
expect(out.auditId).toBe('aa_t1');
expect(out.announced).toEqual([
{ city: 'Berlin', externalId: 'tryst_tour_1' },
{ city: 'Paris', externalId: 'tryst_tour_2' },
]);
expect(calls.filter((c) => c === 'submit:tour_announce')).toHaveLength(2);
expect(record).toHaveBeenCalledTimes(1);
const write = record.mock.calls[0]?.[0];
expect(write?.autoExecuted).toBe(false);
expect(write?.specialistId).toBe('bookings-tryst');
expect(write?.actionType).toBe('tour_announce');
expect(write?.targetKind).toBe('surface_tour');
});
});

View file

@ -0,0 +1,156 @@
/**
* `tour-announce` publish Tryst tour dates (city + date range).
*
* Posture: PROPOSE-ONLY. ai-copilot draws the H3 multi-surface tour card; Quinn
* approves; only then does this action run. Never auto-executed
* (`autoExecuted: false`, descriptor `autoExecutable: false`).
*
* Tier limit: a Tryst account may declare at most N concurrent tour slots, where
* N is tier-dependent (surface-tryst.brief.md §canonical-facts Basic 1 /
* Standard 5 / Premium 10 / Premium+ 10). The action rejects a request that would
* exceed the tier's slot ceiling deterministically, before any browser work.
*
* Safety (brief K):
* - K3f-2: tour text may reveal city + date range but NEVER an accommodation
* (hotel) name. precheck runs `gateK3fAccommodationName` over the composed
* phrasing using the tenant's known accommodation names.
* - K3c-1: no draft may reference Quinn's govt name. precheck also runs
* `gateK3cGovtName`.
* If either gate fires, `execute` never runs.
*/
import { z } from 'zod';
import {
type ActionDescriptor,
type AdapterContext,
type BlocklistEntry,
type GateRejection,
type SurfaceAdapterAction,
gateK3cGovtName,
gateK3fAccommodationName,
precheckResult,
} from '@cocottetech/surface-adapter-contracts';
import {
type TrystTourLeg,
applyTourAnnounce,
asTrystProfileSession,
composeTourPhrasing,
} from '@cocottetech/bookings-tryst-adapter';
/** Tour-slot ceiling by Tryst tier (§canonical-facts — Basic 1 / Standard 5 / Premium 10 / Premium+ 10). */
const TOUR_SLOTS_BY_TIER = {
basic: 1,
standard: 5,
premium: 10,
'premium-plus': 10,
} as const;
type TrystTier = keyof typeof TOUR_SLOTS_BY_TIER;
const legSchema = z
.object({
city: z.string().min(1),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be YYYY-MM-DD'),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'endDate must be YYYY-MM-DD'),
})
.strict()
.refine((l) => l.startDate <= l.endDate, {
message: 'startDate must be on or before endDate',
});
const schema = z
.object({
tier: z.enum(['basic', 'standard', 'premium', 'premium-plus']),
/** Tour legs to announce. Length is capped by the tier's slot ceiling. */
legs: z.array(legSchema).min(1),
/**
* Tour legs already live on the account (count toward the slot ceiling).
* Absent treated as 0 (this is the only declared tour).
*/
existingLegCount: z.number().int().nonnegative().optional(),
})
.strict();
type Input = z.infer<typeof schema>;
interface AnnouncedLeg {
city: string;
externalId: string;
}
interface Output {
/** `agent_actions.id` of the audit row written. */
auditId: string;
announced: AnnouncedLeg[];
}
/** Accommodation names to forbid = tenant `phrase`/`topic` blocklist values (K3f-2 ⟵ brief R). */
function accommodationNames(entries: readonly BlocklistEntry[]): string[] {
return entries.filter((e) => e.kind === 'phrase' || e.kind === 'topic').map((e) => e.value);
}
/** Identity terms to forbid = tenant `phrase` blocklist values (K3c-1 ⟵ K2). */
function forbiddenNames(entries: readonly BlocklistEntry[]): string[] {
return entries.filter((e) => e.kind === 'phrase').map((e) => e.value);
}
const action: SurfaceAdapterAction<Input, Output> = {
action: 'tour-announce',
schema,
async precheck(input: Input, ctx: AdapterContext) {
const rejections: GateRejection[] = [];
// Tier slot-ceiling check (deterministic, no I/O).
const ceiling = TOUR_SLOTS_BY_TIER[input.tier as TrystTier];
const requested = (input.existingLegCount ?? 0) + input.legs.length;
if (requested > ceiling) {
rejections.push({
gate: 'R-tour-slots',
reason:
`tour would use ${requested} of ${ceiling} ${input.tier} tour slots; ` +
`drop ${requested - ceiling} leg(s) or upgrade tier`,
});
}
const blocklist = await ctx.blocklist.list();
const accommodation = accommodationNames(blocklist);
const forbidden = forbiddenNames(blocklist);
const composed = input.legs.map((leg) => composeTourPhrasing(leg)).join('\n');
rejections.push(...gateK3fAccommodationName(composed, accommodation));
rejections.push(...gateK3cGovtName(composed, forbidden));
return precheckResult(rejections);
},
async execute(input: Input, ctx: AdapterContext): Promise<Output> {
const session = asTrystProfileSession(ctx.session);
const announced: AnnouncedLeg[] = [];
for (const leg of input.legs) {
const tourLeg: TrystTourLeg = leg;
const phrasing = composeTourPhrasing(tourLeg);
const { externalId } = await applyTourAnnounce(session, tourLeg, phrasing);
announced.push({ city: leg.city, externalId });
}
const ref = await ctx.agentActions.record({
userId: ctx.userId,
...(ctx.orgId !== undefined ? { orgId: ctx.orgId } : {}),
specialistId: 'bookings-tryst',
actionType: 'tour_announce',
targetKind: 'surface_tour',
stakes: 'medium',
confidence: 1,
autoExecuted: false,
outcome: { legs: announced },
});
ctx.logger.log(`tour-announce published ${announced.length} leg(s) on tryst`);
return { auditId: ref.id, announced };
},
};
export const descriptor: ActionDescriptor = {
surface: 'tryst',
verb: 'tour-announce',
action: action as SurfaceAdapterAction<unknown, unknown>,
autoExecutable: false,
};

View file

@ -0,0 +1,203 @@
/**
* Tryst surface helpers profile edits + tour announcements.
*
* These helpers drive Quinn's *own* authenticated Tryst session to apply an
* already-approved profile change (about-me / rates / services / hours) or to
* publish a tour announcement (city + date range). They are the surface-write
* counterpart to `tryst-session.ts` (which only *verifies* a session).
*
* Why this file is Playwright-free
* --------------------------------
* The shared adapter contract (`@cocottetech/surface-adapter-contracts`) keeps
* `playwright` out of every action's dependency graph: actions receive an opaque
* {@link SurfaceSession} and never touch the browser directly. We honour that
* same boundary here. Instead of importing Playwright, this module declares a
* narrow {@link TrystProfileDriver} capability interface the field-level
* operations a Tryst profile/tour write needs which the surface-adapter
* container runtime implements on top of its managed Playwright `Page`. The
* helpers below are therefore pure orchestration over that interface: fully
* unit-testable with a fake driver, no browser required.
*
* Identity / location safety (K3c-1, K3f-2) is enforced *before* these run, in
* the calling action's `precheck` via the deterministic K-gates. These helpers
* assume the draft has already cleared the gates.
*/
import type { SurfaceSession } from '@cocottetech/surface-adapter-contracts';
/** Tryst's fixed rate tiers (surface-tryst.brief.md §11 — incall/outcall × duration + overnight). */
export interface TrystRates {
/** Currency code the rate values are expressed in (e.g. `'USD'`). */
readonly currency: string;
readonly incall1h?: number | undefined;
readonly incall2h?: number | undefined;
readonly outcall1h?: number | undefined;
readonly outcall2h?: number | undefined;
readonly overnight?: number | undefined;
}
/**
* A structured Tryst profile edit. Every field is optional only the fields
* present in the diff-approved change are applied. Photo and identity-bearing
* fields are intentionally ABSENT from this shape: this specialist never mutates
* them (specialist-bookings-tryst.contract.md §Never).
*/
export interface TrystProfileEdit {
/** Free-text about-me (tryst-profile-editor.screen.md §About-me). */
readonly aboutMe?: string;
/** Rate table (surface-tryst.brief.md §11). */
readonly rates?: TrystRates;
/** Service chips from Tryst's fixed taxonomy. */
readonly services?: readonly string[];
/** Per-weekday open hours; `null` value = closed that day. */
readonly hours?: Readonly<Record<string, string | null>>;
}
/** One leg of a tour: a city + an inclusive date range (city + dates only — never an address). */
export interface TrystTourLeg {
readonly city: string;
/** Inclusive start date, `YYYY-MM-DD`. */
readonly startDate: string;
/** Inclusive end date, `YYYY-MM-DD`. */
readonly endDate: string;
}
/**
* Narrow capability interface the container runtime implements over its managed
* Playwright page. Deliberately small: only the operations a Tryst profile/tour
* write performs. Keeping it an interface (not a Playwright `Page`) is what lets
* this module and the actions that call it stay Playwright-free.
*/
export interface TrystProfileDriver {
/** Navigate the session to a Tryst app path (e.g. `/account/profile/edit`). */
goto(path: string): Promise<void>;
/** Set a named form field to a string value (text area / input). */
setField(field: string, value: string): Promise<void>;
/** Toggle a named multi-select chip on or off. */
setChip(chip: string, on: boolean): Promise<void>;
/** Submit the current form; resolves with the external id Tryst assigns. */
submit(form: string): Promise<{ externalId: string }>;
}
/**
* The Tryst session shape these helpers require: the base opaque
* {@link SurfaceSession} augmented with the driver capability the container
* supplies. Actions cast `ctx.session` to this type after asserting the surface.
*/
export interface TrystProfileSession extends SurfaceSession {
readonly surface: 'tryst';
readonly driver: TrystProfileDriver;
}
/** Tryst app paths the helpers drive (single source so tests + impl agree). */
export const TRYST_PROFILE_EDIT_PATH = '/account/profile/edit';
export const TRYST_TOUR_ANNOUNCE_PATH = '/account/tours/new';
/** Raised when a Tryst surface write fails; carries the failing form for the audit outcome. */
export class TrystSurfaceWriteError extends Error {
constructor(
readonly form: string,
cause: unknown,
) {
super(`tryst ${form} write failed: ${cause instanceof Error ? cause.message : String(cause)}`);
this.name = 'TrystSurfaceWriteError';
}
}
/** Narrow runtime assertion that an opaque session carries the Tryst profile driver. */
export function asTrystProfileSession(session: SurfaceSession): TrystProfileSession {
if (session.surface !== 'tryst') {
throw new Error(`expected a tryst session, got surface=${session.surface}`);
}
const candidate = session as Partial<TrystProfileSession>;
if (!candidate.driver || typeof candidate.driver.submit !== 'function') {
throw new Error('tryst session is missing its profile driver capability');
}
return session as TrystProfileSession;
}
/** Format a rate value for a Tryst currency-formatted input (integer units). */
function formatRate(value: number): string {
return String(Math.round(value));
}
/**
* Apply a diff-approved profile edit through the session's driver. Only the
* fields present on `edit` are written. Returns the external id of the saved
* profile revision. NEVER touches photos or identity fields. Any driver failure
* is wrapped in {@link TrystSurfaceWriteError} so the caller can record a
* meaningful audit outcome.
*/
export async function applyProfileEdit(
session: TrystProfileSession,
edit: TrystProfileEdit,
): Promise<{ externalId: string }> {
const { driver } = session;
try {
await driver.goto(TRYST_PROFILE_EDIT_PATH);
if (edit.aboutMe !== undefined) {
await driver.setField('about_me', edit.aboutMe);
}
if (edit.rates) {
const r = edit.rates;
await driver.setField('rates_currency', r.currency);
if (r.incall1h !== undefined)
await driver.setField('rate_incall_1h', formatRate(r.incall1h));
if (r.incall2h !== undefined)
await driver.setField('rate_incall_2h', formatRate(r.incall2h));
if (r.outcall1h !== undefined)
await driver.setField('rate_outcall_1h', formatRate(r.outcall1h));
if (r.outcall2h !== undefined)
await driver.setField('rate_outcall_2h', formatRate(r.outcall2h));
if (r.overnight !== undefined)
await driver.setField('rate_overnight', formatRate(r.overnight));
}
if (edit.services) {
for (const chip of edit.services) {
await driver.setChip(chip, true);
}
}
if (edit.hours) {
for (const [day, range] of Object.entries(edit.hours)) {
await driver.setField(`hours_${day.toLowerCase()}`, range ?? 'closed');
}
}
return await driver.submit('profile_edit');
} catch (err) {
throw new TrystSurfaceWriteError('profile_edit', err);
}
}
/**
* Compose Tryst-specific tour-announcement phrasing for one leg. City + date
* range only by construction this NEVER emits an accommodation/address (the
* K3f-2 gate in the calling action's precheck additionally rejects any draft
* that names one).
*/
export function composeTourPhrasing(leg: TrystTourLeg): string {
return `Touring ${leg.city} ${leg.startDate} ${leg.endDate}. Limited dates — book ahead.`;
}
/**
* Publish a tour announcement (city + date range) through the session's driver.
* `phrasing` is the already-composed, gate-cleared announcement text. Driver
* failures are wrapped in {@link TrystSurfaceWriteError}.
*/
export async function applyTourAnnounce(
session: TrystProfileSession,
leg: TrystTourLeg,
phrasing: string,
): Promise<{ externalId: string }> {
const { driver } = session;
try {
await driver.goto(TRYST_TOUR_ANNOUNCE_PATH);
await driver.setField('tour_city', leg.city);
await driver.setField('tour_start', leg.startDate);
await driver.setField('tour_end', leg.endDate);
await driver.setField('tour_description', phrasing);
return await driver.submit('tour_announce');
} catch (err) {
throw new TrystSurfaceWriteError('tour_announce', err);
}
}