Skip to content

v0.2.0

Choose a tag to compare

@github-actions github-actions released this 21 Sep 11:40

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:

  1. Server (mirror). mirrorSseToRun scrive il partial con lo stato
    piegato dallo stream; a stream finito una chip ancora running non
    riceverà più nulla, ma restava tale nel partial servito dal poll.
  2. Client (poll). applyLiveSnapshot Mergeava 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 closeDanglingToolCalls in chat-stream-events.ts (il modulo
    condiviso server/client): a stream terminato per cosa sua (evento
    finish o error visto) ogni chip ancora aperta diventa error con
    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.
  • applyLiveSnapshot mergea 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_runs dei 7
    giorni mostra un run con open_delegate: 1 nel 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

  • agentSessionRow genera il proprio id con crypto.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 logAiCall metteva 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-*.md aggiunti tra l'ultimo tag
    e HEAD, nuove entry prima. Estrarre il testo dagli .ts pubblici 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* e cli-v* restano mondi separati: release.yml filtra su v*,
    cli-release.yml su cli-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 git dev).
    La forma corta è scelta di presentazione, non di stoccaggio — come già fa
    releaseLabel(). Esposto da GET /api/v1/version insieme 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_000 in vite.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 redact allargato 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.test fissa $env/dynamic/private con AGENT_KIT: 'off': la
    scelta del ramo è parte del test, non del computer che lo esegue.
  • I tre test "thread incastrato" aspettano vi.waitFor sulla 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 legge opts.context,
    quindi unknown è il tipo onesto (il default dell'SDK è any; qui si dice unknown).
  • StreamTextResult in adapters.ts non si annota a mano: il tipo vero è quello che
    HarnessAgent.stream restituisce, e il cast a StreamTextResult<ToolSet> lo nascondeva.
    Alias HarnessStreamResult = Awaited<ReturnType<InstanceType<typeof HarnessAgent>['stream']>>
    e i due cast spariscono.
  • wrapTools restituiva out as T — una menzogna: l'execute avvolto accetta due
    argomenti, l'originale anche zero. Mapped type WrappedTools<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à.
  • attachHarness sostituisce prepareStep con uno che accetta { stepNumber } e torna
    Record<string, unknown>: i test che seminavano prepareStep: () => ({}) annotano
    ora la firma reale.
  • queue-dm.test non era ermetico: $env/dynamic/private in vitest porta dentro il
    .env LOCALE, e con AGENT_KIT=on il turno scappava nel ramo kit (live.ts), dove il
    mock di ./subagents non ha createSubagentTools. Sul checkout principale passava
    solo per cache Vite stantia. Fix: il test fissa AGENT_KIT: 'off' — percorso classico,
    che è quello che intende provare. Lezione in LESSONS.md.

Scartato

  • as any / @ts-ignore come scorciatoia: proibiti dalle regole del repo, ogni errore
    risolto alla radice.
  • ToolExecutionOptions<Record<string, unknown>> ovunque: unknown basta dove il
    context non si tocca; Record solo 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_BLOCK descriveva la differenza sub-agent/colleagues ma non imponeva mai la
    proattività: la delega partiva solo se l'utente (o il brief) la chiedeva.
  • message_agent rifiutava il fan-out (N destinatari) senza because_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
    di message_agent, e la nozione che serve a un collega parte in DM (e in add_memory se 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 senza because_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) e delegationFacts conta 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 di message_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

  • seedOnboardingChat seminava solo il thread surface='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, via igniteBrandTeam, 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,
    thread surface='onboarding'), la coda chiama igniteOnboardingTeam: 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 riga user mai scritta da lui sarebbe un falso nel
    transcript. L'incarico operativo sta nel brief (system prompt, mai nel thread).
  • Il turno di continuazione porta SEMPRE un testo solo-per-il-modello (user_message non
    vuoto, mai salvato né mostrato). Scoperto in verifica end-to-end: con user_message vuoto il
    job moriva due volte — prima col gate Missing 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
    dropLeadingAssistant mangia 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: teamContactFacts conta i thread di squadra con almeno un
    messaggio assistente firmato; team-of-agents-contact entra 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 failed esiste già dentro igniteOnboardingTeam — il ramo kit non ne ha
    bisogno di uno proprio.
  • Test guardian: queue-kit-onboarding-ignite.test.ts riproduce il difetto
    (job kit su thread surface='onboarding' → thread surface='team' di
    teamContactsForPlan(plan) devono esistere); scritto prima del fix, visto
    fallire, poi verde.
  • Guardia eval: eval:ux ora decide il motore nel run, non ereditandolo
    dal .env del momento (AGENT_KIT=on npm run eval:ux misura il ramo kit);
    il report registra agentKit in 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 svuota media/<userId>/ su Storage — sempre, anche su fallimento.
  • Fatti prima dei gusti: la griglia di scripts/eval/ux/grader.ts separa 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 solo vite dev e lo chiude alla fine; report,
    evidenze (screenshot + snapshot), transcript e log del server finiscono in eval-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. splitGoalStatus converte
    _…_ 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 da renderMd
    (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 come files della
    skill, come da contratto HarnessV1Skill.
  • 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_SKILLS resta 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

  • startHarnessTurn prende agentId e il bridge lo passa (live.ts).
  • skillsForAgent(agentId) in brand-skills.ts è la mappa: ogni specialista
    e il generalista ricevono le due skill di scrittura; il Motion aggiunge
    remotion-best-practices dal repo — l'unico mestiere che scrive sorgente
    Remotion. Agente sconosciuto = le due di scrittura (fallback, non vuoto).
  • HARNESS_SKILLS resta 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.