Track final Android target presentation state

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Iliyan Malchev
2026-03-21 13:01:19 -07:00
parent ef5894affc
commit 2765aff40c
14 changed files with 465 additions and 74 deletions

View File

@@ -44,10 +44,16 @@ class AgentFrameworkToolBridge(
continue
}
val objective = target.optString("objective").trim().ifEmpty { userObjective }
val finalPresentationPolicy = target.optString("finalPresentationPolicy").trim()
val defaultFinalPresentationPolicy = arguments.optString("finalPresentationPolicy").trim()
add(
AgentDelegationTarget(
packageName = packageName,
objective = objective,
finalPresentationPolicy =
SessionFinalPresentationPolicy.fromWireValue(finalPresentationPolicy)
?: SessionFinalPresentationPolicy.fromWireValue(defaultFinalPresentationPolicy)
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
),
)
}
@@ -60,6 +66,13 @@ class AgentFrameworkToolBridge(
}
throw IOException("Framework session tool did not select an eligible target package")
}
val allowDetachedMode = arguments.optBoolean("allowDetachedMode", true)
val detachedPolicyTargets = targets.filter { it.finalPresentationPolicy.requiresDetachedMode() }
if (!allowDetachedMode && detachedPolicyTargets.isNotEmpty()) {
throw IOException(
"Framework session tool selected detached final presentation without allowDetachedMode: ${detachedPolicyTargets.joinToString(", ") { it.packageName }}",
)
}
return StartDirectSessionRequest(
plan = AgentDelegationPlan(
originalObjective = userObjective,
@@ -67,7 +80,7 @@ class AgentFrameworkToolBridge(
rationale = arguments.optString("reason").trim().ifEmpty { null },
usedOverride = false,
),
allowDetachedMode = arguments.optBoolean("allowDetachedMode", true),
allowDetachedMode = allowDetachedMode,
)
}
}
@@ -177,9 +190,20 @@ class AgentFrameworkToolBridge(
"properties",
JSONObject()
.put("packageName", stringSchema("Installed target Android package name."))
.put("objective", stringSchema("Delegated free-form objective for the child Genie.")),
.put("objective", stringSchema("Delegated free-form objective for the child Genie."))
.put(
"finalPresentationPolicy",
stringSchema(
"Required final target presentation: ATTACHED, DETACHED_HIDDEN, DETACHED_SHOWN, or AGENT_CHOICE.",
),
),
)
.put(
"required",
JSONArray()
.put("packageName")
.put("finalPresentationPolicy"),
)
.put("required", JSONArray().put("packageName"))
.put("additionalProperties", false),
),
)
@@ -272,7 +296,12 @@ class AgentFrameworkToolBridge(
.put("parentSessionId", session.parentSessionId)
.put("targetPackage", session.targetPackage)
.put("state", session.stateLabel)
.put("targetDetached", session.targetDetached),
.put("targetDetached", session.targetDetached)
.put("targetPresentation", session.targetPresentationLabel)
.put(
"requiredFinalPresentation",
session.requiredFinalPresentationPolicy?.wireValue,
),
)
}
return JSONObject()

View File

