From 6ccd79baa09baca02b1a224b365b808f565213dc Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 13 Sep 2026 00:07:19 +0100 Subject: [PATCH] feat(maestro): add Android wire-format data types Kotlin data classes mirroring maestro-protocol's wire types (Session, CatalogEvent, ClientControl, structured-session projections, etc.), @Serializable with kotlinx-serialization. ActivityState and NameSource carry custom lenient deserializers matching the Rust side's forward- compat behavior: an unknown variant degrades to the neutral default instead of failing the whole payload. Verified against fixtures mirrored from maestro-protocol's own test suite (tag placement, snake_case renames, legacy-payload defaults). --- .../pt/naps/maestro/data/ActivityState.kt | 43 ++++++ .../java/pt/naps/maestro/data/ApiError.kt | 19 +++ .../java/pt/naps/maestro/data/BranchInfo.kt | 17 +++ .../java/pt/naps/maestro/data/CatalogEvent.kt | 37 +++++ .../pt/naps/maestro/data/ClientControl.kt | 30 ++++ .../java/pt/naps/maestro/data/DaemonInfo.kt | 41 ++++++ .../main/java/pt/naps/maestro/data/Dirs.kt | 33 +++++ .../main/java/pt/naps/maestro/data/GitRoot.kt | 9 ++ .../src/main/java/pt/naps/maestro/data/Ids.kt | 7 + .../java/pt/naps/maestro/data/InTheWay.kt | 17 +++ .../java/pt/naps/maestro/data/NameBatch.kt | 8 ++ .../java/pt/naps/maestro/data/NameSource.kt | 40 ++++++ .../main/java/pt/naps/maestro/data/Project.kt | 21 +++ .../main/java/pt/naps/maestro/data/Session.kt | 71 +++++++++ .../pt/naps/maestro/data/SessionRequests.kt | 43 ++++++ .../java/pt/naps/maestro/data/Structured.kt | 136 ++++++++++++++++++ .../maestro/data/WireSerializationTest.kt | 114 +++++++++++++++ 17 files changed, 686 insertions(+) create mode 100644 android/app/src/main/java/pt/naps/maestro/data/ActivityState.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/ApiError.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/BranchInfo.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/CatalogEvent.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/ClientControl.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/DaemonInfo.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/Dirs.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/GitRoot.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/Ids.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/InTheWay.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/NameBatch.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/NameSource.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/Project.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/Session.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/SessionRequests.kt create mode 100644 android/app/src/main/java/pt/naps/maestro/data/Structured.kt create mode 100644 android/app/src/test/java/pt/naps/maestro/data/WireSerializationTest.kt diff --git a/android/app/src/main/java/pt/naps/maestro/data/ActivityState.kt b/android/app/src/main/java/pt/naps/maestro/data/ActivityState.kt new file mode 100644 index 0000000..53a23ca --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/ActivityState.kt @@ -0,0 +1,43 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Session activity, orthogonal to [SessionStatus]. Mirrors + * `maestro_protocol::ActivityState`. + * + * Deserializes leniently: an unknown variant from a newer daemon decodes as + * [Idle] instead of failing the whole [Session]. + */ +@Serializable(with = ActivityStateSerializer::class) +enum class ActivityState { + Active, + Idle, + AwaitingInput, + Error, + Starting, +} + +object ActivityStateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("ActivityState", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: ActivityState) { + encoder.encodeString(value.name) + } + + override fun deserialize(decoder: Decoder): ActivityState = + when (decoder.decodeString()) { + "Active" -> ActivityState.Active + "AwaitingInput" -> ActivityState.AwaitingInput + "Error" -> ActivityState.Error + "Starting" -> ActivityState.Starting + else -> ActivityState.Idle + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/data/ApiError.kt b/android/app/src/main/java/pt/naps/maestro/data/ApiError.kt new file mode 100644 index 0000000..a261a87 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/ApiError.kt @@ -0,0 +1,19 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.Serializable + +/** Structured failure body inside an [ApiError] envelope. Mirrors + * `maestro_protocol::ErrorInfo`. */ +@Serializable +data class ErrorInfo( + val code: String, + val message: String, + /** Set only by the kill guard: what blocked an unforced + * `DELETE /api/sessions/:id`. */ + val unsaved_work: InTheWay? = null, +) + +/** The envelope every `/api` failure is wrapped in: `{"error": {...}}`. + * Mirrors `maestro_protocol::ApiError`. */ +@Serializable +data class ApiError(val error: ErrorInfo) diff --git a/android/app/src/main/java/pt/naps/maestro/data/BranchInfo.kt b/android/app/src/main/java/pt/naps/maestro/data/BranchInfo.kt new file mode 100644 index 0000000..158d83c --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/BranchInfo.kt @@ -0,0 +1,17 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.Serializable + +/** One local branch of a repository, from `GET /api/branches`. A client + * disables the entry when [is_head] is set or [worktree] is present — git + * refuses to check the same branch out in two worktrees at once. Mirrors + * `maestro_protocol::BranchInfo`. */ +@Serializable +data class BranchInfo( + /** Short branch name (`main`, `feature/foo`). */ + val name: String, + /** Whether this branch is the main checkout's current HEAD. */ + val is_head: Boolean, + /** Toplevel path of the linked worktree holding this branch, if any. */ + val worktree: String? = null, +) diff --git a/android/app/src/main/java/pt/naps/maestro/data/CatalogEvent.kt b/android/app/src/main/java/pt/naps/maestro/data/CatalogEvent.kt new file mode 100644 index 0000000..314d76d --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/CatalogEvent.kt @@ -0,0 +1,37 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Messages the daemon broadcasts to clients over `WS /api/events`. Mirrors + * `maestro_protocol::CatalogEvent`; tagged by a `type` field. + * + * A subscriber receives a single [Snapshot] immediately on connect, then a + * stream of deltas as sessions are created, updated, and killed. Skipped + * events (broadcast channel lag) surface as [Lagged]. + */ +@Serializable +sealed class CatalogEvent { + @Serializable + @SerialName("Snapshot") + data class Snapshot(val sessions: List) : CatalogEvent() + + @Serializable + @SerialName("SessionAdded") + data class SessionAdded(val session: Session) : CatalogEvent() + + @Serializable + @SerialName("SessionRemoved") + data class SessionRemoved(val id: Uuid) : CatalogEvent() + + @Serializable + @SerialName("SessionUpdated") + data class SessionUpdated(val session: Session) : CatalogEvent() + + /** Server-issued notification that the broadcast queue dropped frames. + * Clients should re-fetch a full snapshot. */ + @Serializable + @SerialName("Lagged") + data class Lagged(val skipped: Long) : CatalogEvent() +} diff --git a/android/app/src/main/java/pt/naps/maestro/data/ClientControl.kt b/android/app/src/main/java/pt/naps/maestro/data/ClientControl.kt new file mode 100644 index 0000000..3039eb4 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/ClientControl.kt @@ -0,0 +1,30 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Text-frame control messages on a `WS /api/attach/:id` connection. PTY + * bytes travel in binary frames and are not modeled here. Mirrors + * `maestro_protocol::ClientControl`; tagged by a `type` field. */ +@Serializable +sealed class ClientControl { + @Serializable + @SerialName("Resize") + data class Resize(val cols: Int, val rows: Int) : ClientControl() + + @Serializable + @SerialName("Ping") + object Ping : ClientControl() + + @Serializable + @SerialName("Pong") + object Pong : ClientControl() + + @Serializable + @SerialName("Error") + data class Error( + val code: String, + val message: String, + val unsaved_work: InTheWay? = null, + ) : ClientControl() +} diff --git a/android/app/src/main/java/pt/naps/maestro/data/DaemonInfo.kt b/android/app/src/main/java/pt/naps/maestro/data/DaemonInfo.kt new file mode 100644 index 0000000..fe21235 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/DaemonInfo.kt @@ -0,0 +1,41 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.Serializable + +/** `GET /api/info` — the daemon's identity plus its spawn presets. Mirrors + * `maestro_protocol::DaemonInfo`. */ +@Serializable +data class DaemonInfo( + val hostname: String = "", + /** The protocol version the daemon implements. */ + val version: String = "", + /** Home dir of the user the daemon runs as; empty for older daemons. */ + val home_dir: String = "", + val presets: List = emptyList(), + val build: BuildInfo = BuildInfo(), + /** Identity of the daemon *process*, regenerated on every start — a + * changed value across a reconnect means the daemon restarted. */ + val instance: String = "", + /** Canonical leader key the operator configured (`[tui].leader`), so a + * client binds the same chord the TUI does. Empty means "use your + * default". */ + val leader: String = "", +) + +/** A spawn-form command preset advertised by the daemon. */ +@Serializable +data class SpawnPreset( + val id: String, + val label: String, + val command: String, + val auto_approve: Boolean = false, +) + +/** Build provenance of the running daemon. Every field is empty when the + * binary was built outside a git checkout. */ +@Serializable +data class BuildInfo( + val commit: String = "", + val commit_date: String = "", + val dirty: Boolean = false, +) diff --git a/android/app/src/main/java/pt/naps/maestro/data/Dirs.kt b/android/app/src/main/java/pt/naps/maestro/data/Dirs.kt new file mode 100644 index 0000000..431299d --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/Dirs.kt @@ -0,0 +1,33 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.Serializable + +/** `GET /api/dirs?path=` — one directory's immediate subdirectories. Mirrors + * `maestro_protocol::DirListing`. */ +@Serializable +data class DirListing( + /** Resolved absolute directory that was listed (after ~/relative + * expansion). */ + val path: String, + /** Immediate subdirectory names, sorted case-insensitively. */ + val entries: List = emptyList(), + /** True when the listing was capped server-side. */ + val truncated: Boolean = false, +) + +/** One deduplicated recent working dir for the spawn form's recents picker. + * Mirrors `maestro_protocol::RecentDir`. */ +@Serializable +data class RecentDir( + val cwd: String, + val last_command: String, + val last_used: IsoTimestamp, +) + +/** `GET /api/recent-dirs` — the daemon's recent working dirs, newest-first + * and deduped by `cwd`. */ +@Serializable +data class RecentDirs( + val dirs: List = emptyList(), + val truncated: Boolean = false, +) diff --git a/android/app/src/main/java/pt/naps/maestro/data/GitRoot.kt b/android/app/src/main/java/pt/naps/maestro/data/GitRoot.kt new file mode 100644 index 0000000..dab833c --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/GitRoot.kt @@ -0,0 +1,9 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.Serializable + +/** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or + * `null` when the path isn't inside a repo. Mirrors + * `maestro_protocol::GitRoot`. */ +@Serializable +data class GitRoot(val root: String? = null) diff --git a/android/app/src/main/java/pt/naps/maestro/data/Ids.kt b/android/app/src/main/java/pt/naps/maestro/data/Ids.kt new file mode 100644 index 0000000..2eed383 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/Ids.kt @@ -0,0 +1,7 @@ +package pt.naps.maestro.data + +/** A session id. Mirrors `uuid::Uuid` on the wire — a plain UUID string. */ +typealias Uuid = String + +/** An RFC 3339 timestamp, as the daemon serializes `chrono::DateTime`. */ +typealias IsoTimestamp = String diff --git a/android/app/src/main/java/pt/naps/maestro/data/InTheWay.kt b/android/app/src/main/java/pt/naps/maestro/data/InTheWay.kt new file mode 100644 index 0000000..03475a2 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/InTheWay.kt @@ -0,0 +1,17 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.Serializable + +/** What currently blocks an unforced kill of a session's worktree, + * recomputed by the daemon on every read. Mirrors + * `maestro_protocol::InTheWay`. */ +@Serializable +data class InTheWay( + val uncommitted_files: Int = 0, + val untracked_files: Int = 0, + val unpushed_commits: Int = 0, + /** Lines added across the tracked changes, staged and unstaged together. */ + val insertions: Int = 0, + /** Lines removed across the same tracked changes. */ + val deletions: Int = 0, +) diff --git a/android/app/src/main/java/pt/naps/maestro/data/NameBatch.kt b/android/app/src/main/java/pt/naps/maestro/data/NameBatch.kt new file mode 100644 index 0000000..de50711 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/NameBatch.kt @@ -0,0 +1,8 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.Serializable + +/** `GET /api/names?count=N` — a batch of fresh session handles. Mirrors + * `maestro_protocol::NameBatch`. */ +@Serializable +data class NameBatch(val names: List) diff --git a/android/app/src/main/java/pt/naps/maestro/data/NameSource.kt b/android/app/src/main/java/pt/naps/maestro/data/NameSource.kt new file mode 100644 index 0000000..60e0ece --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/NameSource.kt @@ -0,0 +1,40 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Where a session's stored name (`metadata["name"]`) came from. Mirrors + * `maestro_protocol::NameSource`. + * + * Deserializes leniently: an unknown spelling decodes as [User], the settled + * variant — a name wrongly painted as settled is only a missed dimming; one + * wrongly painted as provisional invites overwriting a name a person chose. + */ +@Serializable(with = NameSourceSerializer::class) +enum class NameSource { + User, + Generated, + Haiku, +} + +object NameSourceSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("NameSource", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: NameSource) { + encoder.encodeString(value.name) + } + + override fun deserialize(decoder: Decoder): NameSource = + when (decoder.decodeString()) { + "Generated" -> NameSource.Generated + "Haiku" -> NameSource.Haiku + else -> NameSource.User + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/data/Project.kt b/android/app/src/main/java/pt/naps/maestro/data/Project.kt new file mode 100644 index 0000000..e09f0e3 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/Project.kt @@ -0,0 +1,21 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.Serializable + +/** One discovered git repository (or linked worktree) for `GET + * /api/projects`. Mirrors `maestro_protocol::Project`. */ +@Serializable +data class Project( + /** Absolute path of the repository/worktree toplevel. */ + val path: String, + /** Basename of [path]. */ + val name: String, + val is_worktree: Boolean = false, + /** The main checkout this worktree belongs to; `null` on plain repos. */ + val base_repo: String? = null, + /** Later of the repo's git HEAD mtime and the newest spawn-history use of + * any directory inside it; `null` when neither is known. */ + val last_activity: IsoTimestamp? = null, + /** Command last spawned in this project, from spawn history. */ + val last_command: String? = null, +) diff --git a/android/app/src/main/java/pt/naps/maestro/data/Session.kt b/android/app/src/main/java/pt/naps/maestro/data/Session.kt new file mode 100644 index 0000000..8950292 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/Session.kt @@ -0,0 +1,71 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** Default for [Session.last_activity_at] / [Session.activity_since] when an + * older payload omits them — mirrors the daemon's Unix-epoch default. */ +const val EPOCH: IsoTimestamp = "1970-01-01T00:00:00Z" + +/** A live or recently-dead tmux session managed by the daemon. Mirrors + * `maestro_protocol::Session`. */ +@Serializable +data class Session( + val id: Uuid, + val cwd: String, + val command: String, + val env: Map = emptyMap(), + val metadata: Map = emptyMap(), + /** The worktree this session owns, when it was spawned with one. */ + val worktree: SessionWorktree? = null, + val status: SessionStatus, + val created_at: IsoTimestamp, + /** Current foreground command in the pane (e.g. `claude`, `bash`), as the + * daemon's activity watcher observed it. `null` until observed. */ + val foreground: String? = null, + /** Inferred activity dimension, orthogonal to [status]. */ + val activity: ActivityState = ActivityState.Idle, + /** Short human string an agent reported alongside its activity — e.g. why + * it is in [ActivityState.Error]. `null` unless an agent reported one. */ + val activity_detail: String? = null, + val last_activity_at: IsoTimestamp = EPOCH, + val activity_since: IsoTimestamp = EPOCH, + /** The pane's *current* working directory, distinct from [cwd] (the spawn + * dir). `null` until first observed. */ + val current_cwd: String? = null, + /** What currently blocks an unforced kill of this session, as the + * daemon's watcher last probed it. `null` while clear or not yet probed. */ + val blocked_work: InTheWay? = null, + /** When this session last became the pane a client was showing. `null` + * until the session has been focused at least once. */ + val last_focused_at: IsoTimestamp? = null, + /** Terminal (PTY bytes) or structured (ACP rows). Fixed at spawn and + * immutable for the session's life. */ + val view: SessionView = SessionView.Terminal, + /** The ACP session id the selected provider adapter returned from + * `session/new`. Only a [SessionView.Structured] session whose handshake + * succeeded has one. */ + val acp_session_id: String? = null, + /** When `true`, the daemon auto-approves every ACP permission request for + * this session instead of surfacing them to the client. */ + val auto_approve: Boolean = false, + /** Prompts queued on the daemon while a turn was in flight or the adapter + * was still booting. */ + val queued_prompts: List = emptyList(), +) + +@Serializable +enum class SessionStatus { Running, Dead } + +/** A session's process shape: mirrors `maestro_protocol::SessionView`. + * `Terminal` is the default. */ +@Serializable +enum class SessionView { Terminal, Structured } + +/** A session's daemon-created worktree. `base_repo` is the repository's main + * checkout, which is what the session tree groups by. */ +@Serializable +data class SessionWorktree( + val path: String, + val base_repo: String, +) diff --git a/android/app/src/main/java/pt/naps/maestro/data/SessionRequests.kt b/android/app/src/main/java/pt/naps/maestro/data/SessionRequests.kt new file mode 100644 index 0000000..de8f457 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/SessionRequests.kt @@ -0,0 +1,43 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** `POST /api/sessions` body. Mirrors + * `maestro_protocol::CreateSessionRequest`. */ +@Serializable +data class CreateSessionRequest( + val cwd: String, + val command: String, + val env: Map = emptyMap(), + val metadata: Map = emptyMap(), + val worktree: WorktreeSpec? = null, + /** The process shape to spawn. Defaults to a `Terminal` session. */ + val view: SessionView = SessionView.Terminal, + /** When `true`, the daemon auto-approves every ACP permission request for + * this structured session. */ + val auto_approve: Boolean = false, +) + +/** Optional worktree request attached to a [CreateSessionRequest]. Mirrors + * `maestro_protocol::WorktreeSpec`. */ +@Serializable +data class WorktreeSpec( + val base_repo: String, + val branch: String, + /** When `false`, [branch] already exists and is checked out as-is. + * Defaults to `true` (create a new branch). */ + val create_branch: Boolean = true, +) + +/** `PATCH /api/sessions/:id` body: rename a live session. An empty [name] + * clears the custom one, so the card falls back to the command. Mirrors + * `maestro_protocol::RenameSessionRequest`. */ +@Serializable +data class RenameSessionRequest(val name: String) + +/** `POST /api/sessions/:id/focus` body: a client reports whether its pane is + * the one currently showing this session. Mirrors + * `maestro_protocol::SetFocusRequest`. */ +@Serializable +data class SetFocusRequest(val focused: Boolean) diff --git a/android/app/src/main/java/pt/naps/maestro/data/Structured.kt b/android/app/src/main/java/pt/naps/maestro/data/Structured.kt new file mode 100644 index 0000000..9506452 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/data/Structured.kt @@ -0,0 +1,136 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** The semantic shape of a [TranscriptRow]. Mirrors + * `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */ +@Serializable +enum class TranscriptRowKind { + @SerialName("user") User, + @SerialName("agent_text") AgentText, + @SerialName("agent_thought") AgentThought, + @SerialName("tool_call") ToolCall, + @SerialName("tool_diff") ToolDiff, + @SerialName("approval") Approval, + @SerialName("plan") Plan, + @SerialName("notice") Notice, +} + +/** One client-neutral item in a structured session transcript. [data] + * preserves the ACP payload verbatim without committing to an ACP + * dependency. Mirrors `maestro_protocol::TranscriptRow`. */ +@Serializable +data class TranscriptRow( + /** Stable within one session, so later deltas can address this row. */ + val id: String, + val kind: TranscriptRowKind, + val data: JsonElement, +) + +/** The complete transcript at a durable event-log position — a structured + * session's first `WS /api/structured/:id` message. Mirrors + * `maestro_protocol::TranscriptSnapshot`. */ +@Serializable +data class TranscriptSnapshot( + val cursor: Long, + val rows: List = emptyList(), +) + +/** A change to one transcript row, addressed by [id]. Mirrors + * `maestro_protocol::TranscriptDelta`; tagged by a snake_case `type` field. */ +@Serializable +sealed class TranscriptDelta { + @Serializable + @SerialName("append") + data class Append(val row: TranscriptRow) : TranscriptDelta() + + @Serializable + @SerialName("patch") + data class Patch(val row: TranscriptRow) : TranscriptDelta() + + @Serializable + @SerialName("remove") + data class Remove(val id: String) : TranscriptDelta() +} + +/** Per-turn indicators derived from the ACP stream — read verbatim, never + * re-derived client-side. Mirrors `maestro_protocol::TurnState`. */ +@Serializable +data class TurnState( + val responding: Boolean = false, + val thinking: Boolean = false, + val active_tool_calls: List = emptyList(), + /** Wall-clock start of the active prompt, so a reconnecting client keeps + * one continuous elapsed-time display instead of restarting at zero. */ + val started_at: IsoTimestamp? = null, + val started_context_tokens: Long? = null, +) + +/** + * A whole-session view derived from ACP notifications. Mirrors + * `maestro_protocol::ReducedState`. + * + * [pending_approval], [plan], [usage], [context] and [config_options] carry + * opaque ACP JSON — their shape is ACP's, not covered by this crate's own + * compatibility guarantee, so they are kept as [JsonElement] rather than + * modeled strongly. + */ +@Serializable +data class ReducedState( + val turn: TurnState = TurnState(), + val pending_approval: JsonElement? = null, + val plan: JsonElement? = null, + val usage: JsonElement? = null, + val context: JsonElement? = null, + val model: String? = null, + val mode: String? = null, + val config_options: JsonElement? = null, + /** Prompts queued on the daemon while a turn is in flight. */ + val queued_prompts: List = emptyList(), +) + +/** Server messages on `WS /api/structured/:id`. Mirrors + * `maestro_protocol::StructuredEvent`; tagged by a snake_case `type` field. */ +@Serializable +sealed class StructuredEvent { + @Serializable + @SerialName("transcript_snapshot") + data class TranscriptSnapshotEvent(val snapshot: TranscriptSnapshot) : StructuredEvent() + + @Serializable + @SerialName("reduced_state") + data class ReducedStateEvent(val state: ReducedState) : StructuredEvent() + + @Serializable + @SerialName("projection_update") + data class ProjectionUpdate( + val transcript: TranscriptDelta? = null, + val state: ReducedState, + ) : StructuredEvent() +} + +/** `POST /api/sessions/:id/structured/prompt` body. */ +@Serializable +data class StructuredPromptRequest(val prompt: String) + +/** `POST /api/sessions/:id/structured/config` body: change one option + * advertised by a live ACP session. */ +@Serializable +data class StructuredConfigRequest(val config_id: String, val value: JsonElement) + +/** Operator decision for a pending ACP permission request + * (`POST /api/sessions/:id/structured/approvals/:nonce` body) — a bare JSON + * string on the wire, not `{decision: ...}`. Mirrors + * `maestro_protocol::StructuredApprovalDecision`. */ +@Serializable +enum class StructuredApprovalDecision { + @SerialName("allow_once") AllowOnce, + @SerialName("allow_for_session") AllowForSession, + @SerialName("deny") Deny, +} + +/** Response of `POST /api/sessions/:id/structured/attachments`. */ +@Serializable +data class StructuredAttachmentResponse(val path: String) diff --git a/android/app/src/test/java/pt/naps/maestro/data/WireSerializationTest.kt b/android/app/src/test/java/pt/naps/maestro/data/WireSerializationTest.kt new file mode 100644 index 0000000..2caeaf5 --- /dev/null +++ b/android/app/src/test/java/pt/naps/maestro/data/WireSerializationTest.kt @@ -0,0 +1,114 @@ +package pt.naps.maestro.data + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Wire-format checks against fixtures mirrored from + * `crates/maestro-protocol/src/lib.rs`'s own test suite, so a Kotlin type + * that drifts from the Rust source of truth fails here first. */ +class WireSerializationTest { + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + private fun legacySessionJson(extra: String = "") = """ + {"id":"00000000-0000-0000-0000-000000000001","cwd":"/tmp","command":"bash", + "status":"Running","created_at":"2026-05-24T12:00:00Z"$extra} + """.trimIndent() + + @Test + fun `legacy session payload defaults new fields`() { + val s = json.decodeFromString(legacySessionJson()) + assertEquals(ActivityState.Idle, s.activity) + assertEquals(SessionView.Terminal, s.view) + assertNull(s.foreground) + assertNull(s.blocked_work) + assertEquals(EPOCH, s.last_activity_at) + } + + @Test + fun `unknown activity state degrades to Idle`() { + val s = json.decodeFromString(legacySessionJson(""","activity":"QuantumEntangled"""")) + assertEquals(ActivityState.Idle, s.activity) + } + + @Test + fun `unknown name source degrades to User`() { + assertEquals(NameSource.User, json.decodeFromString("\"Rolled\"")) + assertEquals(NameSource.Haiku, json.decodeFromString("\"Haiku\"")) + } + + @Test + fun `catalog event tags by type`() { + assertEquals( + """{"type":"Lagged","skipped":3}""", + json.encodeToString(CatalogEvent.Lagged(3)), + ) + val removed = CatalogEvent.SessionRemoved("00000000-0000-0000-0000-000000000001") + assertEquals(removed, json.decodeFromString(json.encodeToString(removed))) + } + + @Test + fun `client control tags by type`() { + assertEquals( + """{"type":"Resize","cols":120,"rows":40}""", + json.encodeToString(ClientControl.Resize(120, 40)), + ) + assertEquals(ClientControl.Ping, json.decodeFromString("""{"type":"Ping"}""")) + } + + @Test + fun `structured approval decision is a bare string`() { + assertEquals( + StructuredApprovalDecision.AllowOnce, + json.decodeFromString("\"allow_once\""), + ) + assertEquals( + "\"allow_for_session\"", + json.encodeToString(StructuredApprovalDecision.AllowForSession), + ) + } + + @Test + fun `structured event tags snake_case`() { + val event = json.decodeFromString( + """{"type":"transcript_snapshot","snapshot":{"cursor":7,"rows":[]}}""", + ) + assertTrue(event is StructuredEvent.TranscriptSnapshotEvent) + assertEquals(7L, (event as StructuredEvent.TranscriptSnapshotEvent).snapshot.cursor) + } + + @Test + fun `transcript delta tags snake_case and round trips`() { + val row = TranscriptRow( + id = "tool:read-1", + kind = TranscriptRowKind.ToolCall, + data = json.parseToJsonElement("""{"name":"vendor.read"}"""), + ) + val patch = TranscriptDelta.Patch(row) + val encoded = json.encodeToString(patch) + assertTrue(encoded.startsWith("""{"type":"patch","row":""")) + assertEquals(patch, json.decodeFromString(encoded)) + assertEquals("\"agent_thought\"", json.encodeToString(TranscriptRowKind.AgentThought)) + } + + @Test + fun `rename request encodes plainly`() { + assertEquals( + """{"name":"amber-falcon"}""", + json.encodeToString(RenameSessionRequest("amber-falcon")), + ) + } + + @Test + fun `api error decodes nested unsaved work with defaults`() { + val apiError = json.decodeFromString( + """{"error":{"code":"unsaved_work","message":"nope","unsaved_work":{"uncommitted_files":3}}}""", + ) + assertEquals(3, apiError.error.unsaved_work?.uncommitted_files) + assertEquals(0, apiError.error.unsaved_work?.unpushed_commits) + } +}