Adopt Android delegated session notifications

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Iliyan Malchev
2026-04-02 12:11:46 -07:00
parent b20afa97b4
commit b5025b59b5
8 changed files with 427 additions and 9 deletions

View File

@@ -107,6 +107,15 @@ stub SDK docs and the local refactor doc:
detail UI must cancel the parent and all child Genie sessions through the
framework `cancelSession(...)` path, even when some of those sessions are
already terminal.
- Framework-owned session notifications now support delegated AGENT rendering:
- user-facing question/result/error notifications should be rendered by the
Agent app when the framework calls `onShowOrUpdateSessionNotification(...)`
and cancelled when it calls `onCancelSessionNotification(...)`
- the Agent must ACK a posted notification with `ackSessionNotification(...)`
and route inline replies through `answerQuestionFromNotification(...)`
- if delegated rendering is unavailable, the framework may post a generic
fallback notification, so app-side notification code must remain
token-aware and idempotent
## External reference implementations

View File

@@ -77,5 +77,9 @@
<action android:name="com.openai.codex.agent.action.BOOTSTRAP_DESKTOP_BRIDGE" />
</intent-filter>
</receiver>
<receiver
android:name=".AgentNotificationReplyReceiver"
android:exported="false" />
</application>
</manifest>

View File

@@ -0,0 +1,50 @@
package com.openai.codex.agent
import android.app.RemoteInput
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlin.concurrent.thread
class AgentNotificationReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != AgentQuestionNotifier.ACTION_REPLY_FROM_NOTIFICATION) {
return
}
val sessionId = intent.getStringExtra(AgentQuestionNotifier.EXTRA_SESSION_ID)?.trim().orEmpty()
val notificationToken = intent.getStringExtra(
AgentQuestionNotifier.EXTRA_NOTIFICATION_TOKEN,
)?.trim().orEmpty()
val answer = RemoteInput.getResultsFromIntent(intent)
?.getCharSequence(AgentQuestionNotifier.REMOTE_INPUT_KEY)
?.toString()
?.trim()
.orEmpty()
if (sessionId.isEmpty() || answer.isEmpty()) {
return
}
val pendingResult = goAsync()
thread(name = "CodexAgentNotificationReply-$sessionId") {
try {
runCatching {
AgentSessionController(context).answerQuestionFromNotification(
sessionId = sessionId,
notificationToken = notificationToken,
answer = answer,
parentSessionId = null,
)
AgentQuestionNotifier.cancel(context, sessionId)
}.onFailure { err ->
Log.w(TAG, "Failed to answer notification question for $sessionId", err)
}
} finally {
pendingResult.finish()
}
}
}
private companion object {
private const val TAG = "CodexAgentReply"
}
}

View File

