Repository navigation
v0.2.0
La chip di un tool non sopravvive al proprio stream
Perché
Il 27/8 (riga di produzione 7bc0f716 di agent_kit_runs) la sessione è
morta a metà di un delegate_task. Il turno è ripreso con una sessione
fresca, che non riemette il risultato del call precedente: il partial del
run ha quindi conservato la chip running senza output per tutto il resto
del turno — loading perpetuo in UI, refresh per sbloccare.
Due buchi si sommavano:
- Server (mirror).
mirrorSseToRunscrive il partial con lo stato
piegato dallo stream; a stream finito una chip ancorarunningnon
riceverà più nulla, ma restava tale nel partial servito dal poll. - Client (poll).
applyLiveSnapshotMergeava i tool solo quando il
loro numero cresceva: una transizione di stato (running→done) senza
aggiunte non veniva mai riparata, quindi nemmeno un partial vero
avrebbe chiuso la chip.
Decisione
- Nuovo
closeDanglingToolCallsinchat-stream-events.ts(il modulo
condiviso server/client): a stream terminato per cosa sua (evento
finishoerrorvisto) ogni chip ancora aperta diventaerrorcon
un testo onesto. Se invece il mirror si ghiaccia per un client andato
via, lo stato resta com'è: le chip aperte possono essere legittime,
il turno continua senza di noi. applyLiveSnapshotmergea anche a lunghezza invariata (ma mai con
meno tool di quelli che la tab conosce: lo snapshot non rimpicciolisce
la lista).- Scelti i dati prima della teoria: la query su
agent_kit_runsdei 7
giorni mostra un run conopen_delegate: 1nel partial finale e tutti
gli altri chiusi — conferma che il difetto è nel lifecycle del mirror,
non nell'emissione generale dei risultati.
Scartato
Riscrivere il partial alla chiusura del run dalla verità degli steps:
dopo closeRunSaving il run esce dagli stati working e il poll risponde
204 — il partial riconciliato non verrebbe mai letto. La riparazione sta
nel mirror, dove il partial vive ancora.
Le immagini allegate non raggiungevano il modello sui turni ricostruiti dalla history
La PR #24 aveva sistemato il primo invio: l'immagine viaggia come data-URL, una
stringa, e attraversa indenne ogni trasformazione fino a extractUserImages del
patch pi. Ma sui turni RICOSTRUITI dalla history — redo, retry di un turno morto,
continuazioni di obiettivo e anti-ripetizione — messagesFromRow produce parti
immagine con image: new URL(...), un OGGETTO. stripProviderRefs (live.ts) ricostruiva
ogni oggetto con Object.entries, che per un URL restituisce []: la parte
arrivava all'adattatore come image: {}, veniva scartata in silenzio, e il modello
riceveva solo il testo [attached: <url>] — rispondendo di non vedere l'immagine.
Prova di produzione: reasoning del 28/8 18:05 UTC («They attached an image via URL…
I don't actually perceive any image content»), 5 minuti prima della task #57.
Riproduzione deterministica (scripts/debug/pi-kit-image-loop.mjs, harness pi reale
- OpenRouter vero): URL oggetto senza strip → l'immagine arriva; URL oggetto con
stripProviderRefs→ 3/3 turni senza immagine nel body, «Non vedo alcuna immagine».
Fix: stripProviderRefs estratto in provider-refs.ts (modulo puro, testabile senza
tirarsi dietro live.ts) con la conservazione degli URL — serializzati come stringa
https che extractUserImages scarica — e unit test sulle parti multimodali
(provider-refs.test.ts, rosso→verde). Lezione in LESSONS.md: la PR che dice
«risolto» era verificata solo sul primo invio, non sul redo.
Il crash del secondo turno (stessa sessione di diagnosi)
Il giorno stesso, il run c0c11b3d (agente content, glm su openrouter) moriva a fine
turno con «HarnessAgent: received terminal finish with unclosed step content»: la
chain è finish() dell'harness che lancia quando lo step non è chiuso → fail() →
result.steps rigettato → handleFinish catch → «Errore del turno» salvato in chat.
La forma: stopReason 'error' | 'aborted' in pi-agent-core emette turn_end col
messaggio che CONTIENE ancora la tool call mai eseguita; l'adattatore aggiunge gli id
ai pendingStepToolCallIds, finishStep resta muto (size > 0), e il finish
terminale dopo session.prompt trova lo step aperto.
Il contratto dell'harness è esplicito («adapters must emit finish-step before
finish»): il patch ora chiude lo step aperto (finish-step con unified "other",
unico valore onesto nell'enum) prima del finish terminale e prima dell'emissione
d'errore — no-op quando lo step è già chiuso. Verificato coi tre scenari del loop
(errore dopo toolcall, socket cut, turno sano) e dalla suite bridge (118 verdi).
Rilanci e composer (stessa catena, stessa giornata)
- I RILANCI del kit (verdetto, anti-ripetizione) e le CONTINUAZIONI accodate ricostruiscono il
turno con l'ultimo messaggio utente testuale: l'harness collassa la catena su quello, le
immagini di un turno precedente non arrivano, e l'agente smentisce («l'immagine non mi è mai
arrivata») dopo averla vista. Il messaggio di continuazione ora porta con sé le parti immagine
(carryImagesToContinuation); il drain carica la storia col media attivo. - Il COMPOSER può inviare con il downscale ancora in volo (nessun indicatore, payload senza
l'immagine): chip "in elaborazione" nella strip, invio bloccato finché l'allegato non è pronto. - Il decode/encode browser (createImageBitmap, canvas.toBlob, FileReader) è resolve-only: se il
callback non arriva, l'allegato sparisce in silenzio. Tetto di stall (15s) che rifiuta e mostra
l'errore — nessuna perdita silenziosa è più possibile.
Il composer non perde più allegati selezionati all'avvio
Il file input del composer veniva renderizzato anche nel markup SSR. Un browser poteva trovarlo e
selezionare un'immagine prima che Svelte avesse collegato onPickFiles; la selezione si perdeva,
la strip non compariva e il turno partiva senza allegato.
Il picker viene ora montato solo dopo l'hydration del composer. Il selettore del browser aspetta
così un input già interattivo; il percorso di downscale e il blocco dell'invio durante
l'elaborazione restano invariati.
La scatola nera dei sotto-agenti si scriveva a vuoto
Perché
Ogni insert di saveAgentSession moriva in produzione con
null value in column "id" of relation "agent_sessions" violates not-null constraint (23502): agentSessionRow non produceva id, e la migration
0205 definisce la colonna senza default. Gli errori vengono ingoiati di
proposito (la diagnostica non può rompere il turno), quindi il difetto è
stato silenzioso: l'altro writer, harness/persist.ts, include id nel
proprio snapshot ed è l'unico motivo per cui la tabella non è vuota.
L'intera traccia dei turni delegati dalla chat (comandi sandbox, pagine,
report) non è mai arrivata al database.
Decisione
agentSessionRowgenera il proprioidconcrypto.randomUUID(),
come già fa il percorso harness. Fix nel codice e non un
alter column id set default gen_random_uuid(): le deploys non girano
migration, quindi la default sul database avrebbe lasciato il difetto
in produzione fino a un apply manuale — e il contratto «chi scrive
porta il suo id» è già il pattern stabilito dall'altro writer.- Test di regressione sul seam puro (
agentSessionRow): la riga deve
portare un uuid valido. Guarda sia la rimozione del campo sia un
futuro writer che riusa la funzione senza id.
Scartato
Migration con default sulla colonna: rimanda il fix a un passaggio
manuale su produzione e maschera il contratto invece di dichiararlo.
Log di debug sul turno kit: modello, durata, token
Perché esiste
Il turno kit (runKitTurn in src/lib/agent/bridge/live.ts) finiva in silenzio: per diagnosticare
un run lento o un modello sbagliato bisognava leggere ai_calls a posteriori, o aggiungere un
console.log al volo. Quando qualcosa andava storto — un run che ci metteva minuti, un modello che
il resolver non aveva preso come previsto — non c'era nulla nel log di processo che dicesse cosa
stava girando.
Cosa c'era prima
Lo stato dei run viveva già in agent_kit_runs, e logAiCall scriveva già una riga di
telemetria per turno (model, ms, token) — ma solo su database, mai nel log di debug del
worker. Il log di processo parlava solo delle anomalie (abort, retry, verdetto, sfratti).
La decisione
Due righe nuove nel log di processo, una a inizio turno e una a fine turno:
- start: agente, modello (etichetta + provider), thread, run id. Detta SUBITO quale modello il
resolver ha scelto, prima di qualsiasi attesa. - done: durata in secondi, modello, token in/out. Riscatta il dato che
logAiCallmetteva su
DB, rendendolo visibile nel log del worker.
Scartato: gatedare dietro un env var di debug. I log [AGENT_KIT] esistenti sono tutti
incondizionati, e una riga per turno è già il massimo del rumore accettabile — un flag avrebbe
chiesto di accenderlo ogni volta che serve, che è esattamente il momento in cui non lo si fa.
Versioni, tag e release — automatici
Prima: la versione in package.json era decorativa (0.2.0 mai taggato),
scripts/release.mjs alzava il numero e stampava i comandi di tag/push come
passo manuale finale, e l'identità release <semver>+<build> esisteva solo per
il benchmark (src/lib/release.ts). Nessuna GitHub Release dell'app, solo i
tag cli-v* della CLI.
Ora: il merge su main è l'unico trigger. Il workflow release.yml confronta la
versione di package.json con i tag v*: se manca il tag, lo crea e pubblica
la GitHub Release. Se c'è già, il push passa senza fare nulla — la maggioranza
dei merge non è una release, e il check è idempotente (rilanciare non duplica).
Decisioni:
- Il bump vive nella PR di release (dev→main), non nei PR verso dev. Con
molti PR su dev, chiedere un bump a ognuno è cerimonia per null'a; la PR che
mette insieme la release è dove la decisione "minor o patch" si prende comunque. - Le note vengono dai
changelog/YYYY-MM-DD-*.mdaggiunti tra l'ultimo tag
e HEAD, nuove entry prima. Estrarre il testo dagli.tspubblici col regex
era più fragile e menzognero (lì c'è il testo per i clienti, non per chi
revisiona il repo); i soggetti dei commit sono il fallback, il primo release
prende le 20 entry più recenti. v*ecli-v*restano mondi separati:release.ymlfiltra suv*,
cli-release.ymlsucli-v*— nessuno dei due tocca l'altro.- Il build è lo SHA completo, iniettato a build time come
__BUILD_SHA__
(Vercel/Actions lo dicono in env, in locale risponde git, senza gitdev).
La forma corta è scelta di presentazione, non di stoccaggio — come già fa
releaseLabel(). Esposto daGET /api/v1/versioninsieme a versione e
release composta, pubblico e senza auth: non c'è nulla da proteggere.
Le note in locale si vedono con la stessa fonte del workflow:
node scripts/release-notes.mjs <ultimo-tag> HEAD.
2026-08-28 — z.literal(true) nei tool schema: Gemini rifiuta il toolset intero
Il difetto (misurato in locale, 28/8/2026)
Turno accodato del Content Creator fallito prima di chiamare il modello:
Invalid value at 'tools[0].function_declarations[59].parameters
.properties[5].value.enum[0]' (TYPE_STRING), true
Colpevole: update_person (catalog-tools.ts) dichiarava consent: z.literal(true). La
conversione zod→JSON schema dell'SDK produce enum: [true] — e Gemini pretende enum di sole
stringhe. Non un turno perso: l'INTERO toolset rifiutato, quindi qualunque turno che monti
update_person (content, ugc, motion, web, analyst) moriva così.
Fix
consent: z.boolean() con descrizione esplicita ("true ONLY when the USER has just stated…").
Il semantica era già "solo true fa qualcosa" (l'execute guarda consent === true); ora lo dice
anche lo schema in un formato che il provider accetta.
Guardia
src/lib/server/chat/tool-schema.test.ts monta il toolset completo di ogni agente e passa gli
schema zod in cerca di enum con valori non-stringa. Riprodurrebbe il difetto il giorno in cui un
altro z.literal(non-stringa) entra in un tool.
Stabilizzazione della suite: il numero di file rossi non è più una lotteria
La task #20 descriveva "9 poi 11 file rossi sullo stesso albero". La diagnosi
ha trovato tre cause sovrapposte, non una.
Prima. Vitest girava al default: testTimeout 5s, niente config. La suite
(509 file, test I/O-bound con pipeline asincrone vere su modelli finti) produceva
22–43 fallimenti a run, quasi tutti Test timed out in 5000ms; l'insieme dei
file rossi variava col carico della macchina. In più queue-dm.test sceglieva
il ramo kit/classico secondo l'AGENT_KIT del .env locale — passava su un
laptop e moriva sull'altro — e i test di fine turno di live.test aspettavano
il salvataggio asincrono con sonni fissi da 250ms.
Decisioni.
testTimeout: 120_000invite.config.ts: i turni kit completi toccano 40s da soli e
oltre il minuto con la macchina satura. Un hang vero brucia in minuti, quindi la
soglia resta un rilevatore.- Guardrail perf di
redactallargato da 200ms a 1s: misura 48ms da solo,
~300ms sotto carico; il regex catastrofico che il guardrail caccia brucia
in secondi. Soglia sopra il rumore, sotto il disastro. queue-dm.testfissa$env/dynamic/privateconAGENT_KIT: 'off': la
scelta del ramo è parte del test, non del computer che lo esegue.- I tre test "thread incastrato" aspettano
vi.waitForsulla condizione
finale invece del sonno fisso.
Scartato. Timeout per-test espliciti sui soli test lenti: un diff largo e
fragile a ogni nuovo test I/O-bound; il default largo uniforme costa al massimo
60s una volta su un hang vero.
Ambiente, non codice. Le 5 failure "deterministiche" di
harness-pi-images (extractUserImages is not a function) erano node_modules
stantie in worktree: npm ci + copia .env e la classe sparisce. Le lezioni
(nuovo worktree, env locale nei test, sonni fissi, timeout variabile) sono in
LESSONS.md.
Static errors: 415 → 263 (tsc), 456 → 304 (svelte-check)
Perché
Il reaper dei static errors trova il debito: tsc --noEmit e svelte-check su dev
riportavano centinaia di errori, così un errore nuovo si annega in quelli vecchi e la
gate typecheck-runtime (che filtra solo TS2304/18004/2552/2554/2555) resta l'unica
difesa. Questo run accende la prima fetta di silenzio: i cluster a radice unica.
Cosa c'era prima
Il codice era scritto contro l'API precedente di ai (v6): ToolExecutionOptions
senza tipo generico, StreamTextResult<ToolSet> a un argomento. L'upgrade a ai@7
ha reso ToolExecutionOptions<CONTEXT> e StreamTextResult<TOOLS, RUNTIME_CONTEXT, OUTPUT> generici obbligatori: 78 errori TS2314 su 21 file, tutti la stessa radice.
Decisioni
ToolExecutionOptions<unknown>nei 21 file: nessun tool di chat leggeopts.context,
quindiunknownè il tipo onesto (il default dell'SDK èany; qui si dice unknown).StreamTextResultinadapters.tsnon si annota a mano: il tipo vero è quello che
HarnessAgent.streamrestituisce, e il cast aStreamTextResult<ToolSet>lo nascondeva.
AliasHarnessStreamResult = Awaited<ReturnType<InstanceType<typeof HarnessAgent>['stream']>>
e i due cast spariscono.wrapToolsrestituivaout as T— una menzogna: l'execute avvolto accetta due
argomenti, l'originale anche zero. Mapped typeWrappedTools<T>: l'execute del tool
avvolto è(input, opts) => Promise<unknown>; i test che chiamavano.execute(a, b)
su execute a zero argomenti smettono di essere errori perché ORA il tipo dice la verità.attachHarnesssostituisceprepareStepcon uno che accetta{ stepNumber }e torna
Record<string, unknown>: i test che seminavanoprepareStep: () => ({})annotano
ora la firma reale.queue-dm.testnon era ermetico:$env/dynamic/privatein vitest porta dentro il
.envLOCALE, e conAGENT_KIT=onil turno scappava nel ramo kit (live.ts), dove il
mock di./subagentsnon hacreateSubagentTools. Sul checkout principale passava
solo per cache Vite stantia. Fix: il test fissaAGENT_KIT: 'off'— percorso classico,
che è quello che intende provare. Lezione in LESSONS.md.
Scartato
as any/@ts-ignorecome scorciatoia: proibiti dalle regole del repo, ogni errore
risolto alla radice.ToolExecutionOptions<Record<string, unknown>>ovunque:unknownbasta dove il
context non si tocca;Recordsolo nell'agent-lab, dove l'execute lo esige.
2026-08-28 — Delegare è parte del mestiere
Perché
La task #8 chiede agenti proattivi nel lavoro di squadra: chi ha un compito che contiene un
pezzo di un altro mestiere deve girarlo al collega senza aspettare l'ordine, e condividere con
la squadra le nozioni che le servono. Il meccanismo esisteva già (message_agent,
open_session_with_user); mancava il comportamento e una misura.
Cosa c'era prima
ORCHESTRATION_BLOCKdescriveva la differenza sub-agent/colleagues ma non imponeva mai la
proattività: la delega partiva solo se l'utente (o il brief) la chiedeva.message_agentrifiutava il fan-out (N destinatari) senzabecause_user_asked, anche quando
chi chiamava stava eseguendo un obiettivo aperto che richiedeva più mestieri.- Nessun fatto nell'eval:ux misurava la delega.
Decisioni
- Prompt-layer prima, tool dopo (scelta di design): la regola nuova sta in
ORCHESTRATION_BLOCK— "PROACTIVE BY DEFAULT": il pezzo di altro mestiere si gira con UNA riga
dimessage_agent, e la nozione che serve a un collega parte in DM (e inadd_memoryse deve
sopravvivere al turno). Nessun trasporto nuovo: se l'eval mostra che il modello non lo fa, si
valuta un tool dedicato in una task futura. - Fan-out sbloccato dall'obiettivo, non dal buon cuore: con un goal aperto sul thread il
fan-out passa senzabecause_user_asked— chi lavora un mandato sta coordinando, non
allargandosi. Senza goal, la regola sociale resta identica. - Il fatto della delega nell'eval:ux: dopo i contatti del team, il walk manda una domanda
cross-craft (audit + idee post) edelegationFactsconta i DM agente-agente con messaggi.
Non-gate oggi: è la baseline comportamentale che decide se il prompt basta o serve il tool.
Scartato
- Nuovo tool
delegate_to_agent: transport duplicato dimessage_agent; si riprende solo se
l'eval dimostra che il prompt non basta. - Canale di scrittura nel team journal per le notifiche: il journal è già scritto dai report di
fine turno; la notifica che serve a un collega vive nel DM, la nozione che serve a tutti nel
brand memory. - Gate del fan-out rimosso del tutto: senza obiettivo, un fan-out deciso dall'agente resta il
rumore che la regola era nata a fermare.
OpenRouter chat streams ended as errors without a finish reason
OpenRouter can close a valid OpenAI-compatible stream after sending response
text but omit finish_reason. The bundled Pi adapter treated that provider
behavior as a fatal error, so chat turns were marked failed even when the
response had already arrived. The failure was specific to the legacy
@earendil-works/pi-ai dependency used by @ai-sdk/harness-pi.
The dependency patch now marks OpenRouter as tolerant of missing finish
reasons and infers stop for text responses or toolUse when a tool call was
received. Explicit provider errors and missing finish reasons from other
providers remain fatal. A regression test feeds the adapter an OpenRouter SSE
response with valid text and no finish reason, then verifies the completed
result.
2026-08-28 — Il team si presenta nell'onboarding
Perché
La task #38 misura un difetto reale con npm run eval:ux (criterio team-of-agents, FAIL 1/4):
un brand nuovo riceve un solo contatto — l'Analyst. L'utente deve avvertire di parlare con un
intero team, ma nessun altro agente lo contatta mai: il contatto degli altri dipendeva dal modello
che decidesse di usare message_agent/open_session_with_user, cioè da una speranza, non da una
garanzia.
Cosa c'era prima
seedOnboardingChatseminava solo il threadsurface='onboarding'con l'Analyst.- Gli altri specialisti aprono il loro thread utente (
surface='team',getOrCreateTeamThread)
solo se il modello lo sceglieva a runtime (o al checkout, viaigniteBrandTeam, senza contatto). - L'eval:ux giudicava il criterio solo dagli snapshot, senza un fatto del database.
Decisioni
- Server-deterministico, non model-driven. Chiuso il primo turno di setup (
processNextQueuedChatJob,
threadsurface='onboarding'), la coda chiamaigniteOnboardingTeam: gli specialisti mappati
sul piano contattano l'utente nei loro thread. L'eval resta la guardia, ma la garanzia è nel codice. - Mappatura dichiarata in una tabella (
ONBOARDING_TEAM_CONTACTS): go → content+web,
starter → +ugc, pro → +motion, default content+web. Un piano nuovo è una riga, non un if. - La ricetta di
open_session_with_user, non un messaggio utente fabbricato. Riga di apertura
ASSISTANT firmata (chat_messages.name= agente, testo statico che dichiara mestiere e prima
azione) + turno di continuazione accodato con agente forzato e brief server-side. Nel thread di
squadra l'utente non ha scritto nulla: una rigausermai scritta da lui sarebbe un falso nel
transcript. L'incarico operativo sta nelbrief(system prompt, mai nel thread). - Il turno di continuazione porta SEMPRE un testo solo-per-il-modello (
user_messagenon
vuoto, mai salvato né mostrato). Scoperto in verifica end-to-end: conuser_messagevuoto il
job moriva due volte — prima col gateMissing user_message, poi (una volta superato il gate)
col prompt vuoto, perché il provider rifiuta una conversazione che non apre con un turno user e
dropLeadingAssistantmangia l'apertura firmata. La stessa correzione ha risanato
open_session_with_user, che portava lo stesso difetto dalla nascita. - Idempotenza sul contatto, non sul thread. Il thread è get-or-create (indice 0199); il contatto
salta se esiste un job in volo sul thread o una firma dell'agente (name) tra i messaggi. I seed
statici del diario (igniteBrandTeam) non hanno firma e non bloccano il primo contatto vero. - Nessun gate nuovo (scelta presa in design): i turni passano dai tetti di credito esistenti
nella coda, come il turno di Analyst. Custom agent esclusi: all'onboarding non esistono ancora. - Fatti prima dei gusti nell'eval:
teamContactFactsconta i thread di squadra con almeno un
messaggio assistente firmato;team-of-agents-contactentra nei flow facts come gate. Baseline
rossa scritta e osservata prima della fix.
Scartato
- Room di gruppo nel thread di onboarding (GROUP_CHATS): dietro flag, anima solo su messaggio
utente, non misurabile per-agente. Restrà per un modello diverso di convivenza. - Turno dei contatti innescato da Analyst via tool: tornava la dipendenza dal modello che la task
#38 critica. - Ritardi artificiali tra i contatti: la coda è già serializzata; lo stagger era effetto cosmetico
a costo di stato.
Il team contatta il nuovo utente anche sul motore kit
Perché esiste
PR #32 ("Team contacts the new user") seminava il primo contatto degli specialisti
dopo il turno di setup dell'onboarding. Ma il blocco che la eseguiva era stato
scritto solo nel percorso classico del queue worker: con AGENT_KIT=on
(default del .env, quindi anche in produzione) il turno di setup gira sul ramo
kit di queue.ts, che chiude il job e fa return prima di arrivare al blocco
igniteOnboardingTeam del percorso classico. Risultato misurato dall'audit della
task #39 (3 onboarding completi su stack locale): setup done, DM fra agenti
creati, zero thread surface='team' — nessun collega contattava mai
l'utente. La funzione era sana (invocazione diretta funzionava, idempotenza
garantita da teamThreadContacted): era solo il percorso che non la raggiungeva.
Cosa cambia
L'ignite è estratto in igniteTeamAfterSetupTurn e chiamato dal punto di
completamento condiviso da entrambi i motori: il ramo kit lo esegue dopo aver
chiuso il job (e solo se il job è davvero stato eseguito — un 409 kit_busy
torna pending senza seminare nulla), il percorso classico lo esegue come prima.
Una sola copia della regola.
Decisioni
- Percorso condiviso, non duplicazione: la quarta regola del design semplice.
L'alternativa (copiare il blocco nel ramo kit prima del return) lasciava due
copie della regola che divergono alla prima modifica. - Nessuna nuova blindatura: il try/catch con log
[onboarding-team] contact failedesiste già dentroigniteOnboardingTeam— il ramo kit non ne ha
bisogno di uno proprio. - Test guardian:
queue-kit-onboarding-ignite.test.tsriproduce il difetto
(job kit su threadsurface='onboarding'→ threadsurface='team'di
teamContactsForPlan(plan)devono esistere); scritto prima del fix, visto
fallire, poi verde. - Guardia eval:
eval:uxora decide il motore nel run, non ereditandolo
dal.envdel momento (AGENT_KIT=on npm run eval:uxmisura il ramo kit);
il report registraagentKitin meta e il runId distingue la variante
(ux-kit-*). Senza questo, la guardia non copriva il ramo che in produzione
gira davvero.
2026-08-28 — eval:ux, l'eval e2e dell'esperienza utente
Perché
La task Notion #34 chiede test e2e globali in cui l'AI verifica che l'esperienza utente sia quella
voluta, partendo dall'onboarding, con un comando solo appigliabile a un agente in cron. Fino ad
oggi l'unico e2e è la smoke suite db-free di Playwright (tests/e2e/), che non tocca né
l'onboarding né la chat: non c'era nulla che misurasse l'esperienza.
Cosa c'era prima
Niente. In package.json restava solo il riferimento pendente test:durability → eval:durability;
scripts/eval/ e docs/EVAL_PLAN.md citati da AGENTS.md non esistevano. Questo è il primo pezzo
di quell'harness.
Decisioni
- Hybrid walker, non agente libero: la camminata (login → onboarding → pick → setup chat) è
una sequenza deterministica di comandi agent-browser (vercel-labs) con attese esplicite; il
giudizio sulle impressioni attese è un turno LLM (geminiFast) su snapshot a11y + fatti DB.
Un agente libero sarebbe non riproducibile e a costo per run variabile (ricerca
docs/research/2026-08-28-agent-browser-e2e.md, issue #1351); Playwright resta per la
regressione. - Utente eval usa-e-getta via GoTrue admin (stesso percorso di
db-seed.mjs): l'onboarding
parte da un utente senza org/brand, così è il prodotto a crearli. Il teardown cancella l'utente
(cascata su brand/chat) e svuotamedia/<userId>/su Storage — sempre, anche su fallimento. - Fatti prima dei gusti: la griglia di
scripts/eval/ux/grader.tssepara i gate deterministici
(brand creato, risposta dell'agente nella chat di setup entro la finestra) dal giudizio LLM sui
quattro criteri della task. L'exit code è 0 solo se gate e criteri passano tutti. - Server auto-avviato: se l'app non risponde su
EVAL_UX_APP_URL(default
http://localhost:4180), il comando avvia da solovite deve lo chiude alla fine; report,
evidenze (screenshot + snapshot), transcript e log del server finiscono ineval-results/<run>/. - agent-browser si invoca via
npx -y agent-browser(v0.35.1, binario Rust, nessuna dipendenza
aggiunta al package.json).
Scartato
- Giudizio multimodale sugli screenshot: v1 giudica dagli snapshot a11y; le PNG restano come
evidenza umana. v2 possibile. - Percorso wizard completo (people → competitors → strategy → plan → preview): il percorso v1 è
chat-prima ("Inizia la chat" dalla pick), quello che la task #34 descrive. I piani editoriali
sono riportati come informazione, non come gate.
CLI, MCP e skill vivono in questa repo (cli/)
La repo pubblica andreabuttarelli/anomalia-cli chiude: sorgente di CLI, MCP
server (stdio e HTTP), skill agente, plugin Claude/Codex e formula Homebrew
traslocano in cli/, senza copie divergenti da tenere allineate a mano.
La release è un tag cli-vX.Y.Z qui: il workflow cli-release.yml builda i
binari standalone (macOS/Linux, arm64/x64) + checksum, li attacca alla GitHub
Release, aggiorna la formula Homebrew, la pusha su anomaliaso/homebrew-tap
(serve il secret TAP_TOKEN) e pubblica anomalia-cli su npm (secret
NPM_TOKEN). install.sh, anomalia update, npm e Homebrew cambiano tutti
puntamento alla nuova posizione; i manifest dei marketplace plugin
(.claude-plugin/, .agents/plugins/) salgono alla root della repo perché
/plugin marketplace add anomaliaso/anomalia li trovi lì. La vecchia repo va
archiviata dopo la prima release da qui — prima un ultimo commit che reindirizza
il suo install.sh ai nuovi asset, così chi ha già installato non si rompe.
anomalia login --email --password [--password-stdin]
Oltre al flusso browser (che resta il default: unico che supporta provider SSO
e non mette la password negli argomenti di shell) il login accetta ora le
credenziali direttamente, per script e CI: due flag insieme o niente flag,
niente prompt interattivi da nascondere. --password-stdin legge la password
da standard input, fuori da shell history e ps aux. Stessa sessione su
~/.config/anomalia/session.json, stesso refresh silenzioso di sempre.
Il corsivo _…_ che arrivava crudo
Prima: renderMd (src/lib/chat-markdown.ts) sapeva rendere **…**, __…__,
*…*, ~~…~~ ma non _…_ — l'unico stile che il server scrive da solo, e non
per eleganza. failChatJob (queue.ts) quando una ripresa in background muore
senza produrre nulla scrive una riga _…_ nel thread (la «ripresa che muore
senza dirlo», v. CHANGELOG.md:4574), e i goal notice in forma vecchia
portano _…_. Resultato: gli underscore crudi a schermo, nel caso
evidenziato dall'utente («La ripresa in background non è partita — …»).
Ora: renderMd rende _…_ in <em>. Le guard sono parola-per-parola
(lookbehind/lookahead [\w]), così foo_bar_baz resta intatto e _…_ non
ruba testo dentro identificatori. Il filtro sta dopo __…__ → <strong>:
il bold ha sempre la precedenza.
Decisioni:
- Guard a confini di parola, non regex ingorda. Il markdown di CommonMark
esclude underscore intraparo; senza guard, codici e slug del testo del
modello (run_seo_geo_audit,foo_bar_baz) diventerebbero corsivo spezzato. - Niente post-processing tipo goal-status.
splitGoalStatusconverte
_…_in*…*perché il suo paragrafo vive anche nel transcript del modello
e nella CLI. In chat il testo può essere reso direttamente: un fix nel
renderer è più in basso e serve ogni surface che passa darenderMd
(chat globale, thread, editor del post, pannello goal). - Test a tre quase: notice del server in corsivo vero, snake_case
intatto,__…__ancora bold (non doppio consumo).
Le immagini allegate in chat uccidevano il turno sul motore harness
Un utente allega una foto alla chat e il turno muore con «pi: only text
user-message parts are supported; got 'image'». Il percorso: la chat gira
sull'harness pi (live.ts → startHarnessTurn → HarnessAgent →
@ai-sdk/harness-pi), e l'adattatore pi 1.0.89, dentro extractUserText,
scartava OGNI parte non-testo del messaggio utente con
HarnessCapabilityUnsupportedError. Il catch del bridge lo scriveva in chat
come «Errore del turno». Nemmeno l'ultima versione a monte (1.0.93) passa le
immagini: l'adattatore accetta solo stringhe mentre pi stesso le supporta
da sempre (session.prompt(text, { images }), ImageContent base64).
Due decisioni del proprietario: (1) NIENTE scambio di modello — il metodo
vecchio (vision: true in resolveChatModel, che rimpiazzava un pick
solo-testo con Luna sul turno) è RIMOSSO: l'LLM di chat è e deve restare
vision-native, e un tier scelto a mano resta quello che è. (2) Le immagini
devono arrivare all'LLM per il canale nativo, non per sostituzione. La
strada alternativa — appiattire le parti immagine in righe di testo
[image: url] al confine del kit — è stata scartata: il modello riceve un
URL che non può aprire, ed è il modo in cui nasce la risposta inventata su
un'immagine mai vista.
Fix in tre pezzi. Sul motore classico: via visionFallback, via l'opzione
vision e i suoi call sites (turn-prep, chat/+server, il campo morto
vision in RunKitTurnInput). Sul motore harness: patch a
@ai-sdk/harness-pi (patches/@ai-sdk+harness-pi+1.0.89.patch, applicata da
postinstall con patch-package) — extractUserText non lancia più sulle
parti immagine, una nuova extractUserImages converte le parti immagine del
messaggio utente in ImageContent pi (data URL, URL remoto scaricato, byte
grezzi; un'immagine irraggiungibile si scarta invece di uccidere il turno) e
doPromptTurn le passa a session.prompt(text, { images }), il canale
nativo di pi-coding-agent. E il manifest: ensureKieAgentDir scriveva i
modelli come { id }, quindi pi assumeva input: ["text"] e ometteva i
pixel a monte della chiamata («image omitted: model does not support
images») — ora ogni modello servito dal bridge dichiara
input: ["text", "image"]. Il limite del patch-package (pinnato a 1.0.89,
un bump della dipendenza lo invalida) è il costo noto: il postinstall lo
riapplica a ogni install.
Le funzioni patchate sono esportate dal bundle perché il contratto sia
testabile: harness-pi-images.test.ts le copre (5 casi, incluso il limite
di 8 immagini). Suite unit completa verde (5786); verificato nel browser
sullo stack locale: immagine allegata descritta dal modello (testo e colori
letti dal pixel), turno solo-immagine, doppio invio, reload a metà
conversazione e viewport mobile. Durante la verifica sono emersi difetti
dello stack locale che silenziavano gli upload (niente USAGE ON SCHEMA storage per i ruoli, membership mancanti per supabase_storage_admin,
volume file senza xattr): riparati nell'ambiente, candidati per un
seed-repair self-host — non fanno parte di questa voce. Resta noto che la
nota [N image(s) attached] compare nel testo del messaggio utente: il
cleanup è su branch separato.
Skill di scrittura di brand sempre nell'agente chat
Perché
Il prodotto di Anomalia è testo che parla per un brand, e il testo che sa di
chatbot è un difetto di qualità che i test non vedono (i modelli finto-agente
non giudicano la prosa). Le valutazioni misurano fatti, non slop: serve una
barra strutturale dentro il modello, non una regola di stile nel prompt.
Cosa
Tre candidate valutate da skills.sh, vendorate in src/lib/agent-docs/skills/
e cucite direttamente in HarnessAgent (skills: []) dentro startHarnessTurn:
- humanizer (blader/humanizer, MIT, v2.11.2) — 28 pattern di AI-writing da
Wikipedia "Signs of AI writing", con procedura di rewrite che preserva i
claim. Modalità revisione. - stop-slop (hardikpandya/stop-slop, MIT) — regole di generazione:
struttura, ritmo, false agency, attivo. Modalità stesura. I riferimenti
(phrases.md,structures.md,examples.md) viaggiano comefilesdella
skill, come da contrattoHarnessV1Skill. - understand-anything (egonex-ai) — scartata: nove skill di comprensione
codice per agenti di coding, fuori dal dominio dei brand e già coperte nel
repo dalle nostre skill.
Decisioni
- Sempre attive, senza varco: il gate
HARNESS_SKILLSresta per le skill del
repo (default off perché pensate per agenti di codice); queste due sono
brand-facing e viaggiano al di fuori della selezione. - I markdown restano file veri inlineati con
?raw(pattern già in uso in
agent-files.ts): diffabili contro upstream, niente 43KB in template
literal dove backtick e${sono mine sul percorso. - Il frontmatter passa da
parseSkillFrontmatter— un solo parser, due usi.
Skill per agente, non per tutti
Perché
Con le skill di scrittura sempre addosso a tutti (entry precedente) il passo
successivo era chiedersi CHI riceve COSA: la task Notion 35 chiedeva se ogni
agente del team ha skill diverse. La risposta era no — lo stesso mazzo per
ogni turno — ma la cucitura esisteva già: startHarnessTurn è per-turno e il
chiamante (live.ts) conosce spec.id.
Cosa
startHarnessTurnprendeagentIde il bridge lo passa (live.ts).skillsForAgent(agentId)inbrand-skills.tsè la mappa: ogni specialista
e il generalista ricevono le due skill di scrittura; il Motion aggiunge
remotion-best-practicesdal repo — l'unico mestiere che scrive sorgente
Remotion. Agente sconosciuto = le due di scrittura (fallback, non vuoto).HARNESS_SKILLSresta additivo e globale, com'era.
Scartato
Una colonna skills su custom_agents: i custom agent girano sul motore
classico (il drain esclude DM, persona e stanze dal percorso kit), quindi non
consumerebbero skill harness — peso morto. La superficie arriva quando (se)
i custom agent salgono sul kit.
Da fare quando cresce il mazzo
Nuova skill + riga nella mappa. Il nome vale per entrambe le sorgenti; un
nome che non esiste da nessuna parte cade in silenzio.