Skip to content

Commit 65f22e2

Browse files
Codename-11claude
andcommitted
Merge origin/dev into dev (adopt compileSdk 37, integrate typed stream events)
Catch up the local 1.2.2 work with origin/dev, which moved to compileSdk 37 (206d182) and added typed stream.event passthrough (PR #120). Dropped the local markdown-renderer 0.41.0 / lifecycle 2.10.0 pins (a compileSdk-36 workaround) for compileSdk 37 + the 0.42.0 / 2.11.0 deps origin adopted. Kept the 1.2.2 version bump (code 16) and all feature/fix work; both 2026-06-22 DEVLOG entries retained. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 36b05b6 + 0dfc581 commit 65f22e2

17 files changed

Lines changed: 773 additions & 26 deletions

File tree

‎DEVLOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@
1414

1515
**Follow-up (same session) — cold-start profile-isolation race (`889273a`).** A user-reported sibling of the session-delete bug: on cold start the session drawer (and the restored session context) briefly loaded the SERVER-DEFAULT profile's sessions, then visibly snapped to the persisted profile. Root cause: the `activeConnectionId` observer stamps the persisted profile name pending, calls `resolvePendingProfileFrom(agentProfiles.value)` (empty at that point), then `rebuildChatApiClient()` — so `chatClientReady` flips true and the `RelayApp` `LaunchedEffect` fires the first `refreshSessions()` with a null (server-default) profile *before* the per-connection profile list arrives to resolve the selection; the list lands a tick later, re-resolves, and re-fetches correctly (the "self-reload"). Fix: new `ProfileController.selectionSettled` StateFlow — true once the selection resolved, OR no non-default profile is pending, OR the profile list has arrived (resolution attempted, so a genuinely-missing profile falls back to default rather than gating forever) — exposed via `ConnectionViewModel.profileSelectionSettled` and added as a key + gate to the cold-start effect. While unsettled the first load waits on a 2.5s backstop; the effect re-fires the instant the profile resolves, cancelling the wait so only the correct profile-scoped load lands, and the backstop prevents a permanently-empty drawer if the list never arrives. Same effect also defers the per-profile session-context/transcript restore. Other profile-scoped surfaces (voice prefs, display alias, profile icon) read the live `selectedProfile` and self-correct on resolution without a visible content-flash; gating them on `selectionSettled` is noted as a follow-up. Unbuilt — verify the cold-start drawer on device.
1616

17+
## 2026-06-22 — Typed stream.event Relay passthrough first slice
18+
19+
**Why.** AXI-75 asks Relay/native clients to stop flattening Hermes SSE into assistant text and preserve runtime structure for native UI cards/timelines.
20+
21+
- **Protocol + fixture.** `docs/relay-protocol.md` now defines auth capability negotiation (`supports.typed_stream_events` + `event_schema_version: 1`), the versioned `chat`/`stream.event` envelope, stable event families, ordering/de-dupe semantics, payload safety, fallback behavior, and native rendering guidance. Added `docs/fixtures/typed-stream-v1.jsonl` as a golden tool-using stream.
22+
- **Relay server.** `plugin/relay/server.py` records per-WebSocket client capabilities during `system/auth` and passes them to `ChatHandler`. `plugin/relay/channels/chat.py` now forwards Hermes/API-server SSE as ordered `stream.event` payloads for capable clients, emits final `done`, redacts secret-shaped keys, truncates large result fields, and keeps legacy `chat.delta`/`chat.tool.*`/`chat.completed` fallback for old clients.
23+
- **Native clients.** Android and Desktop auth envelopes advertise typed-stream support. Android gained `RelayStreamEventEnvelope` plus `ChatHandler.applyRelayStreamEvent()` that maps typed events to existing native assistant text, thinking/progress, tool-card, artifact/memory/skill chip, error, and completion state.
24+
- **Verification.** `PYTHONPATH=$PWD python -m unittest discover -s plugin/tests -p test_chat_typed_stream.py` green (typed ordering/final done/redaction + legacy fallback). `python -m py_compile plugin/relay/channels/chat.py plugin/relay/server.py plugin/tests/test_chat_typed_stream.py` green. `desktop/npm ci` then `npm run type-check` green. Android unit task was attempted with `ANDROID_HOME=/home/bailey/Android/Sdk ./gradlew :app:testSideloadDebugUnitTest --tests ...`; it is blocked before Kotlin compile by the current dependency/SDK mismatch (AAR metadata requires compileSdk 37; installed SDK only has android-36). Follow-up commits bump app/relay-core/relay-ui/quest compileSdk to 37 to satisfy current AndroidX/Markdown AAR metadata in CI without changing targetSdk.
25+
1726
## 2026-06-22 — Released plugin-v1.2.1
1827

1928
Cut the Plugin 1.2.1 release — a Realtime Agent reliability patch. Both fixes were already on `dev`: the `session_not_found` brokered-handoff fix (`f6b965a`) and the realtime voice heartbeat-during-long-runs fix (`d1820fb`); 1.2.1 only adds the version bump and release packaging. Release-prep bumped the six plugin version sources via `scripts/bump-plugin-version.sh` (sync check green), folded the relay `session_not_found` fix into the existing `[1.2.1]` `CHANGELOG.md` line (the Desktop-CLI entries stay under `[Unreleased]` for their own `cli-v*` cut), and rewrote `PLUGIN_RELEASE_NOTES.md` as a Fixed-only release body.

‎TODO.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ When the answer becomes clearer, this section becomes either an ADR in `docs/dec
182182
- **LLM client wiring for `android_navigate`** — `_default_vision_model` is stubbed; production swap to a real Anthropic/OpenAI vision client
183183
- **Real screenshots of each flavor's a11y permission dialog** — for `user-docs/guide/release-tracks.md`
184184
- `**llms.txt` standard** — explicitly skipped in favor of the `hermes-relay-self-setup` SKILL.md path; revisit if the standard gains traction in the agent ecosystem
185-
- `**markdown-renderer` pinned at `0.41.0` (compileSdk ceiling)** — `MarkdownContent.kt` is now on the 0.4x API (`MarkdownHighlightedCodeBlock`/`Fence`, m3 `markdownColor`, `Highlights.Builder`), so the old 0.30.0 API blocker is resolved. The current ceiling is **build-tooling, not API**: `0.42.0` (Dependabot #117, `f8141a6`, merged to `dev` 2026-06-22 after the 1.2.1 release) requires `compileSdk 37`, but we target `compileSdk 36` (only `android-36`/`36.1` SDK platforms installed) — it broke the `dev` build and was reverted to `0.41.0` during 1.2.2 prep. **Do not bump `markdown-renderer` past 0.41.0 without also bumping `compileSdk` to 37** (installing the platform + clearing CLAUDE.md's "Compile SDK 36"). Add a Dependabot ignore rule for `markdown-renderer` to stop the recurring break (see next item).
185+
- `**markdown-renderer`/`lifecycle` compileSdk ceiling — RESOLVED via compileSdk 37 (2026-06-22).** `MarkdownContent.kt` is on the 0.4x API, and `markdown-renderer 0.42.0` / `lifecycle 2.11.0` (the Dependabot bumps) require `compileSdk 37`. The project moved to **compileSdk 37** (`206d182`, across app/quest/relay-core/relay-ui; `targetSdk` stays 35), which satisfies them — so the temporary 1.2.2-prep pins (0.41.0 / 2.10.0 on compileSdk 36) were dropped when integrating `origin/dev`. **CLAUDE.md still says "Compile SDK 36" — update it to 37 to match the build.** A Dependabot ignore rule is still worth adding so a future bump that raises the compileSdk floor again fails loudly rather than silently (see next item).
186186
- **Dependabot auto-merge guardrails** — Dependabot merged breaking bumps despite CI failing. Investigate why `.github/workflows/dependabot-auto-merge.yml` isn't gating on CI status, and consider adding an ignore rule for packages we know need manual attention on major bumps (`markdown-renderer`, compose BOM, activity-compose).
187187

188188
---

‎app/build.gradle.kts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ android {
2727
// and `applicationId` is the runtime install identity; they don't have
2828
// to match.
2929
namespace = "com.hermesandroid.relay"
30-
compileSdk = 36
30+
compileSdk = 37
3131

3232
defaultConfig {
3333
// Axiom-Labs, LLC Play Console listing. Changed from the original

‎app/src/main/kotlin/com/hermesandroid/relay/auth/AuthManager.kt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
2222
import kotlinx.serialization.json.Json
2323
import kotlinx.serialization.json.JsonArray
2424
import kotlinx.serialization.json.JsonObject
25+
import kotlinx.serialization.json.JsonObjectBuilder
2526
import kotlinx.serialization.json.JsonPrimitive
2627
import kotlinx.serialization.json.buildJsonObject
2728
import kotlinx.serialization.json.booleanOrNull
@@ -702,6 +703,18 @@ class AuthManager(
702703
pendingEndpoints = endpoints?.takeIf { it.isNotEmpty() }
703704
}
704705

706+
/**
707+
* Capability negotiation advertised in the first system/auth envelope.
708+
* Older relays ignore this object; newer relays use it to send versioned
709+
* `chat:stream.event` payloads instead of flattening Hermes SSE into text.
710+
*/
711+
private fun JsonObjectBuilder.putRelayClientSupports() {
712+
put("supports", buildJsonObject {
713+
put("typed_stream_events", true)
714+
put("event_schema_version", 1)
715+
})
716+
}
717+
705718
/**
706719
* Send auth envelope when connection is established.
707720
*
@@ -737,6 +750,7 @@ class AuthManager(
737750
}
738751
put("device_id", deviceId)
739752
put("device_name", android.os.Build.MODEL)
753+
putRelayClientSupports()
740754
}
741755
}
742756
else -> {
@@ -752,6 +766,7 @@ class AuthManager(
752766
put("pairing_code", codeToSend)
753767
put("device_id", deviceId)
754768
put("device_name", android.os.Build.MODEL)
769+
putRelayClientSupports()
755770
pendingTtlSeconds?.let { put("ttl_seconds", it) }
756771
pendingGrants?.let { grants ->
757772
val obj = buildJsonObject {

‎app/src/main/kotlin/com/hermesandroid/relay/network/upstream/ChatHandler.kt‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import com.hermesandroid.relay.data.VoiceIntentTrace
1212
import com.hermesandroid.relay.network.shared.LocalDispatchResult
1313
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
1414
import com.hermesandroid.relay.network.upstream.models.MessageItem
15+
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
1516
import com.hermesandroid.relay.network.upstream.models.SessionItem
1617
import kotlinx.coroutines.flow.MutableStateFlow
1718
import kotlinx.coroutines.flow.StateFlow
@@ -227,6 +228,78 @@ class ChatHandler {
227228
private val _currentSessionId = MutableStateFlow<String?>(null)
228229
val currentSessionId: StateFlow<String?> = _currentSessionId.asStateFlow()
229230

231+
232+
/**
233+
* Apply a versioned Relay `stream.event` payload to native chat state.
234+
*
235+
* This is the WebSocket counterpart to the direct Hermes SSE mapper in
236+
* HermesApiClient: assistant deltas mutate message text, tool lifecycle
237+
* events update ToolProgressCard rows, progress/thinking stays in the
238+
* subdued reasoning area, artifacts/skill/memory notices become low-noise
239+
* status chips, and terminal/error/completion events explicitly settle the
240+
* streaming state.
241+
*/
242+
fun applyRelayStreamEvent(messageId: String, envelope: RelayStreamEventEnvelope) {
243+
if (envelope.type != "stream.event" || envelope.schemaVersion != 1) {
244+
Log.d(TAG, "Ignoring unsupported relay stream event schema: ${envelope.type} v${envelope.schemaVersion}")
245+
return
246+
}
247+
val payload = envelope.payload
248+
fun textField(vararg names: String): String? = names
249+
.asSequence()
250+
.mapNotNull { name -> (payload[name] as? JsonPrimitive)?.contentOrNull }
251+
.firstOrNull { it.isNotBlank() }
252+
fun boolField(name: String): Boolean? = (payload[name] as? JsonPrimitive)?.booleanOrNull
253+
val toolName = textField("tool_name", "tool", "name") ?: "unknown"
254+
val callId = textField("call_id", "tool_call_id") ?: toolName
255+
256+
when (envelope.event) {
257+
"message.started" -> {
258+
val msgObj = payload["message"] as? JsonObject
259+
val serverMsgId = (msgObj?.get("id") as? JsonPrimitive)?.contentOrNull
260+
if (!serverMsgId.isNullOrBlank()) replaceMessageId(messageId, serverMsgId)
261+
}
262+
"assistant.delta" -> {
263+
textField("delta", "content", "text")?.let { onTextDelta(messageId, it) }
264+
}
265+
"tool.progress" -> {
266+
textField("delta", "thinking_delta", "thinking", "text", "message")?.let {
267+
onThinkingDelta(messageId, it)
268+
}
269+
}
270+
"tool.pending", "tool.started" -> onToolCallStart(messageId, callId, toolName)
271+
"tool.completed" -> onToolCallComplete(messageId, callId, textField("result_preview", "summary", "message"))
272+
"tool.failed" -> onToolCallFailed(messageId, callId, textField("error", "message") ?: "Tool failed")
273+
"memory.updated", "skill.loaded" -> {
274+
val label = when (envelope.event) {
275+
"memory.updated" -> "Memory"
276+
else -> "Skill"
277+
}
278+
addMessageBadges(messageId, listOf(label))
279+
}
280+
"artifact.created" -> {
281+
addMessageBadges(messageId, listOf("Artifact"))
282+
textField("url", "path", "preview", "title")?.takeIf { it.isNotBlank() }?.let {
283+
onThinkingDelta(messageId, "Artifact: $it")
284+
}
285+
}
286+
"assistant.completed" -> {
287+
if (boolField("interrupted") == true) {
288+
onStreamError("Response interrupted")
289+
} else {
290+
onTurnComplete(messageId)
291+
}
292+
}
293+
"run.completed", "done" -> onStreamComplete(messageId)
294+
"error" -> {
295+
addMessageBadges(messageId, listOf("Error"))
296+
onStreamError(textField("message", "error") ?: "Unknown error")
297+
}
298+
"session.created", "run.started" -> Unit
299+
else -> Log.d(TAG, "Unhandled relay stream event: ${envelope.event}")
300+
}
301+
}
302+
230303
// --- Message management ---
231304

232305
fun addUserMessage(message: ChatMessage) {
@@ -1964,6 +2037,22 @@ class ChatHandler {
19642037
}
19652038
}
19662039

2040+
private fun addMessageBadges(messageId: String, badges: List<String>) {
2041+
val cleaned = badges
2042+
.map { it.trim() }
2043+
.filter { it.isNotEmpty() }
2044+
if (cleaned.isEmpty()) return
2045+
_messages.update { messages ->
2046+
messages.map { msg ->
2047+
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
2048+
msg.copy(badges = (msg.badges + cleaned).distinct().take(4))
2049+
} else {
2050+
msg
2051+
}
2052+
}
2053+
}
2054+
}
2055+
19672056
/** Monotonic suffix for synthetic generating / subagent ToolCall ids. */
19682057
private var syntheticToolSeq = 0
19692058

‎app/src/main/kotlin/com/hermesandroid/relay/network/upstream/models/SessionModels.kt‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,23 @@ data class MessageItem(
261261
// error — { message (string), error }
262262
// done — { session_id, run_id, state: "final" }
263263

264+
265+
@Serializable
266+
data class RelayStreamEventEnvelope(
267+
val type: String = "stream.event",
268+
@SerialName("schema_version") val schemaVersion: Int = 1,
269+
@SerialName("session_id")
270+
@Serializable(with = FlexibleIdSerializer::class)
271+
val sessionId: String? = null,
272+
@SerialName("run_id")
273+
@Serializable(with = FlexibleIdSerializer::class)
274+
val runId: String? = null,
275+
val seq: Int? = null,
276+
val event: String,
277+
val ts: String? = null,
278+
val payload: JsonObject = kotlinx.serialization.json.buildJsonObject { },
279+
)
280+
264281
@Serializable
265282
data class HermesSseEvent(
266283
// Event type — may come as "type" or "event" depending on server version

‎app/src/test/kotlin/com/hermesandroid/relay/network/upstream/ChatHandlerTest.kt‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import com.hermesandroid.relay.data.RealtimeTurnTrace
88
import com.hermesandroid.relay.data.ToolCall
99
import com.hermesandroid.relay.data.VoiceIntentTrace
1010
import com.hermesandroid.relay.network.upstream.models.MessageItem
11+
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
1112
import com.hermesandroid.relay.network.upstream.models.SessionItem
1213
import kotlinx.serialization.json.JsonPrimitive
14+
import kotlinx.serialization.json.buildJsonObject
15+
import kotlinx.serialization.json.put
1316
import org.junit.Assert.assertEquals
1417
import org.junit.Assert.assertFalse
1518
import org.junit.Assert.assertNotNull
@@ -1196,6 +1199,128 @@ class ChatHandlerTest {
11961199
assertNull(handler.messages.value[0].voiceIntent)
11971200
}
11981201

1202+
1203+
// --- Relay typed stream.event rendering ---
1204+
1205+
@Test
1206+
fun applyRelayStreamEvent_rendersAssistantDeltaToolLifecycleAndDone() {
1207+
handler.addPlaceholderMessage(
1208+
ChatMessage(
1209+
id = "assist-relay",
1210+
role = MessageRole.ASSISTANT,
1211+
content = "",
1212+
timestamp = 1L,
1213+
isStreaming = true,
1214+
)
1215+
)
1216+
1217+
handler.applyRelayStreamEvent(
1218+
"assist-relay",
1219+
RelayStreamEventEnvelope(
1220+
sessionId = "sess-1",
1221+
runId = "run-1",
1222+
seq = 1,
1223+
event = "assistant.delta",
1224+
payload = buildJsonObject { put("delta", "Hello") },
1225+
),
1226+
)
1227+
handler.applyRelayStreamEvent(
1228+
"assist-relay",
1229+
RelayStreamEventEnvelope(
1230+
sessionId = "sess-1",
1231+
runId = "run-1",
1232+
seq = 2,
1233+
event = "tool.started",
1234+
payload = buildJsonObject {
1235+
put("tool_name", "terminal")
1236+
put("call_id", "call-1")
1237+
},
1238+
),
1239+
)
1240+
handler.applyRelayStreamEvent(
1241+
"assist-relay",
1242+
RelayStreamEventEnvelope(
1243+
sessionId = "sess-1",
1244+
runId = "run-1",
1245+
seq = 3,
1246+
event = "tool.completed",
1247+
payload = buildJsonObject {
1248+
put("tool_name", "terminal")
1249+
put("call_id", "call-1")
1250+
put("result_preview", "ok")
1251+
},
1252+
),
1253+
)
1254+
handler.applyRelayStreamEvent(
1255+
"assist-relay",
1256+
RelayStreamEventEnvelope(
1257+
sessionId = "sess-1",
1258+
runId = "run-1",
1259+
seq = 4,
1260+
event = "done",
1261+
payload = buildJsonObject { put("state", "final") },
1262+
),
1263+
)
1264+
1265+
val msg = handler.messages.value.single()
1266+
assertEquals("Hello", msg.content)
1267+
assertFalse(msg.isStreaming)
1268+
assertEquals(1, msg.toolCalls.size)
1269+
assertEquals("terminal", msg.toolCalls[0].name)
1270+
assertTrue(msg.toolCalls[0].isComplete)
1271+
assertEquals("ok", msg.toolCalls[0].result)
1272+
}
1273+
1274+
@Test
1275+
fun applyRelayStreamEvent_rendersProgressArtifactAndErrorStates() {
1276+
handler.addPlaceholderMessage(
1277+
ChatMessage(
1278+
id = "assist-relay",
1279+
role = MessageRole.ASSISTANT,
1280+
content = "",
1281+
timestamp = 1L,
1282+
isStreaming = true,
1283+
)
1284+
)
1285+
1286+
handler.applyRelayStreamEvent(
1287+
"assist-relay",
1288+
RelayStreamEventEnvelope(
1289+
sessionId = "sess-1",
1290+
runId = "run-1",
1291+
seq = 1,
1292+
event = "tool.progress",
1293+
payload = buildJsonObject { put("delta", "Thinking...") },
1294+
),
1295+
)
1296+
handler.applyRelayStreamEvent(
1297+
"assist-relay",
1298+
RelayStreamEventEnvelope(
1299+
sessionId = "sess-1",
1300+
runId = "run-1",
1301+
seq = 2,
1302+
event = "artifact.created",
1303+
payload = buildJsonObject { put("url", "https://example.invalid/artifact") },
1304+
),
1305+
)
1306+
handler.applyRelayStreamEvent(
1307+
"assist-relay",
1308+
RelayStreamEventEnvelope(
1309+
sessionId = "sess-1",
1310+
runId = "run-1",
1311+
seq = 3,
1312+
event = "error",
1313+
payload = buildJsonObject { put("message", "boom") },
1314+
),
1315+
)
1316+
1317+
val msg = handler.messages.value.single()
1318+
assertTrue(msg.thinkingContent.contains("Thinking..."))
1319+
assertTrue(msg.thinkingContent.contains("Artifact:"))
1320+
assertTrue(msg.badges.contains("Error"))
1321+
assertEquals("boom", handler.error.value)
1322+
}
1323+
11991324
// --- Helper ---
12001325

12011326
private fun createUserMessage(id: String, content: String) = ChatMessage(

‎desktop/src/transport/RelayTransport.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,11 @@ export class RelayTransport extends EventEmitter implements Transport {
482482
payload.ttl_seconds = this.cfg.ttlSeconds
483483
}
484484

485+
payload.supports = {
486+
typed_stream_events: true,
487+
event_schema_version: 1
488+
}
489+
485490
this.sendEnvelope('system', 'auth', payload)
486491
}
487492

0 commit comments

Comments
 (0)