import { computeStateDiff } from '../../state-diff.js' import type { StateDiff } from '../../state-diff.js' import { validatePayload, type ValidationError } from './validate-payload.js' import { checkDispatchGate } from './dispatch-gate.js' import type { MsgSchemaShape } from '../factory.js' import type { MessageAnnotations } from '../../protocol.js' /** * Predict the result of dispatching `msg` without actually applying * it. Runs the reducer in isolation against the current state, * returns the would-be diff and the would-fire effects, but doesn't * commit or run anything. Lets the agent reason about a candidate * action before pulling the trigger: * * - "If I dispatch X, what will change?" — read `stateDiff`. * - "Will it fire effects? Which ones?" — read `effects`. * - "Should I batch?" — predict each, see whether the diffs * compose without conflict. * * The contract is bounded by TEA's purity assumption: the reducer * must be a pure function `(state, msg) → [newState, effects]`. LLui * reducers are pure by convention (the runtime never re-runs them * speculatively for any other reason, so impurity would already be * a latent bug). Apps whose reducers branch on `Date.now()` or read * `localStorage` will see prediction drift from real dispatch by * exactly that amount of impurity — usually negligible, sometimes * surprising; document at the call site. * * **No effects fire.** The returned `effects` array is the literal * effect descriptors the reducer produced — what `onEffect` would * have received. The agent reads them; the runtime ignores them. * This is the entire reason the tool exists separately from * `send_message`: a real dispatch hits the cloud / analytics / * persistence; a predicted one doesn't. */ export type WouldDispatchHost = { getState(): unknown /** * Run the reducer in isolation. `[newState, effects]` shape. * Implemented by the AppHandle as a thin wrapper around * `inst.def.update(state, msg)` — no flush, no subscribe, no * commit. Implementations that can't run the reducer (e.g. * test harnesses with no live instance) return null and the * tool reports unsupported. */ runReducer(msg: { type: string [k: string]: unknown }): { state: unknown; effects: unknown[] } | null /** * The compiler-emitted Msg schema, when available. Used to * validate the candidate payload structurally before running the * reducer — wrong enum values, missing discriminants, primitive * type mismatches surface as structured errors the agent can fix * in one round trip instead of probing field by field. Hosts * without schema metadata return null and the validator skips, * keeping the tool permissive (the reducer still runs). */ getMsgSchema?(): MsgSchemaShape | null /** * The compiler-emitted Msg annotations, when available. Used to apply * the SAME dispatch gate `send_message` enforces — `human-only` and * unknown variants are rejected before the reducer runs, so an agent * can't use prediction to probe a transition it's forbidden to * dispatch. Hosts without annotation metadata return null and the gate * is skipped (the reducer still runs), matching `send_message`. */ getMsgAnnotations?(): Record | null } export type WouldDispatchArgs = { msg: { type: string; [k: string]: unknown } } export type WouldDispatchResult = | { status: 'predicted' /** Diff from current state to the predicted post-reducer state. */ stateDiff: StateDiff /** Effects the reducer would emit. Order matches the reducer's return. */ effects: unknown[] } | { status: 'rejected'; reason: 'invalid' | 'unsupported' | 'human-only'; detail?: string } | { /** * The candidate Msg failed schema validation BEFORE the reducer * ran. `errors` lists every structural mismatch with a path- * keyed description. The agent reads this as "fix these fields * and retry" — no reducer side-effects to roll back, no state * change to predict around. */ status: 'rejected' reason: 'schema-mismatch' errors: ValidationError[] } | { /** * The reducer threw while running against the candidate Msg. * State is poisoned (or the reducer has a latent bug); a real * `send_message` would land state change + an error in * `drain.errors`. `would_dispatch` mirrors that contract: the * "diff" is empty (we couldn't compute it), and the throw text * is surfaced so the agent knows to back off rather than * retrying the same payload. * * Distinct from `'rejected'` because the agent learned something * different: the reducer DOES accept this Msg shape but errors * downstream. Often that means earlier state needs fixing first * (a previously-dispatched bad Msg poisoned a derived path), * not that this candidate is malformed. */ status: 'reducer-threw' message: string stack?: string } export function handleWouldDispatch( host: WouldDispatchHost, args: WouldDispatchArgs, ): WouldDispatchResult { if (!args.msg || typeof args.msg.type !== 'string') { return { status: 'rejected', reason: 'invalid', detail: 'msg.type must be a string' } } // Annotation gate — the SAME policy `send_message` enforces. Applied // before the reducer runs so `would_dispatch` can't be used to probe a // `human-only` (or undeclared) transition the agent is forbidden to // dispatch. Permissive when no annotation metadata is available. const annotations = host.getMsgAnnotations?.() ?? null if (annotations) { const gate = checkDispatchGate(args.msg.type, annotations) if (!gate.ok) { return { status: 'rejected', reason: gate.reason, detail: gate.detail } } } // Schema-driven preflight. Permissive when no schema is available — // the reducer still validates semantically. With a schema, surface // structured errors so the agent doesn't iterate one field per // round trip. const schema = host.getMsgSchema?.() ?? null const validation = validatePayload(args.msg, schema) if (!validation.ok) { return { status: 'rejected', reason: 'schema-mismatch', errors: validation.errors } } let result: { state: unknown; effects: unknown[] } | null try { result = host.runReducer(args.msg) } catch (e) { // The reducer threw — same Phase-5 contract as `send_message`: // surface the throw as structured data instead of letting it // become an HTTP 500 the agent reads as "transport failure." const err = e instanceof Error ? e : new Error(String(e)) const stack = err.stack ? err.stack.split('\n').slice(0, 8).join('\n') : undefined return stack !== undefined ? { status: 'reducer-threw', message: `${err.name}: ${err.message}`, stack } : { status: 'reducer-threw', message: `${err.name}: ${err.message}` } } if (result === null) { return { status: 'rejected', reason: 'unsupported', detail: 'host does not expose a reducer (no live component instance)', } } const prevState = host.getState() return { status: 'predicted', stateDiff: computeStateDiff(prevState, result.state), effects: result.effects, } }