Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] eb93fda060 fix(app): keep image selections through Android Activity recreation
Keep in-flight Expo activity-result callbacks in the live AppContext until the picker returns, then release the detached registration. Run the lifecycle regression tests with the Android module suite.
2026-10-23 19:14:47 +02:00
Ni Xiaoandnistream d6fa231894 fix(plugin): restore ACP history replayed during session load (#7717)
ACP agents replay a loaded session's session/update notifications before
session/load resolves, but AcpRuntime.open registered nativeSessionId only
after the response, so the session filter dropped every replayed update and
restored sessions opened with an empty timeline.

Register the native id before the request, drain the notification lane once
the response arrives so the replay completes before session.opened, and
follow the open request's history contract: replayed updates rebuild the
timeline under history "replay" and are dropped under history "skip".
Replayed user_message_chunk updates now restore user messages through the
chunk fallback scheme, so multi-chunk messages keep their boundaries; live
user chunks stay dropped because admitPrompt already echoes the prompt.

Co-authored-by: nistream <nistream@users.noreply.github.com>
2026-10-23 18:05:54 +02:00
paseo-bot[bot] fed19f3d29 fix(app): identify Android DNS connection failures (#7701)
Recognize explicit name-resolution failures before TLS keywords in hostnames, while preserving certificate-not-found handling and raw details.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-23 14:57:19 +02:00
paseo-ai[bot] 74dc764f0d fix: update lockfile signatures and Nix hash [skip ci] 2026-10-23 12:38:01 +00:00
8 changed files with 363 additions and 23 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
+1 -1
View File
@@ -40,7 +40,7 @@
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
"build:apk": "npm --prefix ../.. run build:apk",
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :expo-modules-core:testReleaseUnitTest --tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -28,6 +28,23 @@ function failure(message: string) {
}
describe("direct connection failure copy", () => {
it.each([
"paseo-qa-nonexistent.invalid",
"ssl-paseo-qa.invalid",
"tls-paseo-qa.invalid",
"certificate-paseo-qa.invalid",
])(
"identifies Android DNS failures for %s without treating the hostname as TLS evidence",
(host) => {
const message = `Unable to resolve host "${host}": No address associated with hostname`;
expect(failure(message)).toEqual({
title: "Failed: tcp://example.test:443?ssl=true",
detail: labels.hostNotFound,
raw: `transport error — ${message}`,
});
},
);
it("identifies Android certificate validation errors without calling them DNS failures", () => {
const message =
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
@@ -43,6 +60,7 @@ describe("direct connection failure copy", () => {
["SSL handshake failed", labels.tlsError],
["TLS handshake failed", labels.tlsError],
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
["getaddrinfo ENOTFOUND ssl.example.test", labels.hostNotFound],
["Host not found", labels.hostNotFound],
["connect ECONNREFUSED", labels.refused],
["connection timed out", labels.timedOut],
@@ -83,6 +83,8 @@ export function buildConnectionFailureCopy(input: {
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (rawLower.includes("unable to resolve host") || rawLower.includes("enotfound")) {
detail = labels.hostNotFound;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("certpathvalidator") ||
@@ -91,7 +93,7 @@ export function buildConnectionFailureCopy(input: {
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
} else if (rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
@@ -374,13 +374,19 @@ class AcpRuntime {
/**
* Fallback ids for streamed chunks that carry no `messageId`, one per
* chunk kind. Consecutive chunks of a kind continue the same timeline
* item; a chunk of the other kind, a tool call, a plan, a user message,
* or a turn boundary ends it (#4699).
* item; a chunk of another kind, a tool call, a plan, or a turn boundary
* ends it (#4699).
*/
private readonly fallbackChunkIds = new Map<
"agent_message_chunk" | "agent_thought_chunk",
"agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk",
string
>();
/**
* Active while a persisted session loads. ACP agents replay the loaded
* session's updates before session/load resolves; the value follows the
* open request's history contract.
*/
private replayWindow: "replay" | "skip" | null = null;
private closing = false;
private processFailed = false;
private configTransaction = false;
@@ -499,15 +505,23 @@ class AcpRuntime {
};
let response: Pick<NewSessionResponse, "modes" | "configOptions">;
if (nativeSessionId) {
response = await this.call(
this.connection.loadSession({
sessionId: nativeSessionId,
cwd: input.config.cwd,
mcpServers,
_meta: metadata,
}),
);
// ACP agents replay the loaded session's updates before session/load resolves,
// so the native id must be registered first for them to pass the session filter.
this.nativeSessionId = nativeSessionId;
this.replayWindow = input.history === "replay" ? "replay" : "skip";
try {
response = await this.call(
this.connection.loadSession({
sessionId: nativeSessionId,
cwd: input.config.cwd,
mcpServers,
_meta: metadata,
}),
);
await this.drainNotifications();
} finally {
this.replayWindow = null;
}
} else {
const newSession = await this.call(
this.connection.newSession({
@@ -898,6 +912,14 @@ class AcpRuntime {
private sessionUpdate(notification: SessionNotification): void {
if (notification.sessionId !== this.nativeSessionId) return;
if (this.replayWindow === "skip") return;
if (
this.replayWindow === "replay" &&
notification.update.sessionUpdate === "user_message_chunk"
) {
this.reduceUserMessageChunk(notification.update);
return;
}
this.reduceUpdate(notification.update);
}
@@ -911,13 +933,14 @@ class AcpRuntime {
this.fallbackChunkIds.clear();
return;
}
// A chunk of one kind ends the other kind's item, whatever its content
// or id: reasoning, then text, then reasoning again are three items.
// A chunk of one kind ends every other kind's open item, whatever its
// content or id: reasoning, then text, then reasoning again are three items.
this.fallbackChunkIds.delete(
update.sessionUpdate === "agent_message_chunk"
? "agent_thought_chunk"
: "agent_message_chunk",
);
this.fallbackChunkIds.delete("user_message_chunk");
if (update.content.type !== "text") return;
const id = this.resolveChunkId(update.sessionUpdate, update.messageId);
const text = `${this.messages.get(id) ?? ""}${update.content.text}`;
@@ -945,8 +968,31 @@ class AcpRuntime {
this.reduceStateUpdate(update);
}
// Historical user messages arrive only as replayed chunks; a live prompt
// already echoes its own user message when it is admitted.
private reduceUserMessageChunk(
update: Extract<SessionUpdate, { sessionUpdate: "user_message_chunk" }>,
): void {
this.fallbackChunkIds.delete("agent_message_chunk");
this.fallbackChunkIds.delete("agent_thought_chunk");
if (update.content.type !== "text") return;
const id = this.resolveChunkId("user_message_chunk", update.messageId);
const text = `${this.messages.get(id) ?? ""}${update.content.text}`;
this.messages.set(id, text);
this.emit({
type: "timeline.item",
sessionId: this.options.boundarySessionId,
item: {
type: "user_message",
id,
text,
...(update.messageId ? { messageId: update.messageId } : {}),
},
});
}
private resolveChunkId(
kind: "agent_message_chunk" | "agent_thought_chunk",
kind: "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk",
messageId: string | null | undefined,
): string {
if (messageId) {
+184
View File
@@ -1500,3 +1500,187 @@ describe("ACP streamed message boundaries", () => {
},
);
});
function userChunk(text: string, messageId?: string): SessionUpdate {
return {
sessionUpdate: "user_message_chunk",
content: { type: "text", text },
...(messageId === undefined ? {} : { messageId }),
};
}
async function openReplaySession(
replay: SessionUpdate[],
history: "replay" | "skip",
): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> {
const harness = connectorHarness({
capabilities: { loadSession: true },
handleRequest(instance, request) {
if (request.method !== "session/load") return false;
for (const update of replay) {
instance.notify("session/update", { sessionId: "connector-session", update });
}
instance.respond(request, {
sessionId: "connector-session",
modes: null,
configOptions: [],
});
return true;
},
});
const registration = runAcpProvider({
id: "replay-acp",
label: "Replay ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message", "session.persistence"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
const input = openInput();
input.persistence = { version: 1, data: { sessionId: "connector-session" } };
input.history = history;
await connection.send(input);
await waitForEvent(events, (event) => event.type === "session.ready");
return { connection, events };
}
function timelineProjection(events: ProviderEvent[]): Array<Record<string, string>> {
return events.flatMap((event) => {
if (event.type !== "timeline.item") return [];
const item = event.item;
if (
item.type === "user_message" ||
item.type === "assistant_message" ||
item.type === "reasoning"
) {
return [{ type: item.type, id: item.id, text: item.text }];
}
if (item.type === "tool_call") return [{ type: item.type, id: item.id, status: item.status }];
return [{ type: item.type, id: item.id }];
});
}
describe("ACP session history replay", () => {
it("restores the timeline replayed before session/load resolves", async () => {
const { connection, events } = await openReplaySession(
[
userChunk("What is the ", "user-1"),
userChunk("temperature?", "user-1"),
textChunk("Checking the sensor", "agent_thought_chunk"),
{
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "Read sensor",
kind: "read",
status: "in_progress",
},
{ sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: "completed" },
textChunk("It is 25°C"),
],
"replay",
);
try {
expect(timelineProjection(events)).toEqual([
{ type: "user_message", id: "user-1", text: "What is the " },
{ type: "user_message", id: "user-1", text: "What is the temperature?" },
{ type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" },
{ type: "tool_call", id: "tool-1", status: "running" },
{ type: "tool_call", id: "tool-1", status: "completed" },
{ type: "assistant_message", id: "agent_message_chunk:2", text: "It is 25°C" },
]);
const lastReplayed = events.findLastIndex((event) => event.type === "timeline.item");
const opened = events.findIndex((event) => event.type === "session.opened");
expect(lastReplayed).toBeLessThan(opened);
} finally {
await connection.close();
}
});
it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => {
const { connection, events } = await openReplaySession(
[
userChunk("first "),
userChunk("question"),
textChunk("answer"),
userChunk("second question"),
],
"replay",
);
try {
expect(timelineProjection(events)).toEqual([
{ type: "user_message", id: "user_message_chunk:1", text: "first " },
{ type: "user_message", id: "user_message_chunk:1", text: "first question" },
{ type: "assistant_message", id: "agent_message_chunk:2", text: "answer" },
{ type: "user_message", id: "user_message_chunk:3", text: "second question" },
]);
} finally {
await connection.close();
}
});
it("does not duplicate the live prompt echo when the agent echoes it as a user chunk", async () => {
const harness = connectorHarness({
capabilities: { loadSession: true },
promptUpdates: (promptText) => [
{ sessionUpdate: "user_message_chunk", content: { type: "text", text: promptText } },
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Acknowledged" } },
],
});
const registration = runAcpProvider({
id: "echo-acp",
label: "Echo ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message", "session.persistence"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
const input = openInput();
input.persistence = { version: 1, data: { sessionId: "connector-session" } };
input.history = "replay";
await connection.send(input);
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.send({
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "echo-1",
delivery: "auto",
input: { type: "message", content: [{ type: "text", text: "hello" }] },
},
});
try {
await waitForEvent(
events,
(event) =>
event.type === "session.turn" &&
event.turnId === "acp:echo-1" &&
event.state === "completed",
);
expect(timelineProjection(events)).toEqual([
{ type: "user_message", id: "echo-1", text: "hello" },
{ type: "assistant_message", id: "agent_message_chunk:1", text: "Acknowledged" },
]);
} finally {
await connection.close();
}
});
it("drops replayed updates when the open request asks to skip history", async () => {
const { connection, events } = await openReplaySession(
[userChunk("What is in the README?", "user-1"), textChunk("It describes the project.")],
"skip",
);
try {
expect(timelineProjection(events)).toEqual([]);
} finally {
await connection.close();
}
});
});
+94 -5
View File
@@ -1,5 +1,5 @@
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt
index a5b971e..7ef1b59 100644
index a5b971e..84ba96f 100644
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt
+++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt
@@ -29,6 +29,7 @@ import expo.modules.kotlin.safeGetParcelable
@@ -10,7 +10,22 @@ index a5b971e..7ef1b59 100644
private const val TAG = "ActivityResultRegistry"
@@ -222,6 +223,7 @@ class AppContextActivityResultRegistry(
@@ -208,7 +209,13 @@ class AppContextActivityResultRegistry(
}
Lifecycle.Event.ON_DESTROY -> {
- unregister(key)
+ if (launchedKeys.contains(key) && keyToCallbacksAndContract[key]?.mainCallback != null) {
+ // The AppContext and awaiting caller survive Activity recreation.
+ // Detach the old lifecycle, but keep the in-flight result callback.
+ keyToLifecycleContainers.remove(key)?.clearObservers()
+ } else {
+ unregister(key)
+ }
}
else -> Unit
@@ -222,6 +229,7 @@ class AppContextActivityResultRegistry(
return object : AppContextActivityResultLauncher<I, O>() {
override fun launch(input: I, callback: ActivityResultCallback<O>) {
val requestCode = keyToRequestCode[key]
@@ -18,7 +33,7 @@ index a5b971e..7ef1b59 100644
?: throw IllegalStateException("Attempting to launch an unregistered ActivityResultLauncher with contract $contract and input $input. You must ensure the ActivityResultLauncher is registered before calling launch()")
keyToCallbacksAndContract[key] = CallbacksAndContract(fallbackCallback, callback, contract)
@@ -240,6 +242,39 @@ class AppContextActivityResultRegistry(
@@ -240,6 +248,39 @@ class AppContextActivityResultRegistry(
}
}
@@ -58,12 +73,24 @@ index a5b971e..7ef1b59 100644
/**
* Persist the state of the registry.
*/
@@ -324,6 +365,11 @@ class AppContextActivityResultRegistry(
val input = keyToInputParam[key] as I
callbacksAndContract.mainCallback.onActivityResult(callbacksAndContract.contract.parseResult(input, resultCode, data))
launchedKeys.remove(key)
+ // Release a completed registration whose Activity was destroyed. The
+ // retained launcher will register against the live Activity on its next use.
+ if (!keyToLifecycleContainers.containsKey(key)) {
+ unregister(key)
+ }
} else if (currentLifecycleState != null && currentLifecycleState.isAtLeast(Lifecycle.State.STARTED) && callbacksAndContract != null && launchedKeys.contains(key)) {
// 2. Activity has already started, so let's proceed with fallback callback scenario
@Suppress("UNCHECKED_CAST")
diff --git a/node_modules/expo-modules-core/android/src/test/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistryRecreationTest.kt b/node_modules/expo-modules-core/android/src/test/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistryRecreationTest.kt
new file mode 100644
index 0000000..89a8eb1
index 0000000..71d740c
--- /dev/null
+++ b/node_modules/expo-modules-core/android/src/test/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistryRecreationTest.kt
@@ -0,0 +1,78 @@
@@ -0,0 +1,140 @@
+package expo.modules.kotlin.activityresult
+
+import android.app.Activity
@@ -71,6 +98,7 @@ index 0000000..89a8eb1
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import expo.modules.kotlin.providers.CurrentActivityProvider
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
@@ -95,6 +123,67 @@ index 0000000..89a8eb1
+ }.setup()
+
+ @Test
+ fun inFlightResultReachesOriginalCallerAfterRepeatedActivityRecreation() {
+ var controller = createActivity()
+ val provider = ActivityProvider(controller.get())
+ val registry = AppContextActivityResultRegistry(provider)
+ val results = mutableListOf<String>()
+ val fallbackResults = mutableListOf<String>()
+ val launcher = registry.register("image", controller.get(), contract) { _, result ->
+ fallbackResults.add(result)
+ }
+
+ // Both selection and cancellation must settle the awaiting picker call.
+ listOf(Activity.RESULT_OK, Activity.RESULT_CANCELED, Activity.RESULT_OK).forEachIndexed { index, code ->
+ launcher.launch("image-$index") { results.add(it) }
+ val started = shadowOf(controller.get()).nextStartedActivityForResult
+ controller.pause().stop().destroy()
+ controller = createActivity()
+ provider.currentActivity = controller.get()
+
+ assertTrue(registry.dispatchResult(started.requestCode, code, null))
+ assertEquals(index + 1, results.size)
+ assertEquals("image-$index:$code", results.last())
+ // A completed request from the destroyed Activity is no longer registered.
+ assertFalse(registry.dispatchResult(started.requestCode, code, null))
+ }
+
+ launcher.launch("next-pick") { results.add(it) }
+ val next = shadowOf(controller.get()).nextStartedActivityForResult
+ assertTrue(registry.dispatchResult(next.requestCode, Activity.RESULT_OK, null))
+ assertEquals(listOf("image-0:-1", "image-1:0", "image-2:-1", "next-pick:-1"), results)
+ assertEquals(emptyList<String>(), fallbackResults)
+ controller.pause().stop().destroy()
+ }
+
+ @Test
+ fun restoredRegistryStillUsesFallbackWithoutTheOriginalAppContext() {
+ val previous = createActivity()
+ val provider = ActivityProvider(previous.get())
+ val original = AppContextActivityResultRegistry(provider)
+ val results = mutableListOf<String>()
+ val launcher = original.register("image", previous.get(), contract) { _, result ->
+ results.add("old-fallback:$result")
+ }
+ launcher.launch("restored-image") { results.add("old-caller:$it") }
+ val started = shadowOf(previous.get()).nextStartedActivityForResult
+ original.persistInstanceState(previous.get())
+ previous.pause().stop().destroy()
+
+ val replacement = createActivity()
+ provider.currentActivity = replacement.get()
+ val restored = AppContextActivityResultRegistry(provider)
+ restored.restoreInstanceState(replacement.get())
+ restored.register("image", replacement.get(), contract) { _, result ->
+ results.add("fallback:$result")
+ }
+
+ assertTrue(restored.dispatchResult(started.requestCode, Activity.RESULT_OK, null))
+ assertEquals(listOf("fallback:restored-image:-1"), results)
+ replacement.pause().stop().destroy()
+ }
+
+ @Test
+ fun retainedLauncherDeliversResultsAfterRepeatedActivityRecreation() {
+ var controller = createActivity()
+ val provider = ActivityProvider(controller.get())
+2 -1
View File
@@ -9,7 +9,8 @@ import { join, relative } from "node:path";
// the patch's node_modules/... paths relative to its working directory.
const patchedPackages = [
// Backport for expo-modules-core 57.0.23: https://github.com/expo/expo/pull/49634.
// Remove when the installed Expo SDK includes the Activity recreation fix.
// Also retain in-flight picker callbacks across same-process Activity recreation (#7557).
// Remove when the installed Expo SDK includes both Activity recreation fixes.
{
nodeModulesPath: "node_modules/expo-modules-core",
patchPrefix: "expo-modules-core+",