import type { MsgActionRequest, MsgGameOver, MsgMatchConfirmRequest } from "../protocol/types"; import type { ServerMessageEnvelope } from "../wsclient/frame-handler"; import type { WSClientMessage, WSWelcome } from "../wsclient/client"; import type { ReconnectCloseInfo, ReconnectEvent } from "../wsclient/reconnect"; export type AgentPhase = "connected" | "queuing" | "confirming" | "matching" | "in_match" | "deciding" | "reporting" | "closed"; export type AgentTransportState = "connected" | "backoff" | "closed"; export interface AgentFSMState { readonly phase: AgentPhase; readonly transport: AgentTransportState; readonly agentId: string; readonly agentName: string; readonly availableGames: readonly string[]; readonly autoConfirmMatches: boolean; readonly queue?: { readonly game: string; readonly mode: string; readonly one_shot?: boolean; }; /** * Bookkeeping for an OPTIMISTIC join_queue: set when command.join_queue * applies the queue locally before the server's verdict arrives, cleared by * queue_joined (accept), an error frame (reject → rolled back), leave_queue, * game_start, or a reconnect. `previous` is the last CONFIRMED queue * membership, so a rejected join restores what the server actually still * holds instead of leaving the runtime believing in a queue that was * refused (the 2026-07-29 desktop state fork: app showed the new game, * the server still had the old one). */ readonly pendingQueueJoin?: { readonly previous?: { readonly game: string; readonly mode: string; readonly one_shot?: boolean; }; }; readonly pendingConfirm?: MsgMatchConfirmRequest["data"]; /** * D1: set once we have SENT match_confirm and are waiting for game_start — * i.e. the "matching" phase. Without it that fact would live only in the * `phase` scalar, and phase is now a derived projection (see derivePhase), so * it has to be recoverable from real state. Single-slot on purpose, same as * `queue`/`pendingConfirm`: the server gives an agent one queue entry. */ readonly confirmed?: { readonly confirmId: string; readonly game: string; readonly mode: string; }; readonly activeMatch?: AgentFSMActiveMatch; readonly activeMatches?: Readonly>; readonly pendingAction?: MsgActionRequest; readonly pendingActions?: Readonly>; /** * Most-recently-processed action_request `request_id` per match (R13-F02 * idempotency). A repeat delivery of the same request_id while that match * still has a decision in flight is dropped instead of spawning a second * (paid) provider call. A DIFFERENT request_id for the same match is a * genuine supersede and is processed (the agent aborts the stale call). */ readonly lastRequestIds?: Readonly>; readonly lastGameOver?: MsgGameOver; readonly lastError?: string; } export interface AgentFSMActiveMatch { readonly sessionId: string; readonly game: string; readonly startedAt: number; } /** * Wire-shape model usage metadata attached to an outbound action message * (protocol v1.1 client_action.schema.json `usage`). Token counts only — * never prompts or model output. Field names are snake_case because this * object is sent verbatim on the wire. */ export interface AgentDecisionWireUsage { readonly model: string; readonly input_tokens?: number; readonly output_tokens?: number; readonly reasoning_tokens?: number; readonly cached_tokens?: number; readonly cache_write_tokens?: number; } /** * Wire-shape decision-provenance telemetry attached to an outbound action * message (protocol v1.2 client_action.schema.json `decision`, F09/AIF-03): * who actually authored the action — the model, the model after corrective * feedback, or the bridge's deterministic fallback. Carried separately from * `usage` because a fallback decision involves no model call. snake_case: * sent verbatim on the wire. */ export interface AgentDecisionWireDecision { readonly source: "model" | "model_retry" | "fallback"; readonly illegal_retries?: number; readonly fallback_reason?: string; } export type AgentFSMInput = { type: "start"; welcome: WSWelcome; autoConfirmMatches?: boolean; now?: number; } | { type: "command.join_queue"; game: string; mode?: string; oneShot?: boolean; } | { type: "command.leave_queue"; } | { type: "command.confirm_match"; confirmId?: string; } | { type: "ws.message"; message: ServerMessageEnvelope; now?: number; } | { type: "decision.ready"; action: unknown; matchId?: string; usage?: AgentDecisionWireUsage; decision?: AgentDecisionWireDecision; } | { type: "decision.failed"; reason: unknown; matchId?: string; } /** * D2: an outbound message could not be handed to the socket. Without this the * FSM believed every send succeeded — see sendFailed() for what that cost. */ | { type: "send.failed"; message: WSClientMessage; restore?: MsgActionRequest; cause: unknown; } | { type: "reconnect.event"; event: ReconnectEvent; } | { type: "reconnect.close"; info: ReconnectCloseInfo; } | { type: "stop"; reason?: string; }; export type AgentFSMEffect = /** * D2: `restoreOnFailure` is the action_request this message answers. Effects * run on a serialized queue, so the send happens strictly AFTER the state * update that produced it — carrying the payload here is what lets a failed * send be undone without parking it in FSM state for the gap in between. */ { type: "send"; message: WSClientMessage; restoreOnFailure?: MsgActionRequest; } | { type: "request_decision"; actionRequest: MsgActionRequest; matchId: string; game?: string; requestId?: string; } | { type: "fallback_required"; actionRequest: MsgActionRequest; reason: unknown; } | { type: "record_result"; gameOver: MsgGameOver; game?: string; } | { type: "notify"; level: "info" | "warning" | "error"; code: string; message: string; }; export interface AgentFSMTransition { readonly state: AgentFSMState; readonly effects: readonly AgentFSMEffect[]; } export interface CreateInitialAgentFSMInput { readonly welcome: WSWelcome; readonly autoConfirmMatches?: boolean; readonly now?: number; } export declare function createInitialAgentFSM(input: CreateInitialAgentFSMInput): AgentFSMState; export declare function transitionAgentFSM(state: AgentFSMState, input: AgentFSMInput): AgentFSMTransition;