@@ -4,13 +4,27 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.RemoteInput
import android.app.agent.AgentSessionInfo
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.os.Build
object AgentQuestionNotifier {
const val ACTION_REPLY_FROM_NOTIFICATION =
"com.openai.codex.agent.action.REPLY_FROM_NOTIFICATION"
const val EXTRA_SESSION_ID = "sessionId"
const val EXTRA_NOTIFICATION_TOKEN = "notificationToken"
const val REMOTE_INPUT_KEY = "codexAgentNotificationReply"
private const val CHANNEL_ID = "codex_agent_questions"
private const val CHANNEL_NAME = "Codex Agent Questions"
private const val MAX_CONTENT_PREVIEW_CHARS = 400
private val notificationStateLock = Any()
private val activeNotificationTokens = mutableMapOf<String, String>()
private val retiredNotificationTokens = mutableMapOf<String, MutableSet<String>>()
fun showQuestion(
context: Context,
@@ -23,7 +37,47 @@ object AgentQuestionNotifier {
manager.notify(notificationId(sessionId), buildNotification(context, sessionId, targetPackage, question))
}
fun showOrUpdateDelegatedNotification(
context: Context,
session: AgentSessionInfo,
notificationToken: String,
notificationText: String,
): Boolean {
if (notificationText.isBlank() || !activateNotificationToken(session.sessionId, notificationToken)) {
return false
}
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
ensureChannel(manager)
manager.notify(
notificationId(session.sessionId),
buildDelegatedNotification(
context = context,
session = session,
notificationToken = notificationToken,
notificationText = notificationText.trim(),
),
)
return true
}
fun cancel(context: Context, sessionId: String) {
retireActiveNotificationToken(sessionId)
val manager = context.getSystemService(NotificationManager::class.java) ?: return
manager.cancel(notificationId(sessionId))
}
fun clearSessionState(sessionId: String) {
clearNotificationToken(sessionId)
}
fun cancel(
context: Context,
sessionId: String,
notificationToken: String,
) {
if (!retireNotificationToken(sessionId, notificationToken)) {
return
}
val manager = context.getSystemService(NotificationManager::class.java) ?: return
manager.cancel(notificationId(sessionId))
}
@@ -55,6 +109,95 @@ object AgentQuestionNotifier {
.build()
}
private fun buildDelegatedNotification(
context: Context,
session: AgentSessionInfo,
notificationToken: String,
notificationText: String,
): Notification {
val targetIdentity = resolveTargetIdentity(context, session.targetPackage)
val contentIntent = PendingIntent.getActivity(
context,
notificationId(session.sessionId),
Intent(context, SessionDetailActivity::class.java).apply {
putExtra(SessionDetailActivity.EXTRA_SESSION_ID, session.sessionId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val contentText = notificationText.take(MAX_CONTENT_PREVIEW_CHARS)
val builder = Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(targetIdentity.icon)
.setLargeIcon(targetIdentity.icon)
.setContentTitle(buildNotificationTitle(session.state, targetIdentity.displayName))
.setContentText(contentText)
.setStyle(Notification.BigTextStyle().bigText(contentText))
.setContentIntent(contentIntent)
.setAutoCancel(false)
.setOngoing(true)
buildInlineReplyAction(
context = context,
session = session,
notificationToken = notificationToken,
)?.let { replyAction ->
builder.addAction(replyAction)
}
return builder.build()
}
private fun buildNotificationTitle(
state: Int,
targetDisplayName: String,
): String {
return when (state) {
AgentSessionInfo.STATE_WAITING_FOR_USER ->
"Codex needs input for $targetDisplayName"
AgentSessionInfo.STATE_RUNNING ->
"Codex is working in $targetDisplayName"
AgentSessionInfo.STATE_COMPLETED ->
"Codex finished $targetDisplayName"
AgentSessionInfo.STATE_FAILED ->
"Codex hit an issue in $targetDisplayName"
AgentSessionInfo.STATE_CANCELLED ->
"Codex cancelled $targetDisplayName"
AgentSessionInfo.STATE_CREATED,
AgentSessionInfo.STATE_QUEUED,
-> "Codex session for $targetDisplayName"
else -> "Codex session for $targetDisplayName"
}
}
private fun buildInlineReplyAction(
context: Context,
session: AgentSessionInfo,
notificationToken: String,
): Notification.Action? {
if (session.state != AgentSessionInfo.STATE_WAITING_FOR_USER || notificationToken.isBlank()) {
return null
}
val replyIntent = PendingIntent.getBroadcast(
context,
notificationId(session.sessionId),
Intent(context, AgentNotificationReplyReceiver::class.java).apply {
action = ACTION_REPLY_FROM_NOTIFICATION
putExtra(EXTRA_SESSION_ID, session.sessionId)
putExtra(EXTRA_NOTIFICATION_TOKEN, notificationToken)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
)
val remoteInput = RemoteInput.Builder(REMOTE_INPUT_KEY)
.setLabel("Reply")
.build()
return Notification.Action.Builder(
Icon.createWithResource(context, android.R.drawable.ic_menu_send),
"Reply",
replyIntent,
)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.build()
}
private fun ensureChannel(manager: NotificationManager) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
@@ -73,7 +216,102 @@ object AgentQuestionNotifier {
manager.createNotificationChannel(channel)
}
private fun activateNotificationToken(
sessionId: String,
notificationToken: String,
): Boolean {
if (notificationToken.isBlank()) {
return false
}
synchronized(notificationStateLock) {
if (retiredNotificationTokens[sessionId]?.contains(notificationToken) == true) {
return false
}
activeNotificationTokens.put(sessionId, notificationToken)?.let { previousToken ->
if (previousToken != notificationToken) {
retiredNotificationTokens.getOrPut(sessionId, ::mutableSetOf)
.add(previousToken)
}
}
return true
}
}
private fun clearNotificationToken(sessionId: String) {
synchronized(notificationStateLock) {
activeNotificationTokens.remove(sessionId)
retiredNotificationTokens.remove(sessionId)
}
}
private fun retireNotificationToken(
sessionId: String,
notificationToken: String,
): Boolean {
if (notificationToken.isBlank()) {
retireActiveNotificationToken(sessionId)
return true
}
synchronized(notificationStateLock) {
retiredNotificationTokens.getOrPut(sessionId, ::mutableSetOf)
.add(notificationToken)
if (activeNotificationTokens[sessionId] != notificationToken) {
return false
}
activeNotificationTokens.remove(sessionId)
return true
}
}
private fun retireActiveNotificationToken(sessionId: String) {
synchronized(notificationStateLock) {
activeNotificationTokens.remove(sessionId)?.let { notificationToken ->
retiredNotificationTokens.getOrPut(sessionId, ::mutableSetOf)
.add(notificationToken)
}
}
}
private fun resolveTargetIdentity(
context: Context,
targetPackage: String?,
): TargetIdentity {
if (targetPackage.isNullOrBlank()) {
return TargetIdentity(
displayName = "Codex Agent",
icon = Icon.createWithResource(context, android.R.drawable.ic_dialog_info),
)
}
val packageManager = context.packageManager
return runCatching {
val appInfo = packageManager.getApplicationInfo(
targetPackage,
PackageManager.ApplicationInfoFlags.of(0),
)
val iconResId = appInfo.icon.takeIf { it != 0 }
TargetIdentity(
displayName = packageManager.getApplicationLabel(appInfo).toString()
.ifBlank { targetPackage },
icon = if (iconResId == null) {
Icon.createWithResource(context, android.R.drawable.ic_dialog_info)
} else {
Icon.createWithResource(targetPackage, iconResId)
},
)
}.getOrDefault(
TargetIdentity(
displayName = targetPackage,
icon = Icon.createWithResource(context, android.R.drawable.ic_dialog_info),
),
)
}
private fun notificationId(sessionId: String): Int {
return sessionId.hashCode()
}
private data class TargetIdentity(
val displayName: String,
val icon: Icon,
)
}

View File

@@ -548,6 +548,26 @@ class AgentSessionController(context: Context) {
}
}
fun ackSessionNotification(
sessionId: String,
notificationToken: String,
) {
requireAgentManager().ackSessionNotification(sessionId, notificationToken)
}
fun answerQuestionFromNotification(
sessionId: String,
notificationToken: String,
answer: String,
parentSessionId: String?,
) {
val manager = requireAgentManager()
manager.answerQuestionFromNotification(sessionId, notificationToken, answer)
if (parentSessionId != null) {
manager.publishTrace(parentSessionId, "Answered question for $sessionId: $answer")
}
}
fun isSessionWaitingForUser(sessionId: String): Boolean {
val manager = agentManager ?: return false
return manager.getSessions(currentUserId()).any { session ->

View File

@@ -44,7 +44,6 @@ class CodexAgentService : AgentService() {
AgentPlannerRuntimeManager.closeSession(session.sessionId)
}
if (session.state != AgentSessionInfo.STATE_WAITING_FOR_USER) {
AgentQuestionNotifier.cancel(this, session.sessionId)
return
}
if (!pendingQuestionLoads.add(session.sessionId)) {
@@ -65,12 +64,37 @@ class CodexAgentService : AgentService() {
AgentPlannerRuntimeManager.closeSession(sessionId)
DesktopInspectionRegistry.removeSession(sessionId)
AgentQuestionNotifier.cancel(this, sessionId)
AgentQuestionNotifier.clearSessionState(sessionId)
presentationPolicyStore.removePolicy(sessionId)
handledGenieQuestions.removeIf { it.startsWith("$sessionId:") }
handledBridgeRequests.removeIf { it.startsWith("$sessionId:") }
pendingGenieQuestions.removeIf { it.startsWith("$sessionId:") }
}
override fun onShowOrUpdateSessionNotification(
session: AgentSessionInfo,
notificationToken: String,
notificationText: String,
) {
showOrUpdateSessionNotification(
session = session,
notificationToken = notificationToken,
notificationText = notificationText,
)
}
override fun onCancelSessionNotification(
sessionId: String,
notificationToken: String,
reason: Int,
) {
cancelSessionNotification(
sessionId = sessionId,
notificationToken = notificationToken,
reason = reason,
)
}
private fun maybeRollUpParentSession(session: AgentSessionInfo) {
val parentSessionId = when {
!session.parentSessionId.isNullOrBlank() -> session.parentSessionId
@@ -206,7 +230,7 @@ class CodexAgentService : AgentService() {
val events = manager.getSessionEvents(session.sessionId)
val question = findLatestQuestion(events) ?: return
if (!isBridgeQuestion(question)) {
AgentQuestionNotifier.cancel(this, session.sessionId)
return
}
maybeAutoAnswerGenieQuestion(session, question)
}
@@ -236,6 +260,67 @@ class CodexAgentService : AgentService() {
}
}
private fun showOrUpdateSessionNotification(
session: AgentSessionInfo,
notificationToken: String,
notificationText: String,
) {
thread(name = "CodexAgentNotificationShow-${session.sessionId}") {
val posted = runCatching {
AgentQuestionNotifier.showOrUpdateDelegatedNotification(
context = this,
session = session,
notificationToken = notificationToken,
notificationText = notificationText,
)
}.onFailure { err ->
Log.w(
TAG,
"Failed to post delegated notification sessionId=${session.sessionId} token=$notificationToken",
err,
)
}.getOrDefault(false)
if (!posted) {
return@thread
}
runCatching {
sessionController.ackSessionNotification(session.sessionId, notificationToken)
}.onFailure { err ->
Log.w(
TAG,
"Failed to ack delegated notification sessionId=${session.sessionId} token=$notificationToken",
err,
)
}
}
}
private fun cancelSessionNotification(
sessionId: String,
notificationToken: String,
reason: Int,
) {
thread(name = "CodexAgentNotificationCancel-$sessionId") {
Log.i(
TAG,
"Cancelling delegated notification sessionId=$sessionId token=$notificationToken reason=${notificationCancelReasonToString(reason)}",
)
AgentQuestionNotifier.cancel(
context = this,
sessionId = sessionId,
notificationToken = notificationToken,
)
}
}
private fun notificationCancelReasonToString(reason: Int): String {
return when (reason) {
NOTIFICATION_CANCEL_REASON_SUPPRESSED -> "SUPPRESSED"
NOTIFICATION_CANCEL_REASON_REMOVED -> "REMOVED"
else -> reason.toString()
}
}
private fun findLatestQuestion(events: List<AgentSessionEvent>): String? {
return events.lastOrNull { event ->
event.type == AgentSessionEvent.TYPE_QUESTION &&

View File

@@ -3,15 +3,13 @@ package com.openai.codex.agent
import android.content.Context
object SessionNotificationCoordinator {
@Suppress("UNUSED_PARAMETER")
fun acknowledgeSessionTree(
context: Context,
sessionController: AgentSessionController,
topLevelSessionId: String,
sessionIds: Collection<String>,
) {
sessionIds.forEach { sessionId ->
AgentQuestionNotifier.cancel(context, sessionId)
}
sessionController.acknowledgeSessionUi(topLevelSessionId)
}
}

View File

@@ -74,7 +74,13 @@ The current repo now contains these implementation slices:
still-live framework session.
- The Agent now auto-answers only internal bridge questions. User-facing Genie
questions remain user-facing and are surfaced through the normal framework
question flow, notifications, and desktop/UI answer surfaces.
question flow, desktop/UI answer surfaces, and framework-delegated
AGENT-rendered notifications with target-app icon branding and inline reply.
- Framework-owned notification lifecycle now delegates question/result/error
notification rendering to the Agent app when available, while retaining a
framework fallback path if the Agent renderer is unavailable or does not ACK
in time. Inline reply actions route through
`answerQuestionFromNotification(sessionId, notificationToken, response)`.
- The Agent now records an explicit per-child final presentation policy
(`ATTACHED`, `DETACHED_HIDDEN`, `DETACHED_SHOWN`, or `AGENT_CHOICE`) and
uses the framework-authoritative `AgentSessionInfo.getTargetPresentation()`
@@ -222,8 +228,10 @@ the Android Agent/Genie flow.
- detached target ensure-hidden/show/hide/attach/close
- typed detached frame capture with runtime-aware recovery
- `request_user_input` bridged from hosted Codex back into AgentSDK questions
- Framework-owned question notifications for Genie questions that need user
input; the Agent app suppresses its former duplicate notification mirror
- Framework-owned notification lifecycle with delegated Agent rendering for
question/result/error notifications; the Agent app posts the visible
notification, ACKs the tokenized callback, and falls back to plain
framework-owned rendering if the new callback surface is unavailable
- Detached-mode Genie sessions now request `showDetachedTarget` before entering
`WAITING_FOR_USER`, so HOME can keep a visible live icon while the user
answers the question
@@ -249,7 +257,8 @@ the Android Agent/Genie flow.
- `android/genie`
- standalone Genie scaffold APK with hosted `codex app-server`
- `android/app/src/main/java/com/openai/codex/agent/CodexAgentService.kt`
- framework `AgentService`
- framework `AgentService`, parent roll-up, bridge auto-answering, and
delegated notification callbacks
- `android/app/src/main/java/com/openai/codex/agent/AgentSessionController.kt`
- Agent-side `AgentManager` orchestration helper
- `android/app/src/main/java/com/openai/codex/agent/AgentFrameworkToolBridge.kt`
@@ -258,6 +267,11 @@ the Android Agent/Genie flow.
- Agent session UI, Agent clarification dialogs, and Agent-native auth controls
- `android/app/src/main/java/com/openai/codex/agent/SessionDetailActivity.kt`
- HOME/AGENT session inspection and question answering UI
- `android/app/src/main/java/com/openai/codex/agent/AgentQuestionNotifier.kt`
- token-aware Agent-side renderer for delegated framework notifications,
including target-app branding and inline reply actions
- `android/app/src/main/java/com/openai/codex/agent/AgentNotificationReplyReceiver.kt`
- inline reply receiver for Agent-rendered question notifications
- `android/app/src/main/java/com/openai/codex/agent/AgentUserInputPrompter.kt`
- Android dialog bridge for hosted Agent `request_user_input` calls
- `android/genie/src/main/java/com/openai/codex/genie/CodexGenieService.kt`