Skip to content

Commit bd7df2d

Browse files
wow-mileyclaude
andcommitted
AMPR-150 #459: add AgentSurface primitive type system
I added the AgentSurface sealed hierarchy (Form, Choice, Confirmation, Card) with companion AgentSurfaceField, AgentSurfaceFieldValue, AgentSurfaceResponse, and AgentSurfaceEvent (Requested/Responded) types in commonMain, plus a typed awaitSurfaceResponse(correlationId, timeout) helper over EventSerialBus. I registered the new event types, extended the existing significance-aware loggers and CLI renderer for the new variants, and wrote round-trip serialization, validation, and bus integration tests. I also documented the contract, lifecycle, and renderer requirements in docs/ampere/agent-surface.md. Closes #459 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent adbb2b1 commit bd7df2d

13 files changed

Lines changed: 1214 additions & 1 deletion

File tree

ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/EventCategorizer.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package link.socket.ampere.cli.watch.presentation
22

3+
import link.socket.ampere.agents.domain.event.AgentSurfaceEvent
34
import link.socket.ampere.agents.domain.event.Event
45
import link.socket.ampere.agents.domain.event.FileSystemEvent
56
import link.socket.ampere.agents.domain.event.GitEvent
@@ -89,7 +90,9 @@ object EventCategorizer {
8990
is TicketEvent.TicketStatusChanged,
9091
is TicketEvent.TicketAssigned,
9192
is TicketEvent.TicketCompleted,
92-
is TicketEvent.TicketMeetingScheduled -> EventSignificance.SIGNIFICANT
93+
is TicketEvent.TicketMeetingScheduled,
94+
is AgentSurfaceEvent.Requested,
95+
is AgentSurfaceEvent.Responded -> EventSignificance.SIGNIFICANT
9396

9497
// Routine cognitive operations - maintenance work
9598
is GitEvent.BranchCreated,

ampere-cli/src/jvmMain/kotlin/link/socket/ampere/renderer/EventRenderer.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import com.github.ajalt.mordant.terminal.Terminal
1212
import kotlinx.datetime.TimeZone
1313
import kotlinx.datetime.toLocalDateTime
1414
import link.socket.ampere.agents.domain.Urgency
15+
import link.socket.ampere.agents.domain.event.AgentSurfaceEvent
1516
import link.socket.ampere.agents.domain.event.Event
1617
import link.socket.ampere.agents.domain.event.EventSource
1718
import link.socket.ampere.agents.domain.event.FileSystemEvent
@@ -156,6 +157,9 @@ class EventRenderer(
156157
is SparkRemovedEvent -> SparkColors.SparkIcons.REMOVED to gray
157158
is CognitiveStateSnapshot -> SparkColors.SparkIcons.SNAPSHOT to magenta
158159
is SparkEvent -> SparkColors.SparkIcons.APPLIED to magenta // fallback
160+
// AgentSurface events: native UI surface request/response
161+
is AgentSurfaceEvent.Requested -> "🪟" to magenta
162+
is AgentSurfaceEvent.Responded -> "🪟" to blue
159163
}
160164
}
161165

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package link.socket.ampere.agents.domain.event
2+
3+
import kotlinx.datetime.Instant
4+
import kotlinx.serialization.Serializable
5+
import link.socket.ampere.agents.domain.Urgency
6+
import link.socket.ampere.agents.events.surface.AgentSurface
7+
import link.socket.ampere.agents.events.surface.AgentSurfaceResponse
8+
import link.socket.ampere.agents.events.surface.CorrelationId
9+
10+
/**
11+
* Bus-level events that carry [AgentSurface] traffic.
12+
*
13+
* Plugin code emits a [Requested] when it needs to render UI; the platform
14+
* renderer replies with a [Responded] carrying the same correlation id. The
15+
* matching [link.socket.ampere.agents.events.surface.awaitSurfaceResponse]
16+
* helper consumes [Responded] and resumes the Plugin coroutine.
17+
*/
18+
@Serializable
19+
sealed interface AgentSurfaceEvent : Event {
20+
21+
/** Echo of [AgentSurface.correlationId] for routing replies. */
22+
val correlationId: CorrelationId
23+
24+
/** A Plugin is asking the platform to render an [AgentSurface]. */
25+
@Serializable
26+
data class Requested(
27+
override val eventId: EventId,
28+
override val timestamp: Instant,
29+
override val eventSource: EventSource,
30+
override val urgency: Urgency = Urgency.MEDIUM,
31+
val surface: AgentSurface,
32+
) : AgentSurfaceEvent {
33+
34+
override val correlationId: CorrelationId = surface.correlationId
35+
36+
override val eventType: EventType = EVENT_TYPE
37+
38+
override fun getSummary(
39+
formatUrgency: (Urgency) -> String,
40+
formatSource: (EventSource) -> String,
41+
): String {
42+
val kind = when (surface) {
43+
is AgentSurface.Form -> "Form '${surface.title}'"
44+
is AgentSurface.Choice -> "Choice '${surface.title}'"
45+
is AgentSurface.Confirmation -> "Confirmation"
46+
is AgentSurface.Card -> "Card '${surface.title}'"
47+
}
48+
return "Surface request: $kind (corr=$correlationId) ${formatUrgency(urgency)} from ${formatSource(
49+
eventSource,
50+
)}"
51+
}
52+
53+
companion object {
54+
const val EVENT_TYPE: EventType = "AgentSurfaceRequested"
55+
}
56+
}
57+
58+
/** The platform renderer replying to a previous [Requested]. */
59+
@Serializable
60+
data class Responded(
61+
override val eventId: EventId,
62+
override val timestamp: Instant,
63+
override val eventSource: EventSource,
64+
override val urgency: Urgency = Urgency.LOW,
65+
val response: AgentSurfaceResponse,
66+
) : AgentSurfaceEvent {
67+
68+
override val correlationId: CorrelationId = response.correlationId
69+
70+
override val eventType: EventType = EVENT_TYPE
71+
72+
override fun getSummary(
73+
formatUrgency: (Urgency) -> String,
74+
formatSource: (EventSource) -> String,
75+
): String {
76+
val outcome = when (response) {
77+
is AgentSurfaceResponse.Submitted -> "submitted"
78+
is AgentSurfaceResponse.Cancelled -> "cancelled"
79+
is AgentSurfaceResponse.TimedOut -> "timed out"
80+
}
81+
return "Surface response: $outcome (corr=$correlationId) ${formatUrgency(urgency)} from ${formatSource(
82+
eventSource,
83+
)}"
84+
}
85+
86+
companion object {
87+
const val EVENT_TYPE: EventType = "AgentSurfaceResponded"
88+
}
89+
}
90+
}

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/event/EventRegistry.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ object EventRegistry {
9898

9999
// PermissionEvent types
100100
PermissionDeniedEvent.EVENT_TYPE,
101+
102+
// AgentSurfaceEvent types
103+
AgentSurfaceEvent.Requested.EVENT_TYPE,
104+
AgentSurfaceEvent.Responded.EVENT_TYPE,
101105
)
102106

