From fd65985e9d588109898fcf84298b6ecd1815462a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 14:45:13 +0700 Subject: [PATCH 1/3] fix(android): resize header fades after rotation (#8349) Clear Android SVG descendant geometry caches when the canvas resizes. Cover growth and shrinkage with a native pixel-rendering regression. --- docs/android.md | 12 +++ patches/react-native-svg+15.15.4.patch | 127 +++++++++++++++++++++++++ scripts/postinstall-patches.mjs | 2 + 3 files changed, 141 insertions(+) diff --git a/docs/android.md b/docs/android.md index 6188589eb8..17bafd6b24 100644 --- a/docs/android.md +++ b/docs/android.md @@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted ## Local build + install +The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after +rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the +resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation +regression renders real gradient pixels across growing and shrinking layouts, including a nested +group. From `packages/app/android`, run it on your selected emulator: + +```bash +ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \ + --no-daemon --max-workers=2 -Dorg.gradle.parallel=false +``` + The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`. It backports [Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing diff --git a/patches/react-native-svg+15.15.4.patch b/patches/react-native-svg+15.15.4.patch index b6b4a85a90..90a30090d7 100644 --- a/patches/react-native-svg+15.15.4.patch +++ b/patches/react-native-svg+15.15.4.patch @@ -59,3 +59,130 @@ index 0c854d6..7dc2828 100644 } return props.transform as TransformsStyle['transform']; } +diff --git a/node_modules/react-native-svg/android/build.gradle b/node_modules/react-native-svg/android/build.gradle +--- a/node_modules/react-native-svg/android/build.gradle ++++ b/node_modules/react-native-svg/android/build.gradle +@@ -103,6 +103,7 @@ + } + + defaultConfig { ++ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + minSdkVersion safeExtGet('minSdkVersion', 16) + //noinspection OldTargetApi + targetSdkVersion safeExtGet('targetSdkVersion', 28) +@@ -136,6 +137,8 @@ + } + + dependencies { ++ androidTestImplementation 'androidx.test:runner:1.6.2' ++ androidTestImplementation 'androidx.test.ext:junit:1.2.1' + implementation 'com.facebook.react:react-native:+' + implementation("com.facebook.fresco:fresco:${FRESCO_VERSION}") { + exclude group: 'com.facebook.soloader' +diff --git a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java +--- a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java ++++ b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java +@@ -159,6 +159,8 @@ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); ++ // Percentage lengths and paths depend on the canvas size, not just SVG props. ++ clearChildCache(); + this.invalidate(); + } + +diff --git a/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java +new file mode 100644 +--- /dev/null ++++ b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java +@@ -0,0 +1,90 @@ ++package com.horcrux.svg; ++ ++import static org.junit.Assert.assertEquals; ++import static org.junit.Assert.assertTrue; ++ ++import android.graphics.Bitmap; ++import android.graphics.Canvas; ++import android.graphics.Color; ++import android.view.View; ++import androidx.test.ext.junit.runners.AndroidJUnit4; ++import androidx.test.platform.app.InstrumentationRegistry; ++import com.facebook.react.bridge.BridgeReactContext; ++import com.facebook.react.bridge.DynamicFromObject; ++import com.facebook.react.bridge.JavaOnlyArray; ++import com.facebook.react.bridge.JavaOnlyMap; ++import com.facebook.react.soloader.OpenSourceMergedSoMapping; ++import com.facebook.react.uimanager.DisplayMetricsHolder; ++import com.facebook.soloader.SoLoader; ++import org.junit.Test; ++import org.junit.runner.RunWith; ++ ++@RunWith(AndroidJUnit4.class) ++public class SvgResizeTest { ++ @Test ++ public void percentageFadeFollowsCanvasGrowthAndShrinkage() { ++ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { ++ android.content.Context app = InstrumentationRegistry.getInstrumentation().getTargetContext(); ++ DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(app); ++ try { ++ SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE); ++ } catch (java.io.IOException exception) { ++ throw new RuntimeException(exception); ++ } ++ BridgeReactContext context = new DrawingContext(app); ++ SvgView svg = new SvgView(context); ++ LinearGradientView gradient = new LinearGradientView(context); ++ gradient.setName("fade"); ++ gradient.setX1(new DynamicFromObject("0%")); ++ gradient.setY1(new DynamicFromObject("0%")); ++ gradient.setX2(new DynamicFromObject("0%")); ++ gradient.setY2(new DynamicFromObject("100%")); ++ gradient.setGradientUnits(0); ++ gradient.setGradient(JavaOnlyArray.of(0.0, Color.RED, 1.0, Color.TRANSPARENT)); ++ svg.addView(gradient); ++ // Exercise recursive invalidation as well as the direct child case. ++ GroupView group = new GroupView(context); ++ RectView rectangle = new RectView(context); ++ rectangle.setX(new DynamicFromObject(0.0)); ++ rectangle.setY(new DynamicFromObject(0.0)); ++ rectangle.setWidth(new DynamicFromObject("100%")); ++ rectangle.setHeight(new DynamicFromObject("100%")); ++ rectangle.setFill(JavaOnlyMap.of("type", 1, "brushRef", "fade")); ++ group.addView(rectangle); ++ svg.addView(group); ++ ++ assertFade(svg, 80, 60); ++ assertFade(svg, 160, 120); ++ assertFade(svg, 60, 40); ++ assertFade(svg, 160, 120); ++ }); ++ } ++ ++ // Pixel rendering needs a React context but does not send layout events to JavaScript. ++ private static final class DrawingContext extends BridgeReactContext ++ implements com.facebook.react.uimanager.events.EventDispatcherProvider { ++ DrawingContext(android.content.Context context) { super(context); } ++ ++ @Override ++ public com.facebook.react.uimanager.events.EventDispatcher getEventDispatcher() { return null; } ++ } ++ ++ private static void assertFade(SvgView svg, int width, int height) { ++ svg.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), ++ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)); ++ svg.layout(0, 0, width, height); ++ Bitmap image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); ++ svg.draw(new Canvas(image)); ++ try { ++ int middle = Color.alpha(image.getPixel(width / 2, height / 2)); ++ int right = Color.alpha(image.getPixel(width - 2, height / 2)); ++ assertTrue("Gradient midpoint must follow canvas height at " + width + "x" + height ++ + "; alpha=" + middle, middle >= 120 && middle <= 135); ++ assertEquals("Fade must reach the right edge at " + width + "x" + height, middle, right); ++ assertTrue("Fade must end at the new bottom edge", ++ Color.alpha(image.getPixel(width / 2, height - 2)) < 20); ++ } finally { ++ image.recycle(); ++ } ++ } ++} diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 312bb90724..fbf87241c7 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -59,6 +59,8 @@ const patchedPackages = [ nodeModulesPath: "node_modules/@gorhom/bottom-sheet", patchPrefix: "@gorhom+bottom-sheet+", }, + // Android resize: retain until upstream clears descendant geometry when the SVG canvas resizes. + // The native pixel regression is included in the patch; preserve the separate transform hunks. { nodeModulesPath: "packages/app/node_modules/react-native-svg", patchPrefix: "react-native-svg+", From a7d9bb34fce2633ecb847786d5bc03ce212f741c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 17:48:42 +0700 Subject: [PATCH 2/3] fix(plugins): keep rewound conversations on their retained branch (#8368) Refresh direct plugin history from authoritative native persistence after conversation/combined rewind, matching the built-in providers' history refresh strategy. Reopen/replay rebuilds translation baselines, rewind tokens and child bindings while preserving outward subscriptions; file-only rewind stays unchanged. Keep replay internal and replace the manager timeline after it completes. Preserve the committed native handle and close routing across refresh failures so retry and reload recover the retained branch. The full native session reload is intentionally inefficient, pending a smarter timeline cut that restores translation state, reused IDs, children and permissions without reopening. Closes #4667 Supersedes #4668 and the rewind-history proposal in #4754. Verified with 72 targeted tests, green CI, and real OMP browser UI A/B/C -> rewind B -> replacement D with native history/timeline equality and a clean browser reload. Co-authored-by: Matt Cowger Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com> --- .../server/src/server/agent/agent-manager.ts | 4 + .../src/server/agent/plugin-provider.test.ts | 568 ++++++++++++++++++ .../src/server/agent/plugin-provider.ts | 193 +++++- public-docs/plugins/providers.md | 5 + 4 files changed, 745 insertions(+), 25 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index a761e723c1..c69a574849 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -3505,6 +3505,10 @@ export class AgentManager { "agent.rewind.complete", ); } catch (error) { + // Native rewind can commit a fork before history refresh fails. Recovery + // must resume that latest handle rather than the abandoned conversation. + this.refreshSessionPersistence(agent); + await this.persistSnapshot(agent); this.logger.warn( { err: error, agentId, provider: agent.provider, messageId, mode }, "agent.rewind.failed", diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 101a19a39f..a9cf3b0ed5 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -53,6 +53,7 @@ const CAPABILITIES = [ interface ProviderHarnessOptions { capabilities?: ProviderConnection["capabilities"]; completeTurn?: boolean; + closeAcknowledgementOnly?: boolean; openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void; handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise; } @@ -208,6 +209,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) { } if (input.type === "session.close") { emit({ type: "session.closed", sessionId: input.sessionId }); + if (options.closeAcknowledgementOnly) return; } if ("requestId" in input) { emit({ type: "request.completed", requestId: input.requestId }); @@ -246,6 +248,572 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"] return events.filter((event) => event.type === type); } +function createRewindProviderHarness( + options: { + reuseAnswerId?: boolean; + forkOnRevert?: boolean; + handleInput?: ProviderHarnessOptions["handleInput"]; + } = {}, +) { + const nativeMessages: string[] = []; + let originalMessages: string[] | null = null; + const harness = createProviderHarness({ + closeAcknowledgementOnly: true, + capabilities: [ + ...CAPABILITIES, + "session.revert.conversation", + "session.revert.files", + "session.revert.both", + ], + async handleInput(input, emit) { + if (await options.handleInput?.(input, emit)) return true; + if (input.type === "session.open" && input.history === "replay") { + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: [ + ...CAPABILITIES, + "session.revert.conversation", + "session.revert.files", + "session.revert.both", + ], + restoration: "core", + persistence: input.persistence ?? { version: 1, data: { token: "root" } }, + cwd: input.config.cwd, + }); + const messages = + options.forkOnRevert && + (input.persistence?.data as { token?: string })?.token === "root" && + originalMessages + ? originalMessages + : nativeMessages; + for (const [index, text] of messages.entries()) { + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "user_message", id: `user-${text}`, text, revertToken: text }, + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "assistant_message", + id: options.reuseAnswerId ? `answer-${index + 1}` : `answer-${text}`, + text: `Reply ${text}`, + }, + }); + } + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return true; + } + if (input.type === "session.prompt") { + if (input.prompt.input.type !== "message") throw new Error("Expected a message"); + const text = input.prompt.input.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join(""); + nativeMessages.push(text); + const turnId = `turn-${text}`; + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId }, + }); + emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "user_message", + id: `user-${text}`, + text, + clientMessageId: input.prompt.clientMessageId, + revertToken: text, + }, + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "assistant_message", + id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`, + text: `Reply ${text}`, + }, + }); + emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" }); + return true; + } + if (input.type === "session.revert") { + if (options.forkOnRevert && input.scope !== "files") { + originalMessages = [...nativeMessages]; + emit({ + type: "session.persistence", + sessionId: input.sessionId, + persistence: { version: 1, data: { token: "fork" } }, + }); + } + if (input.scope !== "files") + nativeMessages.splice(nativeMessages.indexOf(String(input.token))); + emit({ type: "request.completed", requestId: input.requestId }); + return true; + } + return false; + }, + }); + return { ...harness, nativeMessages }; +} + +async function withRewindAgent( + run: (context: { + manager: AgentManager; + agentId: string; + nativeMessages: string[]; + messages: () => string[]; + }) => Promise, + options: Parameters[0] = {}, +) { + const harness = createRewindProviderHarness(options); + const logger = createTestLogger(); + const registry = new PluginAgentClientRegistry(logger); + registry.replace([harness.registration]); + const manager = new AgentManager({ + logger, + clients: registry.clients(), + providerDefinitions: registry.definitions(), + }); + const agent = await manager.createAgent( + { provider: harness.registration.id, cwd: tmpdir() }, + undefined, + { workspaceId: undefined }, + ); + const messages = () => + manager + .getTimeline(agent.id) + .flatMap((item) => + item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [], + ); + try { + await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" }); + await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" }); + await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" }); + expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]); + await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages }); + } finally { + await manager.closeAgent(agent.id); + await registry.shutdown(); + } +} + +test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => { + await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => { + await manager.rewind(agentId, "user-B", "conversation"); + const afterRewind = messages(); + expect(nativeMessages).toEqual(["A"]); + await manager.runAgent(agentId, "D", { clientMessageId: "client-D" }); + const afterReplacement = messages(); + expect(nativeMessages).toEqual(["A", "D"]); + expect({ afterRewind, afterReplacement }).toEqual({ + afterRewind: ["A", "Reply A"], + afterReplacement: ["A", "Reply A", "D", "Reply D"], + }); + }); +}); + +test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => { + await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => { + await manager.rewind(agentId, "user-B", "files"); + expect(nativeMessages).toEqual(["A", "B", "C"]); + expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]); + await manager.runAgent(agentId, "D", { clientMessageId: "client-D" }); + expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]); + }); +}); + +test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => { + const harness = createRewindProviderHarness({ reuseAnswerId: true }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() }); + const live: AgentStreamEvent[] = []; + try { + await session.run("A"); + await session.run("B"); + session.subscribe((event) => live.push(event)); + await session.revertConversation!({ messageId: "user-B" }); + live.length = 0; + await session.run("B revised"); + const assistant = live.flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [], + ); + expect(harness.nativeMessages).toEqual(["A", "B revised"]); + expect(assistant).toEqual(["Reply B revised"]); + } finally { + await session.close(); + await registry.shutdown(); + } +}); + +async function withPluginRewindSession( + options: Parameters[0], + run: ( + session: Awaited>, + harness: ReturnType, + ) => Promise, +) { + const harness = createRewindProviderHarness(options); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() }); + try { + await session.run("A"); + await session.run("B"); + await session.run("C"); + await run(session, harness); + } finally { + await session.close(); + await registry.shutdown(); + } +} + +async function pluginMessages(session: Awaited>) { + const events = await Array.fromAsync(session.streamHistory()); + return events.flatMap((event) => + event.type === "timeline" && + (event.item.type === "user_message" || event.item.type === "assistant_message") + ? [event.item.text] + : [], + ); +} + +test("combined plugin rewind replays the retained native branch", async () => { + await withPluginRewindSession({}, async (session) => { + await session.revertBoth!({ messageId: "user-B" }); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + await session.revertConversation!({ messageId: "user-A" }); + expect(await pluginMessages(session)).toEqual([]); + }); +}); + +test("plugin rewind resumes the latest persistence rather than the original handle", async () => { + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type === "session.revert") + emit({ + type: "session.persistence", + sessionId: input.sessionId, + persistence: { version: 1, data: { token: "fork" } }, + }); + return false; + }, + }, + async (session, harness) => { + await session.revertConversation!({ messageId: "user-B" }); + expect(harness.inputs.findLast((input) => input.type === "session.open")).toMatchObject({ + persistence: { version: 1, data: { token: "fork" } }, + history: "replay", + }); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + }, + ); +}); + +test("failed native plugin rewind preserves history and does not reopen", async () => { + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.revert") return false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "native rewind failed" }, + }); + return true; + }, + }, + async (session, harness) => { + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "native rewind failed", + ); + expect(await pluginMessages(session)).toEqual([ + "A", + "Reply A", + "B", + "Reply B", + "C", + "Reply C", + ]); + expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1); + }, + ); +}); + +test("failed plugin rewind replay blocks prompts until retry without repeating native rewind", async () => { + let failReplay = true; + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.open" || input.history !== "replay" || !failReplay) + return false; + failReplay = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "replay failed" }, + }); + return true; + }, + }, + async (session, harness) => { + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "replay failed", + ); + await expect(session.run("D")).rejects.toThrow("Retry the rewind or reload"); + await session.revertConversation!({ messageId: "user-B" }); + await session.run("D"); + expect(await pluginMessages(session)).toEqual(["A", "Reply A", "D", "Reply D"]); + expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1); + }, + ); +}); + +test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => { + let failReplay = true; + await withRewindAgent( + async ({ manager, agentId, messages }) => { + await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow( + "replay failed", + ); + await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload"); + await manager.rewind(agentId, "user-B", "conversation"); + expect(messages()).toEqual(["A", "Reply A"]); + await manager.runAgent(agentId, "D"); + expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]); + }, + { + handleInput: async (input, emit) => { + if (input.type !== "session.open" || input.history !== "replay" || !failReplay) + return false; + failReplay = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "replay failed" }, + }); + return true; + }, + }, + ); +}); + +test("reload after failed plugin rewind replay resumes the committed fork", async () => { + let failReplay = true; + await withRewindAgent( + async ({ manager, agentId, messages }) => { + await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow( + "replay failed", + ); + await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true }); + await manager.hydrateTimelineFromProvider(agentId, { force: true }); + expect(messages()).toEqual(["A", "Reply A"]); + }, + { + forkOnRevert: true, + handleInput: async (input, emit) => { + if (input.type === "session.close") { + emit({ type: "request.completed", requestId: input.requestId }); + return true; + } + if (input.type !== "session.open" || input.history !== "replay" || !failReplay) + return false; + failReplay = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "replay failed" }, + }); + return true; + }, + }, + ); +}); + +test("closing during plugin rewind replay closes each native binding once", async () => { + let opened!: () => void; + let release!: () => void; + const replayStarted = new Promise((resolve) => { + opened = resolve; + }); + const replayAllowed = new Promise((resolve) => { + release = resolve; + }); + await withPluginRewindSession( + { + handleInput: async (input) => { + if (input.type === "session.open" && input.history === "replay") { + opened(); + await replayAllowed; + } + return false; + }, + }, + async (session, harness) => { + const rewind = session.revertConversation!({ messageId: "user-B" }); + await replayStarted; + const closing = session.close(); + release(); + await Promise.all([rewind, closing]); + const closes = harness.inputs.flatMap((input) => + input.type === "session.close" ? [input.sessionId] : [], + ); + expect(closes).toHaveLength(2); + expect(new Set(closes).size).toBe(2); + }, + ); +}); + +test("plugin rewind retries a failed close before reopening the native writer", async () => { + let failClose = true; + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.close" || !failClose) return false; + failClose = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "close failed" }, + }); + return true; + }, + }, + async (session, harness) => { + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "close failed", + ); + expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1); + }, + ); +}); + +test("plugin rewind retries permission settlement before retiring its session", async () => { + let failPermission = true; + await withPluginRewindSession( + { + handleInput: async (input) => { + if (input.type !== "session.permission" || !failPermission) return false; + failPermission = false; + throw new Error("permission delivery failed"); + }, + }, + async (session, harness) => { + const opened = harness.inputs.find((input) => input.type === "session.open")!; + if (opened.type !== "session.open") throw new Error("Missing root"); + harness.emit({ + type: "session.permission", + sessionId: opened.sessionId, + request: { id: "pending", name: "tool", kind: "tool" }, + }); + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "permission delivery failed", + ); + expect(session.getPendingPermissions()).toHaveLength(1); + expect(harness.inputs.filter((input) => input.type === "session.close")).toHaveLength(0); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + expect(session.getPendingPermissions()).toEqual([]); + expect(harness.inputs.filter((input) => input.type === "session.permission")).toHaveLength(2); + }, + ); +}); + +test("plugin rewind replaces child listeners and settles old pending permissions", async () => { + await withPluginRewindSession({}, async (session, harness) => { + const root = harness.inputs.find((input) => input.type === "session.open")!; + if (root.type !== "session.open") throw new Error("Missing root"); + harness.emit({ + type: "session.permission", + sessionId: root.sessionId, + request: { id: "old-permission", name: "tool", kind: "tool", title: "Run tool" }, + }); + expect(session.getPendingPermissions()).toHaveLength(1); + await session.revertConversation!({ messageId: "user-B" }); + expect(session.getPendingPermissions()).toEqual([]); + expect(harness.inputs).toContainEqual( + expect.objectContaining({ + type: "session.permission", + permissionId: "old-permission", + response: { behavior: "deny", message: "Session replaced after rewind" }, + }), + ); + harness.emit({ + type: "timeline.item", + sessionId: "child-1", + item: { type: "assistant_message", id: "child-message", text: "Discarded child update" }, + }); + const history = await Array.fromAsync(session.streamHistory()); + expect(history.filter((event) => event.type === "provider_subagent")).toEqual([]); + const reopened = harness.inputs.findLast((input) => input.type === "session.open")!; + if (reopened.type !== "session.open") throw new Error("Missing reopened root"); + harness.emit({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: reopened.sessionId, + capabilities: [], + restoration: "parent", + cwd: tmpdir(), + }); + harness.emit({ + type: "timeline.item", + sessionId: "child-1", + item: { type: "assistant_message", id: "child-message", text: "New child reply" }, + }); + const next = await Array.fromAsync(session.streamHistory()); + expect(next).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ text: "New child reply" }), + }), + }), + ); + }); +}); + +test("nonpersisted plugin sessions reject conversation rewind before native mutation", async () => { + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.open") return false; + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"], + restoration: "core", + cwd: input.config.cwd, + }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return true; + }, + }, + async (session, harness) => { + expect(session.capabilities.supportsRewindConversation).toBe(false); + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "requires session persistence", + ); + expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]); + await session.revertFiles!({ messageId: "user-B" }); + expect(harness.nativeMessages).toEqual(["A", "B", "C"]); + }, + ); +}); + function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) { for (const [sessionId, parentSessionId] of [ ["a", rootSessionId], diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index f09f6349b0..2d20056930 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -603,6 +603,7 @@ class ProviderRuntimeSession { >(); private readonly activeTurnIds = new Set(); private terminal = false; + private closePromise: Promise | null = null; config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] }; commands: Array<{ name: string; description: string; argumentHint?: string }> = []; persistence: ProviderPersistence | null = null; @@ -724,6 +725,14 @@ class ProviderRuntimeSession { this.runtime.removeSession(this.id, this.providerSessionId); return; } + this.closePromise ??= this.closeNativeSession().catch((error) => { + this.closePromise = null; + throw error; + }); + await this.closePromise; + } + + private async closeNativeSession(): Promise { try { await this.runtime.complete({ type: "session.close", @@ -732,9 +741,9 @@ class ProviderRuntimeSession { }); } catch (error) { if (!this.runtime.isClosed) throw error; - } finally { - this.runtime.removeSession(this.id, this.providerSessionId); } + // A failed close still needs its routing for a subsequent session.closed ack. + this.runtime.removeSession(this.id, this.providerSessionId); } beginOpen(requestId: string): Promise { @@ -1092,11 +1101,40 @@ class PluginAgentClient implements AgentClient { persistence: input.persistence, history: input.history, }); - const session = new PluginAgentSession(this.provider, bridge, () => { - this.rootsBySession.delete(bridge.id); - }); - this.rootsBySession.set(bridge.id, session); - this.attachPendingChildren(); + const forget = () => { + for (const [id, owner] of this.rootsBySession) { + if (owner === session) this.rootsBySession.delete(id); + } + }; + const bind = (next: ProviderRuntimeSession) => { + this.rootsBySession.set(next.id, session); + this.attachPendingChildren(); + }; + const config = mapSessionConfig(input.config, input.launchContext, input.persist); + const session = new PluginAgentSession( + this.provider, + bridge, + forget, + async (persistence, state) => { + forget(); + return await this.runtime.openSession({ + sessionId: randomUUID(), + config: { + ...config, + model: state.model, + mode: state.mode, + thinkingOption: state.thinkingOption, + settings: Object.fromEntries( + state.settings.map((setting) => [setting.id, setting.value]), + ), + }, + persistence, + history: "replay", + }); + }, + bind, + ); + bind(bridge); return session; } @@ -1157,16 +1195,32 @@ class PluginAgentSession implements AgentSession { private readonly revertTokens = new Map(); private readonly timelineSnapshots = new Map(); private readonly subagentIdsBySession = new Map(); - private readonly childUnsubscribes = new Map void>(); + private readonly children = new Map< + string, + { session: ProviderRuntimeSession; unsubscribe: () => void } + >(); private readonly childSnapshots = new Map>(); private unsubscribe: (() => void) | null = null; private currentTurnId: string | null = null; private closed = false; + private replay: { + persistence: ProviderPersistence; + config: ProviderConfigState; + messageId: string; + scope: "conversation" | "both"; + closed: boolean; + } | null = null; + private replaying: Promise | null = null; constructor( readonly provider: string, - private readonly bridge: ProviderRuntimeSession, + private bridge: ProviderRuntimeSession, private readonly onClose: () => void, + private readonly reopen: ( + persistence: ProviderPersistence, + state: ProviderConfigState, + ) => Promise, + private readonly onBind: (bridge: ProviderRuntimeSession) => void, ) { this.subagentIdsBySession.set(bridge.id, null); for (const event of bridge.history) this.accept(event, false); @@ -1183,7 +1237,13 @@ class PluginAgentSession implements AgentSession { } get capabilities(): AgentCapabilityFlags { - return agentCapabilities(this.bridge.negotiatedCapabilities); + const capabilities = agentCapabilities(this.bridge.negotiatedCapabilities); + const resumable = this.canReplayHistory; + return { + ...capabilities, + supportsRewindConversation: capabilities.supportsRewindConversation && resumable, + supportsRewindBoth: capabilities.supportsRewindBoth && resumable, + }; } get features(): AgentFeature[] { @@ -1211,6 +1271,7 @@ class PluginAgentSession implements AgentSession { prompt: AgentPromptInput, options: AgentRunOptions = {}, ): Promise<{ turnId: string }> { + this.assertReplayComplete(); const clientMessageId = options.clientMessageId ?? randomUUID(); const result = await this.bridge.prompt({ clientMessageId, @@ -1233,6 +1294,7 @@ class PluginAgentSession implements AgentSession { prompt: AgentPromptInput, options: SteerActiveTurnOptions, ): Promise { + this.assertReplayComplete(); if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) { return { status: "unavailable" }; } @@ -1256,6 +1318,7 @@ class PluginAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { + await this.ensureReplay(); for (const event of this.history.read(this.provider)) yield event; } @@ -1283,6 +1346,7 @@ class PluginAgentSession implements AgentSession { } async setMode(modeId: string): Promise { + this.assertReplayComplete(); await this.bridge.configure({ mode: modeId }); } @@ -1319,17 +1383,10 @@ class PluginAgentSession implements AgentSession { async close(): Promise { if (this.closed) return; this.closed = true; - this.unsubscribe?.(); - this.unsubscribe = null; - for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe(); - this.childUnsubscribes.clear(); - this.subagentIdsBySession.clear(); - this.history.clear(); - this.timelineSnapshots.clear(); - this.childSnapshots.clear(); - this.revertTokens.clear(); - this.pendingPermissions.clear(); - this.permissionResponses.clear(); + this.listeners.clear(); + await this.replaying?.catch(() => undefined); + await this.clearBinding(); + this.replay = null; this.listeners.clear(); this.onClose(); await this.bridge.close(); @@ -1344,14 +1401,17 @@ class PluginAgentSession implements AgentSession { } async setModel(modelId: string | null): Promise { + this.assertReplayComplete(); await this.bridge.configure({ model: modelId }); } async setThinkingOption(thinkingOptionId: string | null): Promise { + this.assertReplayComplete(); await this.bridge.configure({ thinkingOption: thinkingOptionId }); } async setFeature(featureId: string, value: unknown): Promise { + this.assertReplayComplete(); await this.bridge.configure({ settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) }, }); @@ -1415,10 +1475,10 @@ class PluginAgentSession implements AgentSession { const snapshots = new Map(); this.childSnapshots.set(childId, snapshots); for (const event of child.history) this.acceptChildEvent(childId, event, snapshots); - this.childUnsubscribes.set( - child.id, - child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)), - ); + this.children.set(child.id, { + session: child, + unsubscribe: child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)), + }); } private accept(event: ProviderEvent, live: boolean): void { @@ -1636,7 +1696,84 @@ class PluginAgentSession implements AgentSession { } } + private async clearBinding(): Promise { + this.unsubscribe?.(); + this.unsubscribe = null; + for (const child of this.children.values()) { + child.unsubscribe(); + await child.session.close(); + } + this.children.clear(); + this.subagentIdsBySession.clear(); + this.history.clear(); + this.timelineSnapshots.clear(); + this.childSnapshots.clear(); + this.revertTokens.clear(); + this.pendingPermissions.clear(); + this.permissionResponses.clear(); + this.currentTurnId = null; + } + + private assertReplayComplete(): void { + if (this.replay) { + throw new Error( + "Plugin rewind history refresh is incomplete. Retry the rewind or reload the agent before continuing.", + ); + } + } + + // A native rewind has already committed. Until replay succeeds, neither history + // nor a new prompt may use the retired binding. Keep its latest handle for retry. + private async ensureReplay(): Promise { + if (!this.replay) return; + if (this.closed) throw new StaleProviderSessionError(this.id); + if (!this.replaying) { + this.replaying = this.replayBinding().finally(() => { + this.replaying = null; + }); + } + await this.replaying; + } + + private async replayBinding(): Promise { + const replay = this.replay!; + if (!replay.closed) { + for (const request of this.pendingPermissions.values()) { + await this.respondToPermission(request.id, { + behavior: "deny", + message: "Session replaced after rewind", + }); + this.pendingPermissions.delete(request.id); + this.permissionResponses.delete(request.id); + } + await this.clearBinding(); + await this.bridge.close(); + replay.closed = true; + } + const bridge = await this.reopen(replay.persistence, replay.config); + this.bridge = bridge; + this.subagentIdsBySession.set(bridge.id, null); + for (const event of bridge.history) this.accept(event, false); + this.unsubscribe = bridge.onEvent((event) => this.accept(event, true)); + this.onBind(bridge); + this.replay = null; + } + + private get canReplayHistory(): boolean { + return ( + this.bridge.restoration === "core" && + this.bridge.persistence !== null && + this.bridge.negotiatedCapabilities.includes("session.persistence") + ); + } + private async revert(messageId: string, scope: "conversation" | "files" | "both") { + const pending = this.replay; + await this.ensureReplay(); + if (pending?.messageId === messageId && pending.scope === scope) return; + if (scope !== "files" && !this.canReplayHistory) { + throw new Error("Plugin conversation rewind requires session persistence for history replay"); + } const token = this.revertTokens.get(messageId); if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`); await this.bridge.revert({ @@ -1646,6 +1783,12 @@ class PluginAgentSession implements AgentSession { token, scope, }); + if (scope !== "files") { + const persistence = this.bridge.persistence; + if (!persistence) throw new Error("Plugin rewind removed session persistence"); + this.replay = { persistence, config: this.bridge.config, messageId, scope, closed: false }; + await this.ensureReplay(); + } } private publish(event: AgentStreamEvent): void { diff --git a/public-docs/plugins/providers.md b/public-docs/plugins/providers.md index 6fbe766c61..9a87dfb155 100644 --- a/public-docs/plugins/providers.md +++ b/public-docs/plugins/providers.md @@ -287,6 +287,11 @@ Paseo refreshes an agent by closing its current provider session and opening it configuration and persistence. Re-read credentials, environment, global configuration, and MCP servers during `session.open`. There is no separate reload operation. +Conversation and combined rewind require a core-restored session with persistence. After a +successful rewind, Paseo closes the session and reopens its latest persistence value with +`history: "replay"`. Publish the retained native branch before `session.ready`, including its +children. File-only rewind keeps the current session and conversation history. + Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and recreate it while restoring the parent. From 0e5bcbd9f50dfca46a8b5835c4b09f158762003d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:05:06 +0000 Subject: [PATCH 3/3] fix(app): open relative chat links inside nested repositories (#8361) * fix(app): open a nested-repo file when the workspace-root path is missing A workspace that contains another git repo often gets paths written relative to that nested repo. `schema/x.md` is really `repo-a/schema/x.md`. A markdown link was joined onto the workspace root and opened a missing file. Inline code already asked the daemon for a suffix match. Resolved workspace-relative paths that contain a slash now use that same lookup before opening. An exact hit stays on the original path. A suffix hit replaces it and keeps the line range. A failed lookup still opens the original path. Bare names, absolute paths, and `~/` paths are unchanged. Example: - Input: workspace `/repo`, link `schema/x.md`, file at `repo-a/schema/x.md` - Before: open `/repo/schema/x.md` (missing) - After: open `/repo/repo-a/schema/x.md` * fix(app): preserve explicit destinations when finding nested files Relative chat links can name a file inside a nested repository. Retain the suffix lookup while using the original href and document base to preserve absolute paths, preview links, and folder destinations. Co-authored-by: Ouyang Xingyuan * fix(app): keep network share links on their absolute destination Use the shared absolute-path classifier so network share hrefs retain their destination alongside drive and POSIX paths. * fix(app): keep nested-file lookup inside ordered path opening Resolve the original directory before considering suffix matches, and allocate click ordering before asynchronous lookup. The existing opener now owns the complete operation so a slow earlier click cannot replace a later selection. * test(app): load UI modules before test lifecycle hooks Cold module transformation exceeded the lifecycle hook deadline before these suites could run. Complete module initialization before registering the tests, retaining their assertions and timeout settings. --------- Co-authored-by: Ouyang Xingyuan Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../assistant-file-link-tooltip.spec.ts | 54 +++++- packages/app/e2e/browser/folder-links.spec.ts | 5 + packages/app/src/agent-stream/view.tsx | 1 + .../assistant-file-links/path-open.test.ts | 52 +++++- .../app/src/assistant-file-links/path-open.ts | 24 ++- .../src/assistant-file-links/resolver.test.ts | 169 ++++++++++++++++++ .../app/src/assistant-file-links/resolver.ts | 63 ++++++- .../composer/draft/input-draft.live.test.tsx | 14 +- .../app/src/hooks/use-agent-history.test.ts | 16 +- 9 files changed, 364 insertions(+), 34 deletions(-) diff --git a/packages/app/e2e/browser/assistant-file-link-tooltip.spec.ts b/packages/app/e2e/browser/assistant-file-link-tooltip.spec.ts index 69cccc52dd..09a9434995 100644 --- a/packages/app/e2e/browser/assistant-file-link-tooltip.spec.ts +++ b/packages/app/e2e/browser/assistant-file-link-tooltip.spec.ts @@ -1,4 +1,5 @@ -import { writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { test, expect, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -67,3 +68,54 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a await agent.cleanup(); } }); + +async function readLinkedGuide(page: Page, linkName: string, content: string): Promise { + await page.getByRole("link", { name: linkName, exact: true }).click(); + await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText( + content, + ); +} + +async function returnToNestedLinks(page: Page): Promise { + await page.getByRole("button", { name: "Nested file links", exact: true }).click(); + await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible(); +} + +test("assistant directory paths find nested files and preserve exact workspace paths", async ({ + page, +}, testInfo) => { + testInfo.setTimeout(120_000); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "nested-file-link-", + title: "Nested file links", + featureValues: { + mockStreamingAssistantResponse: + "Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).", + mockStreamingAssistantIntervalMs: 5, + }, + }); + try { + await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") }); + await writeFile( + path.join(agent.cwd, "repo-a/schema/x.md"), + "# Nested file content\n\nDetails\n", + ); + await mkdir(path.join(agent.cwd, "schema"), { recursive: true }); + await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n"); + await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n"); + await openAgentRoute(page, agent); + await agent.client.sendAgentMessage(agent.agentId, "Show the file links."); + await agent.client.waitForFinish(agent.agentId, 30_000); + + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled(); + await readLinkedGuide(page, "nested guide", "Nested file content"); + await returnToNestedLinks(page); + await readLinkedGuide(page, "exact guide", "Exact file content"); + await returnToNestedLinks(page); + await page.getByRole("link", { name: "missing guide", exact: true }).click(); + await expect(page.getByText("File not found", { exact: true })).toBeVisible(); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts index a893fbcb68..358c579357 100644 --- a/packages/app/e2e/browser/folder-links.spec.ts +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -81,6 +81,8 @@ for (const folder of [ "docs/guides", "docs/guides/", "docs/指南", + "examples/next.js/", + "examples/next.js", ]) { test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => { const session = await seedMockAgentWorkspace({ @@ -92,6 +94,9 @@ for (const folder of [ try { await mkdir(path.join(session.cwd, folder), { recursive: true }); await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n"); + const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, "")); + await mkdir(path.dirname(competingFile), { recursive: true }); + await writeFile(competingFile, "This is a file, not the requested folder.\n"); await session.client.waitForFinish(session.agentId, 30_000); await page.addInitScript(() => { localStorage.setItem( diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index cb9999745b..72923b4212 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -468,6 +468,7 @@ const AgentStreamViewComponent = forwardRef createInlinePathOpener({ workspaceRoot, + resolveRelativeFiles: true, client, openFile: openInlineFile, openDirectory: openInlineDirectory, diff --git a/packages/app/src/assistant-file-links/path-open.test.ts b/packages/app/src/assistant-file-links/path-open.test.ts index a85d4c8ec0..f54ee88765 100644 --- a/packages/app/src/assistant-file-links/path-open.test.ts +++ b/packages/app/src/assistant-file-links/path-open.test.ts @@ -64,7 +64,10 @@ it("ignores an older probe after a newer file link opens", async () => { const opened: string[] = []; const opener = createInlinePathOpener({ workspaceRoot: "/repo", - client: { listDirectory: () => pendingProbe }, + client: { + listDirectory: () => pendingProbe, + getDirectorySuggestions: async () => ({ entries: [], error: null }), + }, openFile: (location) => { opened.push(location.path); }, @@ -91,7 +94,10 @@ it("ignores a pending probe when its view is left", async () => { const opened: string[] = []; const opener = createInlinePathOpener({ workspaceRoot: "/repo", - client: { listDirectory: () => pendingProbe }, + client: { + listDirectory: () => pendingProbe, + getDirectorySuggestions: async () => ({ entries: [], error: null }), + }, openFile: (location) => { opened.push(location.path); }, @@ -130,3 +136,45 @@ it("sends folders outside the workspace away from the workspace Files panel", as expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]); expect(workspaceFolders).toEqual(["docs"]); }); + +it("keeps a later selection when an earlier nested-file lookup finishes last", async () => { + const { createInlinePathOpener } = await import("./path-open"); + const lookup = Promise.withResolvers<{ + entries: Array<{ path: string; kind: "file" }>; + error: null; + }>(); + const queries: string[] = []; + const opened: string[] = []; + const opener = createInlinePathOpener({ + workspaceRoot: "/repo", + resolveRelativeFiles: true, + client: { + listDirectory: async () => { + throw new Error("Not a directory"); + }, + getDirectorySuggestions: async ({ query }: { query: string }) => { + queries.push(query); + return lookup.promise; + }, + }, + openFile: (location) => { + opened.push(location.path); + }, + openDirectory: (directory) => { + opened.push(directory); + }, + openOutsideDirectory: (directory) => { + opened.push(directory); + }, + }); + const earlier = opener.open( + { raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 }, + "side", + ); + await Promise.resolve(); + expect(queries).toEqual(["schema/x.md"]); + await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side"); + lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null }); + await earlier; + expect(opened).toEqual(["guide.md"]); +}); diff --git a/packages/app/src/assistant-file-links/path-open.ts b/packages/app/src/assistant-file-links/path-open.ts index 7c9cf228f4..7ff50c9a1a 100644 --- a/packages/app/src/assistant-file-links/path-open.ts +++ b/packages/app/src/assistant-file-links/path-open.ts @@ -5,6 +5,7 @@ import { type OpenFileDisposition, } from "@/workspace/file-open"; import { isAbsolutePath } from "@/utils/path"; +import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver"; interface PathOpenInput { file?: string; @@ -50,7 +51,11 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise; + getDirectorySuggestions: GetDirectorySuggestions; + } | null; + resolveRelativeFiles?: boolean; openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void; openDirectory: (path: string) => void; openOutsideDirectory: (path: string) => void; @@ -73,10 +78,21 @@ export function createInlinePathOpener(input: { } else if (destination.kind === "outsideDirectory") { input.openOutsideDirectory(destination.directoryPath); } else if (normalized.file) { + const resolvedTarget = + input.resolveRelativeFiles && input.client + ? await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: input.workspaceRoot, + getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client), + }) + : target; + if (ownGeneration !== generation) return; + const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot); + if (!resolved?.file) return; const location = normalizeWorkspaceFileLocation({ - path: normalized.file, - lineStart: target.lineStart, - lineEnd: target.lineEnd, + path: resolved.file, + lineStart: resolvedTarget.lineStart, + lineEnd: resolvedTarget.lineEnd, }); if (location) input.openFile(location, disposition); } diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 121c60f5a0..dc38c24c68 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -7,6 +7,7 @@ import { classifyForResolution, fetchDaemonResolution, getAssistantFileLinkToken, + resolveMissingNestedRelativeFile, UnresolvedFileLinkError, type AssistantFileLinkContext, type DirectorySuggestionEntry, @@ -424,6 +425,174 @@ describe("fetchDaemonResolution", () => { }); }); +describe("resolveMissingNestedRelativeFile", () => { + const target = { + raw: "schema/x.md:12-14", + path: "/Users/test/project/schema/x.md", + lineStart: 12, + lineEnd: 14, + }; + + it("opens a nested-repo suffix when the workspace-root path is missing", async () => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }); + + expect(searches).toEqual([ + { + query: "schema/x.md", + cwd: "/Users/test/project", + matchMode: "suffix", + limit: 1, + }, + ]); + expect(result).toEqual({ + ...target, + path: "/Users/test/project/repo-a/schema/x.md", + }); + }); + + it("keeps the exact workspace path when suggestions return that relative path", async () => { + const { getDirectorySuggestions } = suggestionsFromMap({ + "schema/x.md": [{ path: "schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }); + + expect(result).toEqual(target); + }); + + it("keeps the original target when suggestions are empty", async () => { + const { getDirectorySuggestions } = suggestionsFromMap({}); + + await expect( + resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }), + ).resolves.toEqual(target); + }); + + it("keeps the original target when the daemon throws", async () => { + await expect( + resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions: unavailableSuggestions, + }), + ).resolves.toEqual(target); + }); + + it.each([ + ["a bare filename", { ...target, path: "/Users/test/project/file.ts" }], + ["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }], + ["a home-relative path", { ...target, path: "~/schema/x.md" }], + ])("does not search %s", async (_label, candidate) => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target: candidate, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }); + + expect(searches).toEqual([]); + expect(result).toEqual(candidate); + }); + + it("does not search when the workspace root is blank", async () => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({}); + + const result = await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: " ", + getDirectorySuggestions, + }); + + expect(searches).toEqual([]); + expect(result).toEqual(target); + }); + + it.each([ + ["/Users/test/project/schema/x.md", "/Users/test/project"], + ["file:///Users/test/project/schema/x.md", "/Users/test/project"], + ["C:/repo/schema/x.md", "C:/repo"], + ["\\\\server\\share\\schema\\x.md", "//server/share"], + ])("keeps absolute destination %s", async (raw, workspaceRoot) => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` }; + await expect( + resolveMissingNestedRelativeFile({ + target: absoluteTarget, + workspaceRoot, + getDirectorySuggestions, + }), + ).resolves.toEqual(absoluteTarget); + expect(searches).toEqual([]); + }); + + it.each(["docs/guides", "./docs/guides", "docs/guides/"])( + "keeps folder href %s direct", + async (raw) => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }], + }); + const folderTarget = { raw, path: "/Users/test/project/docs/guides" }; + await expect( + resolveMissingNestedRelativeFile({ + target: folderTarget, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }), + ).resolves.toEqual(folderTarget); + expect(searches).toEqual([]); + }, + ); + + it("normalizes a Windows workspace root before the suffix lookup", async () => { + const windowsTarget = { + raw: "schema/x.md", + path: "C:\\repo\\schema\\x.md", + lineStart: undefined, + lineEnd: undefined, + }; + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target: windowsTarget, + workspaceRoot: "C:\\repo\\", + getDirectorySuggestions, + }); + + expect(searches).toEqual([ + { + query: "schema/x.md", + cwd: "C:\\repo\\", + matchMode: "suffix", + limit: 1, + }, + ]); + expect(result.path).toBe("C:/repo/repo-a/schema/x.md"); + }); +}); + describe("getAssistantFileLinkToken", () => { it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => { expect( diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index 66c4137e2d..f77800b75b 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -5,6 +5,7 @@ import { type InlinePathTarget, } from "./parse"; import { i18n } from "@/i18n/i18next"; +import { isAbsolutePath } from "@/utils/path"; export interface AssistantFileLinkSource { href: string; @@ -105,6 +106,62 @@ export async function fetchDaemonResolution({ }; } +/** Only file-looking paths nested inside the workspace need suffix search. */ +function nestedRelativeLookupQuery( + targetPath: string, + workspaceRoot: string | undefined, +): string | null { + const root = workspaceRoot?.trim(); + if (!root) { + return null; + } + const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalizedPath = targetPath.replace(/\\/g, "/"); + const prefix = `${normalizedRoot}/`; + if (!normalizedPath.startsWith(prefix)) { + return null; + } + const relative = normalizedPath.slice(prefix.length); + if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) { + return null; + } + return relative; +} + +/** When a markdown link points at workspaceRoot/schema/x.md and that file is + * missing, ask the same suffix lookup bare names already use. An exact hit + * stays on that path. A suffix hit such as repo-a/schema/x.md replaces it. + * Lookup failure keeps the original target. + */ +export async function resolveMissingNestedRelativeFile(input: { + target: InlinePathTarget; + workspaceRoot?: string; + getDirectorySuggestions: GetDirectorySuggestions; +}): Promise { + if ( + isAbsoluteInlineCodeToken(input.target.raw) || + input.target.raw.startsWith("~/") || + input.target.raw.endsWith("/") + ) { + return input.target; + } + const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot); + if (!query) { + return input.target; + } + try { + return await fetchDaemonResolution({ + ambiguousQuery: query, + token: input.target.raw, + target: input.target, + workspaceRoot: input.workspaceRoot, + getDirectorySuggestions: input.getDirectorySuggestions, + }); + } catch { + return input.target; + } +} + export function classifyForResolution( source: AssistantFileLinkSource, context: AssistantFileLinkContext, @@ -215,11 +272,7 @@ export function shouldResolveDirectFileThroughSuggestions(input: { } function isAbsoluteInlineCodeToken(token: string): boolean { - return ( - token.startsWith("/") || - token.toLowerCase().startsWith("file://") || - /^[A-Za-z]:[\\/]/.test(token) - ); + return isAbsolutePath(token) || token.toLowerCase().startsWith("file://"); } function isLinkifiedSource(source: AssistantFileLinkSource): boolean { diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 8bc31bb176..82de9d3d73 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -2,7 +2,7 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useDraftStore } from "@/stores/draft-store"; import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; @@ -126,17 +126,13 @@ afterEach(async () => { }); }); -let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft; type DraftRecordForTest = ReturnType["drafts"][string]; -beforeAll(async () => { - Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { - value: true, - configurable: true, - }); - - ({ useAgentInputDraft } = await import("./input-draft")); +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, }); +const { useAgentInputDraft } = await import("./input-draft"); describe("useAgentInputDraft live contract", () => { beforeEach(() => { diff --git a/packages/app/src/hooks/use-agent-history.test.ts b/packages/app/src/hooks/use-agent-history.test.ts index d4da1f01bb..9a5d80e07e 100644 --- a/packages/app/src/hooks/use-agent-history.test.ts +++ b/packages/app/src/hooks/use-agent-history.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentHistoryEntry, @@ -13,18 +13,8 @@ import { allAgentHistoryQueryKey } from "./agent-history-query-key"; } ).__DEV__ = false; -type UseAgentHistoryModule = typeof import("./use-agent-history"); - -let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"]; -let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"]; -let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"]; - -beforeAll(async () => { - const module = await import("./use-agent-history"); - fetchAgentHistoryBatch = module.fetchAgentHistoryBatch; - fetchAgentHistoryPage = module.fetchAgentHistoryPage; - collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors; -}); +const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } = + await import("./use-agent-history"); type FetchAgentHistory = DaemonClient["fetchAgentHistory"]; type FetchAgentHistoryResult = Awaited>;