Compare commits

...
Author SHA1 Message Date
Bailey Dixon e2073b7692 chore: merge dev into session activity fix 2026-08-25 12:26:42 -04:00
Bailey Dixon 70b6d8ee5a Merge pull request #433 from mrvigneshvt/feat/git-state
feat: Git State — Hermex-parity repo management for host repos
2026-08-25 12:03:50 -04:00
Bailey Dixon 1f5e50ccd7 chore: merge dev into git-state 2026-08-25 11:51:44 -04:00
Bailey Dixon 5580c9d9bb Merge pull request #419 from Codename-11/feature/android-supervised-mode
feat(android): add supervised mode
2026-08-25 11:42:21 -04:00
mrvigneshvt ad107ea205 feat(git-state): add AI commit messages, stash-checkout, and push-after-commit
Commit-message generation reuses the upstream async LLM helper via the plugin's deferred-import pattern; empty staged diffs never call the model and failures degrade to an empty message plus notice. stash_checkout auto-stashes a dirty tree before switching (recoverable; stash surfaced as a notice). Push-after-commit toggle auto-starts the push confirmation flow without bypassing the confirmation token. Truncation caps consistent across all bounded endpoints. New UI strings localized across the 12-catalog parity gate.
2026-08-25 13:25:21 +00:00
mrvigneshvt f5aeb27e5a feat(git-state): add git write operations and Android write UI
POST endpoints for stage, unstage, discard, commit (selected paths supported), fetch, pull (--ff-only), push, and checkout with new-branch/track. Destructive ops enforce fixed per-use confirmation tokens (403 on missing or mismatched confirmation); the plugin.api.write grant gates writes client-side per the documented plugin contract; git failures classify into a structured taxonomy mapped to HTTP (409 dirty/conflict, 502 auth/network, 400 invalid). Android: commit dialog, branch picker with track, confirmation dialogs (standard AlertDialog pattern), grant-refusal notice, mutation progress/error rendering, 16 view-model tests. Dashboard: identically gated write controls. 56 endpoint/helper-level tests; deterministic fixtures.
2026-08-25 13:25:21 +00:00
mrvigneshvt fdaeb121d5 feat(git-state): add read-only git workspace browser (scan, status, branches, diff, file read)
Plugin endpoints under /api/plugins/hermes-relay/git/* backed by a scanned-repo allowlist with configurable base path; bounded responses with truncation flags; traversal-rejected file reads returning working-tree content with clear binary/non-UTF-8 errors; remote URLs scrubbed of userinfo; zero shell interpolation. Registers the Git mobile plugin page and a read-only dashboard tab; Android renders the surface via a dedicated Compose screen with view model and unit tests; all locale catalogs refreshed.
2026-08-25 13:25:21 +00:00
Bailey Dixon 06c0df6304 fix(android): make session activity authoritative 2026-08-25 08:51:12 -04:00
71 changed files with 9266 additions and 201 deletions
+4
View File
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Fixed
- **Android session activity now follows live Hermes runtime truth.** Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work no longer come from the Dashboard's five-minute recency hint, and only complete, unambiguously resolved live snapshots clear stale state.
## [Android 1.13.0] - 2026-08-25
### Added
+26
View File
@@ -6,6 +6,32 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Certify Android session activity across lifecycle and profile boundaries
The contract fixture now covers every upstream live status, complete-snapshot
disappearance, client-side ownership of duplicate durable ids across profiles,
and older Gateways without `session.active_list`. Before calling the status
model device-certified:
- Exercise working, quiet tool-heavy work, each pending-input surface, normal
completion, Stop, reconnect, app restart, and process recreation against
current vanilla upstream.
- Verify All Profiles with duplicate session ids across two profiles and two
saved connections; no late snapshot or old socket generation may mark the
wrong row live.
- Confirm failed/unsupported refresh becomes Unavailable, restart revalidation
remains Checking, ambiguous or partially
resolved process-wide snapshots infer no absence, a complete empty snapshot
settles every unambiguously owned scope, and REST `is_active=true` never
renders as Working.
- Run a background process that outlives its parent turn and verify Background
work remains separate from the conversation's Idle state.
- Pursue an upstream `session.active_list` profile field/filter or an aggregate
activity route with explicit profile ownership so multi-profile clients do
not need to resolve process-wide rows from durable keys.
---
## Bot Mode follow-ups after multi-gateway aggregation
Android Bot Mode now has an all-gateway roster, typed `(connectionId, profile)`
@@ -451,7 +451,8 @@ data class ChatSession(
val outputTokens: Int = 0,
val actualCostUsd: Double? = null,
val estimatedCostUsd: Double? = null,
val isActive: Boolean = false,
/** Upstream REST five-minute recency hint; never evidence that a turn is running. */
val recentlyActive: Boolean = false,
val updatedAt: Long = 0L,
val startedAt: Long = 0L,
val lastActivityAt: Long = 0L,
@@ -0,0 +1,163 @@
package com.hermesandroid.relay.data
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.plugins.runtime.ScopedPluginApiClient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.put
// Read + write client for the Hermes-Relay Git State endpoints.
// All requests are confined to the ``hermes-relay`` plugin namespace and the
// ``git/*`` sub-path via ScopedPluginApiClient, which rejects traversal and
// encodes query values.
private fun pathsArray(paths: List<String>) = buildJsonArray { paths.forEach { add(JsonPrimitive(it)) } }
class GitStateApiClient(
dashboard: DashboardApiClient,
) {
private val scoped = ScopedPluginApiClient("hermes-relay", dashboard)
private val json = Json { ignoreUnknownKeys = true }
suspend fun repos(): Result<List<GitRepo>> = scoped
.get("git/repos")
.mapCatching { element ->
json.decodeFromJsonElement<ReposResponse>(element).repos
}
suspend fun status(repo: String): Result<GitStatus> = scoped
.get("git/status", mapOf("repo" to repo))
.mapCatching { element -> json.decodeFromJsonElement<GitStatus>(element) }
suspend fun branches(repo: String): Result<List<GitBranch>> = scoped
.get("git/branches", mapOf("repo" to repo))
.mapCatching { element ->
json.decodeFromJsonElement<BranchesResponse>(element).branches
}
suspend fun diff(repo: String, path: String, kind: String): Result<GitDiff> = scoped
.get("git/diff", mapOf("repo" to repo, "path" to path, "kind" to kind))
.mapCatching { element -> json.decodeFromJsonElement<GitDiff>(element) }
suspend fun file(repo: String, path: String): Result<GitFile> = scoped
.get("git/file", mapOf("repo" to repo, "path" to path))
.mapCatching { element -> json.decodeFromJsonElement<GitFile>(element) }
// ── Write operations ───────────────────────────────────────────────────
// Every write requires the plugin.api.write grant, which the app enforces
// (see GitStateViewModel: a POST is never sent without the grant). The
// server additionally enforces per-use confirmation strings for destructive
// ops (discard/push/dirty-checkout) — the caller passes the echoed token.
suspend fun stage(repo: String, paths: List<String>): Result<GitMutationResult> =
scoped.post("git/stage", buildJsonObject {
put("repo", repo)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun unstage(repo: String, paths: List<String>): Result<GitMutationResult> =
scoped.post("git/unstage", buildJsonObject {
put("repo", repo)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun discard(
repo: String,
paths: List<String>,
confirmation: String,
deleteUntracked: Boolean = false,
): Result<GitMutationResult> = scoped.post("git/discard", buildJsonObject {
put("repo", repo)
put("paths", pathsArray(paths))
put("confirmation", confirmation)
put("delete_untracked", deleteUntracked)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun commit(repo: String, message: String): Result<GitMutationResult> =
scoped.post("git/commit", buildJsonObject {
put("repo", repo)
put("message", message)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun commitSelected(
repo: String,
message: String,
paths: List<String>,
): Result<GitMutationResult> = scoped.post("git/commit_selected", buildJsonObject {
put("repo", repo)
put("message", message)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun fetch(repo: String, remote: String = "origin"): Result<GitMutationResult> =
scoped.post("git/fetch", buildJsonObject {
put("repo", repo)
put("remote", remote)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun pull(repo: String, remote: String = "origin", branch: String = ""): Result<GitMutationResult> =
scoped.post("git/pull", buildJsonObject {
put("repo", repo)
put("remote", remote)
put("branch", branch)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun push(
repo: String,
confirmation: String,
remote: String = "origin",
branch: String = "",
): Result<GitMutationResult> = scoped.post("git/push", buildJsonObject {
put("repo", repo)
put("remote", remote)
put("branch", branch)
put("confirmation", confirmation)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun checkout(
repo: String,
ref: String,
confirmation: String? = null,
newBranch: String = "",
track: Boolean = false,
): Result<GitMutationResult> = scoped.post("git/checkout", buildJsonObject {
put("repo", repo)
put("ref", ref)
if (confirmation != null) put("confirmation", confirmation)
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
put("track", track)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
// ── Phase 3 extras ─────────────────────────────────────────────────────
/** Generate a commit-message suggestion from the staged diff. */
suspend fun commitMessage(repo: String): Result<GitCommitMessage> =
scoped.post("git/commit_message", buildJsonObject {
put("repo", repo)
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
/** Generate a commit-message suggestion from the given paths' staged diff. */
suspend fun commitMessageSelected(
repo: String,
paths: List<String>,
): Result<GitCommitMessage> = scoped.post("git/commit_message_selected", buildJsonObject {
put("repo", repo)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
/** Checkout that auto-stashes a dirty tree first. */
suspend fun stashCheckout(
repo: String,
ref: String,
newBranch: String = "",
track: Boolean = false,
): Result<GitStashCheckoutResult> = scoped.post("git/stash_checkout", buildJsonObject {
put("repo", repo)
put("ref", ref)
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
put("track", track)
}).mapCatching { json.decodeFromJsonElement<GitStashCheckoutResult>(it) }
}
@@ -0,0 +1,101 @@
package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** A repository discovered by the plugin's /git/repos endpoint. */
@Serializable
data class GitRepo(
val id: String,
val name: String,
val root: String,
@SerialName("current_branch") val currentBranch: String? = null,
val dirty: Boolean = false,
)
/** Working-tree status from /git/status. */
@Serializable
data class GitStatus(
val counts: GitStatusCounts = GitStatusCounts(),
val staged: List<GitStatusEntry> = emptyList(),
val modified: List<GitStatusEntry> = emptyList(),
val untracked: List<GitStatusEntry> = emptyList(),
val truncated: Boolean = false,
)
@Serializable
data class GitStatusCounts(
val staged: Int = 0,
val modified: Int = 0,
val untracked: Int = 0,
)
@Serializable
data class GitStatusEntry(
val path: String,
)
/** A branch from /git/branches. */
@Serializable
data class GitBranch(
val name: String,
val upstream: String? = null,
val ahead: Int = 0,
val behind: Int = 0,
@SerialName("is_current") val isCurrent: Boolean = false,
)
/** A per-file diff from /git/diff. */
@Serializable
data class GitDiff(
val path: String,
val kind: String,
val diff: String,
val truncated: Boolean = false,
)
/** A tracked-file read from /git/file. */
@Serializable
data class GitFile(
val path: String,
val content: String,
val truncated: Boolean = false,
)
/** Wrapper for /git/repos response. */
@Serializable
internal data class ReposResponse(
val repos: List<GitRepo> = emptyList(),
val notice: String? = null,
)
/** Wrapper for /git/branches response. */
@Serializable
internal data class BranchesResponse(
val branches: List<GitBranch> = emptyList(),
)
/** A mutation response: fresh HEAD oid + working-tree status (+ branches). */
@Serializable
data class GitMutationResult(
val head: String = "",
val status: GitStatus = GitStatus(),
val branches: List<GitBranch> = emptyList(),
)
/** A /git/commit_message suggestion: generated message + optional notice. */
@Serializable
data class GitCommitMessage(
val message: String = "",
val notice: String = "",
)
/** A /git/stash_checkout result: standard mutation shape + stash flag/message. */
@Serializable
data class GitStashCheckoutResult(
val head: String = "",
val status: GitStatus = GitStatus(),
val branches: List<GitBranch> = emptyList(),
val stashed: Boolean = false,
@SerialName("stash_message") val stashMessage: String = "",
)
@@ -0,0 +1,550 @@
package com.hermesandroid.relay.data
import java.util.Locale
/** Stable ownership boundary for live activity. Runtime ids are aliases, never owners. */
@ConsistentCopyVisibility
data class SessionActivityOwner private constructor(
val connectionId: String,
val profile: String,
val storedSessionId: String,
) {
companion object {
fun of(connectionId: String, profile: String, storedSessionId: String) =
SessionActivityOwner(
connectionId = connectionId.trim(),
profile = profile.trim().lowercase(Locale.ROOT),
storedSessionId = storedSessionId.trim(),
).also {
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
require(it.profile.isNotEmpty()) { "profile must not be blank" }
require(it.storedSessionId.isNotEmpty()) { "storedSessionId must not be blank" }
}
}
}
@ConsistentCopyVisibility
data class SessionActivityScope private constructor(
val connectionId: String,
val profile: String,
) {
companion object {
fun of(connectionId: String, profile: String) = SessionActivityScope(
connectionId = connectionId.trim(),
profile = profile.trim().lowercase(Locale.ROOT),
).also {
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
require(it.profile.isNotEmpty()) { "profile must not be blank" }
}
}
}
enum class SessionActivityPhase {
Starting,
Working,
NeedsInput,
BackgroundWork,
Idle,
}
enum class SessionActivityFreshness {
Confirmed,
Revalidating,
Unavailable,
}
enum class SessionActivityEvidenceSource {
Directory,
LocalSend,
ActiveList,
SessionEvent,
PendingInput,
Terminal,
Checkpoint,
Process,
}
data class SessionActivityEvidence(
val source: SessionActivityEvidenceSource,
val generation: Long,
val observedAtMillis: Long,
)
data class SessionActivityRecord(
val owner: SessionActivityOwner,
/** Authoritative turn state before exact pending-input and background-process overlays. */
val turnPhase: SessionActivityPhase,
val freshness: SessionActivityFreshness,
val evidence: SessionActivityEvidence,
val runtimeId: String? = null,
val pendingInputs: Map<String, Long?> = emptyMap(),
val backgroundProcessIds: Set<String> = emptySet(),
) {
fun phase(nowMillis: Long = Long.MIN_VALUE): SessionActivityPhase {
val hasPendingInput = pendingInputs.any { (_, expiresAt) -> expiresAt == null || expiresAt > nowMillis }
return when {
hasPendingInput -> SessionActivityPhase.NeedsInput
turnPhase != SessionActivityPhase.Idle -> turnPhase
backgroundProcessIds.isNotEmpty() -> SessionActivityPhase.BackgroundWork
else -> SessionActivityPhase.Idle
}
}
/** Presentation projection that never labels uncertain or background activity as Working. */
fun presentationState(nowMillis: Long = Long.MIN_VALUE): SessionActivityState? = when (freshness) {
SessionActivityFreshness.Revalidating -> SessionActivityState.Checking
SessionActivityFreshness.Unavailable -> SessionActivityState.Unavailable
SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) {
SessionActivityPhase.Starting -> SessionActivityState.Starting
SessionActivityPhase.Working -> SessionActivityState.Working
SessionActivityPhase.NeedsInput -> SessionActivityState.NeedsInput
SessionActivityPhase.BackgroundWork -> SessionActivityState.BackgroundWork
SessionActivityPhase.Idle -> null
}
}
}
enum class SessionLiveStatus {
Starting,
Working,
Waiting,
Idle,
}
data class SessionLiveRuntime(
/** Null when transport data cannot be resolved uniquely to a stored session owner. */
val owner: SessionActivityOwner?,
val runtimeId: String,
val status: SessionLiveStatus,
)
sealed interface SessionActivityUpdate {
val generation: Long
val observedAtMillis: Long
data class BeginGeneration(
val scope: SessionActivityScope,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class ObserveOwner(
val owner: SessionActivityOwner,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class LocalSend(
val owner: SessionActivityOwner,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class LiveState(
val owner: SessionActivityOwner,
val runtimeId: String?,
val status: SessionLiveStatus,
val source: SessionActivityEvidenceSource = SessionActivityEvidenceSource.SessionEvent,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class RuntimeState(
val scope: SessionActivityScope,
val runtimeId: String,
val status: SessionLiveStatus,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class ActiveList(
val scope: SessionActivityScope,
val runtimes: List<SessionLiveRuntime>,
/** True only when every upstream row was safely attributable for this scope. */
val isCompleteForScope: Boolean,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class StatusUnavailable(
val scope: SessionActivityScope,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class Terminal(
val owner: SessionActivityOwner,
val runtimeId: String? = null,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class RestoreCheckpoint(
val owner: SessionActivityOwner,
val runtimeId: String?,
val phase: SessionActivityPhase,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class PendingInputOpened(
val owner: SessionActivityOwner,
val requestId: String,
val expiresAtMillis: Long? = null,
val confirmed: Boolean = true,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class PendingInputClosed(
val owner: SessionActivityOwner,
val requestId: String,
val confirmed: Boolean = true,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class ProcessState(
val owner: SessionActivityOwner,
val processId: String,
val running: Boolean,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class Tick(
val nowMillis: Long,
override val generation: Long = Long.MAX_VALUE,
override val observedAtMillis: Long = nowMillis,
) : SessionActivityUpdate
}
/**
* Pure reducer for session activity. Every update is generation-gated per connection/profile.
* An unsuccessful/unsupported refresh never manufactures an idle result.
*/
data class SessionActivityRegistry(
val records: Map<SessionActivityOwner, SessionActivityRecord> = emptyMap(),
private val runtimeAliases: Map<RuntimeAlias, SessionActivityOwner> = emptyMap(),
private val generations: Map<SessionActivityScope, Long> = emptyMap(),
) {
fun record(owner: SessionActivityOwner): SessionActivityRecord? = records[owner]
fun ownerForRuntime(scope: SessionActivityScope, runtimeId: String): SessionActivityOwner? =
runtimeAliases[RuntimeAlias(scope, runtimeId.trim(), generations[scope] ?: return null)]
fun presentationStates(nowMillis: Long = Long.MIN_VALUE): Map<SessionActivityOwner, SessionActivityState> =
records.mapNotNull { (owner, record) -> record.presentationState(nowMillis)?.let { owner to it } }.toMap()
fun reduce(update: SessionActivityUpdate): SessionActivityRegistry {
if (update is SessionActivityUpdate.Tick) return expirePendingInputs(update.nowMillis)
val scope = update.scope()
val currentGeneration = generations[scope]
if (currentGeneration != null && update.generation < currentGeneration) return this
var state = this
if (currentGeneration == null || update.generation > currentGeneration) {
state = state.beginGeneration(scope, update.generation)
}
return when (update) {
is SessionActivityUpdate.BeginGeneration -> state
is SessionActivityUpdate.ObserveOwner -> state.observeOwner(update)
is SessionActivityUpdate.LocalSend -> state.putTurn(
update.owner, null, SessionActivityPhase.Starting, SessionActivityFreshness.Confirmed,
SessionActivityEvidenceSource.LocalSend, update.generation, update.observedAtMillis,
)
is SessionActivityUpdate.LiveState -> state.putLiveState(update)
is SessionActivityUpdate.RuntimeState -> {
val owner = state.ownerForRuntime(update.scope, update.runtimeId) ?: return state
state.putTurn(
owner, update.runtimeId, update.status.phase(), SessionActivityFreshness.Confirmed,
SessionActivityEvidenceSource.SessionEvent, update.generation, update.observedAtMillis,
)
}
is SessionActivityUpdate.ActiveList -> state.applyActiveList(update)
is SessionActivityUpdate.StatusUnavailable -> state.markUnavailable(update.scope)
is SessionActivityUpdate.Terminal -> state.settleTerminal(update)
is SessionActivityUpdate.RestoreCheckpoint -> state.restoreCheckpoint(update)
is SessionActivityUpdate.PendingInputOpened -> state.updatePendingInput(
update.owner, update.requestId, update.expiresAtMillis, true,
update.confirmed, update.generation, update.observedAtMillis,
)
is SessionActivityUpdate.PendingInputClosed -> state.updatePendingInput(
update.owner, update.requestId, null, false,
update.confirmed, update.generation, update.observedAtMillis,
)
is SessionActivityUpdate.ProcessState -> state.updateProcess(update)
is SessionActivityUpdate.Tick -> state
}
}
private fun beginGeneration(scope: SessionActivityScope, generation: Long): SessionActivityRegistry {
val refreshedRecords = records.mapValues { (owner, record) ->
if (owner.scope() == scope) {
record.copy(freshness = SessionActivityFreshness.Revalidating)
} else record
}
return copy(
records = refreshedRecords,
runtimeAliases = runtimeAliases.filterKeys { it.scope != scope },
generations = generations + (scope to generation),
)
}
private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry {
val existing = records[update.owner]
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
val observed = SessionActivityRecord(
owner = update.owner,
turnPhase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Revalidating,
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.Directory,
update.generation,
update.observedAtMillis,
),
)
return copy(records = records + (update.owner to observed))
}
private fun putLiveState(update: SessionActivityUpdate.LiveState): SessionActivityRegistry = putTurn(
owner = update.owner,
runtimeId = update.runtimeId,
phase = update.status.phase(),
freshness = SessionActivityFreshness.Confirmed,
source = update.source,
generation = update.generation,
observedAtMillis = update.observedAtMillis,
)
private fun putTurn(
owner: SessionActivityOwner,
runtimeId: String?,
phase: SessionActivityPhase,
freshness: SessionActivityFreshness,
source: SessionActivityEvidenceSource,
generation: Long,
observedAtMillis: Long,
): SessionActivityRegistry {
val previous = records[owner]
val record = SessionActivityRecord(
owner = owner,
turnPhase = phase,
freshness = freshness,
evidence = SessionActivityEvidence(source, generation, observedAtMillis),
runtimeId = runtimeId ?: previous?.runtimeId,
pendingInputs = previous?.pendingInputs.orEmpty(),
backgroundProcessIds = previous?.backgroundProcessIds.orEmpty(),
)
val alias = runtimeId?.trim()?.takeIf { it.isNotEmpty() }
return copy(
records = records + (owner to record),
runtimeAliases = if (alias == null) runtimeAliases else {
runtimeAliases + (RuntimeAlias(owner.scope(), alias, generation) to owner)
},
)
}
private fun applyActiveList(update: SessionActivityUpdate.ActiveList): SessionActivityRegistry {
require(update.runtimes.all { it.owner == null || it.owner.scope() == update.scope }) {
"Active-list rows must belong to the snapshot scope"
}
var state = copy(runtimeAliases = runtimeAliases.filterKeys { it.scope != update.scope })
val resolvedRuntimes = update.runtimes.filter { it.owner != null }
val observedOwners = resolvedRuntimes.mapTo(mutableSetOf()) { requireNotNull(it.owner) }
resolvedRuntimes.forEach { runtime ->
val resolvedOwner = requireNotNull(runtime.owner)
state = state.putTurn(
resolvedOwner, runtime.runtimeId, runtime.status.phase(), SessionActivityFreshness.Confirmed,
SessionActivityEvidenceSource.ActiveList, update.generation, update.observedAtMillis,
)
if (runtime.status == SessionLiveStatus.Idle) {
val idleRecord = requireNotNull(state.records[resolvedOwner]).copy(pendingInputs = emptyMap())
state = state.copy(records = state.records + (resolvedOwner to idleRecord))
}
}
val snapshotCanSettle = update.isCompleteForScope && resolvedRuntimes.size == update.runtimes.size
if (!snapshotCanSettle) return state
val settled = state.records.mapValues { (owner, record) ->
if (
owner.scope() == update.scope && owner !in observedOwners &&
record.shouldSettleWhenAbsent()
) {
record.copy(
turnPhase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Confirmed,
runtimeId = null,
pendingInputs = emptyMap(),
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.ActiveList,
update.generation,
update.observedAtMillis,
),
)
} else record
}
return state.copy(records = settled)
}
private fun markUnavailable(scope: SessionActivityScope): SessionActivityRegistry = copy(
records = records.mapValues { (owner, record) ->
if (
owner.scope() == scope && record.evidence.source in setOf(
SessionActivityEvidenceSource.ActiveList,
SessionActivityEvidenceSource.Directory,
SessionActivityEvidenceSource.Checkpoint,
)
) {
record.copy(freshness = SessionActivityFreshness.Unavailable)
} else record
},
)
private fun settleTerminal(update: SessionActivityUpdate.Terminal): SessionActivityRegistry {
val settled = putTurn(
update.owner,
runtimeId = null,
phase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Confirmed,
source = SessionActivityEvidenceSource.Terminal,
generation = update.generation,
observedAtMillis = update.observedAtMillis,
)
val record = requireNotNull(settled.records[update.owner]).copy(
runtimeId = null,
pendingInputs = emptyMap(),
)
return settled.copy(
records = settled.records + (update.owner to record),
runtimeAliases = settled.runtimeAliases.filterNot { (alias, owner) ->
alias.scope == update.owner.scope() && owner == update.owner &&
(update.runtimeId == null || alias.runtimeId == update.runtimeId)
},
)
}
private fun restoreCheckpoint(update: SessionActivityUpdate.RestoreCheckpoint): SessionActivityRegistry {
val existing = records[update.owner]
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
return putTurn(
update.owner, update.runtimeId, update.phase, SessionActivityFreshness.Revalidating,
SessionActivityEvidenceSource.Checkpoint, update.generation, update.observedAtMillis,
)
}
private fun updatePendingInput(
owner: SessionActivityOwner,
requestId: String,
expiresAtMillis: Long?,
opened: Boolean,
confirmed: Boolean,
generation: Long,
observedAtMillis: Long,
): SessionActivityRegistry {
val previous = records[owner] ?: SessionActivityRecord(
owner = owner,
turnPhase = SessionActivityPhase.Idle,
freshness = if (confirmed) {
SessionActivityFreshness.Confirmed
} else {
SessionActivityFreshness.Revalidating
},
evidence = SessionActivityEvidence(
if (confirmed) {
SessionActivityEvidenceSource.PendingInput
} else {
SessionActivityEvidenceSource.Checkpoint
},
generation,
observedAtMillis,
),
)
val pending = if (opened) {
previous.pendingInputs + (requestId to expiresAtMillis)
} else {
previous.pendingInputs - requestId
}
return copy(records = records + (owner to previous.copy(
pendingInputs = pending,
freshness = if (confirmed) SessionActivityFreshness.Confirmed else previous.freshness,
evidence = if (confirmed) {
SessionActivityEvidence(
SessionActivityEvidenceSource.PendingInput,
generation,
observedAtMillis,
)
} else previous.evidence,
)))
}
private fun updateProcess(update: SessionActivityUpdate.ProcessState): SessionActivityRegistry {
val previous = records[update.owner] ?: SessionActivityRecord(
owner = update.owner,
turnPhase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Confirmed,
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.Process,
update.generation,
update.observedAtMillis,
),
)
val processes = if (update.running) {
previous.backgroundProcessIds + update.processId
} else {
previous.backgroundProcessIds - update.processId
}
return copy(records = records + (update.owner to previous.copy(
backgroundProcessIds = processes,
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.Process,
update.generation,
update.observedAtMillis,
),
)))
}
private fun expirePendingInputs(nowMillis: Long): SessionActivityRegistry = copy(
records = records.mapValues { (_, record) ->
record.copy(pendingInputs = record.pendingInputs.filterValues { it == null || it > nowMillis })
},
)
private fun SessionActivityRecord.shouldSettleWhenAbsent(): Boolean =
runtimeId != null || evidence.source in setOf(
SessionActivityEvidenceSource.ActiveList,
SessionActivityEvidenceSource.Checkpoint,
SessionActivityEvidenceSource.Directory,
)
private fun SessionActivityUpdate.scope(): SessionActivityScope = when (this) {
is SessionActivityUpdate.BeginGeneration -> scope
is SessionActivityUpdate.ObserveOwner -> owner.scope()
is SessionActivityUpdate.RuntimeState -> scope
is SessionActivityUpdate.ActiveList -> scope
is SessionActivityUpdate.StatusUnavailable -> scope
is SessionActivityUpdate.Terminal -> owner.scope()
is SessionActivityUpdate.LocalSend -> owner.scope()
is SessionActivityUpdate.LiveState -> owner.scope()
is SessionActivityUpdate.RestoreCheckpoint -> owner.scope()
is SessionActivityUpdate.PendingInputOpened -> owner.scope()
is SessionActivityUpdate.PendingInputClosed -> owner.scope()
is SessionActivityUpdate.ProcessState -> owner.scope()
is SessionActivityUpdate.Tick -> error("Tick has no scope")
}
private fun SessionActivityOwner.scope() = SessionActivityScope.of(connectionId, profile)
private fun SessionLiveStatus.phase(): SessionActivityPhase = when (this) {
SessionLiveStatus.Starting -> SessionActivityPhase.Starting
SessionLiveStatus.Working -> SessionActivityPhase.Working
SessionLiveStatus.Waiting -> SessionActivityPhase.NeedsInput
SessionLiveStatus.Idle -> SessionActivityPhase.Idle
}
data class RuntimeAlias(
val scope: SessionActivityScope,
val runtimeId: String,
val generation: Long,
)
}
@@ -2,6 +2,10 @@ package com.hermesandroid.relay.data
/** Live activity surfaced beside a session without conflating it with selection. */
enum class SessionActivityState {
Starting,
Working,
NeedsInput,
BackgroundWork,
Checking,
Unavailable,
}
@@ -2075,7 +2075,7 @@ class ChatHandler {
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
isActive = item.isActive,
recentlyActive = item.isActive,
updatedAt = activityAtMs,
startedAt = startedAtMs,
lastActivityAt = lastActivityAtMs,
@@ -52,6 +52,7 @@ import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
@@ -278,6 +279,12 @@ class GatewayChatClient(
private val _processCapability = MutableStateFlow(GatewayProcessCapability.Unknown)
val processCapability: StateFlow<GatewayProcessCapability> = _processCapability.asStateFlow()
/** Per-socket capability for upstream's process-wide live-session snapshot. */
private val _activeSessionCapability =
MutableStateFlow(GatewayActiveSessionCapability.Unknown)
val activeSessionCapability: StateFlow<GatewayActiveSessionCapability> =
_activeSessionCapability.asStateFlow()
/**
* Active personality the gateway is applying, as a config value ("none" when
* the overlay is cleared, otherwise the personality name). Tracks the
@@ -806,6 +813,21 @@ class GatewayChatClient(
fun currentLiveSessionId(storedId: String): String? =
liveSessionId?.takeIf { storedSessionId == storedId }
/**
* Exact durable/profile owner already held by this client for [runtimeId].
* Unlike `session.active_list`, this mapping is safe for multiplexed profiles
* because Android recorded it when the runtime was created/resumed/detached.
*/
fun knownSessionOwner(runtimeId: String): GatewayKnownSessionOwner? {
if (runtimeId == liveSessionId) {
val storedId = storedSessionId ?: return null
return GatewayKnownSessionOwner(storedId, liveSessionProfile)
}
return backgroundTurns[runtimeId]?.let { owner ->
GatewayKnownSessionOwner(owner.storedSessionId, owner.profile)
}
}
/**
* Point this client at a new dashboard route (e.g. LAN→Tailscale after a
* sustained network change). If a turn is in flight, the current socket is
@@ -2065,6 +2087,47 @@ class GatewayChatClient(
)
}
/**
* Fetch authoritative in-memory execution states from current upstream
* Hermes. `session.active_list` is process-wide: it accepts only an optional
* current runtime id and does not profile-filter its rows. Accordingly this
* transport returns rows unscoped and never derives activity from REST
* `is_active` or stamps the selected profile onto a row.
*/
suspend fun listActiveSessions(): GatewayActiveSessionsResult {
if (_activeSessionCapability.value == GatewayActiveSessionCapability.Unsupported) {
return GatewayActiveSessionsResult.Unsupported
}
try {
connectMutex.withLock { ensureConnected() }
} catch (error: Exception) {
return GatewayActiveSessionsResult.TransientFailure(error)
}
val result = rpc(
"session.active_list",
buildJsonObject {
liveSessionId?.let { put("current_session_id", it) }
},
)
val error = result.exceptionOrNull()
if (error.isMethodNotFound()) {
_activeSessionCapability.value = GatewayActiveSessionCapability.Unsupported
return GatewayActiveSessionsResult.Unsupported
}
if (error != null) {
return GatewayActiveSessionsResult.TransientFailure(error)
}
_activeSessionCapability.value = GatewayActiveSessionCapability.Supported
return try {
val payload = result.getOrThrow()
val rows = payload["sessions"] as? JsonArray
?: throw GatewayRpcException("session.active_list returned no sessions array")
GatewayActiveSessionsResult.Success(rows.map(::parseGatewayActiveSession))
} catch (parseError: Exception) {
GatewayActiveSessionsResult.TransientFailure(parseError)
}
}
/** Stop one process owned by the current live gateway session. */
suspend fun killProcess(processId: String): Result<Unit> {
if (processId.isBlank()) {
@@ -2503,6 +2566,7 @@ class GatewayChatClient(
private suspend fun connectOnce() {
val connectStart = System.nanoTime()
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.MintingTicket
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
@@ -2749,6 +2813,28 @@ class GatewayChatClient(
)
}
private fun parseGatewayActiveSession(
element: kotlinx.serialization.json.JsonElement,
): GatewayActiveSession {
val row = element as? JsonObject
?: throw GatewayRpcException("session.active_list returned a non-object row")
val runtimeId = row.stringField("id")?.takeIf(String::isNotBlank)
?: throw GatewayRpcException("session.active_list row returned no runtime id")
val storedId = row.stringField("session_key")?.takeIf(String::isNotBlank)
?: throw GatewayRpcException("session.active_list row returned no session key")
val status = GatewayActiveSessionStatus.fromWire(row.stringField("status"))
?: throw GatewayRpcException("session.active_list row returned an unknown status")
val lastActive = (row["last_active"] as? JsonPrimitive)?.doubleOrNull
?: throw GatewayRpcException("session.active_list row returned no last_active")
return GatewayActiveSession(
runtimeSessionId = runtimeId,
storedSessionId = storedId,
status = status,
lastActiveEpochSeconds = lastActive,
profile = row.stringField("profile")?.trim()?.takeIf(String::isNotEmpty),
)
}
private fun markProcessUnsupportedIfNeeded(error: Throwable?) {
if (error.isMethodNotFound()) {
_processCapability.value = GatewayProcessCapability.Unsupported
@@ -3222,6 +3308,7 @@ class GatewayChatClient(
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
pendingRpcs.values.forEach {
@@ -3407,6 +3494,7 @@ class GatewayChatClient(
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
}
@@ -320,6 +320,69 @@ enum class GatewayProcessCapability {
Unsupported,
}
/** Authoritative execution state reported by upstream `session.active_list`. */
enum class GatewayActiveSessionStatus(val wireValue: String) {
Idle("idle"),
Starting("starting"),
Working("working"),
Waiting("waiting");
companion object {
fun fromWire(value: String?): GatewayActiveSessionStatus? = when (value?.trim()?.lowercase()) {
"idle" -> Idle
"starting" -> Starting
"working" -> Working
"waiting" -> Waiting
else -> null
}
}
}
/**
* One in-memory runtime returned by upstream `session.active_list`.
*
* The RPC is process-wide in current upstream Hermes. Its rows do not normally
* identify their profile, so [profile] stays null unless a future gateway
* explicitly sends one. Callers must resolve [storedSessionId] against their
* own profile-scoped session registry and fail closed when ownership is
* ambiguous; the transport never synthesizes profile attribution.
*/
data class GatewayActiveSession(
/** Per-process runtime id used by live Gateway events and session RPCs. */
val runtimeSessionId: String,
/** Durable history id (`session_key`) used by the REST/session database. */
val storedSessionId: String,
val status: GatewayActiveSessionStatus,
/** Unix epoch seconds from upstream's in-memory runtime record. */
val lastActiveEpochSeconds: Double,
/** Future-compatible only; null for the current upstream contract. */
val profile: String? = null,
)
/** Exact owner already known by this client for a foreground or detached runtime. */
data class GatewayKnownSessionOwner(
val storedSessionId: String,
val profile: String?,
)
/** Whether the current Gateway socket exposes `session.active_list`. */
enum class GatewayActiveSessionCapability {
Unknown,
Supported,
Unsupported,
}
/**
* Result of one process-wide live-session snapshot request. Unsupported is
* intentionally distinct from transport/protocol failure so callers can use
* another source only for older gateways, while failures remain Unknown.
*/
sealed interface GatewayActiveSessionsResult {
data class Success(val sessions: List<GatewayActiveSession>) : GatewayActiveSessionsResult
data object Unsupported : GatewayActiveSessionsResult
data class TransientFailure(val error: Throwable) : GatewayActiveSessionsResult
}
/**
* Connection-level background-process events. These are deliberately separate
* from [GatewayTurnCallbacks]: output and completion notifications can arrive
@@ -250,6 +250,7 @@ data class SessionItem(
@SerialName("output_tokens") val outputTokens: Int? = null,
@SerialName("actual_cost_usd") val actualCostUsd: Double? = null,
@SerialName("estimated_cost_usd") val estimatedCostUsd: Double? = null,
/** REST recency heuristic from upstream; not live Gateway execution state. */
@SerialName("is_active") val isActive: Boolean = false,
@SerialName("has_model_config")
@Serializable(with = FlexibleBooleanSerializer::class)
@@ -178,6 +178,8 @@ import com.hermesandroid.relay.ui.screens.SupervisedAppearanceSettingsScreen
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
import com.hermesandroid.relay.ui.screens.PluginsScreen
import com.hermesandroid.relay.ui.screens.PluginPageScreen
import com.hermesandroid.relay.ui.screens.GitStateScreen
import com.hermesandroid.relay.viewmodel.GitStateViewModel
import com.hermesandroid.relay.ui.screens.TerminalScreen
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
import com.hermesandroid.relay.ui.screens.ProactiveSettingsScreen
@@ -197,7 +199,9 @@ import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.plugins.runtime.PLUGIN_API_WRITE_CAPABILITY
import com.hermesandroid.relay.viewmodel.PluginsViewModel
import com.hermesandroid.relay.viewmodel.PluginsHubState
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
import com.hermesandroid.relay.viewmodel.TerminalViewModel
import com.hermesandroid.relay.viewmodel.VoiceViewModel
@@ -445,6 +449,7 @@ sealed class Screen(
}
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
data object Plugins : Screen("plugins", "Plugins", Icons.Filled.Extension)
data object GitState : Screen("git_state", "Git", Icons.Filled.Code)
data object PluginPage : Screen(
"plugins/{pluginId}/pages/{pageId}",
"Plugin",
@@ -631,6 +636,7 @@ fun RelayApp() {
val chatViewModel: ChatViewModel = processRuntime.chatViewModel
val terminalViewModel: TerminalViewModel = viewModel()
val pluginsViewModel: PluginsViewModel = viewModel()
val gitStateViewModel: GitStateViewModel = viewModel()
val voiceViewModel: VoiceViewModel = processRuntime.voiceViewModel
val runtimeInitializationState by processRuntime.initializationState.collectAsState()
@@ -862,6 +868,24 @@ fun RelayApp() {
dashboardFactory = connectionViewModel::dashboardClientForActive,
sessionId = currentChatSessionId,
)
val dashboard = effectiveDashboardUrl
.takeIf { it.isNotBlank() }
?.let { connectionViewModel.dashboardClientForActive(it) }
gitStateViewModel.configure(dashboard)
}
// Mirror the plugin.api.write grant into the Git view model so write
// mutations are refused client-side until the user grants write access
// (matches the plug-in's grant gating in PluginsViewModel).
val pluginsHubState by pluginsViewModel.hubState.collectAsState()
LaunchedEffect(pluginsHubState) {
val granted = (pluginsHubState as? PluginsHubState.Ready)
?.plugins
?.firstOrNull { it.catalog.id == "hermes-relay" }
?.preferences
?.grants
?.contains(PLUGIN_API_WRITE_CAPABILITY) == true
gitStateViewModel.setWriteGrant(granted)
}
// What's New auto-show
@@ -2730,10 +2754,20 @@ fun RelayApp() {
viewModel = pluginsViewModel,
onBack = { navController.popBackStack() },
onOpenPage = { pluginId, pageId ->
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
if (pluginId == "hermes-relay" && pageId == "git") {
navController.navigate(Screen.GitState.route)
} else {
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
}
},
)
}
composable(Screen.GitState.route) {
GitStateScreen(
viewModel = gitStateViewModel,
onBack = { navController.popBackStack() },
)
}
composable(
route = Screen.PluginPage.route,
arguments = listOf(
@@ -5,6 +5,7 @@ import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.SweepGradient
import androidx.annotation.StringRes
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
@@ -81,6 +82,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -117,6 +121,7 @@ import com.hermesandroid.relay.ui.theme.resolveProfileAccent
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.delay
internal enum class SessionDrawerFilter {
All,
@@ -280,6 +285,11 @@ fun SessionDrawerContent(
}
val scopedRows = (sessions + provisionalSessions).map { ProfileSessionRow(activeProfileName, it) }
val sourceRows = if (showAllProfiles) allProfileSessions else scopedRows
val scopedActivityStates = scopedSessionActivityStates(
rows = sourceRows,
activityStates = activityStates,
allowBareSessionIds = !showAllProfiles,
)
val sourceSessions = sourceRows.map { it.session }
val showThreads = supervisedSessionActions == null &&
(threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) })
@@ -324,8 +334,13 @@ fun SessionDrawerContent(
sessionWorkLabels(session).any { it.contains(needle, ignoreCase = true) }
}
.toList()
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, activityStates)
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, activityStates)
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, scopedActivityStates)
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, scopedActivityStates)
val drawerNowMillis = rememberDrawerClock(
isEnabled = isOpen && (
viewOptions.showUpdated || viewOptions.grouping == SessionDrawerGrouping.Project
),
)
val drawerTitle = if (showAllProfiles) {
stringResource(R.string.drawer_all_profiles)
} else {
@@ -738,6 +753,7 @@ fun SessionDrawerContent(
ProjectGroupHeader(
label = label,
rows = group.rows,
nowMillis = drawerNowMillis,
expanded = expanded,
onToggle = {
expandedProjectGroups = if (expanded) {
@@ -760,9 +776,7 @@ fun SessionDrawerContent(
if (expanded) items(group.rows, key = ::sessionRowKey) { row ->
val session = row.session
val provisional = session.sessionId.startsWith(PROVISIONAL_THREAD_PREFIX)
val activityState = activityStates[sessionRowKey(row)]
?: activityStates[session.sessionId]
?: if (session.isActive) SessionActivityState.Working else null
val activityState = scopedActivityStates[sessionRowKey(row)]
SessionItem(
modifier = if (isProjectGroup) Modifier.padding(start = 42.dp) else Modifier,
session = session,
@@ -774,6 +788,7 @@ fun SessionDrawerContent(
showUpdated = viewOptions.showUpdated,
showTokens = viewOptions.showTokens,
showCost = viewOptions.showCost,
nowMillis = drawerNowMillis,
actionsEnabled = !provisional && (
supervisedSessionActions == null ||
supervisedSessionActions.pin ||
@@ -1234,7 +1249,11 @@ private fun SessionDrawerOrdering.label(): String = when (this) {
private fun SessionDrawerStatus.label(): String = when (this) {
SessionDrawerStatus.NeedsInput -> "Needs input"
SessionDrawerStatus.Starting -> "Starting"
SessionDrawerStatus.Working -> "Working"
SessionDrawerStatus.BackgroundWork -> "Background work"
SessionDrawerStatus.Checking -> "Checking"
SessionDrawerStatus.Unavailable -> "Unavailable"
SessionDrawerStatus.Idle -> "Idle"
}
@@ -1259,6 +1278,7 @@ private fun compactMetric(value: Double): String = when {
private fun ProjectGroupHeader(
label: String,
rows: List<ProfileSessionRow>,
nowMillis: Long,
expanded: Boolean,
onToggle: () -> Unit,
) {
@@ -1307,7 +1327,7 @@ private fun ProjectGroupHeader(
append(context.resources.getQuantityString(R.plurals.drawer_project_session_count, rows.size, rows.size))
if (latestActivity > 0L) {
append(" · ")
append(formatTimestamp(latestActivity, locale, context))
append(formatTimestamp(latestActivity, locale, context, nowMillis))
}
},
style = MaterialTheme.typography.bodySmall,
@@ -1336,6 +1356,7 @@ private fun SessionItem(
showUpdated: Boolean,
showTokens: Boolean,
showCost: Boolean,
nowMillis: Long,
actionsEnabled: Boolean,
isActive: Boolean,
activityState: SessionActivityState?,
@@ -1355,11 +1376,7 @@ private fun SessionItem(
val locale = LocalLocale.current.platformLocale
val context = LocalContext.current
val untitledLabel = stringResource(R.string.drawer_untitled)
val activityLabel = when (activityState) {
SessionActivityState.Working -> stringResource(R.string.drawer_activity_working)
SessionActivityState.NeedsInput -> stringResource(R.string.drawer_activity_needs_input)
null -> null
}
val activityLabel = activityState?.let { stringResource(sessionActivityLabelResource(it)) }
val motion = rememberAccessibleMotionState()
val backgroundColor = if (isActive) {
MaterialTheme.colorScheme.secondaryContainer
@@ -1459,7 +1476,7 @@ private fun SessionItem(
sourceBadge(session.source)?.let { badge ->
SourceChip(badge)
}
if (showUpdated) sessionTimestampText(session, locale, context)?.let { timestamp ->
if (showUpdated) sessionTimestampText(session, locale, context, nowMillis)?.let { timestamp ->
Text(
text = timestamp,
style = MaterialTheme.typography.bodySmall,
@@ -1601,6 +1618,16 @@ private fun SessionItem(
}
}
@StringRes
internal fun sessionActivityLabelResource(state: SessionActivityState): Int = when (state) {
SessionActivityState.Starting -> R.string.drawer_activity_starting
SessionActivityState.Working -> R.string.drawer_activity_working
SessionActivityState.NeedsInput -> R.string.drawer_activity_needs_input
SessionActivityState.BackgroundWork -> R.string.drawer_activity_background_work
SessionActivityState.Checking -> R.string.drawer_activity_checking
SessionActivityState.Unavailable -> R.string.drawer_activity_unavailable
}
@Composable
private fun SessionWorkBadgeChip(badge: SessionWorkBadge) {
val icon: ImageVector = when (badge.kind) {
@@ -1705,18 +1732,29 @@ private fun ProfileBadge(
@Composable
private fun SessionActivityIndicator(state: SessionActivityState, label: String) {
val color = when (state) {
SessionActivityState.Starting,
SessionActivityState.Working -> RelayRefresh.Relay
SessionActivityState.NeedsInput -> RelayRefresh.Amber
SessionActivityState.BackgroundWork,
SessionActivityState.Checking,
SessionActivityState.Unavailable,
-> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.size(7.dp)
.clip(RoundedCornerShape(50))
.background(color),
modifier = if (state == SessionActivityState.BackgroundWork) {
Modifier
.size(7.dp)
.border(1.dp, color, CircleShape)
} else {
Modifier
.size(7.dp)
.clip(CircleShape)
.background(color)
},
)
Text(
text = label,
@@ -1734,10 +1772,17 @@ private fun Modifier.sessionActivityBorder(
): Modifier {
if (state == null) return this
val color = when (state) {
SessionActivityState.Starting,
SessionActivityState.Working -> RelayRefresh.Relay
SessionActivityState.NeedsInput -> RelayRefresh.Amber
SessionActivityState.BackgroundWork,
SessionActivityState.Checking,
SessionActivityState.Unavailable,
-> MaterialTheme.colorScheme.onSurfaceVariant
}
val shouldRotate = animated && state == SessionActivityState.Working
val shouldRotate = animated && (
state == SessionActivityState.Starting || state == SessionActivityState.Working
)
val phase = if (shouldRotate) {
val transition = rememberInfiniteTransition(label = "session-activity")
transition.animateFloat(
@@ -1843,20 +1888,45 @@ private fun Modifier.sessionActivityBorder(
}
}
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
@Composable
private fun rememberDrawerClock(isEnabled: Boolean): Long {
var nowMillis by remember { mutableStateOf(System.currentTimeMillis()) }
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(isEnabled, lifecycleOwner) {
if (!isEnabled) return@LaunchedEffect
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true) {
nowMillis = System.currentTimeMillis()
delay(MINUTE_MILLIS - (nowMillis % MINUTE_MILLIS))
}
}
}
return nowMillis
}
internal fun sessionTimestampText(
session: ChatSession,
locale: Locale,
context: Context,
nowMillis: Long = System.currentTimeMillis(),
): String? {
val timestamp = session.activityTimestamp
if (timestamp <= 0L) return null
val hasDistinctActivity =
session.lastActivityAt > 0L &&
session.startTimestamp > 0L &&
session.lastActivityAt != session.startTimestamp
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_active) else context.getString(R.string.drawer_timestamp_started)
return "$prefix ${formatTimestamp(timestamp, locale, context)}"
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_updated) else context.getString(R.string.drawer_timestamp_started)
return "$prefix ${formatTimestamp(timestamp, locale, context, nowMillis)}"
}
private fun formatTimestamp(millis: Long, locale: Locale, context: Context): String {
val now = System.currentTimeMillis()
val diff = now - millis
private fun formatTimestamp(
millis: Long,
locale: Locale,
context: Context,
nowMillis: Long = System.currentTimeMillis(),
): String {
val diff = nowMillis - millis
return when {
diff < 60_000 -> context.getString(R.string.drawer_just_now)
diff < 3_600_000 -> "${diff / 60_000}m ago"
@@ -1864,3 +1934,5 @@ private fun formatTimestamp(millis: Long, locale: Locale, context: Context): Str
else -> SimpleDateFormat("MMM d", locale).format(Date(millis))
}
}
private const val MINUTE_MILLIS = 60_000L
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import java.util.Locale
@@ -23,7 +24,11 @@ internal enum class SessionDrawerOrdering {
internal enum class SessionDrawerStatus {
NeedsInput,
Starting,
Working,
BackgroundWork,
Checking,
Unavailable,
Idle,
}
@@ -55,7 +60,7 @@ internal data class SessionDrawerGroup(
)
internal fun sessionRowKey(row: ProfileSessionRow): String =
"${row.profile.lowercase(Locale.ROOT)}:${row.session.sessionId}"
"${AgentDisplay.profileSessionKey(row.profile).lowercase(Locale.ROOT)}:${row.session.sessionId}"
internal fun sessionProjectLabel(session: ChatSession): String {
val raw = (session.gitRepoRoot ?: session.workingDirectory)
@@ -69,12 +74,34 @@ internal fun sessionProjectLabel(session: ChatSession): String {
internal fun sessionDrawerStatus(
row: ProfileSessionRow,
activityStates: Map<String, SessionActivityState>,
): SessionDrawerStatus = when (
activityStates[sessionRowKey(row)] ?: activityStates[row.session.sessionId]
) {
): SessionDrawerStatus = when (activityStates[sessionRowKey(row)]) {
SessionActivityState.NeedsInput -> SessionDrawerStatus.NeedsInput
SessionActivityState.Starting -> SessionDrawerStatus.Starting
SessionActivityState.Working -> SessionDrawerStatus.Working
null -> if (row.session.isActive) SessionDrawerStatus.Working else SessionDrawerStatus.Idle
SessionActivityState.BackgroundWork -> SessionDrawerStatus.BackgroundWork
SessionActivityState.Checking -> SessionDrawerStatus.Checking
SessionActivityState.Unavailable -> SessionDrawerStatus.Unavailable
null -> SessionDrawerStatus.Idle
}
/**
* Normalizes live activity to the drawer's profile-scoped row identity.
*
* A selected-profile drawer may accept the legacy bare session id because every row belongs
* to that one explicit profile. All Profiles must use composite keys exclusively: session ids
* are only unique inside their owning profile.
*/
internal fun scopedSessionActivityStates(
rows: List<ProfileSessionRow>,
activityStates: Map<String, SessionActivityState>,
allowBareSessionIds: Boolean,
): Map<String, SessionActivityState> = buildMap {
rows.forEach { row ->
val rowKey = sessionRowKey(row)
val state = activityStates[rowKey]
?: activityStates[row.session.sessionId].takeIf { allowBareSessionIds }
state?.let { put(rowKey, it) }
}
}
internal fun sessionDrawerPrState(session: ChatSession): SessionDrawerPrState = when {
@@ -98,8 +125,12 @@ internal fun filterAndSortSessionRows(
.toList()
val statusRank = mapOf(
SessionDrawerStatus.NeedsInput to 0,
SessionDrawerStatus.Working to 1,
SessionDrawerStatus.Idle to 2,
SessionDrawerStatus.Starting to 1,
SessionDrawerStatus.Working to 2,
SessionDrawerStatus.BackgroundWork to 3,
SessionDrawerStatus.Checking to 4,
SessionDrawerStatus.Unavailable to 5,
SessionDrawerStatus.Idle to 6,
)
val comparator = when (options.ordering) {
SessionDrawerOrdering.Updated -> compareByDescending<ProfileSessionRow> { it.session.activityTimestamp }
@@ -149,7 +180,11 @@ internal fun groupSessionRows(
private val SessionDrawerStatus.displayLabel: String
get() = when (this) {
SessionDrawerStatus.NeedsInput -> "Needs input"
SessionDrawerStatus.Starting -> "Starting"
SessionDrawerStatus.Working -> "Working"
SessionDrawerStatus.BackgroundWork -> "Background work"
SessionDrawerStatus.Checking -> "Checking"
SessionDrawerStatus.Unavailable -> "Unavailable"
SessionDrawerStatus.Idle -> "Idle"
}
@@ -303,21 +303,6 @@ private const val CHAT_AUTOCOMPLETE_PET_OBSTACLE = "chat-autocomplete-obstacle"
private const val CHAT_RECENT_PROMPTS_PET_OBSTACLE = "chat-recent-prompts-obstacle"
private val CHAT_PET_ROUTES = setOf("chat")
internal fun resolveSessionActivityStates(
background: Map<String, SessionActivityState>,
currentSessionId: String?,
isStreaming: Boolean,
needsInput: Boolean,
): Map<String, SessionActivityState> = background.toMutableMap().apply {
currentSessionId?.let { sessionId ->
when {
needsInput -> put(sessionId, SessionActivityState.NeedsInput)
isStreaming -> put(sessionId, SessionActivityState.Working)
else -> remove(sessionId)
}
}
}
internal fun resolveChatHeaderSubtitle(
isStreaming: Boolean,
statusText: String,
@@ -930,15 +915,9 @@ fun ChatScreen(
mutableStateOf(false)
}
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
val sessionActivityStates = remember(
backgroundSessionActivityStates,
currentSessionId,
isStreaming,
pendingAsk,
) {
resolveSessionActivityStates(
background = backgroundSessionActivityStates,
currentSessionId = currentSessionId,
val sessionActivityStates = backgroundSessionActivityStates
LaunchedEffect(currentSessionId, isStreaming, pendingAsk) {
chatViewModel.updateCurrentSessionActivity(
isStreaming = isStreaming,
needsInput = pendingAsk != null,
)
@@ -966,6 +945,54 @@ fun ChatScreen(
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
var allProfileSessionsLoading by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
suspend fun refreshAllProfileSessions(showError: Boolean) {
if (isProfileLocked || allProfileSessionsLoading) return
allProfileSessionsLoading = true
val result = connectionViewModel.listAllProfileSessions()
result?.fold(
onSuccess = { items ->
allProfileSessions = items.mapNotNull { item ->
val owner = item.profile?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
ProfileSessionRow(
profile = owner,
session = com.hermesandroid.relay.data.ChatSession(
sessionId = item.id,
title = item.title ?: item.preview,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
recentlyActive = item.isActive,
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
source = item.source,
pinned = item.pinned,
archived = item.archived,
workingDirectory = item.cwd,
gitBranch = item.gitBranch,
gitRepoRoot = item.gitRepoRoot,
pullRequestNumber = item.pullRequest?.number,
pullRequestUrl = item.pullRequest?.url,
pullRequestState = item.pullRequest?.state,
pullRequestDraft = item.pullRequest?.draft == true,
),
)
}
chatViewModel.updateSessionActivityDirectory(
rows = allProfileSessions.map { it.profile to it.session.sessionId },
)
},
onFailure = { error ->
if (showError) snackbarHostState.showSnackbar(
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
)
},
)
allProfileSessionsLoading = false
}
val conversationBinding by chatViewModel.conversationBinding.collectAsState()
val explicitBindingProfileName = conversationBinding.profileName
.takeIf { conversationBinding.hasExplicitOwner }
@@ -1378,6 +1405,13 @@ fun ChatScreen(
val listState = rememberLazyListState()
val userScrolledAwayState = remember(currentSessionId) { mutableStateOf(false) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
LaunchedEffect(chatViewModel, drawerState) {
chatViewModel.sessionDirectoryRefreshRequests.collect {
if (drawerState.isOpen || allProfileSessions.isNotEmpty()) {
refreshAllProfileSessions(showError = false)
}
}
}
PetInteractionLayer(
owner = "chat-interaction-layer",
active = shouldHideChatPet(
@@ -1465,7 +1499,6 @@ fun ChatScreen(
}
val clipboard = LocalClipboard.current
val haptic = LocalHapticFeedback.current
val snackbarHostState = remember { SnackbarHostState() }
val handleCardAction: (String, String, HermesCardAction) -> Unit =
remember(chatViewModel, context) {
{ messageId, cardKey, action ->
@@ -2094,6 +2127,7 @@ fun ChatScreen(
// shows up without a manual reload. Cheap dashboard read; the optimistic
// row for the active session is preserved by ChatHandler.updateSessions.
LaunchedEffect(drawerState.isOpen) {
chatViewModel.setSessionActivityDrawerOpen(drawerState.isOpen)
if (drawerState.isOpen && chatReady) {
chatViewModel.refreshSessions()
}
@@ -2524,48 +2558,7 @@ fun ChatScreen(
onProfileColorChange = connectionViewModel::setProfileColor,
onRefreshAllProfiles = {
if (!isProfileLocked && !allProfileSessionsLoading) scope.launch {
allProfileSessionsLoading = true
val result = connectionViewModel.listAllProfileSessions()
result?.fold(
onSuccess = { items ->
allProfileSessions = items.mapNotNull { item ->
val owner = item.profile?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
ProfileSessionRow(
profile = owner,
session = com.hermesandroid.relay.data.ChatSession(
sessionId = item.id,
title = item.title ?: item.preview,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
isActive = item.isActive,
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
source = item.source,
pinned = item.pinned,
archived = item.archived,
workingDirectory = item.cwd,
gitBranch = item.gitBranch,
gitRepoRoot = item.gitRepoRoot,
pullRequestNumber = item.pullRequest?.number,
pullRequestUrl = item.pullRequest?.url,
pullRequestState = item.pullRequest?.state,
pullRequestDraft = item.pullRequest?.draft == true,
),
)
}
},
onFailure = { error ->
snackbarHostState.showSnackbar(
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
)
},
)
allProfileSessionsLoading = false
refreshAllProfileSessions(showError = true)
}
},
onSelectProfileSession = { profileName, sessionId ->
@@ -0,0 +1,794 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.GitBranch
import com.hermesandroid.relay.data.GitDiff
import com.hermesandroid.relay.data.GitFile
import com.hermesandroid.relay.data.GitRepo
import com.hermesandroid.relay.data.GitStatus
import com.hermesandroid.relay.viewmodel.GitConfirmationStrings
import com.hermesandroid.relay.viewmodel.GitContentViewState
import com.hermesandroid.relay.viewmodel.GitMessageGenerationState
import com.hermesandroid.relay.viewmodel.GitMutationState
import com.hermesandroid.relay.viewmodel.GitRepoDetailState
import com.hermesandroid.relay.viewmodel.GitStateUiState
import com.hermesandroid.relay.viewmodel.GitStateViewModel
/**
* Git State screen (read + write): repo picker → working-tree status/branches →
* per-file diff or content. Writes require the ``plugin.api.write`` grant and
* destructive ops (discard/push/dirty-checkout) require an explicit per-use
* confirmation dialog; the fixed confirmation token is sent only on confirm.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GitStateScreen(
viewModel: GitStateViewModel,
onBack: () -> Unit,
) {
val reposState by viewModel.repos.collectAsState()
val detailState by viewModel.detail.collectAsState()
val contentState by viewModel.content.collectAsState()
val mutationState by viewModel.mutation.collectAsState()
val hasGrant = viewModel.hasWriteGrant()
// Hoisted at screen level so confirmation/commit dialogs are modal.
var pendingConfirm by remember { mutableStateOf<ConfirmationRequest?>(null) }
var showCommitDialog by remember { mutableStateOf(false) }
var pushAfterCommit by rememberSaveable { mutableStateOf(false) }
// Staged paths for the AI magic-wand (commit_message_selected) + commit.
val stagedPaths = (detailState as? GitRepoDetailState.Ready)
?.status?.staged?.map { it.path } ?: emptyList()
val messageGenerationState by viewModel.messageGeneration.collectAsState()
val stashNotice by viewModel.stashNotice.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.git_state_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
stringResource(R.string.git_state_back),
)
}
},
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val repos = reposState
when (repos) {
GitStateUiState.Loading -> CenteredSpinner()
is GitStateUiState.Error -> ErrorText(repos.message)
is GitStateUiState.Ready -> {
repos.notice?.let { ErrorText(it, warning = true) }
RepoPicker(
repos = repos.repos,
selectedId = viewModel.selectedRepoIdForDisplay(),
onSelect = viewModel::selectRepo,
)
when (val current = detailState) {
GitRepoDetailState.Idle -> Unit
GitRepoDetailState.Loading -> CenteredSpinner()
is GitRepoDetailState.Error -> ErrorText(current.message)
is GitRepoDetailState.Ready -> {
MutationBanner(
mutation = mutationState,
onClear = viewModel::clearMutationError,
)
stashNotice?.let { notice ->
Card(Modifier.fillMaxWidth()) {
Text(
notice,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (!hasGrant) {
WriteGrantNotice()
}
RepoDetail(
status = current.status,
branches = current.branches,
hasGrant = hasGrant,
onShowDiff = viewModel::loadDiff,
onShowFile = viewModel::loadFile,
onStage = { path -> viewModel.stage(listOf(path)) },
onUnstage = { path -> viewModel.unstage(listOf(path)) },
onDiscard = { paths, deleteUntracked ->
pendingConfirm = ConfirmationRequest.Discard(paths, deleteUntracked)
},
onCommitRequest = { showCommitDialog = true },
onFetch = { viewModel.fetch() },
onPull = { viewModel.pull() },
onPush = { pendingConfirm = ConfirmationRequest.Push },
onSwitchBranch = { ref ->
val dirty = current.status.counts.staged > 0 ||
current.status.counts.modified > 0 ||
current.status.counts.untracked > 0
if (dirty) {
pendingConfirm = ConfirmationRequest.DirtyCheckout(ref)
} else {
viewModel.checkout(ref)
}
},
onStashSwitchBranch = { ref ->
viewModel.stashCheckout(ref)
},
onCreateBranch = { name, track ->
viewModel.checkout("", newBranch = name, track = track)
},
)
}
}
ContentView(state = contentState)
}
}
}
}
if (showCommitDialog) {
CommitDialog(
onDismiss = { showCommitDialog = false },
hasStaged = stagedPaths.isNotEmpty(),
generatingMessage = messageGenerationState is GitMessageGenerationState.Loading,
onGenerate = {
viewModel.generateCommitMessage(
if (stagedPaths.isNotEmpty()) stagedPaths else null,
)
},
generatedMessage = (messageGenerationState as? GitMessageGenerationState.Ready)?.message ?: "",
generationNotice = (messageGenerationState as? GitMessageGenerationState.Ready)?.notice,
pushAfterCommit = pushAfterCommit,
onPushAfterCommitChange = { pushAfterCommit = it },
onCommit = { message ->
showCommitDialog = false
viewModel.commit(message)
if (pushAfterCommit) {
pendingConfirm = ConfirmationRequest.Push
}
},
)
}
pendingConfirm?.let { request ->
val onDismiss = { pendingConfirm = null }
when (request) {
is ConfirmationRequest.Discard -> AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.git_state_confirm_discard_title)) },
text = { Text(stringResource(R.string.git_state_confirm_discard_text)) },
confirmButton = {
TextButton(onClick = {
pendingConfirm = null
viewModel.discard(
request.paths,
GitConfirmationStrings.DISCARD,
request.deleteUntracked,
)
}) {
Text(stringResource(R.string.git_state_confirm_discard_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.git_state_cancel))
}
},
)
ConfirmationRequest.Push -> AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.git_state_confirm_push_title)) },
text = { Text(stringResource(R.string.git_state_confirm_push_text)) },
confirmButton = {
TextButton(onClick = {
pendingConfirm = null
viewModel.push(GitConfirmationStrings.PUSH)
}) {
Text(stringResource(R.string.git_state_confirm_push_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.git_state_cancel))
}
},
)
is ConfirmationRequest.DirtyCheckout -> AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.git_state_confirm_checkout_dirty_title)) },
text = { Text(stringResource(R.string.git_state_confirm_checkout_dirty_text)) },
confirmButton = {
TextButton(onClick = {
pendingConfirm = null
viewModel.checkout(request.ref, GitConfirmationStrings.DIRTY_CHECKOUT)
}) {
Text(stringResource(R.string.git_state_confirm_checkout_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.git_state_cancel))
}
},
)
}
}
}
/** A destructive action awaiting explicit user confirmation. */
private sealed interface ConfirmationRequest {
data class Discard(val paths: List<String>, val deleteUntracked: Boolean) : ConfirmationRequest
data object Push : ConfirmationRequest
data class DirtyCheckout(val ref: String) : ConfirmationRequest
}
@Composable
private fun CommitDialog(
onDismiss: () -> Unit,
hasStaged: Boolean,
generatingMessage: Boolean,
onGenerate: () -> Unit,
generatedMessage: String,
generationNotice: String?,
pushAfterCommit: Boolean,
onPushAfterCommitChange: (Boolean) -> Unit,
onCommit: (String) -> Unit,
) {
var message by rememberSaveable { mutableStateOf("") }
// Pre-fill with the latest generated suggestion when it arrives.
if (generatedMessage.isNotEmpty() && message.isBlank()) {
message = generatedMessage
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.git_state_commit_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = message,
onValueChange = { message = it },
label = { Text(stringResource(R.string.git_state_commit_message_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = false,
trailingIcon = {
IconButton(onClick = onGenerate, enabled = hasStaged && !generatingMessage) {
Icon(
Icons.Filled.AutoAwesome,
stringResource(R.string.git_state_generate_message),
)
}
},
)
if (generatingMessage) {
Text(
stringResource(R.string.git_state_generating_message),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
generationNotice?.let { notice ->
Text(
notice,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = pushAfterCommit, onCheckedChange = onPushAfterCommitChange)
Text(stringResource(R.string.git_state_push_after_commit))
}
}
},
confirmButton = {
TextButton(
onClick = { onCommit(message) },
enabled = message.isNotBlank(),
) {
Text(stringResource(R.string.git_state_commit_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.git_state_cancel))
}
},
)
}
@Composable
private fun RepoPicker(
repos: List<GitRepo>,
selectedId: String?,
onSelect: (String) -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
repos.forEach { repo ->
AssistChip(
onClick = { onSelect(repo.id) },
label = {
Text(
if (repo.dirty) "${repo.name} •" else repo.name,
fontWeight = if (repo.id == selectedId) FontWeight.Bold else FontWeight.Normal,
)
},
)
}
}
}
@Composable
private fun MutationBanner(
mutation: GitMutationState,
onClear: () -> Unit,
) {
when (mutation) {
GitMutationState.Idle -> Unit
is GitMutationState.InProgress -> Card(Modifier.fillMaxWidth()) {
Row(
Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(strokeWidth = 2.dp)
Text(
stringResource(R.string.git_state_mutation_in_progress, displayLabel(mutation.label)),
style = MaterialTheme.typography.bodyMedium,
)
}
}
is GitMutationState.Success -> Card(Modifier.fillMaxWidth()) {
Text(
stringResource(
R.string.git_state_mutation_success,
displayLabel(mutation.label),
mutation.head,
),
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
is GitMutationState.Error -> Card(Modifier.fillMaxWidth()) {
Row(
Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(
R.string.git_state_mutation_failed,
displayLabel(mutation.label),
mutation.message,
),
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
TextButton(onClick = onClear) {
Text(stringResource(R.string.git_state_cancel))
}
}
}
}
}
private fun displayLabel(label: String): String =
label.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
@Composable
private fun WriteGrantNotice() {
Card(Modifier.fillMaxWidth()) {
Text(
stringResource(R.string.git_state_write_grant_required),
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
private fun RepoDetail(
status: GitStatus,
branches: List<GitBranch>,
hasGrant: Boolean,
onShowDiff: (String, String) -> Unit,
onShowFile: (String) -> Unit,
onStage: (String) -> Unit,
onUnstage: (String) -> Unit,
onDiscard: (List<String>, Boolean) -> Unit,
onCommitRequest: () -> Unit,
onFetch: () -> Unit,
onPull: () -> Unit,
onPush: () -> Unit,
onSwitchBranch: (String) -> Unit,
onStashSwitchBranch: (String) -> Unit,
onCreateBranch: (String, Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"${stringResource(R.string.git_state_staged)} ${status.counts.staged} · " +
"${stringResource(R.string.git_state_modified)} ${status.counts.modified} · " +
"${stringResource(R.string.git_state_untracked)} ${status.counts.untracked}",
style = MaterialTheme.typography.labelLarge,
)
if (status.truncated) {
Text(
stringResource(R.string.git_state_truncated),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
status.staged.takeIf { it.isNotEmpty() }?.let { staged ->
GroupHeader(stringResource(R.string.git_state_staged))
staged.forEach { file ->
StatusRow(
path = file.path,
primaryLabel = stringResource(R.string.git_state_unstage),
onPrimary = { onUnstage(file.path) },
secondaryLabel = stringResource(R.string.git_state_discard),
onSecondary = { onDiscard(listOf(file.path), false) },
onOpen = { onShowDiff(file.path, "staged") },
enabled = hasGrant,
)
}
}
status.modified.takeIf { it.isNotEmpty() }?.let { modified ->
GroupHeader(stringResource(R.string.git_state_modified))
modified.forEach { file ->
StatusRow(
path = file.path,
primaryLabel = stringResource(R.string.git_state_stage),
onPrimary = { onStage(file.path) },
secondaryLabel = stringResource(R.string.git_state_discard),
onSecondary = { onDiscard(listOf(file.path), false) },
onOpen = { onShowDiff(file.path, "unstaged") },
enabled = hasGrant,
)
}
}
status.untracked.takeIf { it.isNotEmpty() }?.let { untracked ->
GroupHeader(stringResource(R.string.git_state_untracked))
untracked.forEach { file ->
StatusRow(
path = file.path,
primaryLabel = stringResource(R.string.git_state_stage),
onPrimary = { onStage(file.path) },
secondaryLabel = stringResource(R.string.git_state_discard),
onSecondary = { onDiscard(listOf(file.path), true) },
onOpen = { onShowFile(file.path) },
enabled = hasGrant,
)
}
}
// Commit + sync controls (writes; all gated by the grant).
Row(
Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = onFetch,
enabled = hasGrant && status.counts.untracked == 0,
) {
Text(stringResource(R.string.git_state_fetch))
}
OutlinedButton(onClick = onPull, enabled = hasGrant) {
Text(stringResource(R.string.git_state_pull))
}
OutlinedButton(
onClick = onPush,
enabled = hasGrant && status.counts.staged == 0,
) {
Text(stringResource(R.string.git_state_push))
}
}
Button(
onClick = onCommitRequest,
enabled = hasGrant && status.counts.staged > 0,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.git_state_commit))
}
}
}
if (branches.isNotEmpty()) {
BranchCard(
branches = branches,
hasGrant = hasGrant,
onSwitch = onSwitchBranch,
onStashSwitch = onStashSwitchBranch,
onCreate = onCreateBranch,
)
}
}
}
@Composable
private fun StatusRow(
path: String,
primaryLabel: String,
onPrimary: () -> Unit,
secondaryLabel: String,
onSecondary: () -> Unit,
onOpen: () -> Unit,
enabled: Boolean,
) {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onOpen, modifier = Modifier.weight(1f)) {
Text(
path,
fontFamily = FontFamily.Monospace,
maxLines = 1,
)
}
TextButton(onClick = onPrimary, enabled = enabled) {
Text(primaryLabel)
}
TextButton(onClick = onSecondary, enabled = enabled) {
Text(secondaryLabel)
}
}
}
@Composable
private fun BranchCard(
branches: List<GitBranch>,
hasGrant: Boolean,
onSwitch: (String) -> Unit,
onStashSwitch: (String) -> Unit,
onCreate: (String, Boolean) -> Unit,
) {
var newBranchName by rememberSaveable { mutableStateOf("") }
var track by rememberSaveable { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
stringResource(R.string.git_state_branches),
style = MaterialTheme.typography.titleSmall,
)
branches.forEach { branch ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
branchLabel(branch),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
if (branch.isCurrent) {
Text(
stringResource(R.string.git_state_current),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelSmall,
)
} else {
OutlinedButton(
onClick = { onSwitch(branch.name) },
enabled = hasGrant,
) {
Text(stringResource(R.string.git_state_switch))
}
OutlinedButton(
onClick = { onStashSwitch(branch.name) },
enabled = hasGrant,
) {
Text(stringResource(R.string.git_state_switch_stash))
}
}
}
}
// Create a new branch (optionally tracking the remote).
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = newBranchName,
onValueChange = { newBranchName = it },
label = { Text(stringResource(R.string.git_state_new_branch_hint)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
Button(
onClick = {
val name = newBranchName.trim()
if (name.isNotEmpty()) {
onCreate(name, track)
newBranchName = ""
track = false
}
},
enabled = hasGrant && newBranchName.isNotBlank(),
) {
Text(stringResource(R.string.git_state_create_branch))
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = track, onCheckedChange = { track = it }, enabled = hasGrant)
Text(stringResource(R.string.git_state_track_remote))
}
}
}
}
private fun branchLabel(branch: GitBranch): String {
val base = branch.name
if (branch.upstream == null) return base
val track =
if (branch.ahead > 0 || branch.behind > 0) {
" (ahead ${branch.ahead}, behind ${branch.behind})"
} else {
""
}
return "$base → ${branch.upstream}$track"
}
@Composable
private fun GroupHeader(label: String) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
@Composable
private fun ContentView(state: GitContentViewState) {
when (state) {
GitContentViewState.Idle -> Unit
GitContentViewState.Loading -> CenteredSpinner()
is GitContentViewState.Error -> ErrorText(state.message)
is GitContentViewState.Diff -> MonospaceBlock(state.diff)
is GitContentViewState.File -> MonospaceBlock(state.file)
}
}
@Composable
private fun MonospaceBlock(diff: GitDiff) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"${diff.path} (${diff.kind})",
style = MaterialTheme.typography.labelMedium,
fontFamily = FontFamily.Monospace,
)
if (diff.truncated) {
Text(
stringResource(R.string.git_state_truncated),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Text(
diff.diff.ifEmpty { stringResource(R.string.git_state_no_changes) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
)
}
}
}
@Composable
private fun MonospaceBlock(file: GitFile) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
file.path,
style = MaterialTheme.typography.labelMedium,
fontFamily = FontFamily.Monospace,
)
if (file.truncated) {
Text(
stringResource(R.string.git_state_truncated),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Text(
file.content,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
)
}
}
}
@Composable
private fun CenteredSpinner() {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Composable
private fun ErrorText(message: String, warning: Boolean = false) {
Text(
message,
color = if (warning) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -41,6 +41,14 @@ import com.hermesandroid.relay.data.ProactiveInboxEntry
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import com.hermesandroid.relay.data.RealtimeTurnTrace
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SessionActivityFreshness
import com.hermesandroid.relay.data.SessionActivityOwner
import com.hermesandroid.relay.data.SessionActivityPhase
import com.hermesandroid.relay.data.SessionActivityRegistry
import com.hermesandroid.relay.data.SessionActivityScope
import com.hermesandroid.relay.data.SessionActivityUpdate
import com.hermesandroid.relay.data.SessionLiveRuntime
import com.hermesandroid.relay.data.SessionLiveStatus
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.SupervisedSessionAction
@@ -61,6 +69,9 @@ import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
import com.hermesandroid.relay.network.upstream.GatewayAsk
import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionsResult
import com.hermesandroid.relay.network.upstream.GatewayApprovalMode
import com.hermesandroid.relay.network.upstream.GatewayApprovalModeCapability
import com.hermesandroid.relay.network.upstream.GatewayBackgroundInteractionEvent
@@ -261,6 +272,57 @@ internal fun shouldSuppressPassiveSessionError(context: String?, error: Throwabl
"unauthorized" in message || "forbidden" in message
}
internal data class ResolvedGatewayActiveSessions(
val runtimes: List<SessionLiveRuntime>,
val ambiguous: Boolean,
val ambiguousForCurrent: Boolean,
)
/** Resolve process-wide runtime rows without ever inventing a profile owner. */
internal fun resolveGatewayActiveSessions(
sessions: List<GatewayActiveSession>,
directory: Set<SessionActivityOwner>,
currentOwner: SessionActivityOwner?,
currentRuntimeId: String? = null,
knownOwnersByRuntime: Map<String, SessionActivityOwner> = emptyMap(),
): ResolvedGatewayActiveSessions {
var ambiguous = false
var ambiguousForCurrent = false
val runtimes = sessions.map { row ->
val explicitProfile = row.profile?.trim()?.takeIf(String::isNotEmpty)
?.let(AgentDisplay::profileSessionKey)
val candidates = directory.filter { owner ->
owner.storedSessionId == row.storedSessionId &&
(explicitProfile == null || owner.profile.equals(explicitProfile, ignoreCase = true))
}
val owner = when {
knownOwnersByRuntime[row.runtimeSessionId]
?.takeIf { it.storedSessionId == row.storedSessionId } != null ->
knownOwnersByRuntime.getValue(row.runtimeSessionId)
explicitProfile == null && currentOwner != null && currentRuntimeId != null &&
currentRuntimeId == row.runtimeSessionId &&
currentOwner.storedSessionId == row.storedSessionId -> currentOwner
explicitProfile != null && candidates.size == 1 -> candidates.single()
else -> null
}
if (owner == null) {
ambiguous = true
if (currentOwner?.storedSessionId == row.storedSessionId) ambiguousForCurrent = true
}
SessionLiveRuntime(
owner = owner,
runtimeId = row.runtimeSessionId,
status = when (row.status) {
GatewayActiveSessionStatus.Idle -> SessionLiveStatus.Idle
GatewayActiveSessionStatus.Starting -> SessionLiveStatus.Starting
GatewayActiveSessionStatus.Working -> SessionLiveStatus.Working
GatewayActiveSessionStatus.Waiting -> SessionLiveStatus.Waiting
},
)
}
return ResolvedGatewayActiveSessions(runtimes, ambiguous, ambiguousForCurrent)
}
sealed interface VoiceMessageSubmissionResult {
data class Submitted(val userUiKey: String) : VoiceMessageSubmissionResult
data class Rejected(val reason: String) : VoiceMessageSubmissionResult
@@ -342,27 +404,126 @@ class ChatViewModel : ViewModel() {
val backgroundSessionActivityStates: StateFlow<Map<String, SessionActivityState>> =
_backgroundSessionActivityStates.asStateFlow()
private fun publishBackgroundSessionActivity() {
val contextKey = activeProfileContextKey
_backgroundSessionActivityStates.value = if (contextKey == null) {
private val sessionActivityRegistry = MutableStateFlow(SessionActivityRegistry())
private val sessionActivityGeneration = AtomicLong(0L)
private val sessionActivityPollMutex = Mutex()
private var sessionActivityPollJob: Job? = null
private var sessionActivityDirectory: Set<SessionActivityOwner> = emptySet()
private var lastProjectedProcessIds: Set<String> = emptySet()
private var lastProjectedProcessOwner: SessionActivityOwner? = null
private var lastLocalActivityOwner: SessionActivityOwner? = null
private var lastLocalStreaming = false
private var lastSessionActivityScope: SessionActivityScope? = null
private val _sessionDirectoryRefreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val sessionDirectoryRefreshRequests: SharedFlow<Unit> =
_sessionDirectoryRefreshRequests.asSharedFlow()
private fun activityScope(contextKey: String? = activeProfileContextKey): SessionActivityScope? {
val raw = contextKey?.trim().orEmpty()
val separator = raw.lastIndexOf("::")
if (separator <= 0 || separator >= raw.lastIndex) return null
return SessionActivityScope.of(raw.substring(0, separator), raw.substring(separator + 2))
}
private fun activityOwner(
sessionId: String?,
contextKey: String? = activeProfileContextKey,
): SessionActivityOwner? {
val scope = activityScope(contextKey) ?: return null
val storedId = sessionId?.trim()?.takeIf(String::isNotEmpty) ?: return null
return SessionActivityOwner.of(scope.connectionId, scope.profile, storedId)
}
private fun reduceSessionActivity(update: SessionActivityUpdate) {
sessionActivityRegistry.update { it.reduce(update) }
publishSessionActivityProjection()
}
private fun reduceSessionActivities(updates: Iterable<SessionActivityUpdate>) {
sessionActivityRegistry.update { current ->
updates.fold(current) { state, update -> state.reduce(update) }
}
publishSessionActivityProjection()
}
private fun activateSessionActivityScope() {
val scope = activityScope() ?: return
if (scope == lastSessionActivityScope) return
clearProjectedBackgroundProcesses()
lastSessionActivityScope = scope
val generation = sessionActivityGeneration.incrementAndGet()
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
lastLocalActivityOwner = null
lastLocalStreaming = false
reduceSessionActivity(
SessionActivityUpdate.BeginGeneration(
scope = scope,
generation = generation,
observedAtMillis = System.currentTimeMillis(),
),
)
requestSessionActivityRefresh()
}
private fun publishSessionActivityProjection() {
val activeConnectionId = activityScope()?.connectionId
_backgroundSessionActivityStates.value = if (activeConnectionId == null) {
emptyMap()
} else {
backgroundTurnCheckpoints.keys
.asSequence()
.filter { it.contextKey == contextKey }
.associate { key ->
key.sessionId to if (
key in backgroundNeedsInputKeys ||
backgroundPendingInteractions.containsKey(key)
) {
SessionActivityState.NeedsInput
} else {
SessionActivityState.Working
}
sessionActivityRegistry.value.presentationStates(System.currentTimeMillis())
.filterKeys { it.connectionId == activeConnectionId }
.mapKeys { (owner, _) ->
val displayProfile = owner.profile.takeUnless {
it == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
} ?: "default"
"$displayProfile:${owner.storedSessionId}"
}
}
}
private fun publishBackgroundSessionActivity() {
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
backgroundTurnCheckpoints.forEach { (key, checkpoint) ->
val owner = activityOwner(key.sessionId, key.contextKey) ?: return@forEach
reduceSessionActivity(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = checkpoint.liveSessionId,
phase = SessionActivityPhase.Working,
generation = generation,
observedAtMillis = now,
),
)
if (key in backgroundNeedsInputKeys || backgroundPendingInteractions.containsKey(key)) {
reduceSessionActivity(
SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
confirmed = backgroundPendingInteractions.containsKey(key),
generation = generation,
observedAtMillis = now,
),
)
} else if (sessionActivityRegistry.value.record(owner)
?.pendingInputs
?.containsKey("checkpoint:${key.contextKey}:${key.sessionId}") == true
) {
reduceSessionActivity(
SessionActivityUpdate.PendingInputClosed(
owner = owner,
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
confirmed = false,
generation = generation,
observedAtMillis = now,
),
)
}
}
publishSessionActivityProjection()
}
private fun TurnCheckpointKey.keepAliveKey(): String = "$contextKey::$sessionId"
private fun activeTurnCheckpointKey(): TurnCheckpointKey? =
@@ -1945,10 +2106,329 @@ class ChatViewModel : ViewModel() {
gatewayProcessController.dismiss(processId)
}
/**
* Replaces the known profile/session directory used to attribute process-wide
* `session.active_list` rows. A row is projected only when ownership is exact.
*/
fun updateSessionActivityDirectory(
rows: Collection<Pair<String, String>>,
) {
val scope = activityScope() ?: return
sessionActivityDirectory = rows.mapNotNullTo(mutableSetOf()) { (profile, sessionId) ->
runCatching {
SessionActivityOwner.of(
scope.connectionId,
AgentDisplay.profileSessionKey(profile),
sessionId,
)
}.getOrNull()
}
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
reduceSessionActivities(
sessionActivityDirectory.map { owner ->
SessionActivityUpdate.ObserveOwner(owner, generation, now)
},
)
requestSessionActivityRefresh()
}
private fun updateCurrentProfileActivityDirectory(sessions: Collection<ChatSession>) {
val scope = activityScope() ?: return
sessionActivityDirectory = sessionActivityDirectory
.filterNotTo(mutableSetOf()) {
it.connectionId == scope.connectionId && it.profile == scope.profile
}
.apply {
sessions.forEach { row ->
add(SessionActivityOwner.of(scope.connectionId, scope.profile, row.sessionId))
}
}
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
reduceSessionActivities(
sessionActivityDirectory
.filter { it.connectionId == scope.connectionId && it.profile == scope.profile }
.map { owner -> SessionActivityUpdate.ObserveOwner(owner, generation, now) },
)
}
fun setSessionActivityDrawerOpen(open: Boolean) {
if (open) requestSessionActivityRefresh()
}
/** Local UI edges are immediate evidence, then the active-list poll confirms them. */
fun updateCurrentSessionActivity(isStreaming: Boolean, needsInput: Boolean) {
val owner = activityOwner(chatHandler?.currentSessionId?.value) ?: return
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
val previousAskId = "current-pending-input"
lastLocalActivityOwner?.takeIf { it != owner }?.let { previousOwner ->
if (sessionActivityRegistry.value.record(previousOwner)
?.pendingInputs
?.containsKey(previousAskId) == true
) {
reduceSessionActivity(
SessionActivityUpdate.PendingInputClosed(
previousOwner,
previousAskId,
generation = generation,
observedAtMillis = now,
),
)
}
}
val pendingWasOpen = sessionActivityRegistry.value.record(owner)
?.pendingInputs
?.containsKey(previousAskId) == true
if (needsInput || pendingWasOpen) {
reduceSessionActivity(
if (needsInput) {
SessionActivityUpdate.PendingInputOpened(
owner, previousAskId, generation = generation, observedAtMillis = now,
)
} else {
SessionActivityUpdate.PendingInputClosed(
owner, previousAskId, generation = generation, observedAtMillis = now,
)
},
)
}
val streamingEdge = lastLocalActivityOwner == owner && lastLocalStreaming != isStreaming
val alreadyStarting = sessionActivityRegistry.value.record(owner)
?.phase(now) == SessionActivityPhase.Starting
if ((isStreaming && !alreadyStarting) ||
(lastLocalActivityOwner == owner && lastLocalStreaming)
) {
reduceSessionActivity(
SessionActivityUpdate.LiveState(
owner = owner,
runtimeId = null,
status = if (isStreaming) SessionLiveStatus.Working else SessionLiveStatus.Idle,
generation = generation,
observedAtMillis = now,
),
)
}
lastLocalActivityOwner = owner
lastLocalStreaming = isStreaming
if (streamingEdge) _sessionDirectoryRefreshRequests.tryEmit(Unit)
requestSessionActivityRefresh()
}
private fun markSessionActivityStarting(sessionId: String?) {
val owner = activityOwner(sessionId) ?: return
reduceSessionActivity(
SessionActivityUpdate.LocalSend(
owner = owner,
generation = sessionActivityGeneration.get(),
observedAtMillis = System.currentTimeMillis(),
),
)
requestSessionActivityRefresh()
}
private fun settleSessionActivity(sessionId: String?, runtimeId: String? = null) {
val owner = activityOwner(sessionId) ?: return
reduceSessionActivity(
SessionActivityUpdate.Terminal(
owner = owner,
runtimeId = runtimeId,
generation = sessionActivityGeneration.get(),
observedAtMillis = System.currentTimeMillis(),
),
)
_sessionDirectoryRefreshRequests.tryEmit(Unit)
requestSessionActivityRefresh()
}
fun requestSessionActivityRefresh() {
val client = gatewayClient ?: return
if (streamingEndpoint != "gateway" || !chatVisible) return
sessionActivityPollJob?.cancel()
sessionActivityPollJob = viewModelScope.launch {
pollSessionActivity(client)
}
}
private suspend fun pollSessionActivity(client: GatewayChatClient) {
sessionActivityPollMutex.withLock {
if (gatewayClient !== client || !chatVisible || streamingEndpoint != "gateway") return
val generation = sessionActivityGeneration.get()
val currentScope = activityScope() ?: return
val currentOwner = activityOwner(chatHandler?.currentSessionId?.value)
val directory = buildSet {
addAll(sessionActivityDirectory.filter { it.connectionId == currentScope.connectionId })
currentOwner?.let { add(it) }
chatHandler?.sessions?.value.orEmpty().forEach { row ->
add(SessionActivityOwner.of(
currentScope.connectionId,
currentScope.profile,
row.sessionId,
))
}
}
val now = System.currentTimeMillis()
when (val result = client.listActiveSessions()) {
is GatewayActiveSessionsResult.Success -> {
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
val resolved = resolveGatewayActiveSessions(
sessions = result.sessions,
directory = directory,
currentOwner = currentOwner,
currentRuntimeId = currentOwner?.let {
client.currentLiveSessionId(it.storedSessionId)
},
knownOwnersByRuntime = result.sessions.mapNotNull { row ->
client.knownSessionOwner(row.runtimeSessionId)?.let { known ->
val knownProfile = when {
!known.profile.isNullOrBlank() ->
AgentDisplay.profileSessionKey(known.profile)
currentOwner != null &&
row.runtimeSessionId == client.currentLiveSessionId(
currentOwner.storedSessionId,
) &&
known.storedSessionId == currentOwner.storedSessionId ->
currentOwner.profile
else -> directory.singleOrNull { owner ->
owner.storedSessionId == known.storedSessionId &&
owner.profile in setOf(
"default",
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
)
}?.profile ?: return@let null
}
row.runtimeSessionId to SessionActivityOwner.of(
currentScope.connectionId,
knownProfile,
known.storedSessionId,
)
}
}.toMap(),
)
val scopes = directory.mapTo(mutableSetOf()) {
SessionActivityScope.of(it.connectionId, it.profile)
}
sessionActivityRegistry.value.records.keys
.filterTo(mutableSetOf()) { it.connectionId == currentScope.connectionId }
.mapTo(scopes) { SessionActivityScope.of(it.connectionId, it.profile) }
resolved.runtimes.mapNotNullTo(scopes) { runtime ->
runtime.owner?.let { SessionActivityScope.of(it.connectionId, it.profile) }
}
if (scopes.isEmpty()) scopes += currentScope
reduceSessionActivities(
scopes.map { scope ->
SessionActivityUpdate.ActiveList(
scope = scope,
runtimes = resolved.runtimes.filter { it.owner?.let { owner ->
owner.connectionId == scope.connectionId && owner.profile == scope.profile
} == true },
isCompleteForScope = !resolved.ambiguous,
generation = generation,
observedAtMillis = now,
)
},
)
}
GatewayActiveSessionsResult.Unsupported,
is GatewayActiveSessionsResult.TransientFailure -> {
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
val scopes = directory.mapTo(mutableSetOf()) {
SessionActivityScope.of(it.connectionId, it.profile)
}.apply { add(currentScope) }
reduceSessionActivities(
scopes.map { scope ->
SessionActivityUpdate.StatusUnavailable(scope, generation, now)
},
)
}
}
projectCurrentBackgroundProcesses(generation, now)
}
if (gatewayClient !== client || !chatVisible) return
val hasConfirmedLiveWork = sessionActivityRegistry.value.records.values.any { record ->
record.freshness == SessionActivityFreshness.Confirmed &&
record.phase(System.currentTimeMillis()) != SessionActivityPhase.Idle
}
val delayMs = if (hasConfirmedLiveWork) 1_500L else 30_000L
sessionActivityPollJob = viewModelScope.launch {
delay(delayMs)
if (gatewayClient === client && chatVisible) pollSessionActivity(client)
}
}
private fun projectCurrentBackgroundProcesses(generation: Long, now: Long) {
val sessionId = chatHandler?.currentSessionId?.value ?: return
val owner = activityOwner(sessionId) ?: return
val previousOwner = lastProjectedProcessOwner
if (previousOwner != null && previousOwner != owner) {
reduceSessionActivities(
lastProjectedProcessIds.map { processId ->
SessionActivityUpdate.ProcessState(
owner = previousOwner,
processId = processId,
running = false,
generation = generation,
observedAtMillis = now,
)
},
)
lastProjectedProcessIds = emptySet()
}
lastProjectedProcessOwner = owner
if (!gatewayProcessController.ownsSnapshot(sessionId, activeProfileContextKey)) {
lastProjectedProcessIds = emptySet()
return
}
val activeIds = backgroundProcesses.value.filter { it.isRunning }.mapTo(mutableSetOf()) { it.id }
reduceSessionActivities(
(lastProjectedProcessIds + activeIds).map { processId ->
SessionActivityUpdate.ProcessState(
owner = owner,
processId = processId,
running = processId in activeIds,
generation = generation,
observedAtMillis = now,
)
},
)
lastProjectedProcessIds = activeIds
}
private fun clearProjectedBackgroundProcesses() {
val owner = lastProjectedProcessOwner
if (owner != null && lastProjectedProcessIds.isNotEmpty()) {
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
reduceSessionActivities(
lastProjectedProcessIds.map { processId ->
SessionActivityUpdate.ProcessState(
owner = owner,
processId = processId,
running = false,
generation = generation,
observedAtMillis = now,
)
},
)
}
lastProjectedProcessIds = emptySet()
lastProjectedProcessOwner = null
}
fun updateGatewayClient(client: GatewayChatClient?) {
val previousClient = gatewayClient
val changed = previousClient !== client
if (changed) {
clearProjectedBackgroundProcesses()
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
sessionActivityGeneration.incrementAndGet()
sessionActivityDirectory = emptySet()
lastLocalActivityOwner = null
lastLocalStreaming = false
lastSessionActivityScope = null
gatewayVisibleReattachJob?.cancel()
gatewayVisibleReattachJob = null
previousClient?.setUnsolicitedTurnProvider(null)
@@ -1967,6 +2447,7 @@ class ChatViewModel : ViewModel() {
sessionId = chatHandler?.currentSessionId?.value,
scopeKey = activeProfileContextKey,
)
activateSessionActivityScope()
}
// Bind each gateway session.create/resume to the currently-selected
// profile (pulled live) — the upstream gateway builds the agent from it.
@@ -2114,6 +2595,7 @@ class ChatViewModel : ViewModel() {
) {
prewarmGateway()
}
if (changed && client != null) requestSessionActivityRefresh()
}
/** Remove the detached sibling's recovery snapshot after server completion. */
@@ -2134,7 +2616,20 @@ class ChatViewModel : ViewModel() {
backgroundPendingInteractions.remove(key)
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
}
reduceSessionActivities(
matching.mapNotNull { key ->
activityOwner(key.sessionId, key.contextKey)?.let { owner ->
SessionActivityUpdate.Terminal(
owner = owner,
runtimeId = completion.liveSessionId,
generation = sessionActivityGeneration.get(),
observedAtMillis = System.currentTimeMillis(),
)
}
},
)
publishBackgroundSessionActivity()
requestSessionActivityRefresh()
chatTurnCheckpointStore?.let { store ->
viewModelScope.launch {
checkpointMutex.withLock {
@@ -2580,7 +3075,13 @@ class ChatViewModel : ViewModel() {
fun setChatVisible(visible: Boolean) {
val changed = chatVisible != visible
chatVisible = visible
if (visible && changed) prewarmGateway()
if (visible && changed) {
prewarmGateway()
requestSessionActivityRefresh()
} else if (!visible) {
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
}
}
// === Gateway desktop-parity state ===
@@ -3128,6 +3629,16 @@ class ChatViewModel : ViewModel() {
gatewayStateSyncJob?.cancel()
lastSurfacedCredentialWarning = null
gatewayStateSyncJob = viewModelScope.launch {
launch {
backgroundProcesses.collect {
if (gatewayClient !== client) return@collect
projectCurrentBackgroundProcesses(
generation = sessionActivityGeneration.get(),
now = System.currentTimeMillis(),
)
requestSessionActivityRefresh()
}
}
launch {
client.serverPersonality.collect { value ->
if (gatewayClient !== client || value == null) return@collect
@@ -3835,6 +4346,7 @@ class ChatViewModel : ViewModel() {
sessionId = sessionId,
)
}
activateSessionActivityScope()
handler.activeAgentName = currentAgentDisplayName()
if (
previousBinding.contextKey == contextKey &&
@@ -4102,6 +4614,8 @@ class ChatViewModel : ViewModel() {
currentSessionProfileName() == profileName
) {
handler.updateSessions(sessions)
updateCurrentProfileActivityDirectory(handler.sessions.value)
requestSessionActivityRefresh()
}
},
onFailure = { error ->
@@ -6533,6 +7047,7 @@ class ChatViewModel : ViewModel() {
private fun finalizeTurnSideEffects(handler: ChatHandler, messageId: String) {
val completedOwner = activeQueueOwnerRunId
handler.onStreamComplete(messageId)
settleSessionActivity(handler.currentSessionId.value)
if (completedOwner != null && queuedMessageItems.any { it.ownerRunId == completedOwner }) {
completedQueueOwnerRuns += completedOwner
}
@@ -6561,6 +7076,7 @@ class ChatViewModel : ViewModel() {
private fun finalizeFailedTurnSideEffects(handler: ChatHandler, messageId: String) {
handler.onStreamComplete(messageId)
handler.markError(messageId)
settleSessionActivity(handler.currentSessionId.value)
clearTurnCheckpoint()
activeStream = null
_steerableTurn.value = false
@@ -6603,6 +7119,7 @@ class ChatViewModel : ViewModel() {
} else {
handler.clearStreamingStatus()
}
settleSessionActivity(handler.currentSessionId.value)
}
}
@@ -7106,6 +7623,9 @@ class ChatViewModel : ViewModel() {
badges = listOf("Realtime Agent"),
)
)
if (streamingEndpoint == "gateway") {
markSessionActivityStarting(handler.currentSessionId.value)
}
return assistantMessageId
}
@@ -7996,6 +8516,9 @@ class ChatViewModel : ViewModel() {
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
)
)
if (streamingEndpoint == "gateway") {
markSessionActivityStarting(handler.currentSessionId.value)
}
val streamDeltas = StreamDeltaCoalescer(
scope = viewModelScope,
@@ -8313,6 +8836,7 @@ class ChatViewModel : ViewModel() {
_steerableTurn.value = false
_steerNotice.value = null
clearTurnCheckpoint()
settleSessionActivity(errorSessionId)
} else if (
dispatchedSseEndpoint == "sessions" &&
errorSessionId != null &&
@@ -8362,6 +8886,7 @@ class ChatViewModel : ViewModel() {
),
)
clearTurnCheckpoint()
settleSessionActivity(errorSessionId)
}
} else {
AppAnalytics.onStreamError()
@@ -8402,6 +8927,7 @@ class ChatViewModel : ViewModel() {
_steerableTurn.value = false
_steerNotice.value = null
clearTurnCheckpoint()
settleSessionActivity(errorSessionId)
}
}
val onPreflightErrorCb = { error: Throwable ->
@@ -8433,6 +8959,7 @@ class ChatViewModel : ViewModel() {
_steerableTurn.value = false
_steerNotice.value = null
clearTurnCheckpoint()
settleSessionActivity(handler.currentSessionId.value)
}
// === v0.4.1 voice-intent + v0.7.x card-dispatch session sync ===
@@ -8711,6 +9238,7 @@ class ChatViewModel : ViewModel() {
),
)
handler.setSessionId(sid)
markSessionActivityStarting(sid)
updateTurnCheckpointSession(sid)
selectBackgroundProcessSession(sid)
gatewayProcessController.sessionReady(sid)
@@ -8829,6 +9357,7 @@ class ChatViewModel : ViewModel() {
// don't resurrect the turn on SSE.
intentionallyCancelled = false
activeStream = null
settleSessionActivity(handler.currentSessionId.value)
} else {
// Nothing started server-side — rerun this turn on
// the SSE fallback. Callbacks land on the main
@@ -110,6 +110,10 @@ internal class GatewayProcessController(
resetForSession(sessionId, scopeKey)
}
/** True only when the published process snapshot belongs to this exact owner. */
fun ownsSnapshot(sessionId: String, scopeKey: String?): Boolean =
selectedSessionId == sessionId && selectedScopeKey == scopeKey && readySessionId == sessionId
/**
* Admit process RPCs for [sessionId] after the gateway has created/resumed
* that chat's live session. Stale ready callbacks are ignored.
@@ -0,0 +1,412 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.hermesandroid.relay.data.GitBranch
import com.hermesandroid.relay.data.GitDiff
import com.hermesandroid.relay.data.GitFile
import com.hermesandroid.relay.data.GitRepo
import com.hermesandroid.relay.data.GitStateApiClient
import com.hermesandroid.relay.data.GitStatus
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed interface GitStateUiState {
data object Loading : GitStateUiState
data class Error(val message: String) : GitStateUiState
data class Ready(val repos: List<GitRepo>, val notice: String?) : GitStateUiState
}
sealed interface GitRepoDetailState {
data object Idle : GitRepoDetailState
data object Loading : GitRepoDetailState
data class Error(val message: String) : GitRepoDetailState
data class Ready(
val status: GitStatus,
val branches: List<GitBranch>,
) : GitRepoDetailState
}
sealed interface GitContentViewState {
data object Idle : GitContentViewState
data object Loading : GitContentViewState
data class Error(val message: String) : GitContentViewState
data class Diff(val diff: GitDiff) : GitContentViewState
data class File(val file: GitFile) : GitContentViewState
}
/** A single in-flight or completed write mutation on the selected repo. */
sealed interface GitMutationState {
data object Idle : GitMutationState
data class InProgress(val label: String) : GitMutationState
data class Error(val label: String, val message: String) : GitMutationState
data class Success(val label: String, val head: String) : GitMutationState
}
/** A commit-message generation attempt (AI magic-wand). */
sealed interface GitMessageGenerationState {
data object Idle : GitMessageGenerationState
data object Loading : GitMessageGenerationState
data class Ready(val message: String, val notice: String) : GitMessageGenerationState
}
/** Fixed per-use confirmation tokens matching the plugin's server constants. */
object GitConfirmationStrings {
const val DISCARD = "discard"
const val PUSH = "push"
const val DIRTY_CHECKOUT = "checkout-dirty"
}
/**
* View model for the Git State Android surface (read + write).
*
* Loads the scanned repo list from the Hermes-Relay plugin and, on selection,
* fetches working-tree status + branches. Mutations (stage/unstage/discard/
* commit/fetch/pull/push/checkout) all require the ``plugin.api.write`` grant:
* ``configure`` receives the grant set and every mutation refuses (surfacing a
* readable message, never a POST) when the grant is absent. Destructive ops
* (discard/push/dirty-checkout) additionally require a per-use confirmation
* string the caller echoes from GitConfirmationStrings.
*/
class GitStateViewModel(application: Application) : AndroidViewModel(application) {
private val _repos = MutableStateFlow<GitStateUiState>(GitStateUiState.Loading)
val repos: StateFlow<GitStateUiState> = _repos.asStateFlow()
private val _detail = MutableStateFlow<GitRepoDetailState>(GitRepoDetailState.Idle)
val detail: StateFlow<GitRepoDetailState> = _detail.asStateFlow()
private val _content = MutableStateFlow<GitContentViewState>(GitContentViewState.Idle)
val content: StateFlow<GitContentViewState> = _content.asStateFlow()
private val _mutation = MutableStateFlow<GitMutationState>(GitMutationState.Idle)
val mutation: StateFlow<GitMutationState> = _mutation.asStateFlow()
private val _messageGeneration =
MutableStateFlow<GitMessageGenerationState>(GitMessageGenerationState.Idle)
val messageGeneration: StateFlow<GitMessageGenerationState> = _messageGeneration.asStateFlow()
private val _pushAfterCommit = MutableStateFlow(false)
val pushAfterCommit: StateFlow<Boolean> = _pushAfterCommit.asStateFlow()
private val _stashNotice = MutableStateFlow<String?>(null)
val stashNotice: StateFlow<String?> = _stashNotice.asStateFlow()
private var api: GitStateApiClient? = null
private var loadJob: Job? = null
private var selectedRepoId: String? = null
private var writeGrant: Boolean = false
fun selectedRepoIdForDisplay(): String? = selectedRepoId
fun configure(dashboard: DashboardApiClient?) {
api = dashboard?.let(::GitStateApiClient)
loadRepos()
}
/** Grants the plugin.api.write capability for this connection/profile. */
fun setWriteGrant(granted: Boolean) {
writeGrant = granted
}
fun hasWriteGrant(): Boolean = writeGrant
fun loadRepos() {
val client = api ?: run {
_repos.value = GitStateUiState.Error("Dashboard connection unavailable")
return
}
loadJob?.cancel()
loadJob = viewModelScope.launch {
_repos.value = GitStateUiState.Loading
client.repos().fold(
onSuccess = { list -> _repos.value = GitStateUiState.Ready(list, null) },
onFailure = { error ->
_repos.value = GitStateUiState.Error(error.message ?: "Failed to load repositories")
},
)
}
}
fun selectRepo(repoId: String) {
val client = api ?: return
selectedRepoId = repoId
_content.value = GitContentViewState.Idle
_mutation.value = GitMutationState.Idle
loadJob?.cancel()
loadJob = viewModelScope.launch {
_detail.value = GitRepoDetailState.Loading
val statusResult = client.status(repoId)
val branchesResult = client.branches(repoId)
if (statusResult.isFailure) {
_detail.value = GitRepoDetailState.Error(
statusResult.exceptionOrNull()?.message ?: "Failed to load status",
)
return@launch
}
val status: GitStatus = statusResult.getOrThrow()
val branches: List<GitBranch> = branchesResult.getOrDefault(emptyList())
_detail.value = GitRepoDetailState.Ready(status, branches)
}
}
/** Runs a mutation through the shared gate (grant + confirmation). */
private fun runMutation(label: String, block: suspend (GitStateApiClient, String) -> Result<GitMutationState>) {
val client = api ?: run {
_mutation.value = GitMutationState.Error(label, "Dashboard connection unavailable")
return
}
val repoId = selectedRepoId ?: run {
_mutation.value = GitMutationState.Error(label, "No repository selected")
return
}
if (!writeGrant) {
_mutation.value = GitMutationState.Error(
label,
"Allow plugin changes (plugin.api.write) before using this action.",
)
return
}
loadJob?.cancel()
loadJob = viewModelScope.launch {
_mutation.value = GitMutationState.InProgress(label)
block(client, repoId).fold(
onSuccess = {
_mutation.value = it
_content.value = GitContentViewState.Idle
refreshDetail(repoId)
},
onFailure = { error ->
_mutation.value = GitMutationState.Error(
label,
error.message ?: "Git action failed",
)
},
)
}
}
private fun refreshDetail(repoId: String) {
val client = api ?: return
viewModelScope.launch {
val statusResult = client.status(repoId)
val branchesResult = client.branches(repoId)
if (statusResult.isSuccess) {
_detail.value = GitRepoDetailState.Ready(
statusResult.getOrDefault(GitStatus()),
branchesResult.getOrDefault(emptyList()),
)
}
}
}
// ── Read operations ────────────────────────────────────────────────────
fun loadDiff(path: String, kind: String) {
val repoId = selectedRepoId ?: return
val client = api ?: return
loadJob?.cancel()
loadJob = viewModelScope.launch {
_content.value = GitContentViewState.Loading
client.diff(repoId, path, kind).fold(
onSuccess = { diff -> _content.value = GitContentViewState.Diff(diff) },
onFailure = { error ->
_content.value = GitContentViewState.Error(
error.message ?: "Failed to load diff",
)
},
)
}
}
fun loadFile(path: String) {
val repoId = selectedRepoId ?: return
val client = api ?: return
loadJob?.cancel()
loadJob = viewModelScope.launch {
_content.value = GitContentViewState.Loading
client.file(repoId, path).fold(
onSuccess = { file -> _content.value = GitContentViewState.File(file) },
onFailure = { error ->
_content.value = GitContentViewState.Error(
error.message ?: "Failed to load file",
)
},
)
}
}
// ── Write operations ───────────────────────────────────────────────────
fun stage(paths: List<String>) = runMutation("Stage") { c, r ->
c.stage(r, paths).map { GitMutationState.Success("stage", it.head) }
}
fun unstage(paths: List<String>) = runMutation("Unstage") { c, r ->
c.unstage(r, paths).map { GitMutationState.Success("unstage", it.head) }
}
fun discard(paths: List<String>, confirmation: String, deleteUntracked: Boolean = false) =
runMutation("Discard") { c, r ->
c.discard(r, paths, confirmation, deleteUntracked)
.map { GitMutationState.Success("discard", it.head) }
}
fun commit(message: String) = runMutation("Commit") { c, r ->
c.commit(r, message).map {
GitMutationState.Success("commit", it.head)
}
}
fun commitSelected(message: String, paths: List<String>) = runMutation("Commit") { c, r ->
c.commitSelected(r, message, paths).map {
GitMutationState.Success("commit", it.head)
}
}
fun fetch(remote: String = "origin") = runMutation("Fetch") { c, r ->
c.fetch(r, remote).map { GitMutationState.Success("fetch", it.head) }
}
fun pull(remote: String = "origin", branch: String = "") = runMutation("Pull") { c, r ->
c.pull(r, remote, branch).map { GitMutationState.Success("pull", it.head) }
}
fun push(confirmation: String, remote: String = "origin", branch: String = "") =
runMutation("Push") { c, r ->
c.push(r, confirmation, remote, branch).map { GitMutationState.Success("push", it.head) }
}
fun checkout(
ref: String,
confirmation: String? = null,
newBranch: String = "",
track: Boolean = false,
) = runMutation("Checkout") { c, r ->
c.checkout(r, ref, confirmation, newBranch, track)
.map { GitMutationState.Success("checkout", it.head) }
}
// ── Phase 3 extras ─────────────────────────────────────────────────────
/** Toggle the push-after-commit flow (default OFF; never bypasses confirm). */
fun setPushAfterCommit(enabled: Boolean) {
_pushAfterCommit.value = enabled
}
/**
* Generate a commit-message suggestion from the staged diff (AI magic-wand).
* Empty staged diff / model-unavailable degrade to a notice, never an error.
* Uses the shared write grant gate (a POST is never sent without the grant).
*/
fun generateCommitMessage(paths: List<String>? = null) {
val client = api ?: run {
_messageGeneration.value =
GitMessageGenerationState.Ready("", "Dashboard connection unavailable")
return
}
val repoId = selectedRepoId ?: run {
_messageGeneration.value = GitMessageGenerationState.Ready("", "No repository selected")
return
}
if (!writeGrant) {
_messageGeneration.value = GitMessageGenerationState.Ready(
"",
"Allow plugin changes (plugin.api.write) before using this action.",
)
return
}
loadJob?.cancel()
loadJob = viewModelScope.launch {
_messageGeneration.value = GitMessageGenerationState.Loading
val result = if (paths != null) {
client.commitMessageSelected(repoId, paths)
} else {
client.commitMessage(repoId)
}
result.fold(
onSuccess = { msg ->
_messageGeneration.value = GitMessageGenerationState.Ready(msg.message, msg.notice)
},
onFailure = { error ->
_messageGeneration.value = GitMessageGenerationState.Ready(
"",
error.message ?: "Could not generate a commit message.",
)
},
)
}
}
/**
* Checkout that auto-stashes a dirty tree first. No confirmation is needed
* because a stash is recoverable. Surfaces the stash message via [stashNotice].
*/
fun stashCheckout(ref: String, newBranch: String = "", track: Boolean = false) {
val client = api ?: run {
_mutation.value = GitMutationState.Error("Stash Checkout", "Dashboard connection unavailable")
return
}
val repoId = selectedRepoId ?: run {
_mutation.value = GitMutationState.Error("Stash Checkout", "No repository selected")
return
}
if (!writeGrant) {
_mutation.value = GitMutationState.Error(
"Stash Checkout",
"Allow plugin changes (plugin.api.write) before using this action.",
)
return
}
_stashNotice.value = null
loadJob?.cancel()
loadJob = viewModelScope.launch {
_mutation.value = GitMutationState.InProgress("Stash Checkout")
client.stashCheckout(repoId, ref, newBranch, track).fold(
onSuccess = { result ->
if (result.stashed) {
_stashNotice.value =
"Stashed changes on $ref as \"${result.stashMessage}\". Use \"git stash pop\" to restore them."
}
_mutation.value = GitMutationState.Success("stash-checkout", result.head)
_content.value = GitContentViewState.Idle
refreshDetail(repoId)
},
onFailure = { error ->
_mutation.value = GitMutationState.Error(
"Stash Checkout",
error.message ?: "Git action failed",
)
},
)
}
}
fun clearStashNotice() {
_stashNotice.value = null
}
/** True when the push-after-commit toggle is currently enabled. */
fun isPushAfterCommitEnabled(): Boolean = _pushAfterCommit.value
/** True when the named destructive op needs a confirmation echo. */
fun requiresConfirmation(op: String): Boolean = op in setOf("discard", "push", "dirty-checkout")
/** Fixed confirmation token for a destructive op (matches the server). */
fun confirmationFor(op: String): String? = when (op) {
"discard" -> GitConfirmationStrings.DISCARD
"push" -> GitConfirmationStrings.PUSH
"dirty-checkout" -> GitConfirmationStrings.DIRTY_CHECKOUT
else -> null
}
fun clearMutationError() {
if (_mutation.value is GitMutationState.Error) {
_mutation.value = GitMutationState.Idle
}
}
}
+47 -1
View File
@@ -884,6 +884,10 @@
<string name="drawer_search_sessions">Pesquisar sessões</string>
<string name="drawer_activity_working">Em andamento</string>
<string name="drawer_activity_needs_input">Precisa de resposta</string>
<string name="drawer_activity_starting">Iniciando</string>
<string name="drawer_activity_background_work">Trabalho em segundo plano</string>
<string name="drawer_activity_checking">Verificando</string>
<string name="drawer_activity_unavailable">Indisponível</string>
<string name="drawer_new_thread">Nova Thread</string>
<string name="drawer_chats_not_named">Os chats não recebem nomes automáticos nesta conexão — use ⋮ → Renomear.</string>
<string name="drawer_loading_sessions">Carregando sessões…</string>
@@ -913,7 +917,7 @@
<string name="drawer_filter_pinned">Fixadas</string>
<string name="drawer_filter_archive">Arquivo</string>
<string name="drawer_filter_sessions">Sessões</string>
<string name="drawer_timestamp_active">Ativa</string>
<string name="drawer_timestamp_updated">Atualizada</string>
<string name="drawer_timestamp_started">Iniciada</string>
<string name="drawer_just_now">Agora mesmo</string>
<!-- P1: BridgeScreen -->
@@ -3854,6 +3858,48 @@
<string name="plugins_keep">Manter</string>
<string name="plugins_remove">Remover</string>
<string name="plugins_remove_confirm">Remover “%1$s”? Esta página do plugin não aparecerá mais nos dispositivos Android conectados.</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">Voltar</string>
<string name="git_state_staged">Preparados</string>
<string name="git_state_modified">Modificados</string>
<string name="git_state_untracked">Não rastreados</string>
<string name="git_state_branches">Ramos</string>
<string name="git_state_truncated">Resultados truncados — apenas as primeiras entradas são exibidas.</string>
<string name="git_state_no_changes">(sem alterações)</string>
<string name="git_state_write_grant_required">Alterações de gravação exigem a permissão de alterações de plugin. Ative "Permitir alterações de plugins (plugin.api.write)" nas configurações de permissão de Plugins.</string>
<string name="git_state_stage">Preparar</string>
<string name="git_state_unstage">Despreparar</string>
<string name="git_state_discard">Descartar</string>
<string name="git_state_commit">Confirmar</string>
<string name="git_state_commit_title">Confirmar alterações preparadas</string>
<string name="git_state_commit_message_hint">Mensagem do commit</string>
<string name="git_state_commit_confirm">Confirmar</string>
<string name="git_state_cancel">Cancelar</string>
<string name="git_state_fetch">Buscar</string>
<string name="git_state_pull">Puxar</string>
<string name="git_state_push">Enviar</string>
<string name="git_state_new_branch_hint">Nome da nova ramificação</string>
<string name="git_state_create_branch">Criar ramificação</string>
<string name="git_state_track_remote">Rastrear ramificação remota</string>
<string name="git_state_switch">Alternar</string>
<string name="git_state_current">Atual</string>
<string name="git_state_mutation_in_progress">%1$s em andamento…</string>
<string name="git_state_mutation_success">%1$s concluído. HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s falhou: %2$s</string>
<string name="git_state_confirm_discard_title">Descartar alterações?</string>
<string name="git_state_confirm_discard_text">As alterações locais dos arquivos selecionados serão descartadas e não poderão ser recuperadas.</string>
<string name="git_state_confirm_discard_confirm">Descartar</string>
<string name="git_state_confirm_push_title">Enviar alterações?</string>
<string name="git_state_confirm_push_text">Envia a ramificação atual para o repositório remoto. Isso publica os commits locais no repositório remoto.</string>
<string name="git_state_confirm_push_confirm">Enviar</string>
<string name="git_state_confirm_checkout_dirty_title">Alternar ramificação com alterações locais?</string>
<string name="git_state_confirm_checkout_dirty_text">A árvore de trabalho tem alterações não confirmadas. A alternância pode carregá-las para a ramificação de destino.</string>
<string name="git_state_confirm_checkout_confirm">Alternar</string>
<string name="git_state_generate_message">Gerar mensagem de commit</string>
<string name="git_state_generating_message">Gerando mensagem de commit…</string>
<string name="git_state_push_after_commit">Enviar após o commit</string>
<string name="git_state_switch_stash">Alternar (guardar se houver alterações)</string>
<string name="support_bundle_review">Revisar informações de suporte</string>
<string name="support_bundle_title">Informações de suporte</string>
<string name="support_bundle_privacy">Nada é enviado automaticamente. Revise a exportação local com até %1$d relatórios.</string>
+47 -1
View File
@@ -930,6 +930,10 @@
<string name="drawer_search_sessions">搜索会话</string>
<string name="drawer_activity_working">正在处理</string>
<string name="drawer_activity_needs_input">需要输入</string>
<string name="drawer_activity_starting">正在启动</string>
<string name="drawer_activity_background_work">后台工作</string>
<string name="drawer_activity_checking">正在检查</string>
<string name="drawer_activity_unavailable">不可用</string>
<string name="drawer_new_thread">新话题</string>
<string name="drawer_chats_not_named">此连接上的对话不会自动命名——使用 ⋮ → 重命名。</string>
<string name="drawer_loading_sessions">正在加载会话…</string>
@@ -959,7 +963,7 @@
<string name="drawer_filter_pinned">已固定</string>
<string name="drawer_filter_archive">归档</string>
<string name="drawer_filter_sessions">会话</string>
<string name="drawer_timestamp_active">活跃</string>
<string name="drawer_timestamp_updated">更新</string>
<string name="drawer_timestamp_started">已开始</string>
<string name="drawer_just_now">刚刚</string>
@@ -3942,6 +3946,48 @@
<string name="plugins_keep">保留</string>
<string name="plugins_remove">移除</string>
<string name="plugins_remove_confirm">要移除“%1$s”吗?此插件页面将不再显示在已连接的 Android 设备上。</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">返回</string>
<string name="git_state_staged">已暂存</string>
<string name="git_state_modified">已修改</string>
<string name="git_state_untracked">未跟踪</string>
<string name="git_state_branches">分支</string>
<string name="git_state_truncated">结果已截断 — 仅显示前几条。</string>
<string name="git_state_no_changes">(无更改)</string>
<string name="git_state_write_grant_required">写入更改需要插件更改权限。请在“插件”的权限设置中启用“允许插件更改 (plugin.api.write)”。</string>
<string name="git_state_stage">暂存</string>
<string name="git_state_unstage">取消暂存</string>
<string name="git_state_discard">丢弃</string>
<string name="git_state_commit">提交</string>
<string name="git_state_commit_title">提交已暂存的更改</string>
<string name="git_state_commit_message_hint">提交消息</string>
<string name="git_state_commit_confirm">提交</string>
<string name="git_state_cancel">取消</string>
<string name="git_state_fetch">获取</string>
<string name="git_state_pull">拉取</string>
<string name="git_state_push">推送</string>
<string name="git_state_new_branch_hint">新分支名称</string>
<string name="git_state_create_branch">创建分支</string>
<string name="git_state_track_remote">跟踪远程分支</string>
<string name="git_state_switch">切换</string>
<string name="git_state_current">当前</string>
<string name="git_state_mutation_in_progress">%1$s 正在进行…</string>
<string name="git_state_mutation_success">%1$s 已完成。HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s 失败:%2$s</string>
<string name="git_state_confirm_discard_title">丢弃更改?</string>
<string name="git_state_confirm_discard_text">所选文件的本地更改将被丢弃,且无法恢复。</string>
<string name="git_state_confirm_discard_confirm">丢弃</string>
<string name="git_state_confirm_push_title">推送更改?</string>
<string name="git_state_confirm_push_text">将当前分支推送到其远程仓库。这会把本地提交发送到远程仓库。</string>
<string name="git_state_confirm_push_confirm">推送</string>
<string name="git_state_confirm_checkout_dirty_title">切换包含本地更改的分支?</string>
<string name="git_state_confirm_checkout_dirty_text">工作区有未提交的更改。切换后,这些更改可能会被带到目标分支。</string>
<string name="git_state_confirm_checkout_confirm">切换</string>
<string name="git_state_generate_message">生成提交信息</string>
<string name="git_state_generating_message">正在生成提交信息…</string>
<string name="git_state_push_after_commit">提交后推送</string>
<string name="git_state_switch_stash">切换(有更改时暂存)</string>
<string name="support_bundle_review">查看支持信息</string>
<string name="support_bundle_title">支持信息</string>
<string name="support_bundle_privacy">不会自动上传任何内容。请查看最多包含 %1$d 个报告的本地导出。</string>
+47 -1
View File
@@ -933,6 +933,10 @@
<string name="drawer_search_sessions">Sitzungen durchsuchen</string>
<string name="drawer_activity_working">Wird bearbeitet</string>
<string name="drawer_activity_needs_input">Eingabe erforderlich</string>
<string name="drawer_activity_starting">Wird gestartet</string>
<string name="drawer_activity_background_work">Hintergrundarbeit</string>
<string name="drawer_activity_checking">Wird geprüft</string>
<string name="drawer_activity_unavailable">Nicht verfügbar</string>
<string name="drawer_new_thread">Neuer Thread</string>
<string name="drawer_chats_not_named">Chats werden bei dieser Verbindung nicht automatisch benannt — verwende ⋮ → Umbenennen.</string>
<string name="drawer_loading_sessions">Sitzungen werden geladen…</string>
@@ -962,7 +966,7 @@
<string name="drawer_filter_pinned">Angeheftet</string>
<string name="drawer_filter_archive">Archiv</string>
<string name="drawer_filter_sessions">Sitzungen</string>
<string name="drawer_timestamp_active">Aktiv</string>
<string name="drawer_timestamp_updated">Aktualisiert</string>
<string name="drawer_timestamp_started">Gestartet</string>
<string name="drawer_just_now">Gerade eben</string>
@@ -4014,6 +4018,48 @@
<string name="plugins_keep">Behalten</string>
<string name="plugins_remove">Entfernen</string>
<string name="plugins_remove_confirm">„%1$s“ entfernen? Diese Plugin-Seite wird auf verbundenen Android-Geräten nicht mehr angezeigt.</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">Zurück</string>
<string name="git_state_staged">Gestaged</string>
<string name="git_state_modified">Geändert</string>
<string name="git_state_untracked">Unverfolgt</string>
<string name="git_state_branches">Branches</string>
<string name="git_state_truncated">Ergebnisse abgeschnitten – nur die ersten Einträge werden angezeigt.</string>
<string name="git_state_no_changes">(keine Änderungen)</string>
<string name="git_state_write_grant_required">Für Schreibvorgänge ist die Plugin-Änderungsberechtigung erforderlich. Aktivieren Sie „Änderungen durch Plugins zulassen (plugin.api.write)“ in den Plugin-Berechtigungseinstellungen.</string>
<string name="git_state_stage">Bereitstellen</string>
<string name="git_state_unstage">Zurücksetzen</string>
<string name="git_state_discard">Verwerfen</string>
<string name="git_state_commit">Committen</string>
<string name="git_state_commit_title">Bereitgestellte Änderungen committen</string>
<string name="git_state_commit_message_hint">Commit-Nachricht</string>
<string name="git_state_commit_confirm">Committen</string>
<string name="git_state_cancel">Abbrechen</string>
<string name="git_state_fetch">Abrufen</string>
<string name="git_state_pull">Pullen</string>
<string name="git_state_push">Pushen</string>
<string name="git_state_new_branch_hint">Name des neuen Branches</string>
<string name="git_state_create_branch">Branch erstellen</string>
<string name="git_state_track_remote">Remote-Branch verfolgen</string>
<string name="git_state_switch">Wechseln</string>
<string name="git_state_current">Aktuell</string>
<string name="git_state_mutation_in_progress">%1$s läuft…</string>
<string name="git_state_mutation_success">%1$s abgeschlossen. HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s fehlgeschlagen: %2$s</string>
<string name="git_state_confirm_discard_title">Änderungen verwerfen?</string>
<string name="git_state_confirm_discard_text">Lokale Änderungen an den ausgewählten Dateien werden verworfen und können nicht wiederhergestellt werden.</string>
<string name="git_state_confirm_discard_confirm">Verwerfen</string>
<string name="git_state_confirm_push_title">Änderungen pushen?</string>
<string name="git_state_confirm_push_text">Pushen Sie den aktuellen Branch zu seinem Remote. Dabei werden lokale Commits an das Remote-Repository gesendet.</string>
<string name="git_state_confirm_push_confirm">Pushen</string>
<string name="git_state_confirm_checkout_dirty_title">Branch mit lokalen Änderungen wechseln?</string>
<string name="git_state_confirm_checkout_dirty_text">Der Arbeitsbaum enthält nicht committete Änderungen. Beim Wechseln werden sie möglicherweise auf den Zielbranch übertragen.</string>
<string name="git_state_confirm_checkout_confirm">Wechseln</string>
<string name="git_state_generate_message">Commit-Nachricht generieren</string>
<string name="git_state_generating_message">Commit-Nachricht wird generiert…</string>
<string name="git_state_push_after_commit">Nach Commit pushen</string>
<string name="git_state_switch_stash">Wechseln (bei Änderungen stashen)</string>
<string name="support_bundle_review">Supportinformationen prüfen</string>
<string name="support_bundle_title">Supportinformationen</string>
<string name="support_bundle_privacy">Nichts wird automatisch hochgeladen. Prüfen Sie den lokalen Export mit bis zu %1$d Berichten.</string>
+47 -1
View File
@@ -848,6 +848,10 @@
<string name="drawer_search_sessions">Buscar sesiones</string>
<string name="drawer_activity_working">En curso</string>
<string name="drawer_activity_needs_input">Requiere intervención</string>
<string name="drawer_activity_starting">Iniciando</string>
<string name="drawer_activity_background_work">Trabajo en segundo plano</string>
<string name="drawer_activity_checking">Comprobando</string>
<string name="drawer_activity_unavailable">No disponible</string>
<string name="drawer_new_thread">Nuevo hilo</string>
<string name="drawer_chats_not_named">Los chats no tienen nombre automático en esta conexión. Utiliza «→Renombrar».</string>
<string name="drawer_loading_sessions">Cargando sesiones…</string>
@@ -877,7 +881,7 @@
<string name="drawer_filter_pinned">Fijado</string>
<string name="drawer_filter_archive">Archivo</string>
<string name="drawer_filter_sessions">Sesiones</string>
<string name="drawer_timestamp_active">Activo</string>
<string name="drawer_timestamp_updated">Actualizado</string>
<string name="drawer_timestamp_started">Comenzó</string>
<string name="drawer_just_now">En este momento</string>
<string name="bridge_back">Atrás</string>
@@ -3699,6 +3703,48 @@
<string name="plugins_keep">Conservar</string>
<string name="plugins_remove">Eliminar</string>
<string name="plugins_remove_confirm">¿Eliminar «%1$s»? Esta página del plugin dejará de aparecer en los dispositivos Android conectados.</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">Atrás</string>
<string name="git_state_staged">Preparados</string>
<string name="git_state_modified">Modificados</string>
<string name="git_state_untracked">Sin seguimiento</string>
<string name="git_state_branches">Ramas</string>
<string name="git_state_truncated">Resultados truncados: solo se muestran las primeras entradas.</string>
<string name="git_state_no_changes">(sin cambios)</string>
<string name="git_state_write_grant_required">Los cambios de escritura requieren el permiso de cambios de plugin. Activa «Permitir cambios de plugins (plugin.api.write)» en la configuración de permisos de Plugins.</string>
<string name="git_state_stage">Preparar</string>
<string name="git_state_unstage">Quitar de preparados</string>
<string name="git_state_discard">Descartar</string>
<string name="git_state_commit">Confirmar</string>
<string name="git_state_commit_title">Confirmar cambios preparados</string>
<string name="git_state_commit_message_hint">Mensaje de confirmación</string>
<string name="git_state_commit_confirm">Confirmar</string>
<string name="git_state_cancel">Cancelar</string>
<string name="git_state_fetch">Obtener</string>
<string name="git_state_pull">Traer</string>
<string name="git_state_push">Enviar</string>
<string name="git_state_new_branch_hint">Nombre de la nueva rama</string>
<string name="git_state_create_branch">Crear rama</string>
<string name="git_state_track_remote">Seguir rama remota</string>
<string name="git_state_switch">Cambiar</string>
<string name="git_state_current">Actual</string>
<string name="git_state_mutation_in_progress">%1$s en curso…</string>
<string name="git_state_mutation_success">%1$s completado. HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s falló: %2$s</string>
<string name="git_state_confirm_discard_title">¿Descartar cambios?</string>
<string name="git_state_confirm_discard_text">Los cambios locales de los archivos seleccionados se descartarán y no podrán recuperarse.</string>
<string name="git_state_confirm_discard_confirm">Descartar</string>
<string name="git_state_confirm_push_title">¿Enviar cambios?</string>
<string name="git_state_confirm_push_text">Envía la rama actual a su remoto. Esto sube los commits locales al repositorio remoto.</string>
<string name="git_state_confirm_push_confirm">Enviar</string>
<string name="git_state_confirm_checkout_dirty_title">¿Cambiar de rama con cambios locales?</string>
<string name="git_state_confirm_checkout_dirty_text">El árbol de trabajo tiene cambios sin confirmar. Cambiar puede trasladarlos a la rama de destino.</string>
<string name="git_state_confirm_checkout_confirm">Cambiar</string>
<string name="git_state_generate_message">Generar mensaje de confirmación</string>
<string name="git_state_generating_message">Generando mensaje de confirmación…</string>
<string name="git_state_push_after_commit">Enviar después de confirmar</string>
<string name="git_state_switch_stash">Cambiar (guardar en stash si hay cambios)</string>
<string name="support_bundle_review">Revisar información de soporte</string>
<string name="support_bundle_title">Información de soporte</string>
<string name="support_bundle_privacy">Nada se sube automáticamente. Revisa la exportación local con hasta %1$d informes.</string>
+47 -1
View File
@@ -946,6 +946,10 @@
<string name="drawer_search_sessions">セッションを検索</string>
<string name="drawer_activity_working">処理中</string>
<string name="drawer_activity_needs_input">入力が必要</string>
<string name="drawer_activity_starting">開始中</string>
<string name="drawer_activity_background_work">バックグラウンド処理</string>
<string name="drawer_activity_checking">確認中</string>
<string name="drawer_activity_unavailable">利用不可</string>
<string name="drawer_new_thread">新しいスレッド</string>
<string name="drawer_chats_not_named">この接続ではチャットの名前は自動的に付けられません。「⋮」→「名前の変更」を使用してください。</string>
<string name="drawer_loading_sessions">セッションを読み込み中…</string>
@@ -975,7 +979,7 @@
<string name="drawer_filter_pinned">固定された</string>
<string name="drawer_filter_archive">アーカイブ</string>
<string name="drawer_filter_sessions">セッション</string>
<string name="drawer_timestamp_active">アクティブ</string>
<string name="drawer_timestamp_updated">更新</string>
<string name="drawer_timestamp_started">開始しました</string>
<string name="drawer_just_now">ちょうど今</string>
@@ -4013,6 +4017,48 @@
<string name="plugins_keep">保持</string>
<string name="plugins_remove">削除</string>
<string name="plugins_remove_confirm">「%1$s」を削除しますか?接続された Android 端末にこのプラグインページは表示されなくなります。</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">戻る</string>
<string name="git_state_staged">ステージ済み</string>
<string name="git_state_modified">変更</string>
<string name="git_state_untracked">未追跡</string>
<string name="git_state_branches">ブランチ</string>
<string name="git_state_truncated">結果は切り詰められています。最初の項目のみ表示されます。</string>
<string name="git_state_no_changes">(変更なし)</string>
<string name="git_state_write_grant_required">書き込み変更にはプラグイン変更権限が必要です。Plugins の権限設定で「プラグインの変更を許可する(plugin.api.write)」を有効にしてください。</string>
<string name="git_state_stage">ステージ</string>
<string name="git_state_unstage">ステージ解除</string>
<string name="git_state_discard">破棄</string>
<string name="git_state_commit">コミット</string>
<string name="git_state_commit_title">ステージ済みの変更をコミット</string>
<string name="git_state_commit_message_hint">コミットメッセージ</string>
<string name="git_state_commit_confirm">コミット</string>
<string name="git_state_cancel">キャンセル</string>
<string name="git_state_fetch">フェッチ</string>
<string name="git_state_pull">プル</string>
<string name="git_state_push">プッシュ</string>
<string name="git_state_new_branch_hint">新しいブランチ名</string>
<string name="git_state_create_branch">ブランチを作成</string>
<string name="git_state_track_remote">リモートブランチを追跡</string>
<string name="git_state_switch">切り替え</string>
<string name="git_state_current">現在</string>
<string name="git_state_mutation_in_progress">%1$s を実行中…</string>
<string name="git_state_mutation_success">%1$s が完了しました。HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s に失敗しました: %2$s</string>
<string name="git_state_confirm_discard_title">変更を破棄しますか?</string>
<string name="git_state_confirm_discard_text">選択したファイルへのローカル変更は破棄され、元に戻せません。</string>
<string name="git_state_confirm_discard_confirm">破棄</string>
<string name="git_state_confirm_push_title">変更をプッシュしますか?</string>
<string name="git_state_confirm_push_text">現在のブランチをリモートにプッシュします。ローカルのコミットがリモートリポジトリに送信されます。</string>
<string name="git_state_confirm_push_confirm">プッシュ</string>
<string name="git_state_confirm_checkout_dirty_title">ローカル変更のあるブランチに切り替えますか?</string>
<string name="git_state_confirm_checkout_dirty_text">作業ツリーに未コミットの変更があります。切り替えると、それらが対象ブランチへ引き継がれる可能性があります。</string>
<string name="git_state_confirm_checkout_confirm">切り替え</string>
<string name="git_state_generate_message">コミットメッセージを生成</string>
<string name="git_state_generating_message">コミットメッセージを生成中…</string>
<string name="git_state_push_after_commit">コミット後にプッシュ</string>
<string name="git_state_switch_stash">切り替え(変更があればスタッシュ)</string>
<string name="support_bundle_review">サポート情報を確認</string>
<string name="support_bundle_title">サポート情報</string>
<string name="support_bundle_privacy">自動アップロードはありません。最大 %1$d 件のレポートを含む端末内エクスポートを確認してください。</string>
+47 -1
View File
@@ -956,6 +956,10 @@
<string name="drawer_search_sessions">Поиск сессий</string>
<string name="drawer_activity_working">Выполняется</string>
<string name="drawer_activity_needs_input">Требуется ввод</string>
<string name="drawer_activity_starting">Запуск</string>
<string name="drawer_activity_background_work">Фоновая работа</string>
<string name="drawer_activity_checking">Проверка</string>
<string name="drawer_activity_unavailable">Недоступно</string>
<string name="drawer_new_thread">Новая ветка</string>
<string name="drawer_chats_not_named">Чаты не автоматически именуются на этом соединении — используйте ⋮ → Переименовать.</string>
<string name="drawer_loading_sessions">Загрузка сессий…</string>
@@ -985,7 +989,7 @@
<string name="drawer_filter_pinned">Закрепленные</string>
<string name="drawer_filter_archive">Архив</string>
<string name="drawer_filter_sessions">Сессии</string>
<string name="drawer_timestamp_active">Активен</string>
<string name="drawer_timestamp_updated">Обновлено</string>
<string name="drawer_timestamp_started">Начат</string>
<string name="drawer_just_now">Только что</string>
<string name="bridge_back">Назад</string>
@@ -3735,6 +3739,48 @@
<string name="plugins_keep">Оставить</string>
<string name="plugins_remove">Удалить</string>
<string name="plugins_remove_confirm">Удалить «%1$s»? Эта страница плагина больше не будет отображаться на подключённых устройствах Android.</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">Назад</string>
<string name="git_state_staged">В индексе</string>
<string name="git_state_modified">Изменённые</string>
<string name="git_state_untracked">Неотслеживаемые</string>
<string name="git_state_branches">Ветки</string>
<string name="git_state_truncated">Результаты усечены — показаны только первые записи.</string>
<string name="git_state_no_changes">(без изменений)</string>
<string name="git_state_write_grant_required">Для записи изменений требуется разрешение на изменение плагина. Включите «Разрешить изменения плагинов (plugin.api.write)» в настройках разрешений Plugins.</string>
<string name="git_state_stage">Добавить в индекс</string>
<string name="git_state_unstage">Убрать из индекса</string>
<string name="git_state_discard">Отменить</string>
<string name="git_state_commit">Коммит</string>
<string name="git_state_commit_title">Зафиксировать индексированные изменения</string>
<string name="git_state_commit_message_hint">Сообщение коммита</string>
<string name="git_state_commit_confirm">Коммит</string>
<string name="git_state_cancel">Отмена</string>
<string name="git_state_fetch">Обновить</string>
<string name="git_state_pull">Вытянуть</string>
<string name="git_state_push">Отправить</string>
<string name="git_state_new_branch_hint">Имя новой ветки</string>
<string name="git_state_create_branch">Создать ветку</string>
<string name="git_state_track_remote">Отслеживать удалённую ветку</string>
<string name="git_state_switch">Переключить</string>
<string name="git_state_current">Текущая</string>
<string name="git_state_mutation_in_progress">%1$s выполняется…</string>
<string name="git_state_mutation_success">%1$s завершено. HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s не удалось: %2$s</string>
<string name="git_state_confirm_discard_title">Отменить изменения?</string>
<string name="git_state_confirm_discard_text">Локальные изменения выбранных файлов будут отменены и их нельзя будет восстановить.</string>
<string name="git_state_confirm_discard_confirm">Отменить</string>
<string name="git_state_confirm_push_title">Отправить изменения?</string>
<string name="git_state_confirm_push_text">Отправить текущую ветку в её удалённый репозиторий. Это передаст локальные коммиты в удалённый репозиторий.</string>
<string name="git_state_confirm_push_confirm">Отправить</string>
<string name="git_state_confirm_checkout_dirty_title">Переключить ветку с локальными изменениями?</string>
<string name="git_state_confirm_checkout_dirty_text">В рабочем дереве есть незакоммиченные изменения. Переключение может перенести их в целевую ветку.</string>
<string name="git_state_confirm_checkout_confirm">Переключить</string>
<string name="git_state_generate_message">Создать сообщение коммита</string>
<string name="git_state_generating_message">Создание сообщения коммита…</string>
<string name="git_state_push_after_commit">Отправить после коммита</string>
<string name="git_state_switch_stash">Переключить (сохранить изменения, если есть)</string>
<string name="support_bundle_review">Проверить сведения для поддержки</string>
<string name="support_bundle_title">Сведения для поддержки</string>
<string name="support_bundle_privacy">Ничего не загружается автоматически. Проверьте локальный экспорт с не более чем %1$d отчётами.</string>
+50 -1
View File
@@ -1048,6 +1048,10 @@
<string name="drawer_search_sessions">Search sessions</string>
<string name="drawer_activity_working">Working</string>
<string name="drawer_activity_needs_input">Needs input</string>
<string name="drawer_activity_starting">Starting</string>
<string name="drawer_activity_background_work">Background work</string>
<string name="drawer_activity_checking">Checking</string>
<string name="drawer_activity_unavailable">Unavailable</string>
<string name="drawer_new_thread">New Thread</string>
<string name="drawer_chats_not_named">Chats aren\'t auto-named on this connection — use ⋮ → Rename.</string>
<string name="drawer_loading_sessions">Loading sessions…</string>
@@ -1077,7 +1081,7 @@
<string name="drawer_filter_pinned">Pinned</string>
<string name="drawer_filter_archive">Archive</string>
<string name="drawer_filter_sessions">Sessions</string>
<string name="drawer_timestamp_active">Active</string>
<string name="drawer_timestamp_updated">Updated</string>
<string name="drawer_timestamp_started">Started</string>
<string name="drawer_just_now">Just now</string>
@@ -4210,6 +4214,51 @@
<string name="plugins_keep">Keep</string>
<string name="plugins_remove">Remove</string>
<string name="plugins_remove_confirm">Remove “%1$s”? This plugin page will no longer appear on connected Android devices.</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">Back</string>
<string name="git_state_staged">Staged</string>
<string name="git_state_modified">Modified</string>
<string name="git_state_untracked">Untracked</string>
<string name="git_state_branches">Branches</string>
<string name="git_state_truncated">Results truncated — showing the first entries only.</string>
<string name="git_state_no_changes">(no changes)</string>
<!-- Git write surface (Phase 2). All mutations require the plugin.api.write grant. -->
<string name="git_state_write_grant_required">Write changes require the plugin change permission. Enable “Allow plugin changes (plugin.api.write)” in the Plugins grant settings.</string>
<string name="git_state_stage">Stage</string>
<string name="git_state_unstage">Unstage</string>
<string name="git_state_discard">Discard</string>
<string name="git_state_commit">Commit</string>
<string name="git_state_commit_title">Commit staged changes</string>
<string name="git_state_commit_message_hint">Commit message</string>
<string name="git_state_commit_confirm">Commit</string>
<string name="git_state_cancel">Cancel</string>
<string name="git_state_fetch">Fetch</string>
<string name="git_state_pull">Pull</string>
<string name="git_state_push">Push</string>
<string name="git_state_new_branch_hint">New branch name</string>
<string name="git_state_create_branch">Create branch</string>
<string name="git_state_track_remote">Track remote branch</string>
<string name="git_state_switch">Switch</string>
<string name="git_state_current">Current</string>
<string name="git_state_mutation_in_progress">%1$s in progress…</string>
<string name="git_state_mutation_success">%1$s completed. HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s failed: %2$s</string>
<!-- Destructive-op confirmations (token is sent only on explicit user confirmation). -->
<string name="git_state_confirm_discard_title">Discard changes?</string>
<string name="git_state_confirm_discard_text">Local changes to the selected file(s) will be discarded and cannot be recovered.</string>
<string name="git_state_confirm_discard_confirm">Discard</string>
<string name="git_state_confirm_push_title">Push changes?</string>
<string name="git_state_confirm_push_text">Push the current branch to its remote. This sends local commits to the remote repository.</string>
<string name="git_state_confirm_push_confirm">Push</string>
<string name="git_state_confirm_checkout_dirty_title">Switch branch with local changes?</string>
<string name="git_state_confirm_checkout_dirty_text">The working tree has uncommitted changes. Switching may carry them onto the target branch.</string>
<string name="git_state_confirm_checkout_confirm">Switch</string>
<string name="git_state_generate_message">Generate commit message</string>
<string name="git_state_generating_message">Generating commit message…</string>
<string name="git_state_push_after_commit">Push after commit</string>
<string name="git_state_switch_stash">Switch (stash if dirty)</string>
<string name="support_bundle_review">Review support information</string>
<string name="support_bundle_title">Support information</string>
<string name="support_bundle_privacy">Nothing is uploaded automatically. Review the exact local export below. It contains up to %1$d recent reports.</string>
@@ -0,0 +1,453 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionActivityRegistryTest {
private val owner = owner("connection-a", "Default", "session-a")
private val scope = SessionActivityScope.of("connection-a", "default")
@Test
fun `directory owner is checking until status is unavailable or confirms idle`() {
val checking = SessionActivityRegistry().reduce(
SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1),
)
assertEquals(SessionActivityPhase.Idle, checking.record(owner)?.phase())
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
val unavailable = checking.reduce(
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2),
)
assertEquals(SessionActivityState.Unavailable, unavailable.record(owner)?.presentationState())
val confirmedIdle = checking.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, confirmedIdle.record(owner)?.freshness)
assertNull(confirmedIdle.record(owner)?.presentationState())
}
@Test
fun `directory observation cannot downgrade confirmed live evidence`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 20))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
}
@Test
fun `owner normalizes profile without collapsing connection ownership`() {
assertEquals(owner, owner("connection-a", " DEFAULT ", "session-a"))
val otherConnection = owner("connection-b", "default", "session-a")
assertEquals(2, setOf(owner, otherConnection).size)
}
@Test
fun `exact pending input outranks live working and answer restores it`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase(nowMillis = 10))
state = state.reduce(closeInput(owner, "request-a", generation = 1))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 10))
}
@Test
fun `expired pending input no longer overrides live state`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1, expiresAt = 50))
.reduce(SessionActivityUpdate.Tick(nowMillis = 50))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 50))
}
@Test
fun `checkpoint is revalidating until successful snapshot settles it`() {
var state = SessionActivityRegistry().reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 1,
),
)
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
state = state.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
}
@Test
fun `successful snapshot absence settles only the same profile`() {
val otherProfile = owner("connection-a", "work", "session-a")
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(live(otherProfile, "runtime-a", SessionLiveStatus.Working, generation = 1))
state = state.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(otherProfile)?.phase())
}
@Test
fun `same ids cannot alias across profiles or connections`() {
val profileB = owner("connection-a", "work", "session-a")
val connectionB = owner("connection-b", "default", "session-a")
var state = SessionActivityRegistry()
listOf(owner, profileB, connectionB).forEach {
state = state.reduce(live(it, "runtime-shared", SessionLiveStatus.Working, generation = 2))
}
assertEquals(owner, state.ownerForRuntime(scope, "runtime-shared"))
assertEquals(
profileB,
state.ownerForRuntime(SessionActivityScope.of("connection-a", "work"), "runtime-shared"),
)
assertEquals(
connectionB,
state.ownerForRuntime(SessionActivityScope.of("connection-b", "default"), "runtime-shared"),
)
}
@Test
fun `unscoped active row cannot mark duplicate stored ids as working`() {
val profileB = owner("connection-a", "work", "session-a")
var state = SessionActivityRegistry()
.reduce(live(owner, "old-a", SessionLiveStatus.Working, generation = 1))
.reduce(live(profileB, "old-b", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
activeList(
scope,
1,
false,
SessionLiveRuntime(
owner = null,
runtimeId = "ambiguous-runtime",
status = SessionLiveStatus.Working,
),
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(profileB)?.phase())
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
}
@Test
fun `partial snapshot applies resolved row without settling absent owner`() {
val absentOwner = owner("connection-a", "default", "session-b")
var state = SessionActivityRegistry()
.reduce(live(absentOwner, "runtime-b", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
activeList(
scope,
1,
false,
SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Working),
SessionLiveRuntime(null, "ambiguous-runtime", SessionLiveStatus.Working),
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(absentOwner)?.phase())
assertEquals(owner, state.ownerForRuntime(scope, "runtime-a"))
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
}
@Test
fun `new generation rejects late terminal event and invalidates old alias`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-old", SessionLiveStatus.Working, generation = 3))
.reduce(SessionActivityUpdate.BeginGeneration(scope, generation = 4, observedAtMillis = 20))
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
assertNull(state.ownerForRuntime(scope, "runtime-old"))
state = state.reduce(live(owner, "runtime-old", SessionLiveStatus.Idle, generation = 3))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
}
@Test
fun `failed or unsupported status refresh is unavailable rather than idle`() {
val state = SessionActivityRegistry()
.reduce(
SessionActivityUpdate.LiveState(
owner = owner,
runtimeId = "runtime-a",
status = SessionLiveStatus.Working,
source = SessionActivityEvidenceSource.ActiveList,
generation = 1,
observedAtMillis = 10,
),
)
.reduce(
SessionActivityUpdate.StatusUnavailable(
scope = scope,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
assertEquals(SessionActivityState.Unavailable, state.record(owner)?.presentationState())
}
@Test
fun `active-list failure does not override exact live session event`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(
SessionActivityUpdate.StatusUnavailable(
scope = scope,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
assertEquals(SessionActivityState.Working, state.record(owner)?.presentationState())
}
@Test
fun `presentation keeps starting background and revalidation distinct from working`() {
val starting = SessionActivityRegistry().reduce(
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
)
assertEquals(SessionActivityState.Starting, starting.record(owner)?.presentationState())
val background = starting
.reduce(process(owner, "process-a", running = true, generation = 1))
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
assertEquals(SessionActivityState.BackgroundWork, background.record(owner)?.presentationState())
val checking = starting.reduce(
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
)
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
}
@Test
fun `terminal turn with running process projects background work separately`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(process(owner, "process-a", running = true, generation = 1))
.reduce(terminal(owner, "runtime-a", generation = 1))
assertEquals(SessionActivityPhase.BackgroundWork, state.record(owner)?.phase())
state = state.reduce(process(owner, "process-a", running = false, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
}
@Test
fun `terminal settles pending input and removes its runtime alias`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
state = state.reduce(terminal(owner, "runtime-a", generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertNull(state.ownerForRuntime(scope, "runtime-a"))
}
@Test
fun `authoritative live state is not overwritten by restored checkpoint`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
.reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 30,
),
)
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
}
@Test
fun `restored needs-input checkpoint stays checking until live confirmation`() {
val state = SessionActivityRegistry().reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.NeedsInput,
generation = 1,
observedAtMillis = 10,
),
)
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
}
@Test
fun `synthetic checkpoint input does not confirm restored working state`() {
var state = SessionActivityRegistry()
.reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 10,
),
)
.reduce(
SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = "checkpoint:session-a",
confirmed = false,
generation = 1,
observedAtMillis = 11,
),
)
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
state = state.reduce(
SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = "checkpoint:session-a",
confirmed = true,
generation = 1,
observedAtMillis = 12,
),
)
assertEquals(SessionActivityState.NeedsInput, state.record(owner)?.presentationState())
}
@Test
fun `authoritative idle active-list row clears stale pending input`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
.reduce(
activeList(
scope,
generation = 1,
runtimes = arrayOf(SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Idle)),
),
)
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertTrue(state.record(owner)?.pendingInputs.orEmpty().isEmpty())
assertNull(state.record(owner)?.presentationState())
}
@Test
fun `runtime-only update requires alias in current scoped generation`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
SessionActivityUpdate.RuntimeState(
scope = scope,
runtimeId = "runtime-a",
status = SessionLiveStatus.Waiting,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
state = state.reduce(SessionActivityUpdate.BeginGeneration(scope, 2, 30))
.reduce(
SessionActivityUpdate.RuntimeState(
scope = scope,
runtimeId = "runtime-a",
status = SessionLiveStatus.Idle,
generation = 2,
observedAtMillis = 40,
),
)
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
}
private fun owner(connection: String, profile: String, session: String) =
SessionActivityOwner.of(connection, profile, session)
private fun live(
owner: SessionActivityOwner,
runtime: String,
status: SessionLiveStatus,
generation: Long,
) = SessionActivityUpdate.LiveState(
owner = owner,
runtimeId = runtime,
status = status,
generation = generation,
observedAtMillis = 10,
)
private fun activeList(
scope: SessionActivityScope,
generation: Long,
complete: Boolean = true,
vararg runtimes: SessionLiveRuntime,
) = SessionActivityUpdate.ActiveList(
scope = scope,
runtimes = runtimes.toList(),
isCompleteForScope = complete,
generation = generation,
observedAtMillis = 20,
)
private fun openInput(
owner: SessionActivityOwner,
request: String,
generation: Long,
expiresAt: Long? = null,
) = SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = request,
expiresAtMillis = expiresAt,
generation = generation,
observedAtMillis = 10,
)
private fun closeInput(owner: SessionActivityOwner, request: String, generation: Long) =
SessionActivityUpdate.PendingInputClosed(
owner = owner,
requestId = request,
generation = generation,
observedAtMillis = 20,
)
private fun process(owner: SessionActivityOwner, id: String, running: Boolean, generation: Long) =
SessionActivityUpdate.ProcessState(
owner = owner,
processId = id,
running = running,
generation = generation,
observedAtMillis = 10,
)
private fun terminal(owner: SessionActivityOwner, runtime: String, generation: Long) =
SessionActivityUpdate.Terminal(
owner = owner,
runtimeId = runtime,
generation = generation,
observedAtMillis = 20,
)
}
@@ -195,6 +195,11 @@ class GatewayClientHarness(
put("sessions", JsonArray(emptyList()))
}
@Volatile
var activeSessionListPayload: JsonObject = buildJsonObject {
put("sessions", JsonArray(emptyList()))
}
@Volatile
var profileCreatePayload: JsonObject = buildJsonObject {
put("ok", true)
@@ -316,6 +321,7 @@ class GatewayClientHarness(
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
)
"session.list" -> sessionListPayload
"session.active_list" -> activeSessionListPayload
"session.title" -> buildJsonObject { put("ok", true) }
"prompt.submit" -> promptSubmitPayload
"session.interrupt" -> buildJsonObject { put("ok", true) }
@@ -1514,6 +1520,112 @@ class GatewayChatClientTest {
assertTrue((refreshParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
}
@Test
fun `active session list parses every authoritative upstream state`() = runBlocking {
harness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray {
listOf("idle", "starting", "working", "waiting").forEachIndexed { index, status ->
add(buildJsonObject {
put("id", "runtime-$index")
put("session_key", "stored-$index")
put("status", status)
put("last_active", 1_774_000_000.25 + index)
})
}
})
}
val result = client.listActiveSessions()
val rows = (result as GatewayActiveSessionsResult.Success).sessions
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
assertEquals(
listOf(
GatewayActiveSessionStatus.Idle,
GatewayActiveSessionStatus.Starting,
GatewayActiveSessionStatus.Working,
GatewayActiveSessionStatus.Waiting,
),
rows.map(GatewayActiveSession::status),
)
assertEquals("runtime-2", rows[2].runtimeSessionId)
assertEquals("stored-2", rows[2].storedSessionId)
assertEquals(1_774_000_002.25, rows[2].lastActiveEpochSeconds, 0.0)
assertTrue(rows.all { it.profile == null })
}
@Test
fun `active session list treats empty successful snapshot as authoritative`() = runBlocking {
val result = client.listActiveSessions()
assertEquals(emptyList<GatewayActiveSession>(), (result as GatewayActiveSessionsResult.Success).sessions)
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
}
@Test
fun `active session list stays process wide and never synthesizes fixed profile`() = runBlocking {
val routeHarness = GatewayClientHarness()
routeHarness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray {
add(buildJsonObject {
put("id", "runtime-operator")
put("session_key", "stored-shared")
put("status", "working")
put("last_active", 1_774_000_000.0)
})
})
}
val routeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val routeClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = routeHarness.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
),
fixedSessionProfile = "operator",
okHttpClient = OkHttpClient(),
callbackDispatcher = { it() },
scope = routeScope,
)
try {
val result = routeClient.listActiveSessions() as GatewayActiveSessionsResult.Success
val params = routeHarness.awaitRpc("session.active_list")
val requests = List(2) { routeHarness.server.takeRequest(5, TimeUnit.SECONDS) }
assertFalse(params.containsKey("profile"))
assertNull(result.sessions.single().profile)
assertTrue(requests.filterNotNull().any { it.path?.contains("profile=operator") == true })
} finally {
routeClient.shutdown()
routeScope.cancel()
routeHarness.shutdown()
}
}
@Test
fun `active session method not found is explicit and sticky for current socket`() = runBlocking {
harness.methodNotFound += "session.active_list"
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
assertEquals(GatewayActiveSessionCapability.Unsupported, client.activeSessionCapability.value)
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
}
@Test
fun `active session transient error stays distinct from unsupported`() = runBlocking {
harness.rpcErrors["session.active_list"] = 5036 to "could not enumerate active sessions"
val failed = client.listActiveSessions()
assertTrue(failed is GatewayActiveSessionsResult.TransientFailure)
assertEquals(GatewayActiveSessionCapability.Unknown, client.activeSessionCapability.value)
harness.rpcErrors.remove("session.active_list")
assertTrue(client.listActiveSessions() is GatewayActiveSessionsResult.Success)
assertEquals(2, harness.rpcLog.count { it.first == "session.active_list" })
}
@Test
fun `process list uses live session id and parses typed snapshot`() = runBlocking {
assertTrue(client.prewarmAwait("stored-session"))
@@ -4027,6 +4139,10 @@ class GatewayChatClientTest {
harness.awaitRpc("prompt.submit")
assertTrue(client.backgroundActiveTurn())
assertEquals(
GatewayKnownSessionOwner("20260612_120000_abc123", "coder"),
client.knownSessionOwner("live-1"),
)
client.clearSession()
client.sessionProfileProvider = { "writer" }
@@ -61,6 +61,7 @@ import com.hermesandroid.relay.data.AppearancePreferences
import com.hermesandroid.relay.data.DashboardConnectionStatus
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.PetBehaviorPreferences
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.relayDataStore
import com.hermesandroid.relay.ui.components.ChatInputBar
import com.hermesandroid.relay.ui.components.ChatInputPickerControl
@@ -138,9 +139,14 @@ class StoreScreenshotTest {
@get:Rule
val compose = createComposeRule()
private fun capture(name: String, themeId: String = "hermes-relay", body: @Composable () -> Unit) {
private fun capture(
name: String,
themeId: String = "hermes-relay",
themePreference: String = "dark",
body: @Composable () -> Unit,
) {
compose.setContent {
HermesRelayTheme(appThemeId = themeId, themePreference = "dark") {
HermesRelayTheme(appThemeId = themeId, themePreference = themePreference) {
// Adaptive is the app's real default skin (resolve("auto") -> Adaptive);
// it recolors to the active theme. The preview/test fallback is Classic,
// which mismatches the app and reads poorly on light themes.
@@ -251,6 +257,14 @@ class StoreScreenshotTest {
}
@Test fun s06_manage() = capture("06_manage", "hermes-relay") { ManageScene() }
@Test fun s04_sessions() = capture("04_sessions", "hermes-relay") { SessionsScene() }
@Test fun s12_session_activity_states() = capture("12_session_activity_states", "hermes-relay") {
SessionActivityStatesScene()
}
@Test fun s12_session_activity_states_light() = capture(
"12_session_activity_states_light",
"nous-blue",
themePreference = "light",
) { SessionActivityStatesScene() }
@Test fun s07_connections() = capture("07_connections", "hermes-relay") { ConnectionsScene() }
// Real Appearance screen scrolled to the new Font picker — proves the
// bundled Inter/Nunito faces load as visibly distinct previews (vs System).
@@ -805,6 +819,41 @@ private fun SessionsScene() {
}
}
@Composable
private fun SessionActivityStatesScene() {
val states = SessionActivityState.entries
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim)) {
SessionDrawerContent(
sessions = states.mapIndexed { index, state ->
ChatSession(
sessionId = "activity-$index",
title = when (state) {
SessionActivityState.Starting -> "Launching the agent"
SessionActivityState.Working -> "Reviewing the release"
SessionActivityState.NeedsInput -> "Approval required"
SessionActivityState.BackgroundWork -> "Build still running"
SessionActivityState.Checking -> "Reconnecting to Hermes"
SessionActivityState.Unavailable -> "Offline session"
},
model = "gpt-5.6-sol",
)
},
currentSessionId = null,
activeProfileName = "default",
scopeTitle = "Hermes",
scopeSubtitle = "Live session status",
activityStates = states.mapIndexed { index, state ->
"default:activity-$index" to state
}.toMap(),
animationEnabled = false,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
@Composable
private fun ConnectionsScene() = ConnectionsSettingsScreen(
connections = marketingConnections,
@@ -1,8 +1,10 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class SessionDrawerPolicyTest {
@@ -19,6 +21,127 @@ class SessionDrawerPolicyTest {
assertEquals("alpha:same", sessionRowKey(alpha))
assertEquals("beta:same", sessionRowKey(beta))
assertEquals("default:same", sessionRowKey(row("default", "same")))
}
@Test
fun `rest recent activity alone does not mark a session working`() {
val recentlyActive = row("default", "recent", recentlyActive = true)
assertEquals(
SessionDrawerStatus.Idle,
sessionDrawerStatus(recentlyActive, activityStates = emptyMap()),
)
}
@Test
fun `duplicate session ids cannot leak activity across profiles`() {
val alpha = row("alpha", "same")
val beta = row("beta", "same")
val scoped = scopedSessionActivityStates(
rows = listOf(alpha, beta),
activityStates = mapOf(sessionRowKey(alpha) to SessionActivityState.Working),
allowBareSessionIds = false,
)
assertEquals(SessionDrawerStatus.Working, sessionDrawerStatus(alpha, scoped))
assertEquals(SessionDrawerStatus.Idle, sessionDrawerStatus(beta, scoped))
assertFalse(sessionRowKey(beta) in scoped)
}
@Test
fun `all profiles ignores ambiguous bare session activity`() {
val alpha = row("alpha", "same")
val beta = row("beta", "same")
val scoped = scopedSessionActivityStates(
rows = listOf(alpha, beta),
activityStates = mapOf("same" to SessionActivityState.Working),
allowBareSessionIds = false,
)
assertEquals(emptyMap<String, SessionActivityState>(), scoped)
}
@Test
fun `selected profile may scope legacy bare session activity`() {
val row = row("work", "session")
val scoped = scopedSessionActivityStates(
rows = listOf(row),
activityStates = mapOf("session" to SessionActivityState.NeedsInput),
allowBareSessionIds = true,
)
assertEquals(
mapOf(sessionRowKey(row) to SessionActivityState.NeedsInput),
scoped,
)
}
@Test
fun `status filter and grouping use the same authoritative state`() {
val restOnly = row("default", "rest-only", recentlyActive = true)
val working = row("default", "working")
val states = mapOf(sessionRowKey(working) to SessionActivityState.Working)
val filtered = filterAndSortSessionRows(
rows = listOf(restOnly, working),
options = SessionDrawerViewOptions(statuses = setOf(SessionDrawerStatus.Working)),
activityStates = states,
)
val grouped = groupSessionRows(
rows = listOf(restOnly, working),
grouping = SessionDrawerGrouping.Status,
activityStates = states,
)
assertEquals(listOf("working"), filtered.map { it.session.sessionId })
assertEquals(listOf("Idle", "Working"), grouped.mapNotNull { it.label })
}
@Test
fun `expanded live phases retain distinct drawer statuses and labels`() {
val phases = listOf(
SessionActivityState.NeedsInput to (SessionDrawerStatus.NeedsInput to "Needs input"),
SessionActivityState.Starting to (SessionDrawerStatus.Starting to "Starting"),
SessionActivityState.Working to (SessionDrawerStatus.Working to "Working"),
SessionActivityState.BackgroundWork to (SessionDrawerStatus.BackgroundWork to "Background work"),
SessionActivityState.Checking to (SessionDrawerStatus.Checking to "Checking"),
SessionActivityState.Unavailable to (SessionDrawerStatus.Unavailable to "Unavailable"),
)
val rows = phases.mapIndexed { index, _ -> row("default", "session-$index") }
val states = rows.zip(phases).associate { (row, phase) -> sessionRowKey(row) to phase.first }
assertEquals(
phases.map { it.second.first },
rows.map { sessionDrawerStatus(it, states) },
)
assertEquals(
phases.map { it.second.second },
groupSessionRows(rows, SessionDrawerGrouping.Status, states).mapNotNull { it.label },
)
assertEquals(
listOf(
R.string.drawer_activity_needs_input,
R.string.drawer_activity_starting,
R.string.drawer_activity_working,
R.string.drawer_activity_background_work,
R.string.drawer_activity_checking,
R.string.drawer_activity_unavailable,
),
phases.map { sessionActivityLabelResource(it.first) },
)
phases.forEachIndexed { index, phase ->
assertEquals(
listOf("session-$index"),
filterAndSortSessionRows(
rows = rows,
options = SessionDrawerViewOptions(statuses = setOf(phase.second.first)),
activityStates = states,
).map { it.session.sessionId },
)
}
}
@Test
@@ -113,6 +236,7 @@ class SessionDrawerPolicyTest {
outputTokens: Int = 0,
cost: Double? = null,
updatedAt: Long = 0L,
recentlyActive: Boolean = false,
) = ProfileSessionRow(
profile = profile,
session = ChatSession(
@@ -126,6 +250,7 @@ class SessionDrawerPolicyTest {
outputTokens = outputTokens,
actualCostUsd = cost,
lastActivityAt = updatedAt,
recentlyActive = recentlyActive,
),
)
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
import org.junit.Rule
import org.junit.Test
@@ -359,6 +360,66 @@ class SessionDrawerTest {
compose.onNodeWithText("Filters").assertIsDisplayed()
}
@Test
fun `drawer renders every authoritative activity phase distinctly`() {
val states = SessionActivityState.entries
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = states.mapIndexed { index, _ ->
ChatSession("session-$index", "Session $index", null)
},
currentSessionId = null,
activeProfileName = "default",
activityStates = states.mapIndexed { index, state ->
"default:session-$index" to state
}.toMap(),
animationEnabled = false,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
listOf(
"Starting",
"Working",
"Needs input",
"Background work",
"Checking",
"Unavailable",
).forEach { label ->
compose.onNodeWithText(label).performScrollTo().assertIsDisplayed()
}
}
@Test
fun `rest recency alone renders no working badge`() {
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = listOf(
ChatSession(
"recent",
"Recently updated",
null,
recentlyActive = true,
),
),
currentSessionId = null,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("Working").assertDoesNotExist()
}
@Test
fun `all profiles customization can override a profile identity color`() {
var changed: Pair<String, String?>? = null
@@ -0,0 +1,57 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.data.ChatSession
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class SessionDrawerTimestampTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun `distinct activity is labeled updated`() {
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
assertEquals(
"Updated Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
}
@Test
fun `relative timestamp changes when the drawer clock advances`() {
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
assertEquals(
"Updated Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
assertEquals(
"Updated 2m ago",
sessionTimestampText(session, Locale.US, context, nowMillis = 240_000L),
)
}
@Test
fun `session without later activity keeps started label`() {
val session = session(startedAt = 120_000L, lastActivityAt = 120_000L)
assertEquals(
"Started Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
}
private fun session(startedAt: Long, lastActivityAt: Long) = ChatSession(
sessionId = "session",
title = "Session",
model = null,
startedAt = startedAt,
lastActivityAt = lastActivityAt,
)
}
@@ -1,49 +0,0 @@
package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class SessionActivityStateTest {
@Test
fun `multiple background turns remain visible beside current working turn`() {
val resolved = resolveSessionActivityStates(
background = mapOf(
"session-a" to SessionActivityState.Working,
"session-b" to SessionActivityState.NeedsInput,
),
currentSessionId = "session-c",
isStreaming = true,
needsInput = false,
)
assertEquals(SessionActivityState.Working, resolved["session-a"])
assertEquals(SessionActivityState.NeedsInput, resolved["session-b"])
assertEquals(SessionActivityState.Working, resolved["session-c"])
}
@Test
fun `needs input takes precedence over current working state`() {
val resolved = resolveSessionActivityStates(
background = emptyMap(),
currentSessionId = "session-a",
isStreaming = true,
needsInput = true,
)
assertEquals(SessionActivityState.NeedsInput, resolved["session-a"])
}
@Test
fun `selected idle session does not retain a stale background state`() {
val resolved = resolveSessionActivityStates(
background = mapOf("session-a" to SessionActivityState.Working),
currentSessionId = "session-a",
isStreaming = false,
needsInput = false,
)
assertFalse(resolved.containsKey("session-a"))
}
}
@@ -14,6 +14,7 @@ import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.upstream.ChatHandler
@@ -23,6 +24,7 @@ import com.hermesandroid.relay.network.upstream.GatewayClientHarness
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.SessionItem
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -291,6 +293,78 @@ class ChatViewModelGatewayInboundTurnTest {
)
}
@Test
fun activeListWorkingThenDisappearanceSettlesDespiteRestRecency() {
bindActivityTestDirectory()
handler.updateSessions(
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
)
gatewayHarness.activeSessionListPayload = activeSessionPayload("working")
viewModel.setChatVisible(true)
gatewayHarness.awaitRpc("session.active_list")
awaitCondition {
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
SessionActivityState.Working
}
gatewayHarness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray { })
}
viewModel.requestSessionActivityRefresh()
gatewayHarness.awaitRpcCount("session.active_list", 2)
awaitCondition {
"default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
}
}
@Test
fun activeListWaitingProjectsNeedsInput() {
bindActivityTestDirectory()
gatewayHarness.activeSessionListPayload = activeSessionPayload("waiting")
viewModel.setChatVisible(true)
gatewayHarness.awaitRpc("session.active_list")
awaitCondition {
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
SessionActivityState.NeedsInput
}
}
@Test
fun unsupportedActiveListProjectsUnavailableInsteadOfRestWorking() {
bindActivityTestDirectory()
handler.updateSessions(
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
)
gatewayHarness.methodNotFound += "session.active_list"
viewModel.setChatVisible(true)
gatewayHarness.awaitRpc("session.active_list")
awaitCondition {
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
SessionActivityState.Unavailable
}
}
@Test
fun rejectedAdmissionCannotLeaveStartingStatusStale() {
bindActivityTestDirectory()
gatewayClient.clearSession()
gatewayHarness.rpcErrors["session.resume"] = 4090 to "stored session is unavailable"
viewModel.sendMessage("This admission should fail")
gatewayHarness.awaitRpc("session.resume")
awaitCondition { !handler.isStreaming.value }
assertTrue(
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] !=
SessionActivityState.Starting,
)
}
@After
fun tearDown() {
DiagnosticsLog.clear()
@@ -2376,6 +2450,27 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(handler.messages.value.any { it.content == BACKGROUND_ANSWER })
}
private fun bindActivityTestDirectory() {
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("connection-a", "default"),
STORED_SESSION_ID,
)
viewModel.updateSessionActivityDirectory(
rows = listOf("default" to STORED_SESSION_ID),
)
}
private fun activeSessionPayload(status: String) = buildJsonObject {
put("sessions", buildJsonArray {
add(buildJsonObject {
put("id", "live-resumed")
put("session_key", STORED_SESSION_ID)
put("status", status)
put("last_active", 1.0)
})
})
}
private fun persistedAnswerHistory(
answer: String = BACKGROUND_ANSWER,
id: String = "persisted-background-answer",
@@ -318,10 +318,14 @@ class GatewayProcessControllerTest {
controller.bind(source, "same-id", scopeKey = "profile-a")
controller.sessionReady("same-id")
runCurrent()
assertTrue(controller.ownsSnapshot("same-id", "profile-a"))
controller.selectSession("same-id", scopeKey = "profile-b")
assertFalse(controller.ownsSnapshot("same-id", "profile-a"))
assertFalse(controller.ownsSnapshot("same-id", "profile-b"))
controller.sessionReady("same-id")
runCurrent()
assertTrue(controller.ownsSnapshot("same-id", "profile-b"))
oldResult.complete(Result.success(listOf(process(id = "old-profile"))))
runCurrent()
@@ -0,0 +1,173 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class GitStateExtrasViewModelTest {
private val mainDispatcher = UnconfinedTestDispatcher()
private lateinit var application: Application
private lateinit var server: MockWebServer
@Before
fun setUp() {
Dispatchers.setMain(mainDispatcher)
application = ApplicationProvider.getApplicationContext()
server = MockWebServer().apply { start() }
}
@After
fun tearDown() {
server.shutdown()
Dispatchers.resetMain()
}
private fun viewModel(grant: Boolean = true): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()))
vm.setWriteGrant(grant)
return vm
}
private fun enqueueJson(body: String) {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(body),
)
}
private fun selectAlpha(vm: GitStateViewModel) {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
vm.selectRepo("alpha")
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
repeat(3) { server.takeRequest() }
}
// ── AI commit message (magic-wand) ─────────────────────────────────────
@Test
fun `generate commit message pre-fills the suggestion via selected paths`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson("""{"message":"feat: add feature","notice":""}""")
vm.generateCommitMessage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
}
assertEquals("feat: add feature", state.message)
assertEquals("", state.notice)
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/commit_message_selected"))
assertTrue(req.body.readUtf8().contains("a.txt"))
}
@Test
fun `generate message without grant is refused before any POST`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.generateCommitMessage(null)
val state = withTimeout(5_000) {
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
}
assertTrue(state.notice.contains("plugin.api.write"))
assertEquals(3, server.requestCount)
}
@Test
fun `empty staged diff surfaces notice without error`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson("""{"message":"","notice":"nothing staged"}""")
vm.generateCommitMessage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
}
assertEquals("", state.message)
assertEquals("nothing staged", state.notice)
}
// ── Push-after-commit toggle ───────────────────────────────────────────
@Test
fun `push after commit defaults off and toggles`() {
val vm = viewModel()
assertTrue(!vm.isPushAfterCommitEnabled())
vm.setPushAfterCommit(true)
assertTrue(vm.isPushAfterCommitEnabled())
vm.setPushAfterCommit(false)
assertTrue(!vm.isPushAfterCommitEnabled())
}
// ── Stash-checkout ─────────────────────────────────────────────────────
@Test
fun `stash checkout surfaces the stash notice on success`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson(
"""{"head":"abc","stashed":true,"stash_message":"git-state: feature","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
)
// refreshDetail fires two reads (status + branches).
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
vm.stashCheckout("feature")
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
val notice = withTimeout(5_000) { vm.stashNotice.first { it != null } }!!
assertTrue(notice.contains("git-state: feature"))
assertTrue(notice.contains("git stash pop"))
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/stash_checkout"))
assertTrue(req.body.readUtf8().contains("feature"))
}
@Test
fun `stash checkout without grant is refused before any POST`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.stashCheckout("feature")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("plugin.api.write"))
assertEquals(3, server.requestCount)
}
@Test
fun `clean stash checkout yields no stash notice`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson(
"""{"head":"abc","stashed":false,"stash_message":"","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
)
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
vm.stashCheckout("feature")
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
// No stash notice for a clean checkout.
assertEquals(null, vm.stashNotice.value)
}
}
@@ -0,0 +1,166 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class GitStateViewModelTest {
private val mainDispatcher = UnconfinedTestDispatcher()
private lateinit var application: Application
private lateinit var server: MockWebServer
@Before
fun setUp() {
Dispatchers.setMain(mainDispatcher)
application = ApplicationProvider.getApplicationContext()
server = MockWebServer().apply { start() }
}
@After
fun tearDown() {
server.shutdown()
Dispatchers.resetMain()
}
private fun viewModel(): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()))
return vm
}
private fun enqueueJson(body: String) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(body),
)
}
@Test
fun `loadRepos maps repo list and notice`() = runBlocking {
enqueueJson(
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":false}],"notice":null}""",
)
val vm = viewModel()
val state = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
}
assertEquals(1, state.repos.size)
assertEquals("alpha", state.repos.single().name)
assertNotNull(vm.repos.value)
}
@Test
fun `loadRepos surfaces server error`() = runBlocking {
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository"}"""))
val vm = viewModel()
val state = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Error>().first()
}
assertTrue(state.message.isNotBlank())
}
@Test
fun `selectRepo loads status and branches and preserves truncation flag`() = runBlocking {
enqueueJson(
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""",
)
// status
enqueueJson(
"""{"counts":{"staged":1,"modified":2,"untracked":3},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":true}""",
)
// branches
enqueueJson(
"""{"branches":[{"name":"main","upstream":"origin/main","ahead":1,"behind":0,"is_current":true}]}""",
)
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("alpha")
val ready = withTimeout(5_000) {
vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first()
}
assertEquals(1, ready.status.counts.staged)
assertEquals(2, ready.status.counts.modified)
assertEquals(3, ready.status.counts.untracked)
assertTrue(ready.status.truncated)
assertEquals("main", ready.branches.single().name)
assertTrue(ready.branches.single().isCurrent)
}
@Test
fun `loadDiff surfaces truncated diff`() = runBlocking {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
enqueueJson("""{"counts":{"staged":0,"modified":1,"untracked":0},"staged":[],"modified":[{"path":"a.txt"}],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
enqueueJson("""{"path":"a.txt","kind":"unstaged","diff":"+change","truncated":true}""")
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("alpha")
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
vm.loadDiff("a.txt", "unstaged")
val content = withTimeout(5_000) {
vm.content.filterIsInstance<GitContentViewState.Diff>().first()
}
assertEquals("a.txt", content.diff.path)
assertTrue(content.diff.truncated)
assertTrue(content.diff.diff.contains("change"))
}
@Test
fun `loadFile surfaces content and truncation`() = runBlocking {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":1},"staged":[],"modified":[],"untracked":[{"path":"new.txt"}],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
enqueueJson("""{"path":"new.txt","content":"hello world","truncated":false}""")
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("alpha")
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
vm.loadFile("new.txt")
val content = withTimeout(5_000) {
vm.content.filterIsInstance<GitContentViewState.File>().first()
}
assertEquals("hello world", content.file.content)
assertFalse(content.file.truncated)
}
@Test
fun `selectRepo surfaces status error for unknown repo`() = runBlocking {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository: bogus"}"""))
enqueueJson("""{"branches":[]}""")
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("bogus")
val error = withTimeout(5_000) {
vm.detail.filterIsInstance<GitRepoDetailState.Error>().first()
}
assertTrue(error.message.contains("unknown repository"))
}
}
@@ -0,0 +1,227 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class GitStateWriteViewModelTest {
private val mainDispatcher = UnconfinedTestDispatcher()
private lateinit var application: Application
private lateinit var server: MockWebServer
@Before
fun setUp() {
Dispatchers.setMain(mainDispatcher)
application = ApplicationProvider.getApplicationContext()
server = MockWebServer().apply { start() }
}
@After
fun tearDown() {
server.shutdown()
Dispatchers.resetMain()
}
private fun viewModel(grant: Boolean = true): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()))
vm.setWriteGrant(grant)
return vm
}
private fun enqueueJson(body: String) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(body),
)
}
/** Loads the repo list + selects ``alpha`` so a mutation has a target. */
private fun selectAlpha(vm: GitStateViewModel) {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
vm.selectRepo("alpha")
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
// Drain the three read requests (repos/status/branches) so the next
// takeRequest() returns the mutation POST we actually assert on.
repeat(3) { server.takeRequest() }
}
private fun enqueuePostSuccess(head: String) {
enqueueJson("""{"head":"$head","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}}""")
// refreshDetail fires two more requests (status + branches).
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
}
// ── Grant gating (security first) ──────────────────────────────────────
@Test
fun `stage without write grant is refused before any POST`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.stage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("plugin.api.write"))
// No write POST was sent (only the 3 read requests for load/select).
assertEquals(3, server.requestCount)
}
@Test
fun `discard without write grant is refused`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("plugin.api.write"))
assertEquals(3, server.requestCount)
}
// ── Happy paths ────────────────────────────────────────────────────────
@Test
fun `stage sends POST and surfaces success + fresh status`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc123")
vm.stage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
}
assertEquals("stage", state.label)
assertEquals("abc123", state.head)
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/stage"))
assertTrue(req.body.readUtf8().contains("a.txt"))
}
@Test
fun `commit sends message and returns head`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("deadbeef")
vm.commit("add feature")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
}
assertEquals("commit", state.label)
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/commit"))
assertTrue(req.body.readUtf8().contains("add feature"))
}
@Test
fun `discard echoes the fixed confirmation token`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/discard"))
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.DISCARD))
}
@Test
fun `push echoes the confirmation token`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
vm.push(GitConfirmationStrings.PUSH)
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/push"))
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.PUSH))
}
@Test
fun `fetch and pull send their endpoints`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
vm.fetch()
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
assertTrue(server.takeRequest().path!!.contains("/git/fetch"))
// Drain the two refreshDetail reads (status + branches) so the next
// takeRequest() sees only the pull POST.
repeat(2) { server.takeRequest() }
enqueuePostSuccess("xyz")
vm.pull("origin", "main")
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
assertTrue(server.takeRequest().path!!.contains("/git/pull"))
}
// ── Error branches ─────────────────────────────────────────────────────
@Test
fun `commit surfaces server error as readable message`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
server.enqueue(
MockResponse().setResponseCode(400).setBody("""{"detail":"commit message must not be empty"}"""),
)
vm.commit(" ")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("must not be empty"))
}
@Test
fun `discard wrong confirmation surfaces server 403`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
server.enqueue(
MockResponse().setResponseCode(403).setBody("""{"detail":"confirmation did not match"}"""),
)
vm.discard(listOf("a.txt"), "wrong")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("confirmation") || state.message.contains("403"))
}
// ── Confirmation gating helpers ────────────────────────────────────────
@Test
fun `requiresConfirmation and confirmationFor match destructive ops`() {
val vm = viewModel()
assertTrue(vm.requiresConfirmation("discard"))
assertTrue(vm.requiresConfirmation("push"))
assertTrue(vm.requiresConfirmation("dirty-checkout"))
assertEquals(GitConfirmationStrings.DISCARD, vm.confirmationFor("discard"))
assertEquals(GitConfirmationStrings.PUSH, vm.confirmationFor("push"))
assertEquals(GitConfirmationStrings.DIRTY_CHECKOUT, vm.confirmationFor("dirty-checkout"))
assertEquals(null, vm.confirmationFor("commit"))
}
}
@@ -0,0 +1,90 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.SessionActivityOwner
import com.hermesandroid.relay.data.SessionLiveStatus
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionActivityResolutionTest {
private val alpha = SessionActivityOwner.of("connection", "alpha", "same")
private val beta = SessionActivityOwner.of("connection", "beta", "same")
@Test
fun `duplicate stored ids stay unresolved without exact runtime binding`() {
val result = resolveGatewayActiveSessions(
sessions = listOf(active("runtime-beta", GatewayActiveSessionStatus.Working)),
directory = setOf(alpha, beta),
currentOwner = alpha,
)
assertNull(result.runtimes.single().owner)
assertTrue(result.ambiguous)
assertTrue(result.ambiguousForCurrent)
}
@Test
fun `exact foreground runtime binding resolves duplicate stored id`() {
val result = resolveGatewayActiveSessions(
sessions = listOf(active("runtime-alpha", GatewayActiveSessionStatus.Waiting)),
directory = setOf(alpha, beta),
currentOwner = alpha,
currentRuntimeId = "runtime-alpha",
)
assertEquals(alpha, result.runtimes.single().owner)
assertEquals(SessionLiveStatus.Waiting, result.runtimes.single().status)
}
@Test
fun `unscoped stored id stays unresolved even when bounded directory looks unique`() {
val unique = SessionActivityOwner.of("connection", "beta", "unique")
val result = resolveGatewayActiveSessions(
sessions = listOf(
GatewayActiveSession(
runtimeSessionId = "runtime",
storedSessionId = "unique",
status = GatewayActiveSessionStatus.Starting,
lastActiveEpochSeconds = 1.0,
),
),
directory = setOf(alpha, unique),
currentOwner = alpha,
)
assertNull(result.runtimes.single().owner)
assertTrue(result.ambiguous)
}
@Test
fun `client known detached runtime resolves exact profile owner`() {
val unique = SessionActivityOwner.of("connection", "beta", "unique")
val result = resolveGatewayActiveSessions(
sessions = listOf(
GatewayActiveSession(
runtimeSessionId = "runtime",
storedSessionId = "unique",
status = GatewayActiveSessionStatus.Starting,
lastActiveEpochSeconds = 1.0,
),
),
directory = setOf(alpha, unique),
currentOwner = alpha,
knownOwnersByRuntime = mapOf("runtime" to unique),
)
assertEquals(unique, result.runtimes.single().owner)
assertEquals(SessionLiveStatus.Starting, result.runtimes.single().status)
}
private fun active(runtimeId: String, status: GatewayActiveSessionStatus) =
GatewayActiveSession(
runtimeSessionId = runtimeId,
storedSessionId = "same",
status = status,
lastActiveEpochSeconds = 1.0,
)
}
+67
View File
@@ -3910,3 +3910,70 @@ an upstream Hermes change, while vanilla/current Hermes retains a bounded
single-account fallback. Merely configuring a provider credential does not
expose tokens to paired devices. The Android UI can add providers without
adding provider-specific screens or silently treating missing data as zero usage.
---
## ADR 68 — Android session activity has one profile-scoped authority
**Status:** Accepted (2026-08-25).
**Context.** Dashboard and API-server session lists expose `is_active`, but
upstream defines it as an unended persisted row updated within the last five
minutes. It is useful recency metadata, not proof that a model turn is running.
Android nevertheless used it as a fallback for **Working**, while local
composer state, detached-turn checkpoints, pending requests, and drawer rows
each derived activity independently. A completed turn could therefore remain
Working, a restart could restore an unverified busy state, and an All Profiles
row could inherit another profile's live status through a bare session id.
Current upstream exposes live authority through the process-wide Gateway
`session.active_list`. It reports attachable in-memory runtimes as `starting`,
`working`, `waiting`, or `idle`, with both the live id and durable session key.
It accepts only an optional `current_session_id`; rows normally have no profile
metadata or filter. Exact pending-request events carry more specific
Needs-input ownership. Exact turn terminals and `session.info {running:false}`
can settle a matching generation. `process.list` is a different contract: a
background process may remain after its parent model turn is idle.
**Decision.** Android owns one composite activity registry keyed by stable
connection identity, normalized profile, and durable session id. Runtime ids
are aliases only within that owner. The same reducer drives drawer badges and
filters, visible composer state, animation, and accessibility.
The precedence is:
1. An exact pending approval, clarify, sudo, secret, or MCP request is **Needs input**.
2. A successfully resolved process-wide `session.active_list` row supplies
**Starting**, **Working**, or **Idle** to its exact client-owned profile
record. Waiting without an exact pending payload remains a conservative
needs-input state until the request detail arrives or clears.
3. An exact terminal event, `session.info {running:false}`, or
`session.activate {running:false}` settles only the matching runtime
generation.
4. A matching `process.list` row may add **Background work** independently; it
never keeps the conversation Working.
5. A checkpoint restored after process recreation is **Checking** until
revalidated. A failed or unsupported live refresh is **Unavailable**.
Android resolves each active-list row through exact foreground or detached
ownership already held by that client, or explicit profile metadata if a
future upstream sends it. A bounded REST directory never proves that a durable
`session_key` is globally unique. Ambiguous or unresolved rows apply no status.
Resolved rows from a partial snapshot may update their exact owners, but they
cannot infer absence. A missing row clears stale live state for a scope only
when the successful process-wide snapshot was complete and every relevant row
was unambiguously resolved. A failed refresh does not settle anything. REST
`is_active`, `last_active`, and relative timestamps never influence execution
state. Old socket generations, late refreshes, and unscoped session ids cannot
revive a newer settled entry.
**Consequences.** Working and Needs input describe current upstream-owned
runtime state instead of recent persistence. All Profiles remains isolated,
restart recovery is honest about uncertainty, and background processes stay
visible without mislabeling their parent turn. Older Gateways remain usable
but show Unavailable when no exact local terminal truth exists. Declarative
Gateway scenarios cover all four upstream states, complete-snapshot
disappearance, client-side profile isolation, and method-not-found; physical
and current-host certification remains tracked in `TODO.md`. An upstream
profile field/filter or explicitly owned aggregate activity route would remove
the remaining ambiguity for multi-profile clients.
+13
View File
@@ -38,6 +38,9 @@ the upstream contract identifiers it depends on.
| `scope_rejection_inputs` | Exact, foreign, and unscoped event inputs |
| `terminal_gap_activate` | Socket closes after live output; replacement `session.activate` reports `running=false`; history is authoritative |
| `terminal_gap_session_info` | Scoped `session.info {running:false}` settles a turn without `message.complete` |
| `active_status_lifecycle` | `session.active_list` reports starting, working, waiting, and idle, then a complete empty process-wide snapshot permits removal of unambiguously owned prior rows |
| `active_status_profile_scope` | A row has no profile metadata and a caller profile hint has no effect; the client must use exact client-held ownership and reject invented attribution |
| `active_status_unsupported` | An older Gateway returns JSON-RPC method-not-found; the client retains Unknown rather than inventing Idle or Working |
Fixture evidence is a bounded metadata-only ring. It records sequence,
connection number, RPC method, event type, scope classification, and outcome.
@@ -125,6 +128,16 @@ The check is source-only and non-mutating. It starts no runtime, creates no
sessions, and uses no provider or authentication credentials. It fails closed
for dirty, fork-marked, or non-vanilla checkouts.
For activity scenarios, the adapter confirms that current upstream owns
`session.active_list`, emits `starting`, `working`, `waiting`, and `idle`, lets
pending input outrank running work, accepts only `current_session_id` as its
optional selector, and returns both the live runtime id and durable session key
from the process-local registry. The runtime fixture then
tests client reconciliation, including successful disappearance and explicit
method-not-found behavior. Because rows normally carry no profile, partial
ownership resolution may update exact matches but cannot infer absence for an
unresolved scope. Source inspection alone does not claim a client pass.
## Planned extensions
The scenario format is intentionally usable by future official Desktop and TUI
+11 -11
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -44,11 +44,11 @@
}
},
"es": {
"native_name": "Español",
"native_name": "Espa\u00f1ol",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -68,11 +68,11 @@
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
},
"ja": {
"native_name": "日本語",
"native_name": "\u65e5\u672c\u8a9e",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -92,11 +92,11 @@
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
},
"pt-BR": {
"native_name": "Português (Brasil)",
"native_name": "Portugu\u00eas (Brasil)",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -116,11 +116,11 @@
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
},
"ru": {
"native_name": "Русский",
"native_name": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -131,11 +131,11 @@
}
},
"zh-Hans": {
"native_name": "简体中文",
"native_name": "\u7b80\u4f53\u4e2d\u6587",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+1
View File
@@ -480,6 +480,7 @@ Bottom navigation bar with 4 tabs:
- **Canonical Bot Chat** — each individual row resolves the exact hidden session titled `Bot Chat` on its owning Gateway. Lookup failure is not absence, so Android creates and materializes the lazy row with `session.title` only after an authoritative empty exact-title result. The dedicated Bot Chat destination retains that route's pooled Gateway client, loads history through the same connection/profile Dashboard, sends only through Gateway, and returns directly to Bot Mode without rebinding Standard Chat or the global connection. `/new` or `/reset` compacts the canonical conversation instead of forking it. The route pool mints a fresh WebSocket ticket per dial, includes the immutable profile in the WebSocket URL, isolates credentials by exact trusted connection origin, and tears down only the removed connection's clients.
- **Bot group projection** — Android merges the bounded `ui_meta["hermes-bots-groups"]` v3 projection across gateways by durable room identity and newest revision. Rooms and recent messages are visibly read-only; Android does not create, rename, disband, join, send, coordinate member turns, or become a second room-log authority. Binary room images are ignored at this metadata boundary.
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete, pin/unpin, and archive/restore. The process-owned conversation binding is the single connection/profile/session identity for Chat; selecting an All Profiles row atomically makes its owner the selected agent and persists that profile/session, while merely browsing All Profiles changes no agent state. Lifecycle or locale-driven Activity recreation cannot replace an explicit binding with stale persisted state, and asynchronous list/history/mutation work is accepted only for the binding's exact namespace. A profile lock hides All Profiles and rejects stale/deep-linked cross-profile opens. The All Profiles browser mode otherwise survives Activity state restoration and refetches its rows after recreation. Pin and archive are durable upstream session fields loaded and patched through the owning connection/profile's Dashboard session API; Android does not keep a second local flag registry. Archived rows are requested explicitly so they remain restorable after recreation. Failed mutations roll back the optimistic row, while refresh and deletion reconcile from server truth. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
- **Authoritative session activity** — one composite registry keyed by connection, normalized profile, and durable session id drives the drawer, filters, grouping, animation, accessibility, and the visible composer. Exact pending approval/clarify/sudo/secret/MCP requests produce **Needs input**; the Gateway's process-wide `session.active_list` supplies **Starting**, **Working**, and **Idle**; exact terminal or `session.info {running:false}` can settle the matching generation. Because active-list rows normally have no profile metadata, Android assigns a row only through exact foreground/detached ownership already held by that client, or explicit profile metadata if a future upstream sends it. A bounded REST directory never proves global uniqueness. Unresolved rows create no status. Resolved rows from a partial snapshot may update their exact owners, but disappearance settles a scope only when the successful process-wide snapshot was completely and unambiguously resolved for it. Restart/checkpoint recovery is **Checking**; a failed or unsupported live refresh is **Unavailable**, never inferred Idle. REST `is_active` remains recency metadata only. `process.list` may add a separate **Background work** indicator and never keeps the parent conversation Working. Old socket generations, bare session ids from another profile, and delayed snapshots cannot revive newer settled state.
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible turn without sending `session.interrupt`; each running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Explicit Stop still interrupts. SSE fallback stays single-stream and cancels on navigation.
- **Queued Gateway follow-ups** — every local queued item is immutably scoped to its originating connection, profile, stored session, transport, and run generation; only that run's completion can make it eligible, and switching sessions shows only that session's queue. Restored text queues retain the same scope, while unavailable/deleted destinations and non-restorable attachment queues fail visibly instead of following the current composer. Drained messages add `queued: true` to `prompt.submit`; ordinary sends omit the field. Authoritative submit rejections (`4004`, `4018`, `4028`, `4029`, `4030`, `4090`, `5008`, `5070`, and `5071`) preserve the server message and never fall through to API-server SSE.
- **Durable composer drafts** — each connection/profile/session owns one app-private draft containing text, quote/edit context, and pending attachment bytes. Metadata and content-addressed blobs live under Android's no-backup directory, are capped at 64 drafts and 128 MB of retained blobs outside the active draft, flush when Chat backgrounds, and are removed after a successful send. Session/profile/connection navigation saves the previous owner before restoring the destination; an opened cross-profile session uses its actual owning profile rather than the global picker.
+16 -1
View File
@@ -1,6 +1,6 @@
# Hermes-Relay Surface Matrix
Updated: 2026-08-20
Updated: 2026-08-25
This matrix records the v1.0.0 route ownership contract. It is meant to keep
future app, plugin, and agent work honest about what is vanilla upstream
@@ -23,6 +23,9 @@ Verified upstream source snapshot:
`hermes_cli/plugins_cmd.py`
- Additive Manage contracts were rechecked at upstream MCP hosted-OAuth commits
through `4dc2b7be0` and custom-endpoint commit `3d9789357`.
- Session activity contracts were rechecked against upstream `main` at
`d736f5d53f1d33fabad5a17cb070eb138b618fb8` in `tui_gateway/server.py`,
`tui_gateway/methods_session.py`, and `hermes_cli/web_routers/sessions.py`.
## Ownership
@@ -35,6 +38,7 @@ Verified upstream source snapshot:
| `/v1/skills`, `/v1/toolsets` | Upstream API server | No | Discovery | Authenticated read-only API-server skill/toolset inventory; Android Diagnostics summarizes enabled toolsets and Relay tool visibility. |
| Dashboard `/api/status`, `/api/auth/me` | Upstream dashboard | No | Manage auth | Dashboard cookie/session path; separate from API bearer. Optional status diagnostics include Nous bootstrap validity and profile/gateway topology; these do not gate transport selection. |
| Dashboard `/api/auth/ws-ticket`, `/api/ws` | Upstream dashboard/tui_gateway | No | Preferred chat transport | Vanilla Hermes gateway chat path with live reasoning/thinking events. `message.complete` is the ordinary terminal event; `session.info {running:false}` is the authoritative settle backstop when a replacement socket missed that terminal frame. A reconnect reactivates the exact live runtime with `session.activate`; durable `session.resume` remains the cold-open path and an explicit rejection never creates a replacement context. |
| Gateway `session.active_list` | Upstream tui_gateway | No | Authoritative process-wide live activity | Returns attachable runtimes across the Gateway process, with live `id`, durable `session_key`, and `starting`, `working`, `waiting`, or `idle`. The only optional selector is `current_session_id`; rows normally carry no profile metadata. Android attributes a row only from exact foreground/detached ownership already held by that client, or from explicit profile metadata if a future upstream sends it. A bounded REST directory never proves global uniqueness. Unresolved rows remain unattributed, and absence settles a scope only after a complete, unambiguously resolved successful snapshot. Method-not-found or refresh failure is Unavailable, not Idle. Pending input outranks running work. |
| Dashboard `model.options` / `/api/model/*` | Upstream dashboard/tui_gateway | No | Provider/model inventory and selection | Source of truth for coherent provider/model identities. A reasoning boolean or exact effort list is consumed when present; clients do not infer provider identity from a model string alone. |
| Gateway `pet.info`, `pet.gallery`, `pet.select`, `pet.disable` | Upstream tui_gateway | No | Profile-scoped animated companion | `pet.info` supplies bounded PNG/WebP sheet bytes, revision, geometry, real frame counts, loop timing, scale, and row taxonomy. Android passes `knownRevision` to avoid duplicate sheet transfer, renders the active pet through its native activity-aware companion, and keeps phone-local pet packs separate. All four RPCs carry the effective profile. |
| Dashboard `/api/audio/transcribe`, `/api/audio/speak-stream`, `/api/audio/speak` | Upstream dashboard | No | Vanilla Hermes voice | Manage sign-in unlocks Vanilla Hermes voice. Assistant text streams into upstream speech when available; older hosts fall back to whole-request speech before audio starts. API server has no `/v1/audio/*` route today. |
@@ -191,6 +195,17 @@ URL for compatibility.
## API Fallback Compatibility Details
- Dashboard/API session-list `is_active` is a persistence-recency hint: an
unended row whose `last_active` is less than five minutes old. It is not a
running-turn signal and must never produce Working, Waiting, or Starting.
- Gateway `process.list` describes separately running background processes. A
process may outlive its parent model turn, so clients present that as
Background work without keeping the conversation in Working.
- Upstream can make multi-profile clients safer and simpler by adding profile
metadata or a profile filter to `session.active_list`, or by publishing an
aggregate activity route with explicit profile ownership. Until then,
clients must fail closed on duplicate or unresolved durable keys.
- Android accepts the API server's final-response image data URLs for PNG,
JPEG, GIF, WebP, and BMP. Decoding is strict: MIME and file signatures must
agree, encoded and decoded bytes are capped at the upstream 5 MiB limit, and
+8 -8
View File
File diff suppressed because one or more lines are too long
+329
View File
@@ -0,0 +1,329 @@
"""Read and write Git state endpoints for the Hermes-Relay dashboard plugin.
Mounted by hermes-agent at ``/api/plugins/hermes-relay/git/*``. These routes
power both the dashboard tab and the Android mobile page.
Route map
---------
Read (GET, no grant):
- ``GET /git/repos`` → scanned repo list under the configured base path
- ``GET /git/status`` → grouped working-tree status for one repo
- ``GET /git/branches`` → branch list (name, upstream, ahead/behind, current)
- ``GET /git/diff`` → per-file diff (kind=staged|unstaged)
- ``GET /git/file`` → read a tracked file
Write (POST, require the plugin's ``plugin.api.write`` grant, which the app
enforces client-side before ever sending the request — the same gate used for
every mutating plugin action in PluginsViewModel.invokeAction):
- ``POST /git/stage`` — stage path list → fresh status
- ``POST /git/unstage`` — unstage path list → fresh status
- ``POST /git/discard`` — discard paths (confirmation) → fresh status
- ``POST /git/commit`` — commit staged index (message) → {head,status}
- ``POST /git/commit_selected``— commit selected paths (message+paths)
- ``POST /git/fetch`` — fetch a remote → {branches,status}
- ``POST /git/pull`` — pull remote/branch → fresh status
- ``POST /git/push`` — push (confirmation) → {branches,status}
- ``POST /git/checkout`` — switch branch (new_branch/track; dirty→confirmation)
Destructive writes (discard, push, dirty checkout) enforce a per-use
confirmation string server-side: missing/wrong → 403. Dirty/conflict trees →
409. The HTTP mapping keeps raw stack traces and JSON dumps out of the UI.
Security
--------
- ``repo`` is an opaque id validated against the scanned allowlist; unknown
ids → 400.
- File paths are validated to reject traversal and absolute escapes.
- Remote URLs are scrubbed of embedded userinfo.
- No ``shell=True`` anywhere: every git invocation uses argument lists.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, HTTPException, Query
from .. import git_state
router = APIRouter(prefix="/git")
def _bad_request(exc: Exception) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
# Structured error taxonomy → HTTP status. The UI renders ``detail`` (a
# human-readable message) plus ``code`` for styling; no raw traces/JSON dumps.
_GIT_ERROR_STATUS = {
"non-repo": 400,
"dirty": 409,
"conflict": 409,
"auth": 502,
"network": 502,
"invalid-input": 400,
"missing-confirmation": 403,
"wrong-confirmation": 403,
}
def _write_error(exc: git_state.GitError) -> HTTPException:
status = _GIT_ERROR_STATUS.get(exc.code, 400)
return HTTPException(status_code=status, detail=str(exc))
def _resolve(repo: str) -> Any:
return git_state.resolve_repo(git_state.base_path(), repo)
@router.get("/repos")
async def get_repos() -> dict[str, Any]:
"""Return the scanned repo list plus a notice when the base path is missing."""
base = git_state.base_path()
repos = git_state.scan_repos(base)
notice = None
if not base.is_dir():
notice = f"Git base path not found: {base}"
return {"repos": repos, "base_path": str(base), "notice": notice}
@router.get("/status")
async def get_status(repo: str = Query(...)) -> dict[str, Any]:
try:
return git_state.repo_status(_resolve(repo))
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.get("/branches")
async def get_branches(repo: str = Query(...)) -> dict[str, Any]:
try:
return {"branches": git_state.repo_branches(_resolve(repo))}
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.get("/diff")
async def get_diff(
repo: str = Query(...),
path: str = Query(...),
kind: str = Query("unstaged"),
) -> dict[str, Any]:
try:
return git_state.repo_diff(_resolve(repo), path, kind)
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.get("/file")
async def get_file(repo: str = Query(...), path: str = Query(...)) -> dict[str, Any]:
try:
return git_state.read_file(_resolve(repo), path)
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
# ── Write endpoints ────────────────────────────────────────────────────────
# Every write requires the plugin.api.write grant (enforced client-side). The
# router re-validates repo + paths against the allowlist/traversal rules and
# classifies git failures into a structured error the UI can render.
def _require_repo(body: dict[str, Any]) -> Any:
repo = body.get("repo")
if not isinstance(repo, str) or not repo:
raise git_state.GitStateError("repo is required")
return git_state.resolve_repo(git_state.base_path(), repo)
@router.post("/stage")
async def post_stage(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.stage(_require_repo(body), _paths(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/unstage")
async def post_unstage(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.unstage(_require_repo(body), _paths(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/discard")
async def post_discard(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.discard(
_require_repo(body),
_paths(body),
confirmation=body.get("confirmation"),
delete_untracked=bool(body.get("delete_untracked", False)),
)
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/commit")
async def post_commit(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.commit(_require_repo(body), _message(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/commit_selected")
async def post_commit_selected(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.commit_selected(_require_repo(body), _message(body), _paths(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/fetch")
async def post_fetch(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.fetch(_require_repo(body), _remote(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/pull")
async def post_pull(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.pull(_require_repo(body), _remote(body), _branch(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/push")
async def post_push(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.push(
_require_repo(body),
_remote(body),
_branch(body),
confirmation=body.get("confirmation"),
)
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/checkout")
async def post_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.checkout(
_require_repo(body),
_ref(body),
confirmation=body.get("confirmation"),
new_branch=_str_opt(body, "new_branch"),
track=bool(body.get("track", False)),
)
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/stash_checkout")
async def post_stash_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""Checkout that auto-stashes a dirty tree first.
Unlike a plain dirty checkout, no confirmation is required: a stash is
recoverable (``git stash pop``), so this is not a data-loss path. The
response carries ``stashed`` + ``stash_message`` so the UI can surface the
stash after a successful switch.
"""
try:
return git_state.stash_checkout(
_require_repo(body),
_ref(body),
new_branch=_str_opt(body, "new_branch"),
track=bool(body.get("track", False)),
)
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/commit_message")
async def post_commit_message(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""Generate a conventional-style commit message from the staged diff.
Empty staged diff → ``{message:"", notice:"nothing staged"}`` without calling
the model. A missing/failed model degrades to an empty message + a
``model unavailable`` notice — never a 500. Only staged content is sent.
"""
try:
return await git_state.commit_message(_require_repo(body))
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/commit_message_selected")
async def post_commit_message_selected(
body: dict[str, Any] = Body(...),
) -> dict[str, Any]:
"""Generate a message from the staged diff of the given ``paths`` only."""
try:
return await git_state.commit_message_selected(
_require_repo(body), _paths(body)
)
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
def _paths(body: dict[str, Any]) -> list[str]:
paths = body.get("paths")
if not isinstance(paths, list) or not paths or not all(isinstance(p, str) for p in paths):
raise git_state.GitStateError("paths must be a non-empty list of strings")
return paths
def _message(body: dict[str, Any]) -> str:
message = body.get("message")
if not isinstance(message, str):
raise git_state.GitStateError("message is required")
return message
def _remote(body: dict[str, Any]) -> str:
return body.get("remote") or "origin"
def _branch(body: dict[str, Any]) -> str:
return body.get("branch") or ""
def _ref(body: dict[str, Any]) -> str:
ref = body.get("ref")
if not isinstance(ref, str) or not ref:
raise git_state.GitStateError("ref is required")
return ref
def _str_opt(body: dict[str, Any], key: str) -> str:
value = body.get(key)
return value if isinstance(value, str) else ""
__all__ = ["router"]
+9
View File
@@ -32,6 +32,15 @@ async def get_mobile_manifest() -> dict[str, Any]:
@router.get("/pages/{plugin_id}")
async def get_mobile_page(plugin_id: str = Path(...)) -> dict[str, Any]:
# The static read-only Git page is served directly from the git_state
# module (not the generated-page store). It carries no filesystem paths
# per the android-plugins.md document contract.
if plugin_id == "git":
from .. import git_state
document = git_state.build_git_document(git_state.base_path())
document["host_revision"] = 1
return document
try:
entry = _store().get(plugin_id)
document = dict(entry["document"])
+1
View File
@@ -142,6 +142,7 @@ def _validate_public_url(url: str) -> str:
router = APIRouter()
router.include_router(_plugin_module("dashboard.mobile_plugin_api").router)
router.include_router(_plugin_module("dashboard.git_api").router)
def _relay_unreachable(err: Exception) -> HTTPException:
+3
View File
@@ -6,6 +6,7 @@ import RelayManagement from "./tabs/RelayManagement.jsx";
import BridgeActivity from "./tabs/BridgeActivity.jsx";
import MediaInspector from "./tabs/MediaInspector.jsx";
import RemoteAccess from "./tabs/RemoteAccess.jsx";
import GitState from "./tabs/GitState.jsx";
import RelayStatusSlot from "./components/RelayStatusSlot.jsx";
import MobileConnectDialog from "./components/MobileConnectDialog.jsx";
import { Button, Switch } from "./lib/ui-shims.jsx";
@@ -19,6 +20,7 @@ const TABS = [
{ key: "activity", label: "Activity" },
{ key: "media", label: "Media" },
{ key: "remote", label: "Remote Access" },
{ key: "git", label: "Git" },
];
function readAutoRefresh() {
@@ -113,6 +115,7 @@ function RelayPluginRoot() {
{tab === "activity" && <BridgeActivity autoRefresh={autoRefresh} />}
{tab === "media" && <MediaInspector autoRefresh={autoRefresh} />}
{tab === "remote" && <RemoteAccess autoRefresh={autoRefresh} />}
{tab === "git" && <GitState autoRefresh={autoRefresh} />}
</div>
<MobileConnectDialog
open={mobileConnectOpen}
+108
View File
@@ -139,3 +139,111 @@ export function mintPairingWithMode({ mode, publicUrl, prefer, ...rest } = {}) {
body: JSON.stringify(body),
});
}
// ── Git State tab ───────────────────────────────────────────────────────────
export function getGitRepos() {
return fetchJSON("/git/repos");
}
export function getGitStatus(repo) {
return fetchJSON(`/git/status?repo=${encodeURIComponent(repo)}`);
}
export function getGitBranches(repo) {
return fetchJSON(`/git/branches?repo=${encodeURIComponent(repo)}`);
}
export function getGitDiff(repo, path, kind = "unstaged") {
return fetchJSON(
`/git/diff?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}&kind=${encodeURIComponent(kind)}`,
);
}
export function getGitFile(repo, path) {
return fetchJSON(
`/git/file?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}`,
);
}
// ── Git State write operations ────────────────────────────────────────────
// Every write POST goes through the authenticated plugin namespace and is
// gated by the plugin.api.write grant (enforced client-side before any POST
// is sent). Destructive ops pass a per-use confirmation token in the body.
function postGit(path, body) {
return fetchJSON(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
export function gitStage(repo, paths) {
return postGit("/git/stage", { repo, paths });
}
export function gitUnstage(repo, paths) {
return postGit("/git/unstage", { repo, paths });
}
export function gitDiscard(repo, paths, confirmation, deleteUntracked = false) {
return postGit("/git/discard", {
repo,
paths,
confirmation,
delete_untracked: deleteUntracked,
});
}
export function gitCommit(repo, message) {
return postGit("/git/commit", { repo, message });
}
export function gitCommitSelected(repo, message, paths) {
return postGit("/git/commit_selected", { repo, message, paths });
}
export function gitFetch(repo, remote = "origin") {
return postGit("/git/fetch", { repo, remote });
}
export function gitPull(repo, remote = "origin", branch = "") {
return postGit("/git/pull", { repo, remote, branch });
}
export function gitPush(repo, confirmation, remote = "origin", branch = "") {
return postGit("/git/push", { repo, remote, branch, confirmation });
}
export function gitCheckout(repo, ref, opts = {}) {
const body = {
repo,
ref,
confirmation: opts.confirmation,
new_branch: opts.newBranch || "",
track: !!opts.track,
};
return postGit("/git/checkout", body);
}
// ── Git State Phase 3 extras ───────────────────────────────────────────────
// AI commit-message suggestions + auto-stashing checkout.
export function gitCommitMessage(repo) {
return postGit("/git/commit_message", { repo });
}
export function gitCommitMessageSelected(repo, paths) {
return postGit("/git/commit_message_selected", { repo, paths });
}
export function gitStashCheckout(repo, ref, opts = {}) {
const body = {
repo,
ref,
new_branch: opts.newBranch || "",
track: !!opts.track,
};
return postGit("/git/stash_checkout", body);
}
+140
View File
@@ -0,0 +1,140 @@
// Pure data-mapping helpers for the Git State dashboard tab.
// Kept free of React/DOM so they are unit-testable with node:test.
/**
* Normalize a /git/repos response into a stable repo list.
* Accepts {repos:[...]} or a bare array.
*/
export function normalizeRepos(data) {
const list = Array.isArray(data) ? data : (data && data.repos) || [];
return list.filter((r) => r && typeof r.id === "string");
}
/**
* Normalize a /git/status response into {counts, staged, modified, untracked,
* truncated}. Missing groups become empty arrays.
*/
export function normalizeStatus(data) {
const src = data && typeof data === "object" ? data : {};
return {
counts: {
staged: Number(src.counts && src.counts.staged) || 0,
modified: Number(src.counts && src.counts.modified) || 0,
untracked: Number(src.counts && src.counts.untracked) || 0,
},
staged: Array.isArray(src.staged) ? src.staged : [],
modified: Array.isArray(src.modified) ? src.modified : [],
untracked: Array.isArray(src.untracked) ? src.untracked : [],
truncated: !!src.truncated,
};
}
/**
* Normalize a /git/branches response into a branch list.
* Accepts {branches:[...]} or a bare array.
*/
export function normalizeBranches(data) {
const list = Array.isArray(data) ? data : (data && data.branches) || [];
return list.filter((b) => b && typeof b.name === "string");
}
/**
* Build a human-readable branch label, e.g. "main → origin/main (ahead 1)".
*/
export function branchLabel(branch) {
if (!branch) return "";
const base = branch.name || "";
if (!branch.upstream) return base;
const track =
branch.ahead > 0 || branch.behind > 0
? ` (ahead ${branch.ahead}, behind ${branch.behind})`
: "";
return `${base} → ${branch.upstream}${track}`;
}
/**
* True when a status response is truncated (over the server cap).
*/
export function isTruncated(status) {
return !!(status && status.truncated);
}
/**
* Fixed per-use confirmation tokens for destructive git mutations. These
* mirror the plugin's server-side constants (plugin/git_state.py) and are
* sent in the POST body so the server can enforce the destructive gate.
* The dashboard tab shows a human-readable description before echoing these.
*/
export const CONFIRMATIONS = {
discard: "discard",
push: "push",
dirtyCheckout: "checkout-dirty",
};
/** Ops that require a per-use confirmation string before the POST is sent. */
const DESTRUCTIVE_OPS = new Set(["discard", "push", "dirty-checkout"]);
/**
* True when the named mutation requires a per-use confirmation string.
* The tab must not send the POST without it (matches the server gate).
*/
export function requiresConfirmation(op) {
return DESTRUCTIVE_OPS.has(op);
}
/**
* Return the fixed confirmation token for a destructive op, or null when the
* op is non-destructive (no confirmation needed).
*/
export function confirmationFor(op) {
if (!requiresConfirmation(op)) return null;
if (op === "discard") return CONFIRMATIONS.discard;
if (op === "push") return CONFIRMATIONS.push;
return CONFIRMATIONS.dirtyCheckout;
}
/**
* Normalize a mutation response ({head, status, branches}) into a stable
* shape, filling missing groups so the tab can render without defensive
* branching.
*/
export function normalizeMutationResult(data) {
const src = data && typeof data === "object" ? data : {};
return {
head: typeof src.head === "string" ? src.head : "",
status: normalizeStatus(src.status),
branches: normalizeBranches(src.branches),
};
}
/**
* Normalize a /git/commit_message response into {message, notice, stashed}.
* Missing fields degrade safely so the tab can render without branching.
*/
export function normalizeCommitMessage(data) {
const src = data && typeof data === "object" ? data : {};
return {
message: typeof src.message === "string" ? src.message : "",
notice: typeof src.notice === "string" ? src.notice : "",
};
}
/**
* True when a commit-message suggestion is usable (non-empty message).
*/
export function hasCommitSuggestion(result) {
return !!(result && result.message && result.message.trim());
}
/**
* Normalize a /git/stash_checkout response: the standard mutation shape plus
* {stashed, stash_message}.
*/
export function normalizeStashCheckout(data) {
const src = data && typeof data === "object" ? data : {};
return {
...normalizeMutationResult(data),
stashed: !!src.stashed,
stashMessage: typeof src.stash_message === "string" ? src.stash_message : "",
};
}
+710
View File
@@ -0,0 +1,710 @@
const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
const { useState, useEffect, useCallback } = SDK.hooks;
import {
getGitRepos,
getGitStatus,
getGitBranches,
getGitDiff,
getGitFile,
gitStage,
gitUnstage,
gitDiscard,
gitCommit,
gitCommitMessage,
gitCommitMessageSelected,
gitStashCheckout,
gitFetch,
gitPull,
gitPush,
gitCheckout,
} from "../lib/api.js";
import {
normalizeMutationResult,
normalizeCommitMessage,
normalizeStashCheckout,
hasCommitSuggestion,
requiresConfirmation,
confirmationFor,
} from "../lib/git-state.mjs";
import {
Alert,
AlertTitle,
AlertDescription,
CardDescription,
Button,
Badge,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "../lib/ui-shims.jsx";
const {
Card,
CardHeader,
CardTitle,
CardContent,
Label,
} = SDK.components;
function TruncationNotice({ truncated }) {
if (!truncated) return null;
return (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600">
Results truncated — showing the first entries only.
</div>
);
}
function StatusRow({ status }) {
if (!status) return null;
const counts = status.counts || {};
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="text-xs">
{counts.staged || 0} staged
</Badge>
<Badge variant="outline" className="text-xs">
{counts.modified || 0} modified
</Badge>
<Badge variant="outline" className="text-xs">
{counts.untracked || 0} untracked
</Badge>
</div>
<TruncationNotice truncated={status.truncated} />
{["staged", "modified", "untracked"].map((group) => {
const items = status[group] || [];
if (items.length === 0) return null;
return (
<div key={group}>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{group}
</div>
<ul className="mt-1 space-y-0.5">
{items.map((item) => (
<li key={`${group}-${item.path}`} className="font-mono text-xs">
{item.path}
</li>
))}
</ul>
</div>
);
})}
</div>
);
}
function BranchesRow({ branches }) {
if (!branches || branches.length === 0) return null;
return (
<div className="space-y-1">
{branches.map((b) => (
<div key={b.name} className="flex items-center gap-2 text-xs">
<span className="font-mono">{b.name}</span>
{b.is_current ? <Badge className="text-xs">current</Badge> : null}
{b.upstream ? (
<span className="text-muted-foreground">
→ {b.upstream}
{b.ahead > 0 || b.behind > 0
? ` (ahead ${b.ahead}, behind ${b.behind})`
: ""}
</span>
) : null}
</div>
))}
</div>
);
}
/**
* Write controls for the GitState tab. Every mutation is gated by the
* plugin.api.write grant (the tab is only reachable after the user grants it)
* and destructive ops (discard/push/dirty-checkout) are confirmed via the
* per-use confirmation-string mechanics before the POST is sent.
*/
function WriteControls({
status,
selected,
commitMessage,
onCommitMessageChange,
generatingMessage,
onGenerateMessage,
commitNotice,
newBranch,
onNewBranchChange,
branchRef,
onBranchRefChange,
mutating,
pushAfterCommit,
onPushAfterCommitChange,
onStageAll,
onStage,
onUnstage,
onDiscard,
onCommit,
onFetch,
onPull,
onPush,
onCheckout,
onStashCheckout,
}) {
const staged = (status && status.staged || []).map((e) => e.path);
const modified = (status && status.modified || []).map((e) => e.path);
return (
<div className="space-y-3 rounded-md border p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Write controls
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={mutating || modified.length === 0} onClick={() => modified.forEach(onStage)}>
Stage modified
</Button>
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={() => staged.forEach(onUnstage)}>
Unstage staged
</Button>
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={() => staged.forEach(onDiscard)}>
Discard staged
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="text"
value={commitMessage}
onChange={(e) => onCommitMessageChange(e.target.value)}
placeholder="Commit message"
className="w-full rounded-md border px-2 py-1 text-xs"
/>
<Button
size="sm"
variant="outline"
disabled={mutating || generatingMessage || staged.length === 0}
onClick={onGenerateMessage}
title="Generate a commit message from the staged diff"
>
{generatingMessage ? "Generating…" : "Generate"}
</Button>
</div>
{commitNotice ? (
<div className="text-xs text-amber-600">{commitNotice}</div>
) : null}
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={pushAfterCommit}
onChange={(e) => onPushAfterCommitChange(e.target.checked)}
/>
Push after commit
</label>
<Button size="sm" disabled={mutating || !commitMessage.trim()} onClick={onCommit}>
Commit
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
value={branchRef}
onChange={(e) => onBranchRefChange(e.target.value)}
placeholder="Branch to switch to"
className="w-40 rounded-md border px-2 py-1 text-xs"
/>
<input
type="text"
value={newBranch}
onChange={(e) => onNewBranchChange(e.target.value)}
placeholder="New branch name"
className="w-40 rounded-md border px-2 py-1 text-xs"
/>
<Button size="sm" variant="outline" disabled={mutating || !branchRef.trim()} onClick={onCheckout}>
Checkout
</Button>
<Button
size="sm"
variant="outline"
disabled={mutating || !branchRef.trim()}
onClick={onStashCheckout}
title="Switch branches, auto-stashing a dirty tree first"
>
Stash-checkout
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={mutating} onClick={onFetch}>
Fetch
</Button>
<Button size="sm" variant="outline" disabled={mutating} onClick={onPull}>
Pull
</Button>
<Button size="sm" variant="destructive" disabled={mutating} onClick={onPush}>
Push
</Button>
</div>
</div>
);
}
export default function GitState({ autoRefresh }) {
const [repos, setRepos] = useState(null);
const [selected, setSelected] = useState(null);
const [status, setStatus] = useState(null);
const [branches, setBranches] = useState(null);
const [diff, setDiff] = useState(null);
const [file, setFile] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [notice, setNotice] = useState(null);
const loadRepos = useCallback(async () => {
setError(null);
try {
const data = await getGitRepos();
const list = (data && data.repos) || [];
setRepos(list);
setNotice((data && data.notice) || null);
if (selected && !list.some((r) => r.id === selected)) {
setSelected(null);
setStatus(null);
setBranches(null);
setDiff(null);
setFile(null);
}
} catch (err) {
setError(err && err.message ? err.message : String(err));
} finally {
setLoading(false);
}
}, [selected]);
useEffect(() => {
loadRepos();
}, [loadRepos]);
useEffect(() => {
if (!autoRefresh) return undefined;
const id = setInterval(loadRepos, 15000);
return () => clearInterval(id);
}, [autoRefresh, loadRepos]);
const selectRepo = useCallback(async (repoId) => {
setSelected(repoId);
setStatus(null);
setBranches(null);
setDiff(null);
setFile(null);
setError(null);
try {
const [st, br] = await Promise.all([
getGitStatus(repoId),
getGitBranches(repoId),
]);
setStatus(st);
setBranches(br && br.branches);
} catch (err) {
setError(err && err.message ? err.message : String(err));
}
}, []);
const showDiff = useCallback(async (path, kind) => {
if (!selected) return;
setFile(null);
setError(null);
try {
setDiff(await getGitDiff(selected, path, kind));
} catch (err) {
setError(err && err.message ? err.message : String(err));
}
}, [selected]);
const showFile = useCallback(async (path) => {
if (!selected) return;
setDiff(null);
setError(null);
try {
setFile(await getGitFile(selected, path));
} catch (err) {
setError(err && err.message ? err.message : String(err));
}
}, [selected]);
// ── Write controls (gated by plugin.api.write + confirmations) ───────────
const [commitMessage, setCommitMessage] = useState("");
const [newBranch, setNewBranch] = useState("");
const [branchRef, setBranchRef] = useState("");
const [mutating, setMutating] = useState(false);
const [mutationError, setMutationError] = useState(null);
const [generatingMessage, setGeneratingMessage] = useState(false);
const [commitNotice, setCommitNotice] = useState(null);
const [pushAfterCommit, setPushAfterCommit] = useState(false);
const refreshDetail = useCallback(async (repoId) => {
const [st, br] = await Promise.all([
getGitStatus(repoId),
getGitBranches(repoId),
]);
setStatus(st);
setBranches(br && br.branches);
}, []);
const applyMutation = useCallback(
async (op, paths, opts) => {
if (!selected) return;
setMutationError(null);
setMutating(true);
try {
if (op === "stage") await gitStage(selected, paths);
else if (op === "unstage") await gitUnstage(selected, paths);
else if (op === "fetch") await gitFetch(selected, opts?.remote || "origin");
else if (op === "pull") await gitPull(selected, opts?.remote || "origin", opts?.branch || "");
else if (op === "commit") await gitCommit(selected, opts?.message);
else if (op === "commitSelected") await gitCommitSelected(selected, opts?.message, paths);
else if (op === "discard") await gitDiscard(selected, paths, opts?.confirmation, opts?.deleteUntracked);
else if (op === "push") await gitPush(selected, opts?.confirmation, opts?.remote || "origin", opts?.branch || "");
else if (op === "dirty-checkout") {
await gitCheckout(selected, opts.ref, {
confirmation: opts.confirmation,
newBranch: opts.newBranch,
track: opts.track,
});
}
await refreshDetail(selected);
} catch (err) {
setMutationError(err && err.message ? err.message : String(err));
} finally {
setMutating(false);
}
},
[selected, refreshDetail],
);
/**
* Generate a commit-message suggestion from the staged diff (AI). Empty
* staged diff / model-unavailable degrade to a notice, never an error.
*/
const generateMessage = useCallback(async () => {
if (!selected) return;
setGeneratingMessage(true);
setCommitNotice(null);
try {
const stagedPaths = (status && status.staged || []).map((e) => e.path);
const data = stagedPaths.length > 0
? await gitCommitMessageSelected(selected, stagedPaths)
: await gitCommitMessage(selected);
const result = normalizeCommitMessage(data);
if (hasCommitSuggestion(result)) {
setCommitMessage(result.message);
setCommitNotice(result.notice || null);
} else {
setCommitNotice(result.notice || "Nothing staged to generate a message from.");
}
} catch (err) {
setCommitNotice(err && err.message ? err.message : String(err));
} finally {
setGeneratingMessage(false);
}
}, [selected, status]);
/**
* Stash-checkout: switch branches, auto-stashing a dirty tree first. No
* confirmation is needed because a stash is recoverable (git stash pop).
* On success, surface the stash message so the user can pop it later.
*/
const doStashCheckout = useCallback(async () => {
const ref = branchRef.trim();
if (!ref || !selected) return;
setMutationError(null);
setCommitNotice(null);
setMutating(true);
try {
const data = await gitStashCheckout(selected, ref, {
newBranch: newBranch.trim(),
track: false,
});
const result = normalizeStashCheckout(data);
if (result.stashed) {
setCommitNotice(
`Stashed changes on ${ref} as “${result.stashMessage}”. Use “git stash pop” to restore them.`,
);
}
await refreshDetail(selected);
} catch (err) {
setMutationError(err && err.message ? err.message : String(err));
} finally {
setMutating(false);
setBranchRef("");
setNewBranch("");
}
}, [selected, branchRef, newBranch, refreshDetail]);
/**
* Destructive ops (discard, push, dirty-checkout) gate on a per-use
* confirmation echoed back to the server. Matches the dashboard's existing
* confirm-before-destructive pattern (see RelayManagement onRevoke) and the
* plugin's confirmation-string mechanics.
*/
const requestMutation = useCallback((op, opts) => {
if (requiresConfirmation(op)) {
const description =
op === "discard"
? "Discard local changes? This cannot be undone."
: op === "push"
? "Push local commits to the remote repository?"
: "Working tree has uncommitted changes. Switch branches anyway?";
if (!window.confirm(description)) return;
const token = confirmationFor(op);
if (op === "discard") applyMutation("discard", opts?.paths, { confirmation: token, deleteUntracked: opts?.deleteUntracked });
else if (op === "push") applyMutation("push", [], { confirmation: token, remote: opts?.remote, branch: opts?.branch });
else if (op === "dirty-checkout") applyMutation("dirty-checkout", [], { ...opts, confirmation: token });
return;
}
applyMutation(op, opts?.paths, opts);
}, [applyMutation]);
const doCommit = useCallback(async () => {
const message = commitMessage.trim();
if (!message) {
setMutationError("Commit message must not be empty.");
return;
}
const stagedPaths = (status && status.staged || []).map((e) => e.path);
if (stagedPaths.length > 0) {
await applyMutation("commitSelected", stagedPaths, { message });
} else {
await applyMutation("commit", [], { message });
}
setCommitMessage("");
// Push-after-commit: when the toggle is ON, immediately start the existing
// push confirmation flow. Confirmation is still required (never bypassed);
// the toggle only auto-starts it after a successful commit.
if (pushAfterCommit) {
requestMutation("push", {});
}
}, [commitMessage, status, applyMutation, pushAfterCommit, requestMutation]);
const doCheckout = useCallback(async () => {
const ref = branchRef.trim();
if (!ref) return;
const dirty = status && (status.counts.modified + status.counts.staged + status.counts.untracked) > 0;
const opts = { ref, newBranch: newBranch.trim(), track: false };
if (dirty && !opts.newBranch) {
requestMutation("dirty-checkout", opts);
setBranchRef("");
setNewBranch("");
return;
}
await applyMutation("checkout", [], opts);
setBranchRef("");
setNewBranch("");
}, [branchRef, newBranch, status, applyMutation, requestMutation]);
if (loading && repos === null) {
return <div className="text-sm text-muted-foreground">Loading repositories…</div>;
}
if (error) {
return (
<Alert variant="destructive">
<AlertTitle>Git state unavailable</AlertTitle>
<AlertDescription>
<pre className="whitespace-pre-wrap text-xs">{error}</pre>
<Button className="mt-2" size="sm" variant="outline" onClick={loadRepos}>
Retry
</Button>
</AlertDescription>
</Alert>
);
}
const list = repos || [];
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Repositories</CardTitle>
<CardDescription>
Repositories scanned from the configured Git base path.
</CardDescription>
</CardHeader>
<CardContent>
{notice ? (
<div className="mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600">
{notice}
</div>
) : null}
{list.length === 0 ? (
<div className="text-sm text-muted-foreground">
No repositories found.
</div>
) : (
<div className="flex flex-wrap gap-2">
{list.map((repo) => (
<Button
key={repo.id}
size="sm"
variant={selected === repo.id ? "default" : "outline"}
onClick={() => selectRepo(repo.id)}
>
{repo.name}
{repo.dirty ? " •" : ""}
</Button>
))}
</div>
)}
</CardContent>
</Card>
{selected ? (
<Card>
<CardHeader>
<CardTitle>{selected}</CardTitle>
<CardDescription>
Working tree and branches. Tap a changed file to view its diff or
content.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{mutationError ? (
<Alert variant="destructive">
<AlertTitle>Git mutation failed</AlertTitle>
<AlertDescription>
<pre className="whitespace-pre-wrap text-xs">{mutationError}</pre>
</AlertDescription>
</Alert>
) : null}
<StatusRow status={status} />
<BranchesRow branches={branches} />
<WriteControls
status={status}
branches={branches}
selected={selected}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatingMessage={generatingMessage}
onGenerateMessage={generateMessage}
commitNotice={commitNotice}
newBranch={newBranch}
onNewBranchChange={setNewBranch}
branchRef={branchRef}
onBranchRefChange={setBranchRef}
mutating={mutating}
pushAfterCommit={pushAfterCommit}
onPushAfterCommitChange={setPushAfterCommit}
onStageAll={() => requestMutation("stage", { paths: (status && status.modified || []).map((e) => e.path) })}
onStage={(path) => requestMutation("stage", { paths: [path] })}
onUnstage={(path) => requestMutation("unstage", { paths: [path] })}
onDiscard={(path) => requestMutation("discard", { paths: [path], deleteUntracked: false })}
onCommit={doCommit}
onFetch={() => requestMutation("fetch", {})}
onPull={() => requestMutation("pull", {})}
onPush={() => requestMutation("push", {})}
onCheckout={doCheckout}
onStashCheckout={doStashCheckout}
/>
{status && (status.staged || []).length > 0 ? (
<div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Staged diffs
</div>
<ul className="mt-1 space-y-0.5">
{(status.staged || []).map((item) => (
<li key={`sd-${item.path}`}>
<button
type="button"
className="font-mono text-xs text-primary underline"
onClick={() => showDiff(item.path, "staged")}
>
{item.path}
</button>
</li>
))}
</ul>
</div>
) : null}
{status && (status.modified || []).length > 0 ? (
<div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Unstaged diffs
</div>
<ul className="mt-1 space-y-0.5">
{(status.modified || []).map((item) => (
<li key={`ud-${item.path}`}>
<button
type="button"
className="font-mono text-xs text-primary underline"
onClick={() => showDiff(item.path, "unstaged")}
>
{item.path}
</button>
</li>
))}
</ul>
</div>
) : null}
{status && (status.untracked || []).length > 0 ? (
<div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Untracked files
</div>
<ul className="mt-1 space-y-0.5">
{(status.untracked || []).map((item) => (
<li key={`uf-${item.path}`}>
<button
type="button"
className="font-mono text-xs text-primary underline"
onClick={() => showFile(item.path)}
>
{item.path}
</button>
</li>
))}
</ul>
</div>
) : null}
{diff ? (
<div>
<div className="flex items-center justify-between">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Diff — {diff.path} ({diff.kind})
</div>
<TruncationNotice truncated={diff.truncated} />
</div>
<pre className="mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs">
{diff.diff || "(no changes)"}
</pre>
</div>
) : null}
{file ? (
<div>
<div className="flex items-center justify-between">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
File — {file.path}
</div>
<TruncationNotice truncated={file.truncated} />
</div>
<pre className="mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs">
{file.content}
</pre>
</div>
) : null}
</CardContent>
</Card>
) : null}
</div>
);
}
@@ -0,0 +1,96 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
normalizeMutationResult,
confirmationFor,
requiresConfirmation,
CONFIRMATIONS,
} from "../src/lib/git-state.mjs";
test("normalizeMutationResult maps head/status/branches safely", () => {
const r = normalizeMutationResult({
head: "abc123",
status: { counts: { staged: 1 } },
branches: [{ name: "main" }],
});
assert.equal(r.head, "abc123");
assert.equal(r.status.counts.staged, 1);
assert.equal(r.branches.length, 1);
});
test("normalizeMutationResult tolerates missing fields", () => {
const r = normalizeMutationResult(null);
assert.equal(r.head, "");
assert.deepEqual(r.status.counts, { staged: 0, modified: 0, untracked: 0 });
assert.deepEqual(r.branches, []);
});
test("CONFIRMATIONS holds the fixed confirmation tokens", () => {
assert.equal(CONFIRMATIONS.discard, "discard");
assert.equal(CONFIRMATIONS.push, "push");
assert.equal(CONFIRMATIONS.dirtyCheckout, "checkout-dirty");
});
test("confirmationFor returns the token only for destructive ops", () => {
assert.equal(confirmationFor("discard"), CONFIRMATIONS.discard);
assert.equal(confirmationFor("push"), CONFIRMATIONS.push);
assert.equal(confirmationFor("dirty-checkout"), CONFIRMATIONS.dirtyCheckout);
assert.equal(confirmationFor("commit"), null);
assert.equal(confirmationFor("stage"), null);
});
test("requiresConfirmation gates only destructive ops", () => {
assert.equal(requiresConfirmation("discard"), true);
assert.equal(requiresConfirmation("push"), true);
assert.equal(requiresConfirmation("dirty-checkout"), true);
assert.equal(requiresConfirmation("stage"), false);
assert.equal(requiresConfirmation("commit"), false);
assert.equal(requiresConfirmation("fetch"), false);
});
// ── Phase 3 extras ─────────────────────────────────────────────────────────
import {
normalizeCommitMessage,
hasCommitSuggestion,
normalizeStashCheckout,
} from "../src/lib/git-state.mjs";
test("normalizeCommitMessage maps message/notice safely", () => {
const r = normalizeCommitMessage({ message: "feat: add x", notice: "" });
assert.equal(r.message, "feat: add x");
assert.equal(r.notice, "");
});
test("normalizeCommitMessage tolerates missing/empty fields", () => {
assert.deepEqual(normalizeCommitMessage(null), { message: "", notice: "" });
assert.deepEqual(normalizeCommitMessage({}), { message: "", notice: "" });
});
test("hasCommitSuggestion is false for empty/whitespace messages", () => {
assert.equal(hasCommitSuggestion({ message: "feat: add x" }), true);
assert.equal(hasCommitSuggestion({ message: "" }), false);
assert.equal(hasCommitSuggestion({ message: " " }), false);
assert.equal(hasCommitSuggestion(null), false);
});
test("normalizeStashCheckout carries stashed + stash_message", () => {
const r = normalizeStashCheckout({
head: "abc",
stashed: true,
stash_message: "git-state: feature",
status: { counts: { staged: 0 } },
branches: [],
});
assert.equal(r.head, "abc");
assert.equal(r.stashed, true);
assert.equal(r.stashMessage, "git-state: feature");
assert.equal(r.status.counts.staged, 0);
});
test("normalizeStashCheckout defaults stashed false when absent", () => {
const r = normalizeStashCheckout({});
assert.equal(r.stashed, false);
assert.equal(r.stashMessage, "");
});
+60
View File
@@ -0,0 +1,60 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
normalizeRepos,
normalizeStatus,
normalizeBranches,
branchLabel,
isTruncated,
} from "../src/lib/git-state.mjs";
test("normalizeRepos accepts object and bare-array shapes", () => {
assert.deepEqual(
normalizeRepos({ repos: [{ id: "a", name: "A" }] }),
[{ id: "a", name: "A" }],
);
assert.deepEqual(normalizeRepos([{ id: "b", name: "B" }]), [
{ id: "b", name: "B" },
]);
assert.deepEqual(normalizeRepos(null), []);
assert.deepEqual(normalizeRepos({ repos: [{ id: 1 }, null] }), []);
});
test("normalizeStatus fills missing groups and preserves truncation", () => {
const status = normalizeStatus({
counts: { staged: 1, modified: 2, untracked: 3 },
staged: [{ path: "a" }],
truncated: true,
});
assert.equal(status.counts.staged, 1);
assert.equal(status.counts.modified, 2);
assert.equal(status.counts.untracked, 3);
assert.deepEqual(status.staged, [{ path: "a" }]);
assert.deepEqual(status.modified, []);
assert.deepEqual(status.untracked, []);
assert.equal(status.truncated, true);
assert.equal(isTruncated(status), true);
assert.equal(isTruncated(normalizeStatus({})), false);
});
test("normalizeBranches accepts object and bare-array shapes", () => {
assert.deepEqual(normalizeBranches({ branches: [{ name: "main" }] }), [
{ name: "main" },
]);
assert.deepEqual(normalizeBranches([{ name: "dev" }]), [{ name: "dev" }]);
assert.deepEqual(normalizeBranches(null), []);
});
test("branchLabel renders upstream and ahead/behind", () => {
assert.equal(branchLabel({ name: "main" }), "main");
assert.equal(
branchLabel({ name: "main", upstream: "origin/main" }),
"main → origin/main",
);
assert.equal(
branchLabel({ name: "feature", upstream: "origin/feature", ahead: 1, behind: 2 }),
"feature → origin/feature (ahead 1, behind 2)",
);
assert.equal(branchLabel(null), "");
});
+548
View File
@@ -0,0 +1,548 @@
"""Tests for the write (POST) Git State endpoints in plugin/dashboard/git_api.py.
Write endpoints are POST and rely on the plugin's ``plugin.api.write`` grant
which the app enforces client-side (see PluginsViewModel.invokeAction — the
precedent gate). Server-side, destructive operations are additionally enforced
by a required confirmation string; missing/wrong confirmation maps to 403 and
dirty/conflict trees map to 409, so the UI can render a readable message.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from plugin.dashboard import git_api
from plugin import git_state
def _run(cmd: list[str], cwd: Path) -> str:
return subprocess.run(
cmd, cwd=cwd, capture_output=True, text=True, check=True
).stdout.strip()
def _git(repo: Path, *args: str) -> str:
return _run(["git", "-C", str(repo), *args], repo)
def _init_repo(root: Path, name: str) -> Path:
repo = root / name
repo.mkdir(parents=True)
_run(["git", "init", "-q", "-b", "main"], repo)
_run(["git", "config", "user.email", "test@example.com"], repo)
_run(["git", "config", "user.name", "Test User"], repo)
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
_git(repo, "add", "README.md")
_git(repo, "commit", "-q", "-m", "initial commit")
return repo
def _init_bare_remote(root: Path, name: str) -> Path:
remote = root / name
remote.mkdir(parents=True, exist_ok=True)
_run(["git", "init", "-q", "--bare", "-b", "main"], remote)
return remote
class GitWriteApiTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "alpha")
self.env = patch.dict(
os.environ,
{"HERMES_HOME": self.temp.name, "GIT_STATE_BASE_PATH": str(self.base)},
)
self.env.start()
self.addCleanup(self.env.stop)
app = FastAPI()
app.include_router(git_api.router)
self.client = TestClient(app)
def _stage(self, path: str) -> None:
(self.repo / path).write_text("x", encoding="utf-8")
self.client.post("/git/stage", json={"repo": "alpha", "paths": [path]})
def test_stage_returns_fresh_status(self) -> None:
(self.repo / "new.txt").write_text("x", encoding="utf-8")
response = self.client.post(
"/git/stage", json={"repo": "alpha", "paths": ["new.txt"]}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual(1, body["status"]["counts"]["staged"])
def test_unknown_repo_rejected(self) -> None:
response = self.client.post(
"/git/stage", json={"repo": "bogus", "paths": ["x"]}
)
self.assertEqual(400, response.status_code, response.text)
def test_commit_creates_commit(self) -> None:
self._stage("feature.txt")
before = _git(self.repo, "rev-parse", "HEAD")
response = self.client.post(
"/git/commit", json={"repo": "alpha", "message": "add feature"}
)
self.assertEqual(200, response.status_code, response.text)
after = _git(self.repo, "rev-parse", "HEAD")
self.assertNotEqual(before, after)
def test_commit_empty_message_rejected(self) -> None:
self._stage("feature.txt")
response = self.client.post(
"/git/commit", json={"repo": "alpha", "message": " "}
)
self.assertEqual(400, response.status_code, response.text)
def test_discard_without_confirmation_is_403(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
response = self.client.post(
"/git/discard", json={"repo": "alpha", "paths": ["tracked.txt"]}
)
self.assertEqual(403, response.status_code, response.text)
def test_discard_wrong_confirmation_is_403(self) -> None:
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
response = self.client.post(
"/git/discard",
json={
"repo": "alpha",
"paths": ["tracked.txt"],
"confirmation": "wrong",
},
)
self.assertEqual(403, response.status_code, response.text)
def test_discard_with_confirmation_succeeds(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
response = self.client.post(
"/git/discard",
json={
"repo": "alpha",
"paths": ["tracked.txt"],
"confirmation": git_state.CONFIRM_DISCARD,
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("v1", (self.repo / "tracked.txt").read_text(encoding="utf-8"))
def test_push_requires_confirmation(self) -> None:
remote = _init_bare_remote(self.base, "origin-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
self._stage("feature.txt")
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
response = self.client.post(
"/git/push", json={"repo": "alpha", "remote": "origin", "branch": "main"}
)
self.assertEqual(403, response.status_code, response.text)
def test_push_with_confirmation_succeeds(self) -> None:
remote = _init_bare_remote(self.base, "remote-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
self._stage("feature.txt")
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
response = self.client.post(
"/git/push",
json={
"repo": "alpha",
"remote": "origin",
"branch": "main",
"confirmation": git_state.CONFIRM_PUSH,
},
)
self.assertEqual(200, response.status_code, response.text)
def test_checkout_dirty_requires_confirmation(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
response = self.client.post(
"/git/checkout", json={"repo": "alpha", "ref": "feature"}
)
# Missing confirmation on a dirty-tree switch is the destructive gate.
self.assertEqual(403, response.status_code, response.text)
# Confirming proceeds (git still refuses to overwrite conflicting work).
response = self.client.post(
"/git/checkout",
json={
"repo": "alpha",
"ref": "feature",
"confirmation": git_state.CONFIRM_DIRTY_CHECKOUT,
},
)
self.assertEqual(200, response.status_code, response.text)
def test_pull_dirty_returns_409_never_clobbers(self) -> None:
remote = _init_bare_remote(self.base, "remote-bare2")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
# Advance the remote from a descendant clone.
other = self.base / "other"
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
_git(other, "config", "user.email", "t@e.c")
_git(other, "config", "user.name", "T")
(other / "tracked.txt").write_text("remote", encoding="utf-8")
_git(other, "add", "tracked.txt")
_git(other, "commit", "-q", "-m", "remote")
_git(other, "push", "-q", "origin", "main")
(self.repo / "tracked.txt").write_text("local-uncommitted", encoding="utf-8")
response = self.client.post(
"/git/pull", json={"repo": "alpha", "remote": "origin", "branch": "main"}
)
self.assertEqual(409, response.status_code, response.text)
self.assertEqual("local-uncommitted", (self.repo / "tracked.txt").read_text(encoding="utf-8"))
def test_fetch_returns_branches(self) -> None:
remote = _init_bare_remote(self.base, "remote-bare3")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
response = self.client.post(
"/git/fetch", json={"repo": "alpha", "remote": "origin"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("branches", body)
def test_structured_error_taxonomy_is_readable(self) -> None:
# Unknown repo → 400 with a readable detail, never a stack trace.
response = self.client.post(
"/git/stage", json={"repo": "missing", "paths": ["x"]}
)
self.assertEqual(400, response.status_code, response.text)
self.assertNotIn("Traceback", response.text)
self.assertNotIn("subprocess", response.text.lower())
def test_push_wrong_confirmation_is_403(self) -> None:
remote = _init_bare_remote(self.base, "push-wrong-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
self._stage("feature.txt")
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
response = self.client.post(
"/git/push",
json={
"repo": "alpha",
"remote": "origin",
"branch": "main",
"confirmation": "nope",
},
)
self.assertEqual(403, response.status_code, response.text)
def test_push_with_confirmation_updates_remote_and_returns_branches(self) -> None:
remote = _init_bare_remote(self.base, "push-ok-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
self._stage("feature.txt")
r = self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
self.assertEqual(200, r.status_code, r.text)
head = r.json()["head"]
response = self.client.post(
"/git/push",
json={
"repo": "alpha",
"remote": "origin",
"branch": "main",
"confirmation": git_state.CONFIRM_PUSH,
},
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("branches", body)
self.assertIn("status", body)
remote_head = _run(
["git", "ls-remote", str(remote), "refs/heads/main"], self.base
)
self.assertIn(head, remote_head)
def test_checkout_dirty_tree_wrong_confirmation_is_403(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
response = self.client.post(
"/git/checkout",
json={"repo": "alpha", "ref": "feature", "confirmation": "wrong"},
)
self.assertEqual(403, response.status_code, response.text)
def test_checkout_clean_tree_works_without_confirmation(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
response = self.client.post(
"/git/checkout", json={"repo": "alpha", "ref": "feature"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("branches", body)
self.assertIn("status", body)
self.assertEqual(
"feature", _git(self.repo, "symbolic-ref", "--short", "HEAD")
)
def test_checkout_new_branch_with_track_sets_upstream(self) -> None:
remote = _init_bare_remote(self.base, "newbranch-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
response = self.client.post(
"/git/checkout",
json={
"repo": "alpha",
"ref": "main",
"new_branch": "exp",
"track": True,
},
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("branches", body)
self.assertIn("status", body)
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
# Upstream is set — rev-parse resolves to a commit, not an error.
self.assertTrue(_git(self.repo, "rev-parse", "exp@{upstream}"))
def test_fetch_updates_remote_tracking_ref(self) -> None:
remote = _init_bare_remote(self.base, "fetch-adv-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
other = self.base / "other"
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
_git(other, "config", "user.email", "t@e.c")
_git(other, "config", "user.name", "T")
(other / "remote.txt").write_text("rc fetch", encoding="utf-8")
_git(other, "add", "remote.txt")
_git(other, "commit", "-q", "-m", "rc fetch")
_git(other, "push", "-q", "origin", "main")
response = self.client.post(
"/git/fetch", json={"repo": "alpha", "remote": "origin"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("branches", body)
self.assertIn("status", body)
remote_main = _git(self.repo, "rev-parse", "origin/main")
self.assertNotEqual(remote_main, _git(self.repo, "rev-parse", "HEAD"))
def test_pull_returns_200_with_remote_commit(self) -> None:
remote = _init_bare_remote(self.base, "pull-ok-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
other = self.base / "other"
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
_git(other, "config", "user.email", "t@e.c")
_git(other, "config", "user.name", "T")
(other / "remote.txt").write_text("rc pull", encoding="utf-8")
_git(other, "add", "remote.txt")
_git(other, "commit", "-q", "-m", "rc pull")
_git(other, "push", "-q", "origin", "main")
response = self.client.post(
"/git/pull",
json={"repo": "alpha", "remote": "origin", "branch": "main"},
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("status", body)
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
self.assertIn("remote.txt", head_files)
def test_commit_selected_commits_only_given_paths(self) -> None:
(self.repo / "kept.txt").write_text("keep", encoding="utf-8")
(self.repo / "skip.txt").write_text("skip", encoding="utf-8")
_git(self.repo, "add", "kept.txt", "skip.txt")
response = self.client.post(
"/git/commit_selected",
json={"repo": "alpha", "message": "commit kept", "paths": ["kept.txt"]},
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("head", body)
self.assertIn("status", body)
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
self.assertIn("kept.txt", head_files)
self.assertNotIn("skip.txt", head_files)
def test_commit_response_includes_head_and_fresh_status(self) -> None:
self._stage("feature.txt")
response = self.client.post(
"/git/commit", json={"repo": "alpha", "message": "add feature"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("head", body)
self.assertEqual(body["head"], _git(self.repo, "rev-parse", "HEAD"))
self.assertIn("status", body)
self.assertEqual(0, body["status"]["counts"]["staged"])
self.assertEqual(0, body["status"]["counts"]["modified"])
def test_unstage_returns_fresh_status(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
r = self.client.post(
"/git/stage", json={"repo": "alpha", "paths": ["tracked.txt"]}
)
self.assertEqual(200, r.status_code, r.text)
response = self.client.post(
"/git/unstage", json={"repo": "alpha", "paths": ["tracked.txt"]}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("status", body)
status = body["status"]
self.assertNotIn(
"tracked.txt", [e["path"] for e in status["staged"]]
)
self.assertIn(
"tracked.txt", [e["path"] for e in status["modified"]]
)
def test_stage_missing_repo_in_body_is_400(self) -> None:
response = self.client.post("/git/stage", json={"paths": ["x"]})
self.assertEqual(400, response.status_code, response.text)
def test_stage_traversal_path_is_400(self) -> None:
response = self.client.post(
"/git/stage", json={"repo": "alpha", "paths": ["../escape"]}
)
self.assertEqual(400, response.status_code, response.text)
def test_stage_too_many_paths_is_400(self) -> None:
paths = [f"f{i}.txt" for i in range(201)]
response = self.client.post(
"/git/stage", json={"repo": "alpha", "paths": paths}
)
self.assertEqual(400, response.status_code, response.text)
class GitExtrasApiTests(unittest.TestCase):
"""Endpoints for the Phase 3 extras surface (AI messages + stash-checkout)."""
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "alpha")
self.env = patch.dict(
os.environ,
{"HERMES_HOME": self.temp.name, "GIT_STATE_BASE_PATH": str(self.base)},
)
self.env.start()
self.addCleanup(self.env.stop)
app = FastAPI()
app.include_router(git_api.router)
self.client = TestClient(app)
def _staged(self, path: str, content: str) -> None:
(self.repo / path).write_text(content, encoding="utf-8")
self.client.post("/git/stage", json={"repo": "alpha", "paths": [path]})
def test_commit_message_empty_staged_returns_notice_without_model(self) -> None:
# Clean tree → nothing staged → no model needed, no 500.
response = self.client.post("/git/commit_message", json={"repo": "alpha"})
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual("", body["message"])
self.assertEqual("nothing staged", body["notice"])
def test_commit_message_with_staged_diff_generates_message(self) -> None:
self._staged("feature.txt", "new feature\n")
with patch.object(git_state, "_llm_call", new=AsyncMock(return_value="resp")), patch.object(
git_state, "_llm_extract", return_value="feat: add feature"
):
response = self.client.post(
"/git/commit_message", json={"repo": "alpha"}
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("feat: add feature", response.json()["message"])
def test_commit_message_model_failure_degrades_gracefully(self) -> None:
self._staged("a.txt", "x\n")
with patch.object(
git_state, "_llm_call", new=AsyncMock(side_effect=RuntimeError("no model"))
):
response = self.client.post(
"/git/commit_message", json={"repo": "alpha"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual("", body["message"])
self.assertIn("model", body["notice"].lower())
def test_commit_message_selected_honors_only_given_paths(self) -> None:
self._staged("kept.txt", "kept\n")
self._staged("skip.txt", "skip\n")
with patch.object(git_state, "_llm_call", new=AsyncMock(return_value="ok")), patch.object(
git_state, "_llm_extract", return_value="add kept"
):
response = self.client.post(
"/git/commit_message_selected",
json={"repo": "alpha", "paths": ["kept.txt"]},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("add kept", response.json()["message"])
def test_commit_message_unknown_repo_is_400(self) -> None:
response = self.client.post(
"/git/commit_message", json={"repo": "bogus"}
)
self.assertEqual(400, response.status_code, response.text)
def test_stash_checkout_dirty_tree_returns_stash_notice(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "README.md").write_text("dirty", encoding="utf-8")
response = self.client.post(
"/git/stash_checkout", json={"repo": "alpha", "ref": "feature"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertTrue(body["stashed"])
self.assertEqual("git-state: feature", body["stash_message"])
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
self.assertIn("git-state: feature", _git(self.repo, "stash", "list"))
def test_stash_checkout_clean_tree_plain_checkout(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
response = self.client.post(
"/git/stash_checkout", json={"repo": "alpha", "ref": "feature"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertFalse(body["stashed"])
self.assertEqual("", body["stash_message"])
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_stash_checkout_bad_ref_is_400(self) -> None:
response = self.client.post(
"/git/stash_checkout", json={"repo": "alpha", "ref": "nope"}
)
self.assertEqual(400, response.status_code, response.text)
if __name__ == "__main__":
unittest.main()
+22 -2
View File
@@ -50,7 +50,8 @@ class MobilePluginApiTests(unittest.TestCase):
manifest = self.client.get("/mobile/manifest").json()
self.assertEqual("hermes-relay", manifest["id"])
self.assertEqual("draft", manifest["contributions"][0]["status"])
draft = next(c for c in manifest["contributions"] if c["id"] == "system-status")
self.assertEqual("draft", draft["status"])
loaded_document = self.client.get("/mobile/pages/system-status").json()
self.assertEqual(1, loaded_document.pop("host_revision"))
self.assertEqual(_document(), loaded_document)
@@ -70,7 +71,26 @@ class MobilePluginApiTests(unittest.TestCase):
json={"expected_digest": published_digest},
)
self.assertEqual({"ok": True, "id": "system-status"}, removed.json())
self.assertEqual([], self.client.get("/mobile/manifest").json()["contributions"])
remaining = self.client.get("/mobile/manifest").json()["contributions"]
self.assertEqual(["git"], [c["id"] for c in remaining])
def test_manifest_exposes_static_git_page(self) -> None:
manifest = self.client.get("/mobile/manifest").json()
git = next(c for c in manifest["contributions"] if c["id"] == "git")
self.assertEqual("page", git["surface"])
self.assertEqual("mobile/pages/git", git["document"]["path"])
# The Git page document is a GET-only read surface; it must not carry
# any action.request (which would require the write grant).
page = self.client.get("/mobile/pages/git").json()
self.assertNotIn("action", str(page))
def test_git_page_document_is_served(self) -> None:
response = self.client.get("/mobile/pages/git")
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual(1, body["schemaVersion"])
self.assertEqual("git", body["pages"][0]["id"])
self.assertEqual(1, body["host_revision"])
def test_traversal_and_bad_document_are_rejected(self) -> None:
traversal = self.client.put(
+910
View File
@@ -0,0 +1,910 @@
"""Read-only Git state surface for the Hermes-Relay plugin.
Discovers repositories under a configurable base path (default ``~/projects``)
and exposes working-tree status, branches, per-file diffs, and tracked-file
reads. All git execution uses argument lists with ``git -C <repo>`` and never
shell interpolation. Every operation is bounded (timeouts + size caps) and every
``repo`` reference is validated against the scanned allowlist.
Security contract
-----------------
- ``repo`` params are opaque ids resolved against the scanned repo set; an
unknown id is rejected before any filesystem access.
- File paths are validated to reject traversal (``..``), absolute escapes, and
null bytes. Git itself treats paths as repo-relative, so this is defense in
depth.
- Remote URLs are scrubbed of embedded userinfo before they reach any client.
"""
from __future__ import annotations
import logging
import os
import re
import subprocess
from pathlib import Path
from typing import Any
from .config import raw_config_value
logger = logging.getLogger(__name__)
# Bounded response caps (bytes / entries). Configurable via env for operators.
MAX_STATUS_ENTRIES = int(os.environ.get("GIT_STATE_MAX_STATUS_ENTRIES", "200"))
MAX_DIFF_BYTES = int(os.environ.get("GIT_STATE_MAX_DIFF_BYTES", "64_000"))
MAX_FILE_BYTES = int(os.environ.get("GIT_STATE_MAX_FILE_BYTES", "256_000"))
GIT_TIMEOUT_SECONDS = float(os.environ.get("GIT_STATE_TIMEOUT_SECONDS", "10"))
# Default base path for repo discovery.
DEFAULT_BASE_PATH = "~/projects"
_BASE_PATH_ENV = "GIT_STATE_BASE_PATH"
# Matches a remote URL's userinfo (user[:password]@) so it can be scrubbed.
_USERINFO_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*://)([^/@]+)@")
_SSH_USERINFO_RE = re.compile(r"^([^/@:]+)@([^:]+):")
class GitStateError(ValueError):
"""A caller supplied an invalid repo id, path, or diff kind."""
class GitError(GitStateError):
"""A mutation failed in a way git or the security gate reported.
``code`` is a stable, machine-readable taxonomy tag the UI can map to a
readable message without ever rendering a raw stack trace or JSON dump:
``non-repo``, ``dirty``, ``conflict``, ``auth``, ``network``,
``invalid-input``, ``missing-confirmation``, ``wrong-confirmation``.
"""
_CODES = {
"non-repo",
"dirty",
"conflict",
"auth",
"network",
"invalid-input",
"missing-confirmation",
"wrong-confirmation",
}
def __init__(self, message: str, code: str = "invalid-input") -> None:
if code not in self._CODES:
raise ValueError(f"unknown git error code: {code}")
super().__init__(message)
self.code = code
def base_path() -> Path:
"""Resolve the configured discovery base path (default ``~/projects``)."""
raw = raw_config_value(_BASE_PATH_ENV) or DEFAULT_BASE_PATH
return Path(raw).expanduser()
def _git(repo: Path, *args: str) -> str:
"""Run ``git -C <repo> <args>`` and return stdout. Raises on failure."""
try:
result = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
timeout=GIT_TIMEOUT_SECONDS,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise GitStateError(f"git timed out for {repo.name}") from exc
except OSError as exc:
raise GitStateError(f"could not run git for {repo.name}: {exc}") from exc
if result.returncode != 0:
raise GitStateError(
f"git {args[0] if args else 'command'} failed for {repo.name}: "
f"{result.stderr.strip() or result.stdout.strip()}"
)
return result.stdout
def _is_git_repo(path: Path) -> bool:
"""True if ``path`` is a git work tree (has a .git entry)."""
return (path / ".git").exists()
def repo_id(repo: Path) -> str:
"""Opaque, stable id for a repo — its directory basename."""
return repo.name
def scan_repos(base: Path) -> list[dict[str, Any]]:
"""Recursively scan ``base`` for git repositories.
Returns a list of ``{id, name, root, current_branch, dirty}``. A missing
base path yields an empty list (never an exception). ``.git`` internals are
never reported as repositories.
"""
if not base.is_dir():
return []
repos: list[dict[str, Any]] = []
for root in sorted(base.rglob("*")):
if not root.is_dir():
continue
if root.name == ".git":
continue
if not _is_git_repo(root):
continue
repos.append(_describe_repo(root))
return repos
def _describe_repo(repo: Path) -> dict[str, Any]:
"""Build the scan entry for one repository."""
current_branch = ""
try:
current_branch = _git(repo, "symbolic-ref", "--short", "-q", "HEAD").strip()
except GitStateError:
# Detached HEAD or unborn branch — leave empty.
pass
dirty = False
try:
dirty = bool(_git(repo, "status", "--porcelain").strip())
except GitStateError:
pass
return {
"id": repo_id(repo),
"name": repo.name,
"root": str(repo),
"current_branch": current_branch,
"dirty": dirty,
}
def _scan_index(base: Path) -> dict[str, Path]:
"""Build the id → root allowlist from a scan."""
return {entry["id"]: Path(entry["root"]) for entry in scan_repos(base)}
def resolve_repo(base: Path, repo: str) -> Path:
"""Resolve a ``repo`` id against the scanned allowlist.
Raises [GitStateError] if the id is unknown (never accepts an arbitrary
path).
"""
index = _scan_index(base)
root = index.get(repo)
if root is None:
raise GitStateError(f"unknown repository: {repo}")
return root
def resolve_repo_path(repo: Path, path: str) -> str:
"""Validate a repo-relative file path, rejecting traversal and escapes.
Returns the normalized path. Raises [GitStateError] on any unsafe input.
"""
if not isinstance(path, str) or not path:
raise GitStateError("path is required")
if "\x00" in path:
raise GitStateError("path contains a null byte")
if "%" in path:
# Reject URL-encoding artifacts; the framework may decode %2F to a
# path separator, so a literal % is never safe in a repo path.
raise GitStateError("path contains a percent-encoding artifact")
normalized = path.replace("\\", "/")
if normalized.startswith("/"):
raise GitStateError("absolute paths are not allowed")
if normalized.startswith("~"):
raise GitStateError("home-relative paths are not allowed")
segments = normalized.split("/")
if any(seg in ("", ".", "..") for seg in segments):
raise GitStateError("path traversal is not allowed")
return normalized
def repo_status(repo: Path) -> dict[str, Any]:
"""Return grouped working-tree status with counts and truncation flag."""
porcelain = _git(repo, "status", "--porcelain=v1", "-z")
staged: list[dict[str, str]] = []
modified: list[dict[str, str]] = []
untracked: list[dict[str, str]] = []
truncated = False
# -z separates records with NUL; each record is "<XY> <path>\0". A rename
# or copy emits TWO records ("R new\0old\0"); the bare source-path record
# must be skipped, not misparsed as an XY record.
records = porcelain.split("\0")
i = 0
while i < len(records):
record = records[i]
i += 1
if not record:
continue
xy = record[:2]
path = record[3:]
if not path:
continue
if xy == "??":
untracked.append({"path": path})
# Independent checks: a file staged AND modified lands in both groups.
if xy[0] in ("M", "A", "D", "R", "C"):
staged.append({"path": path})
if xy[1] in ("M", "D"):
modified.append({"path": path})
if xy[0] in ("R", "C"):
# The immediately following record is the rename/copy source path;
# skip it so it is not misparsed as an XY record.
i += 1
def _bounded(items: list[dict[str, str]]) -> list[dict[str, str]]:
nonlocal truncated
if len(items) > MAX_STATUS_ENTRIES:
truncated = True
return items[:MAX_STATUS_ENTRIES]
return items
staged = _bounded(staged)
modified = _bounded(modified)
untracked = _bounded(untracked)
return {
"counts": {
"staged": len(staged),
"modified": len(modified),
"untracked": len(untracked),
},
"staged": staged,
"modified": modified,
"untracked": untracked,
"truncated": truncated,
}
def repo_branches(repo: Path) -> list[dict[str, Any]]:
"""Return branch list with name, upstream, ahead/behind, is_current."""
current = ""
try:
current = _git(repo, "symbolic-ref", "--short", "-q", "HEAD").strip()
except GitStateError:
pass
# %(upstream:track) yields "[ahead 1, behind 2]", "[gone]", or "".
fmt = "%(refname:short)%00%(upstream:short)%00%(upstream:track)"
raw = _git(repo, "for-each-ref", "refs/heads", f"--format={fmt}")
branches: list[dict[str, Any]] = []
for line in raw.splitlines():
if not line:
continue
name, upstream, track = line.split("\x00", 2)
ahead, behind = _parse_track(track)
branches.append(
{
"name": name,
"upstream": upstream or None,
"ahead": ahead,
"behind": behind,
"is_current": name == current,
}
)
return branches
def _parse_track(track: str) -> tuple[int, int]:
"""Parse ``[ahead 1, behind 2]`` / ``[gone]`` / ``""`` into (ahead, behind)."""
if not track or track == "[gone]":
return 0, 0
ahead = behind = 0
for part in re.findall(r"(ahead|behind)\s+(\d+)", track):
if part[0] == "ahead":
ahead = int(part[1])
else:
behind = int(part[1])
return ahead, behind
def repo_diff(repo: Path, path: str, kind: str) -> dict[str, Any]:
"""Return a per-file diff for ``kind`` in (``staged``, ``unstaged``)."""
if kind not in ("staged", "unstaged"):
raise GitStateError(f"invalid diff kind: {kind}")
safe_path = resolve_repo_path(repo, path)
args = ["diff", "--no-color", "--"]
if kind == "staged":
args = ["diff", "--cached", "--no-color", "--"]
output = _git(repo, *args, safe_path)
truncated = len(output) > MAX_DIFF_BYTES
if truncated:
output = output[:MAX_DIFF_BYTES]
return {"path": safe_path, "kind": kind, "diff": output, "truncated": truncated}
def read_file(repo: Path, path: str) -> dict[str, Any]:
"""Read a tracked file's working-tree content. Untracked/binary/missing → error."""
safe_path = resolve_repo_path(repo, path)
# Confirm the file is tracked before reading.
try:
_git(repo, "ls-files", "--error-unmatch", "--", safe_path)
except GitStateError as exc:
raise GitStateError(f"file is not tracked: {safe_path}") from exc
# Read the working-tree file from disk (not `git show HEAD:`), so a
# modified-but-uncommitted file returns what is on disk. Read bytes first:
# binary content dies on the NUL check (before any decode), and non-UTF-8
# text raises a clear GitStateError instead of an unhandled 500.
disk_path = repo / safe_path
try:
raw = disk_path.read_bytes()
except OSError as exc:
raise GitStateError(f"could not read file: {safe_path}") from exc
if b"\x00" in raw:
raise GitStateError(f"binary file is not supported: {safe_path}")
truncated = len(raw) > MAX_FILE_BYTES
if truncated:
raw = raw[:MAX_FILE_BYTES]
try:
content = raw.decode("utf-8", errors="strict")
except UnicodeDecodeError as exc:
raise GitStateError(f"file is not valid UTF-8 text: {safe_path}") from exc
return {"path": safe_path, "content": content, "truncated": truncated}
# ── Write (mutation) surface ──────────────────────────────────────────────
# Every mutation requires the plugin's ``plugin.api.write`` grant (enforced at
# the router boundary, matching the app's existing grant gating). Destructive
# operations additionally require a per-use confirmation string echoed by the
# app/tab. The confirmation values are fixed opaque tokens chosen here; the
# client shows the human-readable description and sends the token back.
CONFIRM_DISCARD = "discard"
CONFIRM_PUSH = "push"
CONFIRM_DIRTY_CHECKOUT = "checkout-dirty"
# Bounds keeping payloads and subprocess argv lists bounded.
MAX_MUTATION_PATHS = 200
MAX_COMMIT_MESSAGE = 500
# Return-code markers used to classify git failures into the structured error
# taxonomy the UI renders (non-repo, dirty, conflict, auth, network).
_DIRTY_MARKERS = (
"local changes",
"would be overwritten",
"cannot pull with rebase",
"Your local changes to the following files would be overwritten",
"You have unstaged changes",
"contains uncommitted changes",
"working tree contains modifications",
)
_CONFLICT_MARKERS = (
"merge conflict",
"CONFLICT",
"Automatic merge failed",
"fix conflicts",
"Merge conflict",
)
_AUTH_MARKERS = (
"Authentication failed",
"could not read Username",
"Permission denied (publickey)",
"does not appear to be a git repository",
"Repository not found",
"Invalid username or password",
"authentication failed",
"could not read Password",
)
_NETWORK_MARKERS = (
"Could not resolve host",
"Connection timed out",
"Network is unreachable",
"Operation timed out",
"Could not read from remote repository",
"unable to access",
"Failed to connect",
"Name or service not known",
"getaddrinfo",
"Temporary failure in name resolution",
)
def _classify_git_failure(stderr: str) -> str:
"""Map a git stderr to a stable taxonomy code."""
for marker in _DIRTY_MARKERS:
if marker in stderr:
return "dirty"
for marker in _CONFLICT_MARKERS:
if marker in stderr:
return "conflict"
for marker in _AUTH_MARKERS:
if marker in stderr:
return "auth"
for marker in _NETWORK_MARKERS:
if marker in stderr:
return "network"
return "invalid-input"
def _require_confirmation(confirmation: str | None, expected: str) -> None:
"""Enforce the per-use confirmation string for a destructive mutation."""
if not confirmation:
raise GitError("this action requires confirmation", code="missing-confirmation")
if confirmation != expected:
raise GitError("confirmation did not match", code="wrong-confirmation")
def _validate_paths(paths: list[str] | None) -> list[str]:
"""Normalize a bounded list of repo-relative paths with traversal checks."""
if not paths:
raise GitStateError("paths are required")
if len(paths) > MAX_MUTATION_PATHS:
raise GitStateError(f"too many paths (max {MAX_MUTATION_PATHS})")
return [resolve_repo_path(Path("."), p) for p in paths]
def _is_git_repo(path: Path) -> bool:
"""True if ``path`` is a git work tree (has a .git entry)."""
return (path / ".git").exists()
def _run_mutation(repo: Path, args: list[str]) -> str:
"""Run a mutating ``git -C <repo> <args>``; raise a classified GitError.
Arg lists only (never shell interpolation); bounded by a timeout.
"""
try:
result = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
timeout=GIT_TIMEOUT_SECONDS,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise GitError(f"git timed out for {repo.name}", code="network") from exc
except OSError as exc:
raise GitError(f"could not run git for {repo.name}: {exc}", code="non-repo") from exc
if result.returncode != 0:
stderr = result.stderr.strip() or result.stdout.strip()
raise GitError(
f"git {args[0] if args else 'command'} failed for {repo.name}: {stderr}",
code=_classify_git_failure(stderr),
)
return result.stdout
def _mutate(repo: Path, args: list[str]) -> str:
"""Validate ``repo`` is a real git work tree, then run the mutation."""
if not _is_git_repo(repo):
raise GitError(f"not a git repository: {repo.name}", code="non-repo")
return _run_mutation(repo, args)
def _is_dirty(repo: Path) -> bool:
try:
return bool(_git(repo, "status", "--porcelain").strip())
except GitError:
return False
def _validate_commit_message(message: str) -> str:
if not isinstance(message, str) or not message.strip():
raise GitError("commit message must not be empty", code="invalid-input")
return message.strip()[:MAX_COMMIT_MESSAGE]
def _fresh_mutation_result(
repo: Path,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Post-mutation snapshot: new HEAD oid + fresh working-tree status."""
head = ""
try:
head = _git(repo, "rev-parse", "HEAD").strip()
except GitError:
# Unborn branch — no HEAD yet.
pass
result: dict[str, Any] = {"head": head, "status": repo_status(repo)}
if extra:
result.update(extra)
return result
def stage(repo: Path, paths: list[str]) -> dict[str, Any]:
"""Stage ``paths`` (repo-relative) and return fresh status."""
safe = _validate_paths(paths)
_mutate(repo, ["add", "--"] + safe)
return _fresh_mutation_result(repo)
def unstage(repo: Path, paths: list[str]) -> dict[str, Any]:
"""Unstage ``paths`` and return fresh status."""
safe = _validate_paths(paths)
_mutate(repo, ["restore", "--staged", "--"] + safe)
return _fresh_mutation_result(repo)
def discard(
repo: Path,
paths: list[str],
confirmation: str | None,
delete_untracked: bool = False,
) -> dict[str, Any]:
"""Discard local changes to ``paths`` (confirmation required).
With ``delete_untracked`` the named untracked files are removed from disk.
Returns fresh status.
"""
_require_confirmation(confirmation, CONFIRM_DISCARD)
safe = _validate_paths(paths)
tracked: list[str] = []
for path in safe:
try:
_git(repo, "ls-files", "--error-unmatch", "--", path)
tracked.append(path)
except GitStateError:
# Untracked path — only touched when delete_untracked is set.
if not delete_untracked:
continue
root = repo.resolve()
candidate = (repo / path).resolve()
if candidate != root and root not in candidate.parents:
raise GitStateError(f"path escapes repository: {path}")
if candidate.is_file():
candidate.unlink()
# Revert tracked modifications for the given paths.
if tracked:
_mutate(repo, ["checkout", "--"] + tracked)
return _fresh_mutation_result(repo)
def commit(repo: Path, message: str) -> dict[str, Any]:
"""Create a commit from the staged index. Empty message is rejected."""
message = _validate_commit_message(message)
_mutate(repo, ["commit", "-m", message])
return _fresh_mutation_result(repo)
def commit_selected(repo: Path, message: str, paths: list[str]) -> dict[str, Any]:
"""Commit only the given ``paths`` (staged + modified) under ``message``."""
message = _validate_commit_message(message)
safe = _validate_paths(paths)
_mutate(repo, ["commit", "-m", message, "--"] + safe)
return _fresh_mutation_result(repo)
def fetch(repo: Path, remote: str = "origin") -> dict[str, Any]:
"""Fetch from ``remote`` (default origin) and return fresh status/branches."""
_mutate(repo, ["fetch", "--prune", remote])
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
def pull(repo: Path, remote: str = "origin", branch: str = "") -> dict[str, Any]:
"""Pull from ``remote``/``branch`` (defaults: origin + current branch).
Pull never clobbers local work: a tree git refuses to fast-forward without
discarding local changes surfaces as a structured ``dirty`` GitError.
"""
args = ["pull", "--ff-only", remote]
if branch:
args.append(branch)
try:
_mutate(repo, args)
except GitError as exc:
if exc.code == "dirty":
raise GitError(
"Pull would overwrite local changes — commit or discard them first.",
code="dirty",
) from exc
raise
return _fresh_mutation_result(repo)
def push(
repo: Path,
remote: str = "origin",
branch: str = "",
confirmation: str | None = None,
) -> dict[str, Any]:
"""Push to ``remote``/``branch``. Confirmation is required.
Auth/network failures surface as classified GitErrors; fresh status/branches
are returned so the UI can reflect ahead/behind after a successful push.
"""
_require_confirmation(confirmation, CONFIRM_PUSH)
args = ["push", remote]
if branch:
args.append(branch)
_mutate(repo, args)
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
def checkout(
repo: Path,
ref: str,
confirmation: str | None = None,
new_branch: str = "",
track: bool = False,
) -> dict[str, Any]:
"""Switch to ``ref`` (optionally creating ``new_branch``, optionally --track).
A dirty tree switch requires confirmation. Git still refuses to overwrite
conflicting local changes, so there is no data-loss path.
"""
if not ref:
raise GitStateError("ref is required")
if new_branch:
args = ["checkout", "-b", new_branch]
if track:
args.append("--track")
_mutate(repo, args)
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
if _is_dirty(repo):
_require_confirmation(confirmation, CONFIRM_DIRTY_CHECKOUT)
args = ["checkout"]
if track:
# ``git checkout --track <remote>/<branch>`` creates a local tracking
# branch; only meaningful when the target is a remote-tracking ref.
args.append("--track")
args.append(ref)
_mutate(repo, args)
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
def _staged_diff(repo: Path, paths: list[str] | None) -> tuple[str, bool]:
"""Return the bounded staged diff for ``paths`` (or all staged when None).
Returns ``(diff_text, truncated)``. The diff is capped at MAX_DIFF_BYTES so
the LLM prompt stays bounded. Untracked/modified-but-unstaged content is
never included: only what is staged is eligible for a commit message.
"""
args = ["diff", "--cached", "--no-color"]
if paths:
args.append("--")
args.extend(paths)
output = _git(repo, *args)
truncated = len(output) > MAX_DIFF_BYTES
if truncated:
output = output[:MAX_DIFF_BYTES]
return output, truncated
def _llm_call(messages: list[dict[str, str]]):
"""Call the agent's in-process LLM on a chat ``messages`` list.
Reuses the plugin's existing upstream plumbing: ``agent.auxiliary_client``
(the same in-process helper the upstream tools use). The import is deferred
so the module stays importable in test envs without the agent package; the
message-generation tests monkeypatch ``_llm_call``/``_llm_extract``.
"""
client = _resolve_llm()
return client(messages=messages)
def _llm_extract(response: Any) -> str:
from agent.auxiliary_client import extract_content_or_reasoning
return extract_content_or_reasoning(response)
def _resolve_llm():
"""Resolve the deferred async model client callable (raises if absent)."""
from agent.auxiliary_client import async_call_llm
return async_call_llm
async def _generate_message(diff: str) -> dict[str, Any]:
"""Build the LLM prompt for a bounded staged diff and return the suggestion.
A missing model or failed call degrades to an empty message with a
``model unavailable`` notice — never an exception/500.
"""
if not diff.strip():
return {"message": "", "notice": "nothing staged"}
messages = [
{
"role": "user",
"content": (
"Write a conventional commit message for the staged diff below. "
"Return ONLY the message: a subject line under 72 characters in "
"the form 'type: summary', then a blank line, then an optional "
"body describing what and why. Do not wrap in quotes.\n\n"
f"<staged diff>\n{diff}\n</staged diff>"
),
}
]
try:
response = await _llm_call(messages=messages)
text = _llm_extract(response).strip()
except Exception as exc: # noqa: BLE001
logger.info("commit message generation unavailable: %s", exc)
return {"message": "", "notice": "model unavailable for commit messages"}
if not text:
return {"message": "", "notice": "model returned no suggestion"}
return {"message": text, "notice": ""}
async def commit_message(repo: Path) -> dict[str, Any]:
"""Generate a conventional-style commit-message suggestion from the staged diff.
Empty staged diff → ``{"message": "", "notice": "nothing staged"}`` WITHOUT
calling the LLM. A missing model or failed call degrades to an empty message
with a ``model unavailable`` notice — never an exception/500. Only staged
content is ever sent.
"""
diff, _ = _staged_diff(repo, None)
return await _generate_message(diff)
async def commit_message_selected(
repo: Path, paths: list[str]
) -> dict[str, Any]:
"""Generate a commit-message suggestion from the staged diff of ``paths``.
Only the given paths' staged content is considered; a path with nothing
staged contributes nothing. Empty staged diff → no LLM call.
"""
safe = _validate_paths(paths)
diff, _ = _staged_diff(repo, safe)
return await _generate_message(diff)
def stash_checkout(
repo: Path,
ref: str,
new_branch: str = "",
track: bool = False,
) -> dict[str, Any]:
"""Checkout that auto-stashes a dirty tree first.
Dirty tree: ``git stash push -m "git-state: <ref>"`` then checkout; returns
``{stashed: true, stash_message}`` so the UI can surface the stash. Clean
tree: plain checkout with ``{stashed: false}``. No confirmation is required
because a stash is recoverable (``git stash``), unlike discard — documented
in the endpoint and UI. Git still refuses to overwrite conflicting changes,
so there is no data-loss path. ``new_branch``/``track`` mirror the plain
checkout surface.
"""
if not ref:
raise GitStateError("ref is required")
stashed = False
stash_message = ""
if _is_dirty(repo):
stash_message = f"git-state: {ref}"
_mutate(repo, ["stash", "push", "-m", stash_message])
stashed = True
if new_branch:
args = ["checkout", "-b", new_branch]
if track:
args.append("--track")
_mutate(repo, args)
return {
**{"stashed": stashed, "stash_message": stash_message},
**_fresh_mutation_result(repo, {"branches": repo_branches(repo)}),
}
args = ["checkout"]
if track:
args.append("--track")
args.append(ref)
_mutate(repo, args)
return {
**{"stashed": stashed, "stash_message": stash_message},
**_fresh_mutation_result(repo, {"branches": repo_branches(repo)}),
}
def repo_remotes(repo: Path) -> list[dict[str, str]]:
"""Return remote names + URLs with embedded userinfo scrubbed."""
raw = _git(repo, "remote", "-v")
remotes: list[dict[str, str]] = []
seen: set[str] = set()
for line in raw.splitlines():
parts = line.split()
if len(parts) < 2:
continue
name, url = parts[0], parts[1]
if name in seen:
continue
seen.add(name)
remotes.append({"name": name, "url": _scrub_url(url)})
return remotes
def _scrub_url(url: str) -> str:
"""Strip userinfo (``user:pass@`` / ``user@``) from a remote URL."""
scrubbed = _USERINFO_RE.sub(r"\1", url)
# scp-style: user@host:path — drop the user but keep the host:path.
scrubbed = _SSH_USERINFO_RE.sub(r"\2:", scrubbed)
return scrubbed
def build_git_document(base: Path) -> dict[str, Any]:
"""Build the declarative Git page document served at ``mobile/pages/git``.
The document is a read-only snapshot: it lists scanned repositories as
literal text and carries no filesystem paths (per the android-plugins.md
document contract). Interactive repo → status → diff/file browsing is
provided by the dedicated Android Compose screen, which calls the
``/git/*`` API endpoints directly; this document is the manifest-declared
fallback surface.
"""
repos = scan_repos(base)
children: list[dict[str, Any]] = []
if not base.is_dir():
children.append(
{
"type": "text",
"id": "notice",
"text": {
"type": "literal",
"value": "Configured Git base path was not found. Check the plugin setting.",
},
}
)
elif not repos:
children.append(
{
"type": "text",
"id": "empty",
"text": {
"type": "literal",
"value": "No repositories found under the configured base path.",
},
}
)
else:
for repo in repos:
dirty = " (dirty)" if repo["dirty"] else ""
children.append(
{
"type": "text",
"id": f"repo-{repo['id']}",
"text": {
"type": "literal",
"value": f"{repo['name']} — {repo['current_branch'] or 'detached'}{dirty}",
},
}
)
return {
"schemaVersion": 1,
"pages": [
{
"id": "git",
"title": {"type": "literal", "value": "Git"},
"content": {
"type": "group",
"id": "git-root",
"direction": "column",
"children": children,
},
}
],
}
__all__ = [
"DEFAULT_BASE_PATH",
"GIT_TIMEOUT_SECONDS",
"GitStateError",
"MAX_DIFF_BYTES",
"MAX_FILE_BYTES",
"MAX_STATUS_ENTRIES",
"base_path",
"build_git_document",
"commit_message",
"commit_message_selected",
"read_file",
"repo_branches",
"repo_diff",
"repo_id",
"repo_remotes",
"repo_status",
"resolve_repo",
"resolve_repo_path",
"scan_repos",
"stash_checkout",
]
+20
View File
@@ -139,6 +139,26 @@ class MobilePluginStore:
def manifest(self) -> dict[str, Any]:
contributions = []
# Static read-only Git page contributed by the relay plugin itself.
# It carries no filesystem paths (per the android-plugins.md document
# contract); repo data flows through the /git/* API responses and is
# rendered as plain text by the host. No plugin.api.write grant is
# required for the read-only Git surface.
contributions.append(
{
"id": "git",
"surface": "page",
"title": "Git",
"description": "Browse repositories on this Hermes host",
"status": "published",
"lifecycle": "persistent",
"revision": 1,
"document": {
"method": "GET",
"path": "mobile/pages/git",
},
}
)
for summary in self.list():
is_draft = summary["status"] == "draft"
contributions.append(
+354
View File
@@ -0,0 +1,354 @@
"""Tests for the read-only Git state surface (plugin/git_state.py).
Fixtures create REAL throwaway git repositories in tmp_path — init, config
user, commits, branches, and bare remotes. Git itself is never mocked.
"""
from __future__ import annotations
import os
import subprocess
import unittest
from pathlib import Path
from plugin import git_state
def _run(cmd: list[str], cwd: Path) -> str:
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def _git(repo: Path, *args: str) -> str:
return _run(["git", "-C", str(repo), *args], repo)
def _init_repo(root: Path, name: str) -> Path:
repo = root / name
repo.mkdir(parents=True)
_run(["git", "init", "-q", "-b", "main"], repo)
_run(["git", "config", "user.email", "test@example.com"], repo)
_run(["git", "config", "user.name", "Test User"], repo)
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
_git(repo, "add", "README.md")
_git(repo, "commit", "-q", "-m", "initial commit")
return repo
def _add_remote(repo: Path, remote_url: str, name: str = "origin") -> None:
_git(repo, "remote", "add", name, remote_url)
class GitStateScanTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = Path(self.tempdir())
def tempdir(self) -> str:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
return self._td.name
def test_scan_finds_nested_repos_and_ignores_non_repos(self) -> None:
base = self.tmp / "projects"
base.mkdir(parents=True)
_init_repo(base, "alpha")
_init_repo(base / "nested", "beta")
# A plain directory with no .git must be ignored.
(base / "plain").mkdir()
(base / "plain" / "file.txt").write_text("x", encoding="utf-8")
repos = git_state.scan_repos(base)
names = {r["name"] for r in repos}
self.assertEqual({"alpha", "beta"}, names)
for repo in repos:
self.assertEqual("main", repo["current_branch"])
self.assertFalse(repo["dirty"])
def test_scan_missing_base_path_returns_empty(self) -> None:
missing = self.tmp / "does-not-exist"
self.assertEqual([], git_state.scan_repos(missing))
def test_scan_excludes_git_internals(self) -> None:
base = self.tmp / "projects"
base.mkdir(parents=True)
_init_repo(base, "alpha")
# A .git directory itself must never be reported as a repo.
repos = git_state.scan_repos(base)
self.assertTrue(all(".git" not in r["name"] for r in repos))
def test_scan_marks_dirty_repo(self) -> None:
base = self.tmp / "projects"
base.mkdir(parents=True)
repo = _init_repo(base, "dirty")
(repo / "new.txt").write_text("untracked", encoding="utf-8")
repos = git_state.scan_repos(base)
dirty = next(r for r in repos if r["name"] == "dirty")
self.assertTrue(dirty["dirty"])
class GitStateStatusTests(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "status-repo")
def test_status_groups_modified_untracked_and_staged(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
# staged change
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
# unstaged change
(self.repo / "README.md").write_text("# Changed\n", encoding="utf-8")
# untracked
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
status = git_state.repo_status(self.repo)
self.assertEqual(1, status["counts"]["staged"])
self.assertEqual(1, status["counts"]["modified"])
self.assertEqual(1, status["counts"]["untracked"])
self.assertEqual("tracked.txt", status["staged"][0]["path"])
self.assertEqual("README.md", status["modified"][0]["path"])
self.assertEqual("untracked.txt", status["untracked"][0]["path"])
self.assertFalse(status["truncated"])
def test_status_truncates_when_over_cap(self) -> None:
for i in range(git_state.MAX_STATUS_ENTRIES + 5):
(self.repo / f"file-{i}.txt").write_text("x", encoding="utf-8")
status = git_state.repo_status(self.repo)
self.assertTrue(status["truncated"])
self.assertLessEqual(
len(status["untracked"]),
git_state.MAX_STATUS_ENTRIES,
)
def test_status_lists_staged_and_modified_same_file(self) -> None:
# A file staged AND then modified again ("MM" in porcelain) must appear
# in BOTH the staged and modified groups (independent checks, not elif).
(self.repo / "mm.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "mm.txt")
_git(self.repo, "commit", "-q", "-m", "add mm")
(self.repo / "mm.txt").write_text("v2", encoding="utf-8")
_git(self.repo, "add", "mm.txt")
(self.repo / "mm.txt").write_text("v3", encoding="utf-8")
status = git_state.repo_status(self.repo)
staged_paths = {e["path"] for e in status["staged"]}
modified_paths = {e["path"] for e in status["modified"]}
self.assertIn("mm.txt", staged_paths)
self.assertIn("mm.txt", modified_paths)
def test_status_rename_emits_single_staged_entry(self) -> None:
# `git mv` produces two NUL-separated porcelain records ("R new\0old\0");
# the bare source-path record must be skipped, not misparsed as an XY
# record. Use a source name starting with "M" so a naive parser would
# misclassify the bare source record as staged with a truncated path.
(self.repo / "Moved.txt").write_text("content", encoding="utf-8")
_git(self.repo, "add", "Moved.txt")
_git(self.repo, "commit", "-q", "-m", "add Moved")
_git(self.repo, "mv", "Moved.txt", "new.txt")
status = git_state.repo_status(self.repo)
self.assertEqual(["new.txt"], [e["path"] for e in status["staged"]])
self.assertEqual([], status["modified"])
self.assertEqual([], status["untracked"])
def test_status_unstaged_rename_shows_delete_and_untracked(self) -> None:
# Rename on disk only (no `git mv`): delete + create → D + ??.
(self.repo / "old.txt").write_text("content", encoding="utf-8")
_git(self.repo, "add", "old.txt")
_git(self.repo, "commit", "-q", "-m", "add old")
(self.repo / "old.txt").unlink()
(self.repo / "new.txt").write_text("content", encoding="utf-8")
status = git_state.repo_status(self.repo)
self.assertEqual([], status["staged"])
self.assertEqual(["old.txt"], [e["path"] for e in status["modified"]])
self.assertEqual(["new.txt"], [e["path"] for e in status["untracked"]])
class GitStateBranchesTests(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "branch-repo")
def test_branches_reports_current_upstream_ahead_behind(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
_git(self.repo, "add", "feature.txt")
_git(self.repo, "commit", "-q", "-m", "feature work")
branches = git_state.repo_branches(self.repo)
by_name = {b["name"]: b for b in branches}
self.assertIn("main", by_name)
self.assertIn("feature", by_name)
self.assertTrue(by_name["feature"]["is_current"])
self.assertFalse(by_name["main"]["is_current"])
class GitStateDiffTests(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "diff-repo")
def test_diff_unstaged_and_staged_kinds(self) -> None:
(self.repo / "a.txt").write_text("one\n", encoding="utf-8")
_git(self.repo, "add", "a.txt")
_git(self.repo, "commit", "-q", "-m", "add a")
# staged change
(self.repo / "a.txt").write_text("two\n", encoding="utf-8")
_git(self.repo, "add", "a.txt")
# unstaged change
(self.repo / "a.txt").write_text("three\n", encoding="utf-8")
staged = git_state.repo_diff(self.repo, "a.txt", kind="staged")
self.assertIn("+two", staged["diff"])
unstaged = git_state.repo_diff(self.repo, "a.txt", kind="unstaged")
self.assertIn("+three", unstaged["diff"])
def test_diff_invalid_kind_raises(self) -> None:
with self.assertRaises(ValueError):
git_state.repo_diff(self.repo, "a.txt", kind="bogus")
def test_diff_truncates_large_output(self) -> None:
big = "x" * 200_000
(self.repo / "big.txt").write_text(big + "\n", encoding="utf-8")
_git(self.repo, "add", "big.txt")
_git(self.repo, "commit", "-q", "-m", "add big")
(self.repo / "big.txt").write_text(big + "y\n", encoding="utf-8")
result = git_state.repo_diff(self.repo, "big.txt", kind="unstaged")
self.assertTrue(result["truncated"])
self.assertLessEqual(len(result["diff"]), git_state.MAX_DIFF_BYTES)
class GitStateFileTests(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "file-repo")
def test_read_tracked_file(self) -> None:
content = git_state.read_file(self.repo, "README.md")
self.assertIn("Hello", content["content"])
def test_read_tracked_file_returns_working_tree_not_committed(self) -> None:
# A modified-but-uncommitted tracked file must return the on-disk
# (working-tree) content, not the last committed version.
(self.repo / "README.md").write_text("# Working Tree\n", encoding="utf-8")
content = git_state.read_file(self.repo, "README.md")
self.assertIn("Working Tree", content["content"])
self.assertNotIn("Hello", content["content"])
def test_read_untracked_file_raises(self) -> None:
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
with self.assertRaises(ValueError):
git_state.read_file(self.repo, "untracked.txt")
def test_read_missing_file_raises(self) -> None:
with self.assertRaises(ValueError):
git_state.read_file(self.repo, "nope.txt")
def test_read_tracked_binary_file_raises_gitstateerror(self) -> None:
# A TRACKED binary file (e.g. a committed PNG) must raise GitStateError
# ("binary file is not supported" / UTF-8), never an unhandled
# UnicodeDecodeError that escapes as a 500.
(self.repo / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00binary\xff\xfe")
_git(self.repo, "add", "image.png")
_git(self.repo, "commit", "-q", "-m", "add binary")
with self.assertRaises(git_state.GitStateError) as ctx:
git_state.read_file(self.repo, "image.png")
message = str(ctx.exception)
self.assertTrue("binary" in message or "UTF-8" in message)
def test_read_tracked_non_utf8_text_raises(self) -> None:
# A tracked, NUL-free but non-UTF-8 text file (Latin-1) must raise a
# clear GitStateError, not an unhandled UnicodeDecodeError.
(self.repo / "latin1.txt").write_bytes(b"caf\xe9 latin1")
_git(self.repo, "add", "latin1.txt")
_git(self.repo, "commit", "-q", "-m", "add latin1")
with self.assertRaises(git_state.GitStateError) as ctx:
git_state.read_file(self.repo, "latin1.txt")
self.assertIn("not valid UTF-8 text", str(ctx.exception))
class GitStateDocumentTests(unittest.TestCase):
def test_document_missing_base_notice_leaks_no_path(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
missing = Path(self._td.name) / "does-not-exist"
doc = git_state.build_git_document(missing)
notice = doc["pages"][0]["content"]["children"][0]
self.assertEqual("notice", notice["id"])
value = notice["text"]["value"]
# The document contract forbids filesystem paths: no "/" and no
# path-like substring (e.g. the tmp dir name).
self.assertNotIn("/", value)
self.assertNotIn(self._td.name, value)
class GitStateSecurityTests(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "sec-repo")
def test_path_traversal_rejected(self) -> None:
for bad in ("../outside", "/etc/passwd", "a/../../b", "..%2Fescape"):
with self.subTest(path=bad):
with self.assertRaises(ValueError):
git_state.resolve_repo_path(self.repo, bad)
def test_remote_urls_scrubbed_of_userinfo(self) -> None:
_add_remote(self.repo, "https://user:secret@example.com/org/repo.git", "https")
_add_remote(self.repo, "ssh://git@example.com:2222/org/repo.git", "ssh")
_add_remote(self.repo, "git@example.com:org/repo.git", "scp")
remotes = git_state.repo_remotes(self.repo)
self.assertEqual(3, len(remotes))
for remote in remotes:
self.assertNotIn("secret", remote["url"])
self.assertNotIn("user:", remote["url"])
self.assertNotIn("git@", remote["url"])
def test_allowlist_accepts_only_scanned_repos(self) -> None:
scanned = git_state.scan_repos(self.base)
ids = {r["id"] for r in scanned}
self.assertIn(git_state.repo_id(self.repo), ids)
self.assertNotIn("bogus-id", ids)
if __name__ == "__main__":
unittest.main()
+225
View File
@@ -0,0 +1,225 @@
"""Tests for the Phase 3 extras surface of git_state.
Covers:
- ``commit_message`` / ``commit_message_selected``: staged-diff → LLM → a
conventional-style message suggestion. The model client is MONKEYPATCHED
(never a real API call); empty staged diff must skip the LLM entirely; a
missing/failing model degrades to an empty message + notice, never a 500.
- ``stash_checkout``: dirty tree → auto-stash + switch (stash list entry
present); clean tree → plain checkout; unknown ref → error.
Fixtures build REAL throwaway git repos in tmp_path. git itself is never
mocked.
"""
from __future__ import annotations
import asyncio
import os
import subprocess
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from plugin import git_state
def _run(cmd: list[str], cwd: Path) -> str:
return subprocess.run(
cmd, cwd=cwd, capture_output=True, text=True, check=True
).stdout.strip()
def _git(repo: Path, *args: str) -> str:
return _run(["git", "-C", str(repo), *args], repo)
def _init_repo(root: Path, name: str) -> Path:
repo = root / name
repo.mkdir(parents=True)
_run(["git", "init", "-q", "-b", "main"], repo)
_run(["git", "config", "user.email", "test@example.com"], repo)
_run(["git", "config", "user.name", "Test User"], repo)
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
_git(repo, "add", "README.md")
_git(repo, "commit", "-q", "-m", "initial commit")
return repo
class _ExtrasBase(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "extras-repo")
def _staged_change(self, path: str, content: str) -> None:
(self.repo / path).write_text(content, encoding="utf-8")
_git(self.repo, "add", path)
def _branch(self, name: str) -> None:
_git(self.repo, "checkout", "-q", "-b", name)
_git(self.repo, "checkout", "-q", "main")
def _patch_llm(self, *, text: str = "", exc: Exception | None = None):
"""Inject a fake agent.auxiliary_client chain into git_state.
``_llm_call`` is the async client; ``_llm_extract`` turns its return
into text. The failure path raises ``exc`` from the client.
"""
client = AsyncMock(side_effect=exc) if exc else AsyncMock(return_value="resp")
extractor = MagicMock(return_value=text)
return patch.object(git_state, "_llm_call", client), client, extractor, patch.object(
git_state, "_llm_extract", extractor
)
class CommitMessageTests(_ExtrasBase):
def test_commit_message_returns_suggestion_from_staged_diff(self) -> None:
self._staged_change("feature.txt", "new feature body\n")
llm_patch, client, extractor, extract_patch = self._patch_llm(
text="feat: add new feature\n\nBrings a new feature."
)
with llm_patch, extract_patch:
result = asyncio.run(git_state.commit_message(self.repo))
# The generated message is the LLM text (subject + optional body) so it
# can flow into the commit dialog and be edited.
self.assertEqual(
{"message": "feat: add new feature\n\nBrings a new feature.", "notice": ""},
result,
)
# The client was called with a messages list containing the staged diff.
_, kwargs = client.call_args
self.assertIn("messages", kwargs)
payload = " ".join(str(m) for m in kwargs["messages"])
self.assertIn("new feature", payload)
extractor.assert_called_once_with("resp")
def test_commit_message_empty_staged_diff_skips_llm(self) -> None:
# Clean tree: nothing staged → no LLM call.
p = patch.object(git_state, "_llm_call", AsyncMock())
with p as client:
result = asyncio.run(git_state.commit_message(self.repo))
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
client.assert_not_called()
def test_commit_message_skips_llm_when_staged_diff_is_empty(self) -> None:
# A tracked file modified but NOT staged must never be sent.
(self.repo / "README.md").write_text("# Hello v2\n", encoding="utf-8")
p = patch.object(git_state, "_llm_call", AsyncMock())
with p as client:
result = asyncio.run(git_state.commit_message(self.repo))
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
client.assert_not_called()
def test_commit_message_degrades_when_model_fails(self) -> None:
self._staged_change("feature.txt", "boom\n")
p, _, _, _ = self._patch_llm(exc=RuntimeError("no provider configured"))
with p:
result = asyncio.run(git_state.commit_message(self.repo))
self.assertEqual("", result["message"])
self.assertIn("model", result["notice"].lower())
def test_commit_message_degrades_when_model_unavailable(self) -> None:
self._staged_change("feature.txt", "x\n")
# Resolver itself fails (e.g. `agent` not installed).
with patch.object(git_state, "_resolve_llm", side_effect=ImportError("no agent")):
result = asyncio.run(git_state.commit_message(self.repo))
self.assertEqual("", result["message"])
self.assertIn("model", result["notice"].lower())
def test_commit_message_bounds_staged_diff(self) -> None:
# A large staged diff is truncated to MAX_DIFF_BYTES and flagged.
big = "x" * (git_state.MAX_DIFF_BYTES + 10)
self._staged_change("big.txt", big)
p, client, extractor, ep = self._patch_llm(text="add big")
with p, ep:
result = asyncio.run(git_state.commit_message(self.repo))
self.assertEqual("add big", result["message"])
_, kwargs = client.call_args
payload = "".join(str(m) for m in kwargs["messages"])
self.assertLessEqual(len(payload), git_state.MAX_DIFF_BYTES + 2048)
self.assertNotIn("nothing staged", result.get("notice", ""))
class CommitMessageSelectedTests(_ExtrasBase):
def test_commit_message_selected_honors_only_given_paths(self) -> None:
self._staged_change("kept.txt", "kept content\n")
self._staged_change("skip.txt", "skip content\n")
p, client, extractor, extract_p = self._patch_llm(text="add kept")
with p, extract_p:
result = asyncio.run(
git_state.commit_message_selected(self.repo, ["kept.txt"])
)
self.assertEqual("add kept", result["message"])
_, kwargs = client.call_args
payload = "".join(str(m) for m in kwargs["messages"])
self.assertIn("kept content", payload)
self.assertNotIn("skip content", payload)
def test_commit_message_selected_skips_llm_when_none_selected_staged(self) -> None:
self._staged_change("kept.txt", "kept\n")
# A path with no staged content → nothing staged → no call.
(self.repo / "other.txt").write_text("other\n", encoding="utf-8")
_git(self.repo, "add", "other.txt")
# Stage only kept.txt, ask for other.txt's diff (not staged) → nothing.
p = patch.object(git_state, "_llm_call", AsyncMock())
with p as client:
# Ask for a path that has no STAGED diff: unstage other first.
_git(self.repo, "restore", "--staged", "other.txt")
result = asyncio.run(
git_state.commit_message_selected(self.repo, ["other.txt"])
)
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
client.assert_not_called()
def test_commit_message_selected_rejects_traversal_path(self) -> None:
self._staged_change("kept.txt", "kept\n")
with self.assertRaises(git_state.GitStateError):
asyncio.run(
git_state.commit_message_selected(self.repo, ["../escape"])
)
class StashCheckoutTests(_ExtrasBase):
def test_dirty_tree_stashes_then_switches(self) -> None:
self._branch("feature")
(self.repo / "README.md").write_text("dirty change\n", encoding="utf-8")
result = git_state.stash_checkout(self.repo, "feature")
self.assertTrue(result["stashed"])
self.assertEqual("git-state: feature", result["stash_message"])
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
stash_list = _git(self.repo, "stash", "list")
self.assertIn("git-state: feature", stash_list)
def test_clean_tree_plain_checkout_no_stash(self) -> None:
self._branch("feature")
result = git_state.stash_checkout(self.repo, "feature")
self.assertFalse(result["stashed"])
self.assertEqual("", result["stash_message"])
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
self.assertEqual("", _git(self.repo, "stash", "list"))
def test_clean_tree_new_branch_switch(self) -> None:
result = git_state.stash_checkout(self.repo, "main", new_branch="exp")
self.assertFalse(result["stashed"])
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_stash_returns_fresh_status_and_branches(self) -> None:
self._branch("feature")
(self.repo / "README.md").write_text("dirty\n", encoding="utf-8")
result = git_state.stash_checkout(self.repo, "feature")
self.assertIn("status", result)
self.assertIn("branches", result)
self.assertIn("head", result)
def test_bad_ref_raises(self) -> None:
with self.assertRaises(git_state.GitStateError):
git_state.stash_checkout(self.repo, "no-such-branch")
if __name__ == "__main__":
unittest.main()
+379
View File
@@ -0,0 +1,379 @@
"""Tests for the write (mutation) surface of git_state.
Security denials are tested FIRST: destructive mutations (discard, push, dirty
checkout) reject when their confirmation string is missing or wrong. Then happy
paths, then error branches. Fixtures build REAL throwaway git repos and bare
remotes in tmp_path — git itself is never mocked.
"""
from __future__ import annotations
import os
import subprocess
import unittest
from pathlib import Path
from plugin import git_state
def _run(cmd: list[str], cwd: Path) -> str:
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def _git(repo: Path, *args: str) -> str:
return _run(["git", "-C", str(repo), *args], repo)
def _init_repo(root: Path, name: str) -> Path:
repo = root / name
repo.mkdir(parents=True)
_run(["git", "init", "-q", "-b", "main"], repo)
_run(["git", "config", "user.email", "test@example.com"], repo)
_run(["git", "config", "user.name", "Test User"], repo)
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
_git(repo, "add", "README.md")
_git(repo, "commit", "-q", "-m", "initial commit")
return repo
def _init_bare_remote(root: Path, name: str) -> Path:
remote = root / name
remote.mkdir(parents=True, exist_ok=True)
_run(["git", "init", "-q", "--bare", "-b", "main"], remote)
return remote
class _MutationBase(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "write-repo")
def _make_change(self, path: str = "feature.txt", content: str = "hello") -> None:
(self.repo / path).write_text(content, encoding="utf-8")
_git(self.repo, "add", path)
def _head(self) -> str:
return _git(self.repo, "rev-parse", "HEAD")
class StageUnstageTests(_MutationBase):
def test_stage_moves_untracked_to_staged(self) -> None:
(self.repo / "new.txt").write_text("x", encoding="utf-8")
git_state.stage(self.repo, ["new.txt"])
status = git_state.repo_status(self.repo)
self.assertIn("new.txt", [e["path"] for e in status["staged"]])
self.assertNotIn("new.txt", [e["path"] for e in status["untracked"]])
def test_stage_accepts_multiple_paths(self) -> None:
(self.repo / "a.txt").write_text("a", encoding="utf-8")
(self.repo / "b.txt").write_text("b", encoding="utf-8")
git_state.stage(self.repo, ["a.txt", "b.txt"])
status = git_state.repo_status(self.repo)
staged = {e["path"] for e in status["staged"]}
self.assertTrue({"a.txt", "b.txt"} <= staged)
def test_unstage_returns_to_modified(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
git_state.unstage(self.repo, ["tracked.txt"])
status = git_state.repo_status(self.repo)
self.assertNotIn("tracked.txt", [e["path"] for e in status["staged"]])
self.assertIn("tracked.txt", [e["path"] for e in status["modified"]])
def test_stage_rejects_traversal_path(self) -> None:
with self.assertRaises(git_state.GitStateError):
git_state.stage(self.repo, ["../outside"])
class CommitTests(_MutationBase):
def test_commit_creates_a_real_commit(self) -> None:
before = self._head()
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
git_state.commit(self.repo, "add feature")
after = self._head()
self.assertNotEqual(before, after)
message = _git(self.repo, "log", "-1", "--format=%s")
self.assertEqual("add feature", message)
def test_commit_rejects_empty_message(self) -> None:
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
with self.assertRaises(git_state.GitStateError):
git_state.commit(self.repo, " ")
def test_commit_returns_fresh_status(self) -> None:
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
result = git_state.commit(self.repo, "add feature")
self.assertEqual(result["head"], self._head())
self.assertEqual(result["status"]["counts"]["staged"], 0)
self.assertEqual(result["status"]["counts"]["modified"], 0)
def test_commit_selected_commits_only_given_paths(self) -> None:
(self.repo / "kept.txt").write_text("keep", encoding="utf-8")
(self.repo / "skip.txt").write_text("skip", encoding="utf-8")
_git(self.repo, "add", "kept.txt", "skip.txt")
git_state.commit_selected(self.repo, "commit kept only", ["kept.txt"])
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
self.assertIn("kept.txt", head_files)
self.assertNotIn("skip.txt", head_files)
def test_commit_selected_returns_fresh_status(self) -> None:
(self.repo / "a.txt").write_text("a", encoding="utf-8")
git_state.stage(self.repo, ["a.txt"])
result = git_state.commit_selected(self.repo, "commit a", ["a.txt"])
self.assertEqual(result["head"], self._head())
self.assertEqual(result["status"]["counts"]["staged"], 0)
def test_commit_selected_rejects_empty_message(self) -> None:
(self.repo / "a.txt").write_text("a", encoding="utf-8")
git_state.stage(self.repo, ["a.txt"])
with self.assertRaises(git_state.GitError):
git_state.commit_selected(self.repo, "", ["a.txt"])
class DiscardConfirmationTests(_MutationBase):
def test_discard_requires_confirmation_string(self) -> None:
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
with self.assertRaises(git_state.GitError) as ctx:
git_state.discard(self.repo, ["tracked.txt"], confirmation="")
self.assertIn("confirmation", str(ctx.exception).lower())
def test_discard_rejects_wrong_confirmation(self) -> None:
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
with self.assertRaises(git_state.GitError):
git_state.discard(self.repo, ["tracked.txt"], confirmation="wrong")
def test_discard_with_confirmation_reverts_tracked_changes(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
git_state.discard(self.repo, ["tracked.txt"], confirmation=git_state.CONFIRM_DISCARD)
content = (self.repo / "tracked.txt").read_text(encoding="utf-8")
self.assertEqual("v1", content)
def test_discard_delete_untracked_removes_untracked(self) -> None:
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
git_state.discard(
self.repo,
["untracked.txt"],
confirmation=git_state.CONFIRM_DISCARD,
delete_untracked=True,
)
self.assertFalse((self.repo / "untracked.txt").exists())
def test_discard_returns_fresh_status(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
result = git_state.discard(
self.repo,
["tracked.txt"],
confirmation=git_state.CONFIRM_DISCARD,
)
self.assertEqual(result["status"]["counts"]["modified"], 0)
class FetchPullPushTests(_MutationBase):
def setUp(self) -> None:
super().setUp()
self.remote = _init_bare_remote(self.base, "origin-bare")
_git(self.repo, "remote", "add", "origin", str(self.remote))
_git(self.repo, "push", "-q", "origin", "main")
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
def test_fetch_updates_remote_refs(self) -> None:
# Advance the remote from a descendant clone (not an independent repo:
# an independent root has its own "initial commit" SHA, and when it
# lands in a different second than the remote's initial commit the push
# is rejected as non-fast-forward, flaking the test).
other = self.base / "other"
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
_git(other, "config", "user.email", "test@example.com")
_git(other, "config", "user.name", "Test User")
(other / "remote.txt").write_text("remote change fetch", encoding="utf-8")
_git(other, "add", "remote.txt")
_git(other, "commit", "-q", "-m", "remote change fetch")
_git(other, "push", "-q", "origin", "main")
git_state.fetch(self.repo, "origin")
remote_main = _git(self.repo, "rev-parse", "origin/main")
self.assertNotEqual(remote_main, self._head())
def test_pull_brings_remote_commits(self) -> None:
other = self.base / "other"
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
_git(other, "config", "user.email", "test@example.com")
_git(other, "config", "user.name", "Test User")
(other / "remote.txt").write_text("remote change pull", encoding="utf-8")
_git(other, "add", "remote.txt")
_git(other, "commit", "-q", "-m", "remote change pull")
_git(other, "push", "-q", "origin", "main")
git_state.pull(self.repo, "origin", "main")
self.assertIn("remote.txt", _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD"))
def test_push_requires_confirmation(self) -> None:
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
git_state.commit(self.repo, "feature")
with self.assertRaises(git_state.GitError) as ctx:
git_state.push(self.repo, "origin", "main", confirmation="")
self.assertIn("confirmation", str(ctx.exception).lower())
def test_push_rejects_wrong_confirmation(self) -> None:
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
git_state.commit(self.repo, "feature")
with self.assertRaises(git_state.GitError):
git_state.push(self.repo, "origin", "main", confirmation="nope")
def test_push_with_confirmation_updates_remote(self) -> None:
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
git_state.commit(self.repo, "feature")
git_state.push(self.repo, "origin", "main", confirmation=git_state.CONFIRM_PUSH)
remote_head = _run(["git", "ls-remote", str(self.remote), "refs/heads/main"], self.remote)
self.assertIn(self._head(), remote_head)
def test_pull_with_local_changes_yields_structured_dirty_error(self) -> None:
# Repo tracks a file, then the remote advances it. A local uncommitted
# edit to that file must never be clobbered by pull → structured dirty.
(self.repo / "tracked.txt").write_text("base", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
_git(self.repo, "push", "-q", "origin", "main")
# Advance the remote from a clean clone whose history descends from it.
other = self.base / "other"
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
_git(other, "config", "user.email", "test@example.com")
_git(other, "config", "user.name", "Test User")
(other / "tracked.txt").write_text("remote", encoding="utf-8")
_git(other, "add", "tracked.txt")
_git(other, "commit", "-q", "-m", "remote tracked")
_git(other, "push", "-q", "origin", "main")
# Local uncommitted change to tracked.txt → git refuses to clobber.
(self.repo / "tracked.txt").write_text("uncommitted", encoding="utf-8")
with self.assertRaises(git_state.GitError) as ctx:
git_state.pull(self.repo, "origin", "main")
self.assertEqual("dirty", ctx.exception.code)
def test_push_auth_failure_maps_to_auth_error(self) -> None:
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
git_state.commit(self.repo, "feature")
# Point origin at a host that will reject credentials.
_git(self.repo, "remote", "set-url", "origin", "https://invalid.invalid/x.git")
with self.assertRaises(git_state.GitError) as ctx:
git_state.push(self.repo, "origin", "main", confirmation=git_state.CONFIRM_PUSH)
self.assertIn(ctx.exception.code, ("auth", "network"))
class CheckoutTests(_MutationBase):
def test_checkout_switches_branch(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
git_state.checkout(self.repo, "feature", confirmation="")
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_checkout_new_branch_creates_branch(self) -> None:
git_state.checkout(
self.repo,
"main",
new_branch="exp",
confirmation="",
)
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_checkout_clean_tree_needs_no_confirmation(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
git_state.checkout(self.repo, "feature", confirmation="")
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_checkout_dirty_tree_requires_confirmation(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
with self.assertRaises(git_state.GitError) as ctx:
git_state.checkout(self.repo, "feature", confirmation="")
self.assertIn("confirmation", str(ctx.exception).lower())
def test_checkout_dirty_tree_wrong_confirmation_rejected(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
with self.assertRaises(git_state.GitError):
git_state.checkout(self.repo, "feature", confirmation="wrong")
def test_checkout_dirty_tree_with_confirmation_switches(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
git_state.checkout(
self.repo,
"feature",
confirmation=git_state.CONFIRM_DIRTY_CHECKOUT,
)
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_checkout_track_sets_upstream(self) -> None:
self.remote = _init_bare_remote(self.base, "remote-bare.git")
_git(self.repo, "remote", "add", "origin", str(self.remote))
_git(self.repo, "push", "-q", "origin", "main")
# Create origin/feature remotely via a second clone.
other = _init_repo(self.base, "other")
_git(other, "remote", "add", "origin", str(self.remote))
_git(other, "checkout", "-q", "-b", "feature")
(other / "feature.txt").write_text("f", encoding="utf-8")
_git(other, "add", "feature.txt")
_git(other, "commit", "-q", "-m", "feature")
_git(other, "push", "-q", "origin", "feature")
# Bring the remote-tracking ref into this repo, then track it.
git_state.fetch(self.repo, "origin")
git_state.checkout(
self.repo,
"origin/feature",
confirmation="",
track=True,
)
current = _git(self.repo, "symbolic-ref", "--short", "HEAD")
self.assertEqual("feature", current)
upstream = _git(self.repo, "rev-parse", "--abbrev-ref", "feature@{upstream}")
self.assertEqual("origin/feature", upstream)
class AllowlistTests(_MutationBase):
def test_mutations_reject_unknown_repo(self) -> None:
with self.assertRaises(git_state.GitStateError):
git_state.stage(Path("/nonexistent"), ["x.txt"])
with self.assertRaises(git_state.GitStateError):
git_state.discard(
Path("/nonexistent"),
["x.txt"],
confirmation=git_state.CONFIRM_DISCARD,
)
if __name__ == "__main__":
unittest.main()
+5 -2
View File
@@ -46,14 +46,17 @@ class MobilePluginStoreTests(unittest.TestCase):
manifest = self.store.manifest()
self.assertEqual("hermes-relay", manifest["id"])
contribution = manifest["contributions"][0]
contribution = next(c for c in manifest["contributions"] if c["id"] == "daily-brief")
self.assertEqual("Draft: Daily Brief", contribution["title"])
self.assertEqual("mobile/pages/daily-brief", contribution["document"]["path"])
self.assertEqual(_document(), self.store.get("daily-brief")["document"])
published = self.store.publish("daily-brief")
self.assertEqual("published", published["status"])
self.assertEqual("Daily Brief", self.store.manifest()["contributions"][0]["title"])
published_contribution = next(
c for c in self.store.manifest()["contributions"] if c["id"] == "daily-brief"
)
self.assertEqual("Daily Brief", published_contribution["title"])
self.assertEqual({"ok": True, "id": "daily-brief"}, self.store.remove("daily-brief"))
self.assertEqual([], self.store.list())
@@ -29,12 +29,14 @@ GATEWAY_TERMINAL = "gateway.message_complete"
GATEWAY_SETTLED_INFO = "gateway.settled_session_info"
SESSION_ACTIVATE = "gateway.session_activate_live"
SESSION_RESUME = "gateway.session_resume_durable"
SESSION_ACTIVE_LIST = "gateway.session_active_list"
API_BOUNDARY = "api.fallback_boundary"
ALL_CONTRACTS = (
GATEWAY_TERMINAL,
GATEWAY_SETTLED_INFO,
SESSION_ACTIVATE,
SESSION_RESUME,
SESSION_ACTIVE_LIST,
API_BOUNDARY,
)
@@ -286,6 +288,54 @@ def _check_resume(methods: SourceFile) -> CheckResult:
return CheckResult(contract, False, (), str(exc))
def _check_active_list(server: SourceFile, methods: SourceFile) -> CheckResult:
contract = SESSION_ACTIVE_LIST
try:
status = server.function("_session_live_status")
item = server.function("_session_live_item")
handler = methods.method_handler("session.active_list")
status_text = server.segment(status)
item_strings = _string_constants(item)
handler_text = methods.segment(handler)
missing_statuses = sorted(
{"starting", "working", "waiting", "idle"} - _string_constants(status)
)
if missing_statuses:
raise ValueError("live status missing state(s): " + ", ".join(missing_statuses))
pending_at = status_text.find("_session_pending_kind(")
running_at = status_text.find('.get("running")')
if pending_at < 0 or running_at < 0 or pending_at > running_at:
raise ValueError("waiting state no longer takes precedence over running")
missing_fields = sorted({"id", "session_key", "status"} - item_strings)
if missing_fields:
raise ValueError("active-list row missing field(s): " + ", ".join(missing_fields))
required_markers = ("_sessions_lock", "_sessions.items()", "_session_live_item(")
missing_markers = [marker for marker in required_markers if marker not in handler_text]
if missing_markers or "sessions" not in _string_constants(handler):
raise ValueError(
"session.active_list no longer snapshots the live registry: "
+ ", ".join(missing_markers or ["sessions result"])
)
handler_strings = _string_constants(handler)
if "current_session_id" not in handler_strings:
raise ValueError("session.active_list no longer accepts current_session_id")
if "profile" in handler_strings:
raise ValueError("session.active_list unexpectedly claims a profile filter")
return CheckResult(
contract,
True,
(
server.evidence(status, "starting, working, waiting, and idle derivation"),
server.evidence(item, "live row carries runtime and durable identities"),
methods.evidence(
handler, "active list snapshots the process-wide in-memory registry"
),
),
)
except ValueError as exc:
return CheckResult(contract, False, (), str(exc))
def _check_api_boundary(api: SourceFile) -> CheckResult:
contract = API_BOUNDARY
try:
@@ -365,6 +415,7 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
GATEWAY_SETTLED_INFO: lambda: _check_settled_info(server),
SESSION_ACTIVATE: lambda: _check_activate(server, methods),
SESSION_RESUME: lambda: _check_resume(methods),
SESSION_ACTIVE_LIST: lambda: _check_active_list(server, methods),
API_BOUNDARY: lambda: _check_api_boundary(api),
}
return [checks[requirement]() for requirement in requirements]
@@ -36,6 +36,26 @@ def _run_prompt_submit(sid, session, agent):
finally:
session["running"] = False
_emit_settled_session_info(sid, session, agent)
def _session_pending_kind(sid):
return "approval" if sid in _pending else ""
def _session_live_status(sid, session):
if _session_pending_kind(sid):
return "waiting"
ready = session.get("agent_ready")
if ready is not None and not ready.is_set() and session.get("agent_build_started"):
return "starting"
if session.get("running"):
return "working"
return "idle"
def _session_live_item(sid, session, current_sid=""):
return {
"id": sid,
"session_key": session.get("session_key", sid),
"status": _session_live_status(sid, session),
}
'''
METHODS_SOURCE = '''
@@ -67,6 +87,14 @@ def _(rid, params):
def _(rid, params):
session, error = _sess_nowait(params, rid)
return _live_session_payload(params["session_id"], session)
@method("session.active_list")
def _(rid, params):
current = str(params.get("current_session_id") or "")
with _sessions_lock:
snapshot = list(_sessions.items())
rows = [_session_live_item(sid, session, current) for sid, session in snapshot]
return _ok(rid, {"sessions": rows})
'''
API_SOURCE = '''
@@ -159,6 +187,25 @@ class GatewayScenarioConformanceTest(unittest.TestCase):
self.assertEqual((module.GATEWAY_SETTLED_INFO, module.SESSION_ACTIVATE), requirements)
def test_active_list_requires_waiting_to_outrank_working(self):
path = self.root / module.SERVER
reordered = SERVER_SOURCE.replace(
' if _session_pending_kind(sid):\n'
' return "waiting"\n'
' ready = session.get("agent_ready")',
' if session.get("running"):\n'
' return "working"\n'
' if _session_pending_kind(sid):\n'
' return "waiting"\n'
' ready = session.get("agent_ready")',
)
path.write_text(reordered, encoding="utf-8")
result = module.audit_sources(self.root, (module.SESSION_ACTIVE_LIST,))[0]
self.assertFalse(result.passed)
self.assertIn("precedence", result.problem)
def test_manifest_rejects_unknown_contract(self):
manifest = self.root / "scenario.json"
manifest.write_text(json.dumps({"requires": ["relay.private_route"]}), encoding="utf-8")
+21 -1
View File
@@ -47,7 +47,8 @@ distribution, and trust installation are deliberately outside this fixture.
- `POST /api/auth/ws-ticket` mints a fresh, single-use 30-second ticket.
- `GET /api/ws?ticket=...` upgrades to WebSocket and sends `gateway.ready`.
- JSON-RPC methods: `session.create`, `session.resume`, `session.activate`,
`prompt.submit`, and `session.interrupt`.
`session.active_list`, `prompt.submit`, and `session.interrupt` when the
selected scenario enables them.
- `GET /api/sessions/{stored-id}/messages` returns persisted, paginated history
and accepts the upstream `profile`, `limit`, `offset`, and `order` query shape.
- Unknown RPC methods return JSON-RPC `-32601`; wrong live/durable identities
@@ -70,6 +71,13 @@ ordered `steps` list using these operations:
| `set_running` | Change the authoritative session running state. |
| `close` | Create a fixture-controlled socket gap without replaying later frames. |
An optional `active_list` object scripts process-wide live-runtime snapshots.
`supported: false` returns JSON-RPC `-32601`, matching an older Gateway.
`supported: true` returns each declared `snapshots` entry in order and retains
the final successful snapshot for later polls. Rows use upstream's
`starting`/`working`/`waiting`/`idle` vocabulary. A successful empty snapshot
is therefore distinct from a failed or unsupported refresh.
Every bundled manifest also declares a top-level `contract_requirements` string
array. Its values use the contract names accepted by the on-demand upstream
conformance adapter (for example, `gateway.settled_session_info` and
@@ -80,6 +88,18 @@ The initial catalog covers ordinary streaming, rapid chunks/reasoning/tool
events, queued turns, scoped and foreign/unscoped inputs, persisted history,
and both issue #365 terminal-gap forms:
- `active_status_lifecycle`: one successful live snapshot contains starting,
working, waiting, and idle rows; the next successful snapshot is empty so a
client can prove a complete, unambiguously resolved snapshot clears prior
live state.
- `active_status_profile_scope`: a process-wide row has no profile metadata and
ignores a caller-supplied profile hint. Client adapters must resolve it from
exact foreground/detached ownership already held by that client (or future
explicit upstream profile metadata); a bounded directory must not invent an
owner from apparent uniqueness.
- `active_status_unsupported`: `session.active_list` returns method-not-found so
older-host fallback remains explicit rather than being mistaken for Idle.
- `terminal_gap_activate`: live deltas arrive, history persists, the socket
closes before `message.complete`, and replacement `session.activate` reports
the exact live session with `running=false`. A bounded two-second fixture delivery
@@ -200,6 +200,47 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
self.assertTrue(any(event.get("session_id") == "fixture-foreign-session" for event in events))
self.assertEqual(fixture.scenario.live_session_id, events[-1]["session_id"])
async def test_active_list_exposes_all_live_states_then_authoritative_absence(self) -> None:
_, base_url = await self.start("active_status_lifecycle")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "session.active_list", {"current_session_id": "fixture-live-working"})
first = (await ws.receive_json())["result"]["sessions"]
self.assertEqual(
["starting", "working", "waiting", "idle"],
[row["status"] for row in first],
)
await self.rpc(ws, 2, "session.active_list", {"current_session_id": "fixture-live-working"})
second = (await ws.receive_json())["result"]["sessions"]
self.assertEqual([], second)
# An exhausted script remains on its last successful snapshot so polling
# cannot accidentally resurrect an earlier live row.
await self.rpc(ws, 3, "session.active_list")
third = (await ws.receive_json())["result"]["sessions"]
self.assertEqual([], third)
async def test_active_list_rows_require_client_owned_profile_resolution(self) -> None:
fixture, base_url = await self.start("active_status_profile_scope")
ws, _ = await self.connect(base_url)
# Upstream accepts current_session_id, not a profile filter. The fixture
# deliberately ignores this extra hint and returns an unscoped row.
await self.rpc(ws, 1, "session.active_list", {"profile": "default"})
rows = (await ws.receive_json())["result"]["sessions"]
self.assertEqual("research", fixture.scenario.profile)
self.assertEqual("fixture-shared-stored-session", rows[0]["session_key"])
self.assertNotIn("profile", rows[0])
async def test_active_list_unsupported_is_explicit_method_not_found(self) -> None:
_, base_url = await self.start("active_status_unsupported")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "session.active_list")
frame = await ws.receive_json()
self.assertEqual(-32601, frame["error"]["code"])
async def test_evidence_is_bounded_and_contains_no_rpc_payloads(self) -> None:
_, base_url = await self.start("ordinary_turn")
ws, _ = await self.connect(base_url)
@@ -221,6 +262,9 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
class ScenarioTestCase(unittest.TestCase):
def test_all_bundled_scenarios_validate(self) -> None:
for name in (
"active_status_lifecycle",
"active_status_profile_scope",
"active_status_unsupported",
"ordinary_turn",
"rapid_tools_interims",
"terminal_gap_activate",
@@ -232,6 +276,21 @@ class ScenarioTestCase(unittest.TestCase):
self.assertEqual(name, scenario.name)
self.assertTrue(scenario.contract_requirements)
def test_active_list_scenario_rejects_unknown_status(self) -> None:
scenario = {
"name": "invalid_activity",
"live_session_id": "live",
"stored_session_id": "stored",
"turns": [],
"active_list": {
"supported": True,
"snapshots": [[{"id": "live", "session_key": "stored", "status": "recent"}]],
},
}
with self.assertRaisesRegex(ScenarioError, "invalid status"):
from vanilla_gateway.scenario import Scenario
Scenario.from_dict(scenario)
def test_terminal_gap_manifests_select_upstream_contracts(self) -> None:
self.assertEqual(
(
@@ -15,6 +15,7 @@ class ScenarioError(ValueError):
_STEP_OPS = {"event", "persist", "sleep", "close", "set_running"}
_LIVE_STATUSES = {"starting", "working", "waiting", "idle"}
_SAFE_NAME = re.compile(r"[A-Za-z0-9_.-]{1,120}")
@@ -27,6 +28,8 @@ class Scenario:
contract_requirements: tuple[str, ...]
initial_history: tuple[dict[str, Any], ...]
turns: tuple[dict[str, Any], ...]
active_list_supported: bool
active_list_snapshots: tuple[tuple[dict[str, Any], ...], ...]
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Scenario":
@@ -34,8 +37,8 @@ class Scenario:
missing = [key for key in required if key not in raw]
if missing:
raise ScenarioError(f"missing scenario fields: {', '.join(missing)}")
if not isinstance(raw["turns"], list) or not raw["turns"]:
raise ScenarioError("turns must be a non-empty list")
if not isinstance(raw["turns"], list):
raise ScenarioError("turns must be a list")
if not isinstance(raw["name"], str) or not _SAFE_NAME.fullmatch(raw["name"]):
raise ScenarioError("name must be a short metadata-safe scenario identifier")
for turn_index, turn in enumerate(raw["turns"]):
@@ -76,6 +79,41 @@ class Scenario:
raise ScenarioError("contract_requirements must be a list of non-empty strings")
if len(set(requirements)) != len(requirements):
raise ScenarioError("contract_requirements must not contain duplicates")
active_list = raw.get("active_list", {})
if not isinstance(active_list, dict):
raise ScenarioError("active_list must be an object")
active_list_supported = active_list.get("supported", False)
if not isinstance(active_list_supported, bool):
raise ScenarioError("active_list supported must be a boolean")
snapshots = active_list.get("snapshots", [])
if not isinstance(snapshots, list):
raise ScenarioError("active_list snapshots must be a list")
if not active_list_supported and snapshots:
raise ScenarioError("unsupported active_list cannot declare snapshots")
validated_snapshots: list[tuple[dict[str, Any], ...]] = []
for snapshot_index, snapshot in enumerate(snapshots):
if not isinstance(snapshot, list):
raise ScenarioError(f"active_list snapshot {snapshot_index} must be a list")
validated_rows: list[dict[str, Any]] = []
for row_index, row in enumerate(snapshot):
if not isinstance(row, dict):
raise ScenarioError(
f"active_list snapshot {snapshot_index} row {row_index} must be an object"
)
if not isinstance(row.get("id"), str) or not row["id"]:
raise ScenarioError(
f"active_list snapshot {snapshot_index} row {row_index} requires an id"
)
if not isinstance(row.get("session_key"), str) or not row["session_key"]:
raise ScenarioError(
f"active_list snapshot {snapshot_index} row {row_index} requires a session_key"
)
if row.get("status") not in _LIVE_STATUSES:
raise ScenarioError(
f"active_list snapshot {snapshot_index} row {row_index} has invalid status"
)
validated_rows.append(dict(row))
validated_snapshots.append(tuple(validated_rows))
return cls(
name=str(raw["name"]),
live_session_id=str(raw["live_session_id"]),
@@ -84,6 +122,8 @@ class Scenario:
contract_requirements=tuple(requirements),
initial_history=tuple(dict(row) for row in history),
turns=tuple(dict(turn) for turn in raw["turns"]),
active_list_supported=active_list_supported,
active_list_snapshots=tuple(validated_snapshots),
)
@@ -0,0 +1,23 @@
{
"name": "active_status_lifecycle",
"live_session_id": "fixture-live-working",
"stored_session_id": "fixture-shared-stored-session",
"profile": "default",
"contract_requirements": [
"gateway.session_active_list"
],
"initial_history": [],
"turns": [],
"active_list": {
"supported": true,
"snapshots": [
[
{"id": "fixture-live-starting", "session_key": "fixture-starting", "status": "starting", "current": false},
{"id": "fixture-live-working", "session_key": "fixture-shared-stored-session", "status": "working", "current": true},
{"id": "fixture-live-waiting", "session_key": "fixture-waiting", "status": "waiting", "current": false},
{"id": "fixture-live-idle", "session_key": "fixture-idle", "status": "idle", "current": false}
],
[]
]
}
}
@@ -0,0 +1,19 @@
{
"name": "active_status_profile_scope",
"live_session_id": "fixture-live-research",
"stored_session_id": "fixture-shared-stored-session",
"profile": "research",
"contract_requirements": [
"gateway.session_active_list"
],
"initial_history": [],
"turns": [],
"active_list": {
"supported": true,
"snapshots": [
[
{"id": "fixture-live-research", "session_key": "fixture-shared-stored-session", "status": "waiting", "current": true}
]
]
}
}
@@ -0,0 +1,15 @@
{
"name": "active_status_unsupported",
"live_session_id": "fixture-live-unsupported",
"stored_session_id": "fixture-stored-unsupported",
"profile": "default",
"contract_requirements": [
"gateway.session_active_list"
],
"initial_history": [],
"turns": [],
"active_list": {
"supported": false,
"snapshots": []
}
}
@@ -42,6 +42,11 @@ class GatewayFixture:
self._tickets: set[str] = set()
self._history_rows = [dict(row) for row in scenario.initial_history]
self._turns = deque(dict(turn) for turn in scenario.turns)
self._active_list_snapshots = deque(
[dict(row) for row in snapshot]
for snapshot in scenario.active_list_snapshots
)
self._last_active_list_snapshot: list[dict[str, Any]] = []
self._queued: deque[_QueuedTurn] = deque()
self._running = False
self._turn_active = False
@@ -137,6 +142,13 @@ class GatewayFixture:
elif method == "session.interrupt":
self._running = False
result = {"ok": True}
elif method == "session.active_list" and self.scenario.active_list_supported:
if self._active_list_snapshots:
self._last_active_list_snapshot = self._active_list_snapshots.popleft()
result = {"sessions": [dict(row) for row in self._last_active_list_snapshot]}
self.evidence.add(
"activity", connection=connection, method=method, outcome="snapshot",
)
else:
await self._rpc_error(socket, request_id, -32601, f"Method not found: {method}")
return
@@ -290,6 +302,8 @@ class GatewayFixture:
"remaining_turns": len(self._turns),
"queued_turns": len(self._queued),
"history_rows": len(self._history_rows),
"profile": self.scenario.profile,
"remaining_active_list_snapshots": len(self._active_list_snapshots),
},
)