103107
/**
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package link.socket.ampere.agents.events.surface
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* A correlation identifier used to pair a surface request with its response.
7+
*
8+
* Plugins generate one of these per [AgentSurface] they emit and pass it to
9+
* [link.socket.ampere.agents.events.surface.awaitSurfaceResponse] to receive
10+
* the [AgentSurfaceResponse] produced by the platform renderer.
11+
*/
12+
typealias CorrelationId = String
13+
14+
/**
15+
* A typed, serializable description of a UI render request emitted by a Plugin.
16+
*
17+
* Platform renderers (iOS, Android Compose Multiplatform, Desktop) translate each
18+
* variant into native UI. This contract lives in commonMain and intentionally
19+
* carries no platform or framework references so it can be expressed across
20+
* every Ampere target.
21+
*
22+
* Every variant carries a [correlationId] so the emitting Plugin can wait for
23+
* the matching [AgentSurfaceResponse] without coupling to bus internals.
24+
*/
25+
@Serializable
26+
sealed interface AgentSurface {
27+
28+
/** Stable identifier used to pair this surface with its response. */
29+
val correlationId: CorrelationId
30+
31+
/**
32+
* A multi-field form. Renderers display the [fields] in order and submit a
33+
* map of field id to typed value when the user confirms.
34+
*/
35+
@Serializable
36+
data class Form(
37+
override val correlationId: CorrelationId,
38+
val title: String,
39+
val description: String? = null,
40+
val fields: List<AgentSurfaceField>,
41+
val submitLabel: String = "Submit",
42+
val cancelLabel: String = "Cancel",
43+
) : AgentSurface
44+
45+
/**
46+
* A single- or multi-select picker. Renderers display the [options] in
47+
* order and submit the chosen option ids.
48+
*/
49+
@Serializable
50+
data class Choice(
51+
override val correlationId: CorrelationId,
52+
val title: String,
53+
val description: String? = null,
54+
val options: List<Option>,
55+
val multiSelect: Boolean = false,
56+
val minSelections: Int = if (multiSelect) 0 else 1,
57+
val maxSelections: Int = if (multiSelect) options.size else 1,
58+
) : AgentSurface {
59+
60+
@Serializable
61+
data class Option(
62+
val id: String,
63+
val label: String,
64+
val description: String? = null,
65+
val enabled: Boolean = true,
66+
)
67+
}
68+
69+
/**
70+
* A confirmation prompt. Renderers display [prompt] with an affordance for
71+
* accept/reject. Use [severity] to influence destructive vs. neutral
72+
* styling without leaking renderer types.
73+
*/
74+
@Serializable
75+
data class Confirmation(
76+
override val correlationId: CorrelationId,
77+
val prompt: String,
78+
val severity: Severity = Severity.Info,
79+
val confirmLabel: String = "Confirm",
80+
val cancelLabel: String = "Cancel",
81+
) : AgentSurface {
82+
83+
@Serializable
84+
enum class Severity {
85+
Info,
86+
Warning,
87+
Destructive,
88+
}
89+
}
90+
91+
/**
92+
* A rich, slot-based card. Each [Slot] is a typed content fragment that the
93+
* renderer composes; new slot kinds can be added without breaking existing
94+
* renderers because slots are sealed.
95+
*/
96+
@Serializable
97+
data class Card(
98+
override val correlationId: CorrelationId,
99+
val title: String,
100+
val slots: List<Slot>,
101+
val actions: List<Action> = emptyList(),
102+
) : AgentSurface {
103+
104+
@Serializable
105+
sealed interface Slot {
106+
107+
@Serializable
108+
data class Heading(val text: String, val level: Int = 2) : Slot
109+
110+
@Serializable
111+
data class Body(val text: String) : Slot
112+
113+
@Serializable
114+
data class KeyValue(val key: String, val value: String) : Slot
115+
116+
@Serializable
117+
data class Image(val url: String, val altText: String? = null) : Slot
118+
119+
@Serializable
120+
data class Code(val source: String, val language: String? = null) : Slot
121+
}
122+
123+
@Serializable
124+
data class Action(
125+
val id: String,
126+
val label: String,
127+
val severity: Confirmation.Severity = Confirmation.Severity.Info,
128+
)
129+
}
130+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package link.socket.ampere.agents.events.surface
2+
3+
import kotlin.time.Duration
4+
import kotlinx.coroutines.CompletableDeferred
5+
import kotlinx.coroutines.TimeoutCancellationException
6+
import kotlinx.coroutines.withTimeout
7+
import kotlinx.datetime.Clock
8+
import link.socket.ampere.agents.definition.AgentId
9+
import link.socket.ampere.agents.domain.Urgency
10+
import link.socket.ampere.agents.domain.event.AgentSurfaceEvent
11+
import link.socket.ampere.agents.domain.event.EventSource
12+
import link.socket.ampere.agents.events.bus.EventSerialBus
13+
import link.socket.ampere.agents.events.bus.subscribe
14+
import link.socket.ampere.agents.events.subscription.EventSubscription
15+
import link.socket.ampere.agents.events.utils.generateUUID
16+
17+
/**
18+
* Emit an [AgentSurfaceEvent.Requested] for [surface] over [this] bus.
19+
*/
20+
suspend fun EventSerialBus.emitSurfaceRequest(
21+
surface: AgentSurface,
22+
eventSource: EventSource,
23+
urgency: Urgency = Urgency.MEDIUM,
24+
) {
25+
publish(
26+
AgentSurfaceEvent.Requested(
27+
eventId = generateUUID(surface.correlationId),
28+
timestamp = Clock.System.now(),
29+
eventSource = eventSource,
30+
urgency = urgency,
31+
surface = surface,
32+
),
33+
)
34+
}
35+
36+
/**
37+
* Subscribe to [AgentSurfaceEvent.Responded] events and suspend until one is
38+
* delivered with the matching [correlationId].
39+
*
40+
* If [timeout] is supplied and elapses first, an
41+
* [AgentSurfaceResponse.TimedOut] is returned instead of throwing. Callers
42+
* that prefer cancellation-style timeouts can pass [timeout] = null and wrap
43+
* with [withTimeout] themselves.
44+
*
45+
* Note: due to the current bus contract, multiple concurrent awaits for the
46+
* same event type share a registration; this helper completes only on the
47+
* first match for [correlationId] and ignores other events. Callers should
48+
* not assume the underlying handler is unregistered when this function
49+
* returns.
50+
*/
51+
suspend fun EventSerialBus.awaitSurfaceResponse(
52+
awaiterAgentId: AgentId,
53+
correlationId: CorrelationId,
54+
timeout: Duration? = null,
55+
): AgentSurfaceResponse {
56+
val deferred = CompletableDeferred<AgentSurfaceResponse>()
57+
58+
subscribe<AgentSurfaceEvent.Responded, EventSubscription.ByEventClassType>(
59+
agentId = awaiterAgentId,
60+
eventType = AgentSurfaceEvent.Responded.EVENT_TYPE,
61+
) { event, _ ->
62+
if (event.correlationId == correlationId && !deferred.isCompleted) {
63+
deferred.complete(event.response)
64+
}
65+
}
66+
67+
return if (timeout == null) {
68+
deferred.await()
69+
} else {
70+
try {
71+
withTimeout(timeout) { deferred.await() }
72+
} catch (_: TimeoutCancellationException) {
73+
AgentSurfaceResponse.TimedOut(
74+
correlationId = correlationId,
75+
timeoutMillis = timeout.inWholeMilliseconds,
76+
)
77+
}
78+
}
79+
}

0 commit comments

Comments
 (0)