Module: Ask::SessionProtocol::Events

Defined in:
lib/ask/session_protocol/events.rb

Overview

The canonical session event vocabulary.

Every event on the wire has the same envelope:

{ "type" => "model.streaming", "seq" => 12, "payload" => { "delta" => "..." } }
  • type — one of the canonical dot-names below (additive growth only; clients must tolerate unknown types within a major version)
  • seq — monotonically increasing per session; clients use it for ordering, dedup, and replay (ask "events after seq N")
  • payload — the event body, string-keyed

Events marked interaction (approval.required, plan.proposed) are resolvable by id from ANY client through the interaction/* and plan/* methods — a terminal, web console, or bot resolves the same pending interaction, and the host tombstones delivery per subscriber.

Defined Under Namespace

Classes: Event

Constant Summary collapse

FIELD_TYPES =

Payload field types accepted by the validator.

%i[string integer number boolean object array any].freeze
TYPES =

The canonical event registry. Each entry describes the event and the shape of its payload. This registry is the single source of truth for the event vocabulary — the JSON Schema artifact is generated from it, and hosts/clients validate against it.

{
  # ── Session lifecycle ────────────────────────────────────────────
  "session.created" => {
    description: "A session was created and is ready to receive prompts.",
    interaction: false,
    payload: {
      "sessionId" => { type: :string, required: true, description: "The session identifier." }
    }
  },
  "session.ended" => {
    description: "The session was closed and its resources released.",
    interaction: false,
    payload: {
      "sessionId" => { type: :string, required: true, description: "The session identifier." },
      "reason" => { type: :string, required: false, description: "Why the session ended: closed, aborted, error, ..." }
    }
  },

  # ── Turn lifecycle ───────────────────────────────────────────────
  "turn.started" => {
    description: "A turn (prompt → response → tool loop) began.",
    interaction: false,
    payload: {
      "turnId" => { type: :string, required: true, description: "Identifies the turn; correlates all of its events." }
    }
  },
  "turn.completed" => {
    description: "A turn finished successfully. The accumulated response text is in `response`.",
    interaction: false,
    payload: {
      "turnId" => { type: :string, required: true, description: "The turn identifier." },
      "response" => { type: :string, required: false, description: "Final assistant text for the turn." },
      "inputTokens" => { type: :integer, required: false, description: "LLM input tokens used in the turn." },
      "outputTokens" => { type: :integer, required: false, description: "LLM output tokens used in the turn." },
      "cost" => { type: :number, required: false, description: "Estimated cost of the turn in USD." }
    }
  },
  "turn.failed" => {
    description: "A turn failed; the session remains usable.",
    interaction: false,
    payload: {
      "turnId" => { type: :string, required: true, description: "The turn identifier." },
      "error" => { type: :string, required: true, description: "Human-readable failure reason." }
    }
  },
  "turn.aborted" => {
    description: "A turn was aborted by the user (session/abort) or the host.",
    interaction: false,
    payload: {
      "turnId" => { type: :string, required: true, description: "The turn identifier." }
    }
  },

  # ── Model output ─────────────────────────────────────────────────
  "model.streaming" => {
    description: "A delta of the assistant's response text.",
    interaction: false,
    payload: {
      "delta" => { type: :string, required: true, description: "The next chunk of text." }
    }
  },
  "model.thinking" => {
    description: "A delta of the model's reasoning/thinking text (rendered collapsibly).",
    interaction: false,
    payload: {
      "delta" => { type: :string, required: true, description: "The next chunk of thinking text." }
    }
  },

  # ── Tool execution ───────────────────────────────────────────────
  "tool.use" => {
    description: "The model requested a tool call; execution is about to start.",
    interaction: false,
    payload: {
      "id" => { type: :string, required: true, description: "Tool call identifier; correlates use/delta/result." },
      "name" => { type: :string, required: true, description: "Tool name, e.g. bash, write." },
      "args" => { type: :any, required: false, description: "Tool arguments (object or pre-serialized string)." }
    }
  },
  "tool.delta" => {
    description: "A partial result while a tool is executing.",
    interaction: false,
    payload: {
      "id" => { type: :string, required: true, description: "Tool call identifier." },
      "name" => { type: :string, required: true, description: "Tool name." },
      "partial" => { type: :string, required: true, description: "The next chunk of tool output." }
    }
  },
  "tool.result" => {
    description: "A tool finished executing.",
    interaction: false,
    payload: {
      "id" => { type: :string, required: true, description: "Tool call identifier." },
      "name" => { type: :string, required: true, description: "Tool name." },
      "output" => { type: :any, required: false, description: "Tool output (string or structured value)." },
      "isError" => { type: :boolean, required: false, description: "True when the tool failed." },
      "durationMs" => { type: :integer, required: false, description: "Execution duration in milliseconds." }
    }
  },

  # ── Interactions (resolvable by id from any client) ─────────────
  "approval.required" => {
    description: "A tool is queued for human approval. Resolve via interaction/approve or interaction/reject.",
    interaction: true,
    payload: {
      "id" => { type: :string, required: true, description: "Interaction id; used to resolve this request." },
      "toolName" => { type: :string, required: true, description: "The tool waiting for approval." },
      "args" => { type: :any, required: false, description: "Tool arguments under review." },
      "message" => { type: :string, required: false, description: "Reason the approval was requested." },
      "autoApprovable" => { type: :boolean, required: false, description: "True when a configured rule may auto-approve." }
    }
  },
  "approval.updated" => {
    description: "A pending approval was resolved.",
    interaction: false,
    payload: {
      "id" => { type: :string, required: true, description: "The interaction id from approval.required." },
      "status" => { type: :string, required: true, enum: %w[approved rejected], description: "How it was resolved." }
    }
  },
  "plan.proposed" => {
    description: "The agent proposed a plan (plan mode). Resolve via plan/approve or plan/reject.",
    interaction: true,
    payload: {
      "id" => { type: :string, required: true, description: "Interaction id; used to resolve this proposal." },
      "plan" => { type: :string, required: true, description: "The proposed plan text." }
    }
  },
  "plan.approved" => {
    description: "A proposed plan was approved and the agent may proceed.",
    interaction: false,
    payload: {
      "id" => { type: :string, required: false, description: "The interaction id from plan.proposed, when known." },
      "plan" => { type: :string, required: true, description: "The approved plan text." }
    }
  },
  "plan.rejected" => {
    description: "A proposed plan was rejected; the agent stays in plan mode.",
    interaction: false,
    payload: {
      "id" => { type: :string, required: false, description: "The interaction id from plan.proposed, when known." },
      "plan" => { type: :string, required: true, description: "The rejected plan text." }
    }
  },

  # ── Session state ────────────────────────────────────────────────
  "todos.updated" => {
    description: "The session's todo list changed. Payload carries the full list.",
    interaction: false,
    payload: {
      "todos" => { type: :array, required: true, description: "Full todo list: [{id, title, status}]." }
    }
  },
  "file.changed" => {
    description: "A file in the workspace changed (create/modify/delete).",
    interaction: false,
    payload: {
      "path" => { type: :string, required: true, description: "Workspace-relative file path." },
      "type" => { type: :string, required: true, enum: %w[created modified deleted], description: "Kind of change." },
      "patch" => { type: :string, required: false, description: "Unified diff text for created/modified files." }
    }
  },

  # ── Errors ───────────────────────────────────────────────────────
  "error" => {
    description: "A non-fatal error was emitted outside a turn (recoverable when flagged).",
    interaction: false,
    payload: {
      "error" => { type: :string, required: true, description: "Error message." },
      "recoverable" => { type: :boolean, required: false, description: "True when the host can continue." }
    }
  }
}.freeze

Class Method Summary collapse

Class Method Details

.event(type:, seq:, payload: {}) ⇒ Event

Build and validate an event.

Parameters:

  • canonical event type

  • monotonic per-session sequence number

  • (defaults to: {})

    event body; validated against the registry

Returns:



234
235
236
237
# File 'lib/ask/session_protocol/events.rb', line 234

def self.event(type:, seq:, payload: {})
  validate_payload!(type, payload)
  Event.new(type: type, seq: seq, payload: payload)
end

.from_h(hash) ⇒ Event

Rebuild an event from its wire shape, validating as it goes.

Parameters:

  • { "type" =>, "seq" =>, "payload" => }

Returns:

Raises:



243
244
245
246
247
248
249
250
251
# File 'lib/ask/session_protocol/events.rb', line 243

def self.from_h(hash)
  hash = hash.transform_keys(&:to_s)
  raise ArgumentError, "event must be a Hash" unless hash.is_a?(Hash)
  raise ArgumentError, "event missing type" if hash["type"].nil?
  raise ArgumentError, "event missing seq" if hash["seq"].nil?

  payload = hash["payload"] || {}
  event(type: hash["type"], seq: hash["seq"], payload: payload)
end

.interaction?(type) ⇒ Boolean

Whether an event type is a resolvable interaction.

Returns:



259
260
261
262
# File 'lib/ask/session_protocol/events.rb', line 259

def self.interaction?(type)
  spec = TYPES[type]
  spec && spec[:interaction]
end

.known?(type) ⇒ Boolean

Whether type is a canonical event type.

Returns:



254
255
256
# File 'lib/ask/session_protocol/events.rb', line 254

def self.known?(type)
  TYPES.key?(type)
end

.typesObject

The canonical event types, in registry order.



265
266
267
# File 'lib/ask/session_protocol/events.rb', line 265

def self.types
  TYPES.keys
end

.validate_field!(type, field, field_spec, value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Shared field validator, used by the events, interactions, and methods registries. Public because sibling modules call it with an explicit receiver.

API:

  • private



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/ask/session_protocol/events.rb', line 299

def self.validate_field!(type, field, field_spec, value)
  kind = field_spec[:type]
  ok =
    case kind
    when :any then true
    when :string then value.is_a?(String)
    when :integer then value.is_a?(Integer)
    when :number then value.is_a?(Numeric)
    when :boolean then value == true || value == false
    when :object then value.is_a?(Hash)
    when :array then value.is_a?(Array)
    end
  unless ok
    raise ArgumentError, "event #{type} field #{field.inspect} must be a #{kind}, got #{value.class}"
  end

  if field_spec[:enum] && !field_spec[:enum].include?(value)
    raise ArgumentError,
          "event #{type} field #{field.inspect} must be one of #{field_spec[:enum].inspect}, got #{value.inspect}"
  end
end

.validate_payload!(type, payload) ⇒ true

Validate a payload against the registry spec for type. Raises ArgumentError on unknown types, missing required fields, or type/enum mismatches. Unknown extra fields are allowed (forward compatibility within a major protocol version).

Parameters:

  • canonical event type

  • event body

Returns:

  • when valid

Raises:



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/ask/session_protocol/events.rb', line 277

def self.validate_payload!(type, payload)
  spec = TYPES[type]
  raise ArgumentError, "unknown session event type: #{type.inspect}" unless spec
  raise ArgumentError, "payload for #{type} must be a Hash" unless payload.is_a?(Hash)

  spec[:payload].each do |field, field_spec|
    value = payload[field]
    if field_spec[:required] && value.nil?
      raise ArgumentError, "event #{type} missing required payload field #{field.inspect}"
    end
    next if value.nil?

    validate_field!(type, field, field_spec, value)
  end
  true
end