mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
138d9d63ab |
+11
-1
@@ -174,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
||||
|
||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
||||
|
||||
## Inverted timeline selection
|
||||
## Inverted timeline scrolling
|
||||
|
||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||
@@ -183,6 +183,16 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
|
||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||
|
||||
Nested panels also keep their edge overshoot and unconsumed flings inside the panel instead of
|
||||
moving the inverted timeline in the opposite direction. Nested-scroll registration and scrolling
|
||||
inside the panel are preserved. Run the native selection and nested-scroll regressions after prebuild:
|
||||
|
||||
```bash
|
||||
cd packages/app/android
|
||||
./gradlew :paseo-scroll:connectedDebugAndroidTest \
|
||||
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
||||
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -12,14 +12,8 @@ function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendDraftWhenReady(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await pressInComposer(page, "Enter");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await fillComposerDraft(page, text);
|
||||
await sendDraftWhenReady(page);
|
||||
await submitMessage(page, text);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
@@ -55,7 +49,7 @@ test.describe("Composer recall", () => {
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await sendDraftWhenReady(page);
|
||||
await pressInComposer(page, "Enter");
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity android:name="sh.paseo.scroll.NestedScrollActivity" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package sh.paseo.scroll
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
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.react.uimanager.ReactStylesDiffMap
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.views.scroll.ReactScrollView
|
||||
import com.facebook.react.views.scroll.ReactScrollViewManager
|
||||
import com.facebook.soloader.SoLoader
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
class NestedScrollActivity : Activity()
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NestedScrollTest {
|
||||
@Test fun invertedTimelineDoesNotConsumePanelOvershoot() = withNestedScroll(inverted = true) { parent, child ->
|
||||
val before = parent.scrollY
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.scrollTo(0, 600)
|
||||
assertEquals(600, child.scrollY)
|
||||
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
|
||||
assertEquals("Panel overshoot must not move the inverted timeline", before, parent.scrollY)
|
||||
child.scrollTo(0, 0)
|
||||
assertTrue(child.dispatchNestedScroll(0, 0, 0, -100, null))
|
||||
assertEquals("Overshoot at either edge must stay inside the panel", before, parent.scrollY)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
|
||||
@Test fun ordinaryParentStillConsumesPanelOvershoot() = withNestedScroll(inverted = false) { parent, child ->
|
||||
val before = parent.scrollY
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.scrollTo(0, 600)
|
||||
assertEquals(600, child.scrollY)
|
||||
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
|
||||
assertEquals(before + 100, parent.scrollY)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
|
||||
@Test fun panelCanStillScrollInsideInvertedTimeline() = withNestedScroll(inverted = true) { parent, child ->
|
||||
val before = parent.scrollY
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.scrollTo(0, 100)
|
||||
assertTrue(child.dispatchNestedScroll(0, 100, 0, 0, null))
|
||||
assertEquals(100, child.scrollY)
|
||||
assertEquals(before, parent.scrollY)
|
||||
child.stopNestedScroll()
|
||||
parent.scrollTo(0, before + 100)
|
||||
assertEquals(before + 100, parent.scrollY)
|
||||
}
|
||||
|
||||
@Test fun invertedTimelineAbsorbsUnconsumedPanelFling() = checkFling(inverted = true)
|
||||
|
||||
@Test fun ordinaryParentStillAcceptsUnconsumedPanelFling() = checkFling(inverted = false)
|
||||
|
||||
private fun checkFling(inverted: Boolean) = withNestedScroll(
|
||||
inverted = inverted,
|
||||
afterFrames = { parent ->
|
||||
if (inverted) assertEquals("Panel fling must not move the inverted timeline", 400, parent.scrollY)
|
||||
else assertTrue("Control fling must move an ordinary parent", parent.scrollY > 400)
|
||||
}
|
||||
) { parent, child ->
|
||||
parent.scrollTo(0, 400)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.scrollTo(0, 600)
|
||||
assertEquals(600, child.scrollY)
|
||||
assertTrue(child.dispatchNestedFling(0f, 1500f, false))
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
|
||||
private fun withNestedScroll(
|
||||
inverted: Boolean,
|
||||
afterFrames: ((ReactScrollView) -> Unit)? = null,
|
||||
check: (ReactScrollView, ReactScrollView) -> Unit
|
||||
) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val activity = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.context, NestedScrollActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
lateinit var parent: ReactScrollView
|
||||
try {
|
||||
instrumentation.runOnMainSync {
|
||||
val appContext = instrumentation.targetContext
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||
val reactContext = ScrollTestContext(appContext)
|
||||
val context = ThemedReactContext(reactContext, appContext)
|
||||
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||
parent = manager.createViewInstance(context)
|
||||
val child = manager.createViewInstance(context).apply { isNestedScrollingEnabled = true }
|
||||
val panelContent = View(context)
|
||||
child.addView(panelContent, FrameLayout.LayoutParams(300, 800))
|
||||
val content = FrameLayout(context)
|
||||
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 500 })
|
||||
parent.addView(content, FrameLayout.LayoutParams(300, 2000))
|
||||
panelContent.measure(exact(300), exact(800))
|
||||
panelContent.layout(0, 0, 300, 800)
|
||||
child.measure(exact(300), exact(200))
|
||||
child.layout(0, 500, 300, 700)
|
||||
content.measure(exact(300), exact(2000))
|
||||
content.layout(0, 0, 300, 2000)
|
||||
parent.measure(exact(300), exact(400))
|
||||
parent.layout(0, 0, 300, 400)
|
||||
// The same transform React Native applies to an inverted FlatList on Android.
|
||||
manager.updateProperties(parent, ReactStylesDiffMap(JavaOnlyMap.of(
|
||||
"transform", JavaOnlyArray.of(JavaOnlyMap.of("scale", if (inverted) -1.0 else 1.0))
|
||||
)))
|
||||
activity.setContentView(parent, ViewGroup.LayoutParams(300, 400))
|
||||
parent.scrollTo(0, 400)
|
||||
check(parent, child)
|
||||
}
|
||||
if (afterFrames != null) {
|
||||
// Let the framework's real fling animation advance, with an ordinary-parent control.
|
||||
SystemClock.sleep(500)
|
||||
instrumentation.runOnMainSync { afterFrames(parent) }
|
||||
}
|
||||
} finally {
|
||||
instrumentation.runOnMainSync { activity.finish() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
|
||||
}
|
||||
+1
-2
@@ -8,7 +8,6 @@ import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.facebook.react.bridge.BridgeReactContext
|
||||
import com.facebook.react.bridge.JavaOnlyArray
|
||||
import com.facebook.react.bridge.JavaOnlyMap
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
@@ -70,7 +69,7 @@ class ScrollSelectionTest {
|
||||
val appContext = instrumentation.targetContext
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||
val reactContext = BridgeReactContext(appContext)
|
||||
val reactContext = ScrollTestContext(appContext)
|
||||
val context = ThemedReactContext(reactContext, appContext)
|
||||
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||
val scroll = manager.createViewInstance(context)
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package sh.paseo.scroll;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.react.bridge.BridgeReactContext;
|
||||
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||
|
||||
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
|
||||
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||
ScrollTestContext(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventDispatcher getEventDispatcher() {
|
||||
return BlackHoleEventDispatcher.INSTANCE;
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -2,6 +2,7 @@ package sh.paseo.scroll
|
||||
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ViewManagerOnDemandReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
@@ -30,13 +31,30 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
|
||||
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
||||
private val transformValues = FloatArray(9)
|
||||
|
||||
private val isInverted: Boolean
|
||||
get() {
|
||||
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
||||
matrix.getValues(transformValues)
|
||||
return transformValues[Matrix.MSCALE_Y] < 0
|
||||
}
|
||||
|
||||
// Both RN focus handling and Android selection visibility requests use this calculation.
|
||||
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
||||
// movement to user gestures and explicit scroll commands.
|
||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
|
||||
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
||||
matrix.getValues(transformValues)
|
||||
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
|
||||
if (isInverted) return 0
|
||||
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||
}
|
||||
|
||||
// A nested panel's remaining delta uses its own visual direction. Consuming it in
|
||||
// an inverted timeline moves the timeline the opposite way when the panel hits an edge.
|
||||
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int) {
|
||||
if (isInverted) return
|
||||
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed)
|
||||
}
|
||||
|
||||
override fun onNestedFling(target: View, velocityX: Float, velocityY: Float, consumed: Boolean): Boolean {
|
||||
if (isInverted && !consumed) return true
|
||||
return super.onNestedFling(target, velocityX, velocityY, consumed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import type { ClaudeQueryFactory } from "./query.js";
|
||||
|
||||
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
|
||||
|
||||
interface ScriptedTurn {
|
||||
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
|
||||
errorReply?: string;
|
||||
// Assistant text the model wrote before or after it.
|
||||
before?: string;
|
||||
after?: string;
|
||||
isError: boolean;
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCostUsd: number;
|
||||
};
|
||||
}
|
||||
|
||||
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
|
||||
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
|
||||
return ({ prompt }) => {
|
||||
let index = 0;
|
||||
const assistant = (id: string, text: string, error?: string) => ({
|
||||
type: "assistant",
|
||||
session_id: "error-session",
|
||||
parent_tool_use_id: null,
|
||||
uuid: id,
|
||||
message: {
|
||||
id,
|
||||
role: "assistant",
|
||||
model: error ? "<synthetic>" : "claude-opus-5-5",
|
||||
content: [{ type: "text", text }],
|
||||
stop_reason: "stop_sequence",
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
...(error ? { error } : {}),
|
||||
});
|
||||
const messages = (async function* () {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
for await (const _message of prompt) {
|
||||
const turn = turns[index];
|
||||
index += 1;
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "error-session",
|
||||
permissionMode: "default",
|
||||
model: "claude-opus-5-5",
|
||||
};
|
||||
if (turn.before) yield assistant(`before-${index}`, turn.before);
|
||||
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
|
||||
if (turn.after) yield assistant(`after-${index}`, turn.after);
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "error-session",
|
||||
duration_ms: 1,
|
||||
duration_api_ms: 0,
|
||||
is_error: turn.isError,
|
||||
num_turns: 1,
|
||||
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
|
||||
stop_reason: "stop_sequence",
|
||||
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
|
||||
usage: {
|
||||
input_tokens: turn.usage?.inputTokens ?? 0,
|
||||
output_tokens: turn.usage?.outputTokens ?? 0,
|
||||
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: `result-${index}`,
|
||||
};
|
||||
}
|
||||
})();
|
||||
return Object.assign(messages, {
|
||||
close: () => {
|
||||
void messages.return();
|
||||
},
|
||||
interrupt: async () => undefined,
|
||||
setModel: async () => undefined,
|
||||
setPermissionMode: async () => undefined,
|
||||
supportedModels: async () => [],
|
||||
supportedCommands: async () => [],
|
||||
}) as ReturnType<ClaudeQueryFactory>;
|
||||
};
|
||||
}
|
||||
|
||||
async function runTurns(turns: ScriptedTurn[]) {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: scriptedQuery(turns),
|
||||
resolveBinary: async () => process.execPath,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
try {
|
||||
const results = [];
|
||||
for (const [index] of turns.entries()) {
|
||||
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
|
||||
return events.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
|
||||
return events.find(
|
||||
(event) =>
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled",
|
||||
);
|
||||
}
|
||||
|
||||
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
|
||||
const [limited, overloaded, next] = await runTurns([
|
||||
{ errorReply: LIMIT, isError: true },
|
||||
{
|
||||
before: "Checking the deploy doc.",
|
||||
errorReply: "API Error: 529 Overloaded",
|
||||
isError: true,
|
||||
},
|
||||
{ after: "Done.", isError: false },
|
||||
]);
|
||||
|
||||
expect(terminal(limited)).toMatchObject({
|
||||
type: "turn_failed",
|
||||
error: LIMIT,
|
||||
});
|
||||
expect(assistantTexts(limited)).toEqual([]);
|
||||
|
||||
expect(terminal(overloaded)).toMatchObject({
|
||||
type: "turn_failed",
|
||||
error: "API Error: 529 Overloaded",
|
||||
});
|
||||
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
|
||||
|
||||
// The session keeps working after a failed turn.
|
||||
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(next)).toEqual(["Done."]);
|
||||
});
|
||||
|
||||
test("a failed turn retains its final token counts and cost", async () => {
|
||||
const usage = {
|
||||
inputTokens: 120,
|
||||
cachedInputTokens: 80,
|
||||
outputTokens: 30,
|
||||
totalCostUsd: 0.012,
|
||||
};
|
||||
const [failed] = await runTurns([
|
||||
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
|
||||
]);
|
||||
|
||||
expect(failed).toContainEqual(
|
||||
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
|
||||
);
|
||||
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
|
||||
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
|
||||
});
|
||||
|
||||
test("an error reply the turn moves past is still shown", async () => {
|
||||
const [recovered, completed] = await runTurns([
|
||||
{
|
||||
errorReply: "API Error: 529 Overloaded",
|
||||
after: "Recovered.",
|
||||
isError: false,
|
||||
},
|
||||
{ errorReply: LIMIT, isError: false },
|
||||
]);
|
||||
|
||||
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
|
||||
|
||||
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(completed)).toEqual([LIMIT]);
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { constants as bufferConstants } from "node:buffer";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -237,64 +236,4 @@ describe("importing a renamed Claude session", () => {
|
||||
|
||||
await expect(importSession(sessionId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("reads the rename from a transcript too large to hold as one string", async () => {
|
||||
const sessionId = "oversized-transcript";
|
||||
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
// A hole stands in for the long session this used to fail on: the file only
|
||||
// has to be longer than a string can be, and the rename sits at its end.
|
||||
await fs.writeFile(transcript, "");
|
||||
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
|
||||
await fs.appendFile(
|
||||
transcript,
|
||||
`\n${[
|
||||
userRecord({ sessionId, content: "Trace the slow query" }),
|
||||
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Slow query hunt");
|
||||
});
|
||||
|
||||
test("reads the rename from behind more records than one read covers", async () => {
|
||||
const sessionId = "late-records-rename";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
userRecord({ sessionId, content: "Review this project" }),
|
||||
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
|
||||
// One record longer than a single read, then enough records to leave the
|
||||
// rename several reads back from the end of the file.
|
||||
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
|
||||
...Array.from({ length: 64 }, (_, index) =>
|
||||
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Scrollback review");
|
||||
});
|
||||
|
||||
test("imports a large record after the last rename without repeatedly copying it", async () => {
|
||||
const sessionId = "large-record-after-rename";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
|
||||
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Large attachment review");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2389,12 +2389,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private mainTurnInFlight = false;
|
||||
private foregroundHasVisibleActivity = false;
|
||||
private activeTurnHasAssistantText = false;
|
||||
/**
|
||||
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
|
||||
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
|
||||
* failed turn shows the same text as its error, so showing both would repeat it.
|
||||
*/
|
||||
private withheldErrorReply: AgentTimelineItem[] = [];
|
||||
private readonly contextUsage: ClaudeContextUsageState;
|
||||
private userMessageIds: string[] = [];
|
||||
private readonly emittedUserMessageIds = new Set<string>();
|
||||
@@ -2537,7 +2531,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.foregroundHasVisibleActivity = false;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.withheldErrorReply = [];
|
||||
this.contextUsage.beginTurn();
|
||||
this.transitionTurnState("foreground", "foreground turn started");
|
||||
this.clearRecentStderr();
|
||||
@@ -4426,21 +4419,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}) satisfies AgentStreamEvent,
|
||||
);
|
||||
|
||||
if (!readClaudeParentToolUseId(message)) {
|
||||
if (message.type === "assistant" && message.error) {
|
||||
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
|
||||
return messageEvents;
|
||||
}
|
||||
if (
|
||||
message.type === "assistant" ||
|
||||
message.type === "user" ||
|
||||
message.type === "stream_event"
|
||||
) {
|
||||
const released: AgentStreamEvent[] = [];
|
||||
this.releaseWithheldErrorReply(released);
|
||||
return [...released, ...messageEvents, ...assistantTimelineEvents];
|
||||
}
|
||||
}
|
||||
return [...messageEvents, ...assistantTimelineEvents];
|
||||
}
|
||||
|
||||
@@ -4988,23 +4966,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const usage = this.convertUsage(message);
|
||||
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
|
||||
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
|
||||
if (message.subtype === "success" && message.is_error) {
|
||||
const withheld = this.withheldErrorReply;
|
||||
this.withheldErrorReply = [];
|
||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||
const withheldText = withheld
|
||||
.map((item) => (item.type === "assistant_message" ? item.text : ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
|
||||
events.push(...this.sidechainTracker.finishAll("failed"));
|
||||
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
|
||||
return;
|
||||
}
|
||||
const releasedErrorReply = this.withheldErrorReply.length > 0;
|
||||
this.releaseWithheldErrorReply(events);
|
||||
if (message.subtype === "success") {
|
||||
events.push(...this.sidechainTracker.finishAll("completed"));
|
||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||
@@ -5014,12 +4975,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// accounting from provider gateways does not duplicate streamed output.
|
||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||
const outputTokens = message.usage?.output_tokens;
|
||||
if (
|
||||
resultText.length > 0 &&
|
||||
outputTokens === 0 &&
|
||||
!this.activeTurnHasAssistantText &&
|
||||
!releasedErrorReply
|
||||
) {
|
||||
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
@@ -5041,14 +4997,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
events.push(this.buildTurnFailedEvent(errorMessage));
|
||||
}
|
||||
|
||||
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
|
||||
if (this.withheldErrorReply.length === 0) return;
|
||||
for (const item of this.withheldErrorReply) {
|
||||
events.push({ type: "timeline", item, provider: "claude" });
|
||||
}
|
||||
this.withheldErrorReply = [];
|
||||
}
|
||||
|
||||
private createClaudeSessionChangedNotice(
|
||||
oldSessionId: string,
|
||||
newSessionId: string,
|
||||
@@ -6905,79 +6853,40 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
|
||||
}
|
||||
|
||||
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
||||
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
|
||||
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
|
||||
// The needle also matches a prompt that quotes it, so a record still has to
|
||||
// parse as a `custom-title` entry carrying a usable name. Records without the
|
||||
// needle are never decoded, so a long one costs a byte search and nothing more.
|
||||
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(record.toString("utf8").trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const entry = toObjectRecord(parsed);
|
||||
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||
return null;
|
||||
}
|
||||
return normalizeClaudeSessionTitle(
|
||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `custom-title` record is re-emitted every time the session is saved, so
|
||||
* the current name is the last usable one in the file. Reading backwards a chunk
|
||||
* at a time finds it without walking the transcript, which matters because
|
||||
* sessions reach tens of megabytes, and never holds the file as one string, so a
|
||||
* transcript past V8's maximum string length still imports.
|
||||
* the current name is the last usable one in the file. Searching backwards finds it without
|
||||
* walking the transcript, which matters because sessions reach tens of
|
||||
* megabytes.
|
||||
*/
|
||||
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
|
||||
const handle = await fsPromises.open(filePath, "r");
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile()) {
|
||||
// Opening a directory succeeds on Windows even though reading it does not,
|
||||
// so reject the way reading the transcript as a file would.
|
||||
throw Object.assign(
|
||||
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
|
||||
{ code: "EISDIR", path: filePath },
|
||||
function readLastClaudeCustomTitle(content: string): string | null {
|
||||
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
|
||||
let at = content.lastIndexOf(needle);
|
||||
while (at >= 0) {
|
||||
const lineStart = content.lastIndexOf("\n", at) + 1;
|
||||
const lineEnd = content.indexOf("\n", at);
|
||||
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line.trim());
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
const entry = toObjectRecord(parsed);
|
||||
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||
const title = normalizeClaudeSessionTitle(
|
||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||
);
|
||||
}
|
||||
let end = stats.size;
|
||||
// Retain unfinished record pieces in reverse file order. Assemble each
|
||||
// complete record once, so a large tool/image record costs linear work.
|
||||
let pieces: Buffer[] = [];
|
||||
while (end > 0) {
|
||||
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
|
||||
const chunk = Buffer.alloc(length);
|
||||
await handle.read(chunk, 0, length, end - length);
|
||||
let recordEnd = chunk.length;
|
||||
for (
|
||||
let at = chunk.lastIndexOf(10);
|
||||
at >= 0;
|
||||
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
|
||||
) {
|
||||
pieces.push(chunk.subarray(at + 1, recordEnd));
|
||||
const record = Buffer.concat(pieces.toReversed());
|
||||
const title = readClaudeCustomTitleFromRecord(record);
|
||||
if (title) return title;
|
||||
pieces = [];
|
||||
recordEnd = at;
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
|
||||
end -= length;
|
||||
}
|
||||
// The first record in the file has no newline in front of it.
|
||||
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
|
||||
} finally {
|
||||
await handle.close();
|
||||
// The needle also matches a prompt that quotes it, and a record can carry no
|
||||
// usable name, so keep looking backwards for one that does.
|
||||
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||
@@ -6989,8 +6898,9 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
sessionId: string;
|
||||
configDir: string;
|
||||
}): Promise<string | null> {
|
||||
let content: string;
|
||||
try {
|
||||
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
|
||||
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
|
||||
} catch (error) {
|
||||
// A session with no transcript on disk has no rename to carry over. Every
|
||||
// other read failure is a real fault and stays explicit.
|
||||
@@ -6999,6 +6909,7 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return readLastClaudeCustomTitle(content);
|
||||
}
|
||||
|
||||
function normalizeImportablePromptPreview(text: string): string | null {
|
||||
|
||||
@@ -102,52 +102,53 @@ describe("daemon E2E", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("downloads ASCII and Unicode filenames through one workspace", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Download Token Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
||||
for (const { fileName, disposition } of [
|
||||
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||
{
|
||||
fileName: "café.txt",
|
||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||
},
|
||||
{
|
||||
fileName: "中文报告 (最终版).txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||
},
|
||||
{
|
||||
fileName: "報告書.md",
|
||||
disposition:
|
||||
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
||||
},
|
||||
{
|
||||
fileName: "보고서.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
||||
},
|
||||
{
|
||||
fileName: "report-📄.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||
},
|
||||
]) {
|
||||
test.each([
|
||||
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||
{
|
||||
fileName: "café.txt",
|
||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||
},
|
||||
{
|
||||
fileName: "中文报告 (最终版).txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||
},
|
||||
{
|
||||
fileName: "報告書.md",
|
||||
disposition:
|
||||
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
||||
},
|
||||
{
|
||||
fileName: "보고서.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
||||
},
|
||||
{
|
||||
fileName: "report-📄.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||
},
|
||||
])(
|
||||
"issues token over WS and downloads $fileName via HTTP",
|
||||
async ({ fileName, disposition }) => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const filePath = path.join(cwd, fileName);
|
||||
const fileContents = `download test payload: ${fileName}`;
|
||||
const fileContents = "download test payload";
|
||||
writeFileSync(filePath, fileContents, "utf-8");
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Download Token Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
||||
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
||||
|
||||
expect(tokenResponse.error).toBeNull();
|
||||
@@ -166,11 +167,12 @@ describe("daemon E2E", () => {
|
||||
|
||||
const body = await response.text();
|
||||
expect(body).toBe(fileContents);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const response = await fetch(
|
||||
|
||||
Reference in New Issue
Block a user