@@ -16,6 +16,8 @@ data class ParentSessionChildSummary(
val sessionId: String,
val targetPackage: String?,
val state: Int,
val targetPresentation: Int,
val requiredFinalPresentationPolicy: SessionFinalPresentationPolicy?,
val latestResult: String?,
val latestError: String?,
)
@@ -24,26 +26,67 @@ data class ParentSessionRollup(
val state: Int,
val resultMessage: String?,
val errorMessage: String?,
val sessionsToAttach: List<String>,
)
object AgentParentSessionAggregator {
fun rollup(childSessions: List<ParentSessionChildSummary>): ParentSessionRollup {
val state = computeParentState(childSessions.map(ParentSessionChildSummary::state))
return when (state) {
val baseState = computeParentState(childSessions.map(ParentSessionChildSummary::state))
if (
baseState == AgentSessionInfo.STATE_CREATED ||
baseState == AgentSessionInfo.STATE_RUNNING ||
baseState == AgentSessionInfo.STATE_WAITING_FOR_USER ||
baseState == AgentSessionInfo.STATE_QUEUED
) {
return ParentSessionRollup(
state = baseState,
resultMessage = null,
errorMessage = null,
sessionsToAttach = emptyList(),
)
}
val terminalPresentationMismatches = childSessions.mapNotNull { childSession ->
childSession.presentationMismatch()
}
val sessionsToAttach = terminalPresentationMismatches
.filter { it.requiredPolicy == SessionFinalPresentationPolicy.ATTACHED }
.map(PresentationMismatch::sessionId)
val blockingMismatches = terminalPresentationMismatches
.filterNot { it.requiredPolicy == SessionFinalPresentationPolicy.ATTACHED }
if (sessionsToAttach.isNotEmpty() && baseState == AgentSessionInfo.STATE_COMPLETED) {
return ParentSessionRollup(
state = AgentSessionInfo.STATE_RUNNING,
resultMessage = null,
errorMessage = null,
sessionsToAttach = sessionsToAttach,
)
}
if (blockingMismatches.isNotEmpty()) {
return ParentSessionRollup(
state = AgentSessionInfo.STATE_FAILED,
resultMessage = null,
errorMessage = buildPresentationMismatchError(blockingMismatches),
sessionsToAttach = emptyList(),
)
}
return when (baseState) {
AgentSessionInfo.STATE_COMPLETED -> ParentSessionRollup(
state = state,
state = baseState,
resultMessage = buildParentResult(childSessions),
errorMessage = null,
sessionsToAttach = emptyList(),
)
AgentSessionInfo.STATE_FAILED -> ParentSessionRollup(
state = state,
state = baseState,
resultMessage = null,
errorMessage = buildParentError(childSessions),
sessionsToAttach = emptyList(),
)
else -> ParentSessionRollup(
state = state,
state = baseState,
resultMessage = null,
errorMessage = null,
sessionsToAttach = emptyList(),
)
}
}
@@ -106,6 +149,20 @@ object AgentParentSessionAggregator {
}
}
private fun buildPresentationMismatchError(mismatches: List<PresentationMismatch>): String {
return buildString {
append("Delegated session completed without the required final presentation")
mismatches.forEach { mismatch ->
append("; ")
append(mismatch.targetPackage ?: mismatch.sessionId)
append(": required ")
append(mismatch.requiredPolicy.wireValue)
append(", actual ")
append(targetPresentationToString(mismatch.actualPresentation))
}
}
}
private fun stateToString(state: Int): String {
return when (state) {
AgentSessionInfo.STATE_CREATED -> "CREATED"
@@ -118,4 +175,24 @@ object AgentParentSessionAggregator {
else -> state.toString()
}
}
private fun ParentSessionChildSummary.presentationMismatch(): PresentationMismatch? {
val requiredPolicy = requiredFinalPresentationPolicy ?: return null
if (state != AgentSessionInfo.STATE_COMPLETED || requiredPolicy.matches(targetPresentation)) {
return null
}
return PresentationMismatch(
sessionId = sessionId,
targetPackage = targetPackage,
requiredPolicy = requiredPolicy,
actualPresentation = targetPresentation,
)
}
}
private data class PresentationMismatch(
val sessionId: String,
val targetPackage: String?,
val requiredPolicy: SessionFinalPresentationPolicy,
val actualPresentation: Int,
)

View File

@@ -21,6 +21,7 @@ class AgentSessionController(context: Context) {
}
private val agentManager = context.getSystemService(AgentManager::class.java)
private val presentationPolicyStore = SessionPresentationPolicyStore(context)
fun isAvailable(): Boolean = agentManager != null
@@ -49,7 +50,9 @@ class AgentSessionController(context: Context) {
val manager = agentManager ?: return AgentSnapshot.unavailable
val roleHolders = manager.getGenieRoleHolders(currentUserId())
val selectedGeniePackage = selectGeniePackage(roleHolders)
var sessionDetails = manager.getSessions(currentUserId()).map { session ->
val sessions = manager.getSessions(currentUserId())
presentationPolicyStore.prunePolicies(sessions.map { it.sessionId }.toSet())
var sessionDetails = sessions.map { session ->
AgentSessionDetails(
sessionId = session.sessionId,
parentSessionId = session.parentSessionId,
@@ -57,7 +60,10 @@ class AgentSessionController(context: Context) {
anchor = session.anchor,
state = session.state,
stateLabel = stateToString(session.state),
targetPresentation = session.targetPresentation,
targetPresentationLabel = targetPresentationToString(session.targetPresentation),
targetDetached = session.isTargetDetached,
requiredFinalPresentationPolicy = presentationPolicyStore.getPolicy(session.sessionId),
latestQuestion = null,
latestResult = null,
latestError = null,
@@ -105,6 +111,10 @@ class AgentSessionController(context: Context) {
allowDetachedMode: Boolean,
): SessionStartResult {
val manager = requireAgentManager()
val detachedPolicyTargets = plan.targets.filter { it.finalPresentationPolicy.requiresDetachedMode() }
check(allowDetachedMode || detachedPolicyTargets.isEmpty()) {
"Detached final presentation requires detached mode for ${detachedPolicyTargets.joinToString(", ") { it.packageName }}"
}
val geniePackage = selectGeniePackage(manager.getGenieRoleHolders(currentUserId()))
?: throw IllegalStateException("No GENIE role holder configured")
val parentSession = manager.createDirectSession(currentUserId())
@@ -120,14 +130,15 @@ class AgentSessionController(context: Context) {
plan.targets.forEach { target ->
val childSession = manager.createChildSession(parentSession.sessionId, target.packageName)
childSessionIds += childSession.sessionId
presentationPolicyStore.savePolicy(childSession.sessionId, target.finalPresentationPolicy)
manager.publishTrace(
parentSession.sessionId,
"Created child session ${childSession.sessionId} for ${target.packageName}.",
"Created child session ${childSession.sessionId} for ${target.packageName} with required final presentation ${target.finalPresentationPolicy.wireValue}.",
)
manager.startGenieSession(
childSession.sessionId,
geniePackage,
target.objective,
buildDelegatedPrompt(target),
allowDetachedMode,
)
}
@@ -140,6 +151,7 @@ class AgentSessionController(context: Context) {
} catch (err: RuntimeException) {
childSessionIds.forEach { childSessionId ->
runCatching { manager.cancelSession(childSessionId) }
presentationPolicyStore.removePolicy(childSessionId)
}
runCatching { manager.cancelSession(parentSession.sessionId) }
throw err
@@ -269,6 +281,15 @@ class AgentSessionController(context: Context) {
}
}
private fun buildDelegatedPrompt(target: AgentDelegationTarget): String {
return buildString {
appendLine(target.objective)
appendLine()
appendLine("Required final target presentation: ${target.finalPresentationPolicy.wireValue}")
append(target.finalPresentationPolicy.promptGuidance())
}.trim()
}
private fun findLastEventMessage(events: List<AgentSessionEvent>, type: Int): String? {
for (index in events.indices.reversed()) {
val event = events[index]
@@ -353,6 +374,7 @@ class AgentSessionController(context: Context) {
AgentSessionEvent.TYPE_RESULT -> "Result"
AgentSessionEvent.TYPE_ERROR -> "Error"
AgentSessionEvent.TYPE_POLICY -> "Policy"
AgentSessionEvent.TYPE_DETACHED_ACTION -> "DetachedAction"
AgentSessionEvent.TYPE_ANSWER -> "Answer"
else -> "Event($type)"
}
@@ -415,7 +437,10 @@ data class AgentSessionDetails(
val anchor: Int,
val state: Int,
val stateLabel: String,
val targetPresentation: Int,
val targetPresentationLabel: String,
val targetDetached: Boolean,
val requiredFinalPresentationPolicy: SessionFinalPresentationPolicy?,
val latestQuestion: String?,
val latestResult: String?,
val latestError: String?,

View File

@@ -10,6 +10,7 @@ import org.json.JSONTokener
data class AgentDelegationTarget(
val packageName: String,
val objective: String,
val finalPresentationPolicy: SessionFinalPresentationPolicy,
)
data class AgentDelegationPlan(
@@ -37,7 +38,8 @@ object AgentTaskPlanner {
"targets": [
{
"packageName": "installed.package",
"objective": "free-form delegated objective for the child Genie"
"objective": "free-form delegated objective for the child Genie",
"finalPresentationPolicy": "ATTACHED | DETACHED_HIDDEN | DETACHED_SHOWN | AGENT_CHOICE"
}
],
"reason": "short rationale",
@@ -47,6 +49,11 @@ object AgentTaskPlanner {
- Choose the fewest packages needed to complete the request.
- `targets` must be non-empty.
- Each delegated `objective` should be written for the child Genie, not the user.
- Each target must include `finalPresentationPolicy`.
- Use `ATTACHED` when the user wants the target left on the main screen or explicitly visible to them.
- Use `DETACHED_SHOWN` when the target should remain visible but stay detached.
- Use `DETACHED_HIDDEN` when the target should complete in the background without remaining visible.
- Use `AGENT_CHOICE` only when the final presentation state does not matter.
- Stop after at most 6 shell commands.
- Prefer direct package-manager commands over grepping large package lists.
- Verify each chosen package by inspecting its package dump or query-activities output before returning it.
@@ -76,9 +83,28 @@ object AgentTaskPlanner {
"properties",
JSONObject()
.put("packageName", JSONObject().put("type", "string"))
.put("objective", JSONObject().put("type", "string")),
.put("objective", JSONObject().put("type", "string"))
.put(
"finalPresentationPolicy",
JSONObject()
.put("type", "string")
.put(
"enum",
JSONArray()
.put(SessionFinalPresentationPolicy.ATTACHED.wireValue)
.put(SessionFinalPresentationPolicy.DETACHED_HIDDEN.wireValue)
.put(SessionFinalPresentationPolicy.DETACHED_SHOWN.wireValue)
.put(SessionFinalPresentationPolicy.AGENT_CHOICE.wireValue),
),
),
)
.put(
"required",
JSONArray()
.put("packageName")
.put("objective")
.put("finalPresentationPolicy"),
)
.put("required", JSONArray().put("packageName").put("objective"))
.put("additionalProperties", false),
),
)
@@ -93,6 +119,7 @@ object AgentTaskPlanner {
userObjective: String,
targetPackageOverride: String?,
allowDetachedMode: Boolean,
finalPresentationPolicyOverride: SessionFinalPresentationPolicy? = null,
sessionController: AgentSessionController,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
): SessionStartResult {
@@ -105,6 +132,8 @@ object AgentTaskPlanner {
AgentDelegationTarget(
packageName = targetPackageOverride,
objective = userObjective,
finalPresentationPolicy =
finalPresentationPolicyOverride ?: SessionFinalPresentationPolicy.AGENT_CHOICE,
),
),
rationale = "Using explicit target package override.",

View File

@@ -38,6 +38,7 @@ class CodexAgentService : AgentService() {
private val agentManager by lazy { getSystemService(AgentManager::class.java) }
private val sessionController by lazy { AgentSessionController(this) }
private val presentationPolicyStore by lazy { SessionPresentationPolicyStore(this) }
override fun onCreate() {
super.onCreate()
@@ -73,6 +74,7 @@ class CodexAgentService : AgentService() {
Log.i(TAG, "onSessionRemoved sessionId=$sessionId")
AgentSessionBridgeServer.closeSession(sessionId)
AgentQuestionNotifier.cancel(this, sessionId)
presentationPolicyStore.removePolicy(sessionId)
handledGenieQuestions.removeIf { it.startsWith("$sessionId:") }
handledBridgeRequests.removeIf { it.startsWith("$sessionId:") }
pendingGenieQuestions.removeIf { it.startsWith("$sessionId:") }
@@ -90,9 +92,9 @@ class CodexAgentService : AgentService() {
thread(name = "CodexAgentParentRollup-$parentSessionId") {
try {
runCatching {
rollUpParentSession(parentSessionId)
}.onFailure { err ->
Log.w(TAG, "Parent session roll-up failed for $parentSessionId", err)
rollUpParentSession(parentSessionId)
}.onFailure { err ->
Log.w(TAG, "Parent session roll-up failed for $parentSessionId", err)
}
} finally {
pendingParentRollups.remove(parentSessionId)
@@ -118,11 +120,24 @@ class CodexAgentService : AgentService() {
sessionId = childSession.sessionId,
targetPackage = childSession.targetPackage,
state = childSession.state,
targetPresentation = childSession.targetPresentation,
requiredFinalPresentationPolicy = presentationPolicyStore.getPolicy(childSession.sessionId),
latestResult = findLastEventMessage(events, AgentSessionEvent.TYPE_RESULT),
latestError = findLastEventMessage(events, AgentSessionEvent.TYPE_ERROR),
)
},
)
rollup.sessionsToAttach.forEach { childSessionId ->
runCatching {
manager.attachTarget(childSessionId)
manager.publishTrace(
parentSessionId,
"Requested attach for $childSessionId to satisfy the required final presentation policy.",
)
}.onFailure { err ->
Log.w(TAG, "Failed to attach target for $childSessionId", err)
}
}
if (parentSession.state != rollup.state) {
runCatching {
manager.updateSessionState(parentSessionId, rollup.state)
@@ -426,6 +441,7 @@ class CodexAgentService : AgentService() {
AgentSessionEvent.TYPE_RESULT -> "Result"
AgentSessionEvent.TYPE_ERROR -> "Error"
AgentSessionEvent.TYPE_POLICY -> "Policy"
AgentSessionEvent.TYPE_DETACHED_ACTION -> "DetachedAction"
AgentSessionEvent.TYPE_ANSWER -> "Answer"
else -> "Event($type)"
}

View File

@@ -27,6 +27,7 @@ class MainActivity : Activity() {
"com.openai.codex.agent.action.DEBUG_CANCEL_ALL_AGENT_SESSIONS"
private const val EXTRA_DEBUG_PROMPT = "prompt"
private const val EXTRA_DEBUG_TARGET_PACKAGE = "targetPackage"
private const val EXTRA_DEBUG_FINAL_PRESENTATION_POLICY = "finalPresentationPolicy"
}
@Volatile
@@ -153,10 +154,19 @@ class MainActivity : Activity() {
return
}
val targetPackageOverride = intent.getStringExtra(EXTRA_DEBUG_TARGET_PACKAGE)?.trim()
val finalPresentationPolicyOverride = SessionFinalPresentationPolicy.fromWireValue(
intent.getStringExtra(EXTRA_DEBUG_FINAL_PRESENTATION_POLICY),
)
findViewById<EditText>(R.id.agent_prompt).setText(prompt)
findViewById<EditText>(R.id.agent_target_package).setText(targetPackageOverride.orEmpty())
Log.i(TAG, "Handling debug start intent override=$targetPackageOverride prompt=${prompt.take(160)}")
startDirectAgentSession(findViewById(R.id.agent_start_button))
Log.i(
TAG,
"Handling debug start intent override=$targetPackageOverride finalPresentationPolicy=${finalPresentationPolicyOverride?.wireValue} prompt=${prompt.take(160)}",
)
startDirectAgentSession(
view = findViewById(R.id.agent_start_button),
finalPresentationPolicyOverride = finalPresentationPolicyOverride,
)
intent.action = null
}
@@ -211,13 +221,23 @@ class MainActivity : Activity() {
}
fun startDirectAgentSession(@Suppress("UNUSED_PARAMETER") view: View) {
startDirectAgentSession(view, null)
}
private fun startDirectAgentSession(
@Suppress("UNUSED_PARAMETER") view: View,
finalPresentationPolicyOverride: SessionFinalPresentationPolicy?,
) {
val targetPackageOverride = findViewById<EditText>(R.id.agent_target_package).text.toString().trim()
val prompt = findViewById<EditText>(R.id.agent_prompt).text.toString().trim()
if (prompt.isEmpty()) {
showToast("Enter a prompt")
return
}
Log.i(TAG, "startDirectAgentSession override=$targetPackageOverride prompt=${prompt.take(160)}")
Log.i(
TAG,
"startDirectAgentSession override=$targetPackageOverride finalPresentationPolicy=${finalPresentationPolicyOverride?.wireValue} prompt=${prompt.take(160)}",
)
thread {
val result = runCatching {
AgentTaskPlanner.startSession(
@@ -225,6 +245,7 @@ class MainActivity : Activity() {
userObjective = prompt,
targetPackageOverride = targetPackageOverride.ifBlank { null },
allowDetachedMode = true,
finalPresentationPolicyOverride = finalPresentationPolicyOverride,
sessionController = agentSessionController,
requestUserInputHandler = { questions ->
AgentUserInputPrompter.promptForAnswers(this, questions)
@@ -493,7 +514,8 @@ class MainActivity : Activity() {
answerButton.visibility = if (isWaitingForUser) View.VISIBLE else View.GONE
questionView.text = waitingQuestion ?: ""
val showAttachButton = selectedSession?.targetDetached == true
val showAttachButton =
selectedSession?.targetPresentation != AgentSessionInfo.TARGET_PRESENTATION_ATTACHED
attachButton.visibility = if (showAttachButton) View.VISIBLE else View.GONE
attachButton.isEnabled = showAttachButton
@@ -517,7 +539,10 @@ class MainActivity : Activity() {
append("Session: ${selectedSession.sessionId}\n")
append("State: ${selectedSession.stateLabel}\n")
append("Target: ${selectedSession.targetPackage ?: "direct-agent"}\n")
append("Detached target: ${selectedSession.targetDetached}\n")
append("Target presentation: ${selectedSession.targetPresentationLabel}\n")
selectedSession.requiredFinalPresentationPolicy?.let { policy ->
append("Required final presentation: ${policy.wireValue}\n")
}
val parentSessionId = snapshot.parentSession?.sessionId
if (parentSessionId != null) {
append("Parent: $parentSessionId\n")
@@ -545,8 +570,9 @@ class MainActivity : Activity() {
val detail = session.latestQuestion ?: session.latestResult ?: session.latestError ?: session.latestTrace
buildString {
append("$marker $role ${session.stateLabel} ${session.targetPackage ?: "direct-agent"}")
if (session.targetDetached) {
append(" [detached]")
append(" [${session.targetPresentationLabel}]")
session.requiredFinalPresentationPolicy?.let { policy ->
append(" -> ${policy.wireValue}")
}
append("\n ${session.sessionId}")
if (!detail.isNullOrEmpty()) {

View File

@@ -0,0 +1,97 @@
package com.openai.codex.agent
import android.app.agent.AgentSessionInfo
import java.io.IOException
enum class SessionFinalPresentationPolicy(
val wireValue: String,
val description: String,
) {
ATTACHED(
wireValue = "ATTACHED",
description = "Finish with the target attached to the main user-facing display/task stack.",
),
DETACHED_HIDDEN(
wireValue = "DETACHED_HIDDEN",
description = "Finish with the target still detached and hidden from view.",
),
DETACHED_SHOWN(
wireValue = "DETACHED_SHOWN",
description = "Finish with the target detached but visibly shown through the detached host.",
),
AGENT_CHOICE(
wireValue = "AGENT_CHOICE",
description = "The Agent does not require a specific final presentation state for this target.",
),
;
fun matches(actualPresentation: Int): Boolean {
return when (this) {
ATTACHED -> actualPresentation == AgentSessionInfo.TARGET_PRESENTATION_ATTACHED
DETACHED_HIDDEN -> {
actualPresentation == AgentSessionInfo.TARGET_PRESENTATION_DETACHED_HIDDEN
}
DETACHED_SHOWN -> {
actualPresentation == AgentSessionInfo.TARGET_PRESENTATION_DETACHED_SHOWN
}
AGENT_CHOICE -> true
}
}
fun requiresDetachedMode(): Boolean {
return when (this) {
DETACHED_HIDDEN, DETACHED_SHOWN -> true
ATTACHED, AGENT_CHOICE -> false
}
}
fun promptGuidance(): String {
return when (this) {
ATTACHED -> {
"Before reporting success, ensure the target is ATTACHED to the primary user-facing display. Detached-only visibility is not sufficient."
}
DETACHED_HIDDEN -> {
"Before reporting success, ensure the target remains DETACHED_HIDDEN. Do not attach it or leave it shown."
}
DETACHED_SHOWN -> {
"Before reporting success, ensure the target remains DETACHED_SHOWN. It should stay detached but visibly shown through the detached host."
}
AGENT_CHOICE -> {
"Choose the final target presentation state yourself and describe the final state accurately in your result."
}
}
}
companion object {
fun fromWireValue(value: String?): SessionFinalPresentationPolicy? {
val normalized = value?.trim().orEmpty()
if (normalized.isEmpty()) {
return null
}
return entries.firstOrNull { it.wireValue.equals(normalized, ignoreCase = true) }
}
fun requireFromWireValue(
value: String?,
fieldName: String,
): SessionFinalPresentationPolicy {
return fromWireValue(value)
?: throw IOException("Unsupported $fieldName: ${value?.trim().orEmpty()}")
}
}
}
object AgentTargetPresentationValues {
const val ATTACHED = AgentSessionInfo.TARGET_PRESENTATION_ATTACHED
const val DETACHED_HIDDEN = AgentSessionInfo.TARGET_PRESENTATION_DETACHED_HIDDEN
const val DETACHED_SHOWN = AgentSessionInfo.TARGET_PRESENTATION_DETACHED_SHOWN
}
fun targetPresentationToString(targetPresentation: Int): String {
return when (targetPresentation) {
AgentTargetPresentationValues.ATTACHED -> "ATTACHED"
AgentTargetPresentationValues.DETACHED_HIDDEN -> "DETACHED_HIDDEN"
AgentTargetPresentationValues.DETACHED_SHOWN -> "DETACHED_SHOWN"
else -> targetPresentation.toString()
}
}

View File

@@ -0,0 +1,40 @@
package com.openai.codex.agent
import android.content.Context
class SessionPresentationPolicyStore(
context: Context,
) {
companion object {
private const val PREFS_NAME = "codex_session_presentation_policies"
}
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun savePolicy(
sessionId: String,
policy: SessionFinalPresentationPolicy,
) {
prefs.edit().putString(sessionId, policy.wireValue).apply()
}
fun getPolicy(sessionId: String): SessionFinalPresentationPolicy? {
return SessionFinalPresentationPolicy.fromWireValue(
prefs.getString(sessionId, null),
)
}
fun removePolicy(sessionId: String) {
prefs.edit().remove(sessionId).apply()
}
fun prunePolicies(activeSessionIds: Set<String>) {
val staleSessionIds = prefs.all.keys - activeSessionIds
if (staleSessionIds.isEmpty()) {
return
}
prefs.edit().apply {
staleSessionIds.forEach(::remove)
}.apply()
}
}

View File

@@ -15,7 +15,8 @@ class AgentFrameworkToolBridgeTest {
"targets": [
{
"packageName": "com.android.deskclock",
"objective": "Start the requested timer in Clock."
"objective": "Start the requested timer in Clock.",
"finalPresentationPolicy": "ATTACHED"
}
],
"reason": "Clock is the installed timer app.",
@@ -34,6 +35,10 @@ class AgentFrameworkToolBridgeTest {
assertEquals(1, request.plan.targets.size)
assertEquals("com.android.deskclock", request.plan.targets.single().packageName)
assertEquals("Start the requested timer in Clock.", request.plan.targets.single().objective)
assertEquals(
SessionFinalPresentationPolicy.ATTACHED,
request.plan.targets.single().finalPresentationPolicy,
)
}
@Test
@@ -55,6 +60,10 @@ class AgentFrameworkToolBridgeTest {
)
assertEquals("Start a 5-minute timer.", request.plan.targets.single().objective)
assertEquals(
SessionFinalPresentationPolicy.AGENT_CHOICE,
request.plan.targets.single().finalPresentationPolicy,
)
assertEquals(true, request.allowDetachedMode)
}
@@ -68,7 +77,8 @@ class AgentFrameworkToolBridgeTest {
"targets": [
{
"packageName": "com.unknown.app",
"objective": "Do the task."
"objective": "Do the task.",
"finalPresentationPolicy": "AGENT_CHOICE"
}
]
}
@@ -86,4 +96,32 @@ class AgentFrameworkToolBridgeTest {
)
}
@Test
fun parseStartDirectSessionArgumentsRejectsDetachedPresentationWithoutDetachedMode() {
val err = runCatching {
AgentFrameworkToolBridge.parseStartDirectSessionArguments(
arguments = JSONObject(
"""
{
"targets": [
{
"packageName": "com.android.deskclock",
"finalPresentationPolicy": "DETACHED_SHOWN"
}
],
"allowDetachedMode": false
}
""".trimIndent(),
),
userObjective = "Keep Clock visible in detached mode.",
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
)
}.exceptionOrNull()
assertTrue(err is java.io.IOException)
assertEquals(
"Framework session tool selected detached final presentation without allowDetachedMode: com.android.deskclock",
err?.message,
)
}
}

View File

@@ -1,84 +1,76 @@
package com.openai.codex.agent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AgentParentSessionAggregatorTest {
@Test
fun rollupReturnsCompletedSummaryWhenChildrenComplete() {
fun rollupRequestsAttachWhenAttachedPresentationIsRequired() {
val rollup = AgentParentSessionAggregator.rollup(
listOf(
ParentSessionChildSummary(
sessionId = "child-1",
targetPackage = "com.android.deskclock",
state = AgentSessionStateValues.COMPLETED,
latestResult = "Alarm set for 2:07 PM.",
targetPresentation = AgentTargetPresentationValues.DETACHED_SHOWN,
requiredFinalPresentationPolicy = SessionFinalPresentationPolicy.ATTACHED,
latestResult = "Started the stopwatch.",
latestError = null,
),
),
)
assertEquals(AgentSessionStateValues.COMPLETED, rollup.state)
assertEquals(
"Completed delegated session; com.android.deskclock: Alarm set for 2:07 PM.",
rollup.resultMessage,
)
assertNull(rollup.errorMessage)
assertEquals(AgentSessionStateValues.RUNNING, rollup.state)
assertEquals(listOf("child-1"), rollup.sessionsToAttach)
assertEquals(null, rollup.resultMessage)
assertEquals(null, rollup.errorMessage)
}
@Test
fun rollupReturnsWaitingWhenAnyChildWaitsForUser() {
fun rollupFailsWhenDetachedShownIsRequiredButTargetIsHidden() {
val rollup = AgentParentSessionAggregator.rollup(
listOf(
ParentSessionChildSummary(
sessionId = "child-1",
targetPackage = "com.android.deskclock",
state = AgentSessionStateValues.WAITING_FOR_USER,
latestResult = null,
latestError = null,
),
ParentSessionChildSummary(
sessionId = "child-2",
targetPackage = "com.android.settings",
state = AgentSessionStateValues.COMPLETED,
latestResult = "Completed task.",
latestError = null,
),
),
)
assertEquals(AgentSessionStateValues.WAITING_FOR_USER, rollup.state)
assertNull(rollup.resultMessage)
assertNull(rollup.errorMessage)
}
@Test
fun rollupReturnsFailedSummaryWhenAnyChildFails() {
val rollup = AgentParentSessionAggregator.rollup(
listOf(
ParentSessionChildSummary(
sessionId = "child-1",
targetPackage = "com.android.deskclock",
state = AgentSessionStateValues.FAILED,
latestResult = null,
latestError = "Permission denied.",
),
ParentSessionChildSummary(
sessionId = "child-2",
targetPackage = "com.android.settings",
state = AgentSessionStateValues.COMPLETED,
latestResult = "Completed task.",
targetPresentation = AgentTargetPresentationValues.DETACHED_HIDDEN,
requiredFinalPresentationPolicy = SessionFinalPresentationPolicy.DETACHED_SHOWN,
latestResult = "Started the stopwatch.",
latestError = null,
),
),
)
assertEquals(AgentSessionStateValues.FAILED, rollup.state)
assertNull(rollup.resultMessage)
assertEquals(emptyList<String>(), rollup.sessionsToAttach)
assertEquals(
"Delegated session failed; com.android.deskclock: Permission denied.",
"Delegated session completed without the required final presentation; com.android.deskclock: required DETACHED_SHOWN, actual DETACHED_HIDDEN",
rollup.errorMessage,
)
}
@Test
fun rollupCompletesWhenRequiredPresentationMatches() {
val rollup = AgentParentSessionAggregator.rollup(
listOf(
ParentSessionChildSummary(
sessionId = "child-1",
targetPackage = "com.android.deskclock",
state = AgentSessionStateValues.COMPLETED,
targetPresentation = AgentTargetPresentationValues.ATTACHED,
requiredFinalPresentationPolicy = SessionFinalPresentationPolicy.ATTACHED,
latestResult = "Started the stopwatch.",
latestError = null,
),
),
)
assertEquals(AgentSessionStateValues.COMPLETED, rollup.state)
assertEquals(emptyList<String>(), rollup.sessionsToAttach)
assertEquals(
"Completed delegated session; com.android.deskclock: Started the stopwatch.",
rollup.resultMessage,
)
}
}

View File

@@ -14,7 +14,8 @@ class AgentTaskPlannerTest {
"targets": [
{
"packageName": "com.android.deskclock",
"objective": "Start the requested timer in Clock."
"objective": "Start the requested timer in Clock.",
"finalPresentationPolicy": "ATTACHED"
}
],
"reason": "DeskClock is the installed timer handler.",
@@ -30,6 +31,10 @@ class AgentTaskPlannerTest {
assertEquals(1, request.plan.targets.size)
assertEquals("com.android.deskclock", request.plan.targets.single().packageName)
assertEquals("Start the requested timer in Clock.", request.plan.targets.single().objective)
assertEquals(
SessionFinalPresentationPolicy.ATTACHED,
request.plan.targets.single().finalPresentationPolicy,
)
}
@Test
@@ -52,6 +57,10 @@ class AgentTaskPlannerTest {
)
assertEquals("Start a 5-minute timer.", request.plan.targets.single().objective)
assertEquals(
SessionFinalPresentationPolicy.AGENT_CHOICE,
request.plan.targets.single().finalPresentationPolicy,
)
assertEquals(true, request.allowDetachedMode)
}

View File

@@ -36,6 +36,7 @@ This Codex runtime is operating on an Android device through the Agent Platform.
- Detached launch, shown-detached, and attached are different states.
- `targetDetached=true` means the target is still detached even if it is visible in a detached or mirrored presentation.
- If the delegated objective specifies a required final target presentation such as `ATTACHED`, `DETACHED_HIDDEN`, or `DETACHED_SHOWN`, treat that as a hard completion requirement and do not claim success until the framework session matches it.
- If the task says the app should be visible to the user, do not claim success until the target is attached unless the task explicitly allows detached presentation.
- If the user asks to show an activity on the screen, the Genie must explicitly make its display visible. Launching hidden or leaving the target detached is not enough.
- Treat framework session state as the source of truth for presentation state.

View File

@@ -564,6 +564,7 @@ class CodexAppServerHost(
The Genie may request detached target launch through the framework callback, and after that it may use supported self-targeted shell commands to drive the target app.
If a direct intent launch does not fully complete the task, use detached-target tools to show or inspect the target, then continue with supported shell input and inspection surfaces.
Use Android dynamic tools only for framework-only detached target operations that do not have a working shell equivalent in the paired app sandbox.
The delegated objective may include a required final target presentation such as ATTACHED, DETACHED_HIDDEN, or DETACHED_SHOWN. Treat that as a hard completion requirement and do not report success until the framework session actually matches it.
If you need clarification or a decision from the supervising Agent, call request_user_input with concise free-form question text.
Do not use hidden control protocols.
Finish with a normal assistant message describing what you accomplished or what blocked you.

View File

@@ -59,6 +59,14 @@ The current repo now contains these implementation slices:
runtime before falling back to notification/UI escalation, and now submits
those answers through the same framework-session bridge instead of a separate
Kotlin-only path.
- 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()`
state to verify whether a completed child actually satisfied it.
- Parent roll-up now uses the new presentation state. If a child was required
to finish `ATTACHED` but completes detached, the Agent requests `attachTarget`
before rolling the parent up to success. Detached shown/hidden mismatches are
treated as real completion errors instead of silent success.
- Runtime testing on the emulator showed that direct cross-app `bindService`
and raw local-socket access from the live Genie runtime are not a stable
contract because the runtime executes under the paired target sandbox
@@ -151,6 +159,9 @@ the Android Agent/Genie flow.
- Dedicated framework-session bridge tools for direct Genie-session launch and question resolution
- Framework session inspection UI in the Agent app
- Question answering and detached-target attach controls
- Explicit per-child final target presentation policy in planning/session
launch, backed by framework-authoritative presentation state in session
observation and diagnostics
- Framework session bridge request handling in `AgentSessionBridgeServer`
- Framework session bridge request issuance in `CodexGenieService`
- Agent-hosted runtime metadata for Genie bootstrap