mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0cc911563 |
@@ -0,0 +1,16 @@
|
||||
# Android terminal keyboard diagnostic
|
||||
|
||||
This is a device diagnostic for #4811, not a CI test or an established release performance budget. It measures the real native terminal and real software keyboard through agent-device. It does not inject keyboard frames or replace the terminal.
|
||||
|
||||
Open a terminal against an isolated daemon, hide its keyboard, and leave Android animations enabled. Use an existing agent-device session bound to the app and device being measured. For example, from the repository root:
|
||||
|
||||
```sh
|
||||
PASEO_AGENT_DEVICE_SESSION=terminal-keyboard-stutter \
|
||||
node packages/app/e2e/mobile/terminal-keyboard/stutter.android.mjs 20
|
||||
```
|
||||
|
||||
Pass a maximum median janky-frame percentage explicitly. The example's 20% is a diagnostic comparison threshold, not an agreed product requirement. The script warms one complete cycle, measures five opening/closing cycles, saves raw Android frame metrics to `.dev/agent-device-artifacts/terminal-keyboard-stutter/frames.json`, then fails if either median exceeds the supplied threshold. `AGENT_DEVICE_STATE_DIR` and `ANDROID_SERIAL` are honored.
|
||||
|
||||
Each opening uses the terminal keyboard toggle, and each closing uses Android Back. Accessibility is read only before opening, because real-IME accessibility snapshots stalled during investigation. The script verifies that the real keyboard changes visibility, waits for three stable samples, and includes another 500 ms for delayed terminal refits. It leaves the keyboard hidden and keeps the caller's session open.
|
||||
|
||||
Compare the same app build mode, terminal contents, device, animation settings, and threshold. Development-build and emulator numbers do not establish release or physical-device performance. A failure reproduces frame drops; it does not identify their cause or prove a proposed fix.
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
// Start an agent-device Android session on an idle terminal with its keyboard hidden.
|
||||
// The explicit budget is a diagnostic comparison parameter, not a shipped performance guarantee.
|
||||
const maxJankPercent = Number(process.argv[2]);
|
||||
assert(
|
||||
process.argv[2] &&
|
||||
Number.isFinite(maxJankPercent) &&
|
||||
maxJankPercent >= 0 &&
|
||||
maxJankPercent <= 100,
|
||||
"Pass the maximum median janky-frame percentage, for example: node stutter.android.mjs 20",
|
||||
);
|
||||
const session = process.env.PASEO_AGENT_DEVICE_SESSION ?? "terminal-keyboard-stutter";
|
||||
const serial = process.env.ANDROID_SERIAL ?? "emulator-5554";
|
||||
const artifacts = resolve(".dev/agent-device-artifacts/terminal-keyboard-stutter");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
function keyboardShown() {
|
||||
return /^ mInputShown=true$/m.test(
|
||||
execFileSync("adb", ["-s", serial, "shell", "dumpsys", "input_method"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForKeyboard(shown) {
|
||||
const deadline = Date.now() + 5000;
|
||||
let stableSamples = 0;
|
||||
while (Date.now() < deadline) {
|
||||
stableSamples = keyboardShown() === shown ? stableSamples + 1 : 0;
|
||||
if (stableSamples === 3) {
|
||||
// Include the pane's delayed refits after the keyboard animation ends.
|
||||
await delay(500);
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error(`Keyboard did not settle at shown=${shown}`);
|
||||
}
|
||||
|
||||
function frames() {
|
||||
const result = JSON.parse(ad("perf", "frames", "--json"));
|
||||
const sample = result.data.metrics.fps;
|
||||
assert(sample.available, "Android frame metrics must be available");
|
||||
return sample;
|
||||
}
|
||||
|
||||
function keyboardTogglePoint() {
|
||||
const { data } = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const toggle = data.nodes.find((node) => node.identifier === "terminal-keyboard-toggle");
|
||||
const input = data.nodes.find((node) => node.identifier === "terminal-native-input");
|
||||
assert(input && toggle?.rect, "Open a native terminal before running this diagnostic");
|
||||
return {
|
||||
x: String(Math.round(toggle.rect.x + toggle.rect.width / 2)),
|
||||
y: String(Math.round(toggle.rect.y + toggle.rect.height / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
async function keyboardCycle(point) {
|
||||
ad("press", point.x, point.y);
|
||||
await waitForKeyboard(true);
|
||||
const open = frames();
|
||||
ad("back");
|
||||
await waitForKeyboard(false);
|
||||
const close = frames();
|
||||
return { open, close };
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
|
||||
async function captureKeyboardCycles() {
|
||||
assert.equal(keyboardShown(), false, "Start with the software keyboard hidden");
|
||||
const point = keyboardTogglePoint();
|
||||
frames();
|
||||
await keyboardCycle(point); // Warm the keyboard and terminal before measuring.
|
||||
const samples = [];
|
||||
for (let cycle = 0; cycle < 5; cycle += 1) {
|
||||
samples.push(await keyboardCycle(point));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
const samples = await captureKeyboardCycles();
|
||||
const open = median(samples.map((sample) => sample.open.droppedFramePercent));
|
||||
const close = median(samples.map((sample) => sample.close.droppedFramePercent));
|
||||
const result = { maxJankPercent, medianJankPercent: { open, close }, samples };
|
||||
writeFileSync(resolve(artifacts, "frames.json"), JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify({ maxJankPercent, medianJankPercent: { open, close } }));
|
||||
assert(open <= maxJankPercent, `Keyboard opening: ${open}% exceeds ${maxJankPercent}%`);
|
||||
assert(close <= maxJankPercent, `Keyboard closing: ${close}% exceeds ${maxJankPercent}%`);
|
||||
+114
-137
@@ -122,7 +122,7 @@ async function collectTimelineAcrossTurns(
|
||||
seq: rows.length + 1,
|
||||
timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(),
|
||||
item: event.item,
|
||||
...((event.turnId ?? currentTurnId) ? { turnId: event.turnId ?? currentTurnId } : {}),
|
||||
...(currentTurnId ? { turnId: currentTurnId } : {}),
|
||||
});
|
||||
}
|
||||
if (event.type === "turn_completed" && ++completedTurns === turnCount) resolve();
|
||||
@@ -469,145 +469,122 @@ describe("background Claude subagents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["before", "after"])(
|
||||
"keeps one logged Task card when child tools start %s the parent finishes",
|
||||
async (timing) => {
|
||||
// A child can stream across the main session's turn boundary. Claude opens the next
|
||||
// main-session turn with system/init; bare child frames do not open a turn.
|
||||
queryFactory.mockImplementation(() =>
|
||||
buildQueryMock([
|
||||
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
model: "claude-haiku-4-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_background",
|
||||
name: "Agent",
|
||||
input: {
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
run_in_background: true,
|
||||
},
|
||||
test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => {
|
||||
// A child can stream across the main session's turn boundary. Claude opens the next
|
||||
// main-session turn with system/init; bare child frames do not open a turn.
|
||||
queryFactory.mockImplementation(() =>
|
||||
buildQueryMock([
|
||||
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
model: "claude-haiku-4-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_background",
|
||||
name: "Agent",
|
||||
input: {
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
run_in_background: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
task_id: "bg-task",
|
||||
tool_use_id: "toolu_background",
|
||||
task_type: "local_agent",
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_background",
|
||||
content: "Async agent launched successfully.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
...(timing === "after"
|
||||
? [
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "bg-session",
|
||||
permissionMode: "default",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: {
|
||||
model: "claude-haiku-4-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_ls",
|
||||
name: "Bash",
|
||||
input: { command: "ls -1", description: "List files" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
...(timing === "before"
|
||||
? [
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "bg-session",
|
||||
permissionMode: "default",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: "user",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_updated",
|
||||
task_id: "bg-task",
|
||||
patch: { status: "completed" },
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "bg-task",
|
||||
tool_use_id: "toolu_background",
|
||||
status: "completed",
|
||||
},
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
]),
|
||||
);
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
|
||||
|
||||
const cards = projectTimelineRows({ rows, mode: "projected" })
|
||||
.map((entry) => entry.item)
|
||||
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
|
||||
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toMatchObject({
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
log: "[Bash] ls -1",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
task_id: "bg-task",
|
||||
tool_use_id: "toolu_background",
|
||||
task_type: "local_agent",
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_background",
|
||||
content: "Async agent launched successfully.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: {
|
||||
model: "claude-haiku-4-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_ls",
|
||||
name: "Bash",
|
||||
input: { command: "ls -1", description: "List files" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
|
||||
{
|
||||
type: "user",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_updated",
|
||||
task_id: "bg-task",
|
||||
patch: { status: "completed" },
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "bg-task",
|
||||
tool_use_id: "toolu_background",
|
||||
status: "completed",
|
||||
},
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
]),
|
||||
);
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
|
||||
|
||||
const cards = projectTimelineRows({ rows, mode: "projected" })
|
||||
.map((entry) => entry.item)
|
||||
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
|
||||
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toMatchObject({
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
log: "[Bash] ls -1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
|
||||
@@ -2250,7 +2250,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private readonly sidechainTracker = new ClaudeSidechainTracker({
|
||||
getToolInput: (toolUseId) =>
|
||||
this.toolUseCache.get(toolUseId)?.input ??
|
||||
this.settledSubagentToolCalls.get(toolUseId)?.input ??
|
||||
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.input ??
|
||||
null,
|
||||
// Releases that predate the task protocol announce nothing, so the tracker keeps deriving
|
||||
// identity and status from frames for them. Detecting the capability beats comparing version
|
||||
@@ -2258,11 +2258,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive,
|
||||
// A background child streams its frames after Claude has already answered the call, so its
|
||||
// card is rebuilt from the settled call: a running one would replace the settled, labeled card.
|
||||
// Late card updates retain the turn that answered the call.
|
||||
// A card belongs to the turn that answered the call, so frames from a later turn leave it be.
|
||||
needsSyntheticParentToolCard: (toolUseId) =>
|
||||
(this.toolUseCache.has(toolUseId) || this.settledSubagentToolCalls.has(toolUseId)) &&
|
||||
(this.toolUseCache.has(toolUseId) ||
|
||||
this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) &&
|
||||
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
|
||||
getSettledParentToolCall: (toolUseId) => this.settledSubagentToolCalls.get(toolUseId)?.item,
|
||||
getSettledParentToolCall: (toolUseId) =>
|
||||
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item,
|
||||
});
|
||||
private persistedHistory: PersistedTimelineEntry[] = [];
|
||||
private persistedProviderSubagentEvents: Extract<
|
||||
@@ -4488,12 +4490,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
),
|
||||
).map((event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }));
|
||||
const routedId = canonicalSubagentId ?? parentToolUseId;
|
||||
const settled = this.settledSubagentToolCalls.get(routedId);
|
||||
const sidechainEvents = this.sidechainTracker.handleMessage(message, routedId).map((event) => {
|
||||
if (event.type === "timeline" && settled?.turnId) event.turnId = settled.turnId;
|
||||
return event;
|
||||
});
|
||||
return [...runtimeEvents, ...sidechainEvents];
|
||||
return [...runtimeEvents, ...this.sidechainTracker.handleMessage(message, routedId)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5148,8 +5145,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId =
|
||||
getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? this.autonomousTurn?.id;
|
||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
|
||||
const tagged = turnId ? { ...event, turnId } : event;
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -5625,12 +5621,17 @@ class ClaudeAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
private settledSubagentToolCallInCurrentTurn(subagentId: string) {
|
||||
const settled = this.settledSubagentToolCalls.get(subagentId);
|
||||
const currentTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
||||
return settled && settled.turnId === currentTurnId ? settled : undefined;
|
||||
}
|
||||
|
||||
/** A finished child streams no more frames, so its settled call has nothing left to update. */
|
||||
private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void {
|
||||
for (const observation of observations) {
|
||||
if (observation.kind === "status" && observation.status !== "running") {
|
||||
this.settledSubagentToolCalls.delete(observation.id);
|
||||
this.sidechainTracker.delete(observation.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,40 +4,6 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
|
||||
describe("ClaudeSidechainTracker", () => {
|
||||
it("retains a background child's actions across the parent finishing", () => {
|
||||
const tracker = new ClaudeSidechainTracker({
|
||||
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
|
||||
isDescriptorOwnedElsewhere: () => true,
|
||||
});
|
||||
const action = (id: string, command: string) =>
|
||||
({
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
|
||||
}) as unknown as SDKMessage;
|
||||
tracker.handleMessage(action("ls", "ls -1"), "task-1");
|
||||
expect(tracker.finishAll("completed")).toEqual([]);
|
||||
const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({
|
||||
detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
tracker.delete("task-1");
|
||||
const next = tracker.handleMessage(action("next", "ls"), "task-1");
|
||||
expect(next).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({
|
||||
detail: expect.objectContaining({ log: "[Bash] ls" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Claude's native agent name for the provider subagent title", () => {
|
||||
const tracker = new ClaudeSidechainTracker({
|
||||
getToolInput: () => ({
|
||||
|
||||
@@ -199,7 +199,7 @@ export class ClaudeSidechainTracker {
|
||||
finishAll(status: "completed" | "failed" | "canceled"): AgentStreamEvent[] {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
if (this.isDescriptorOwnedElsewhere()) {
|
||||
// The task protocol owns child completion; the parent finishing does not end its children.
|
||||
this.activeSidechains.clear();
|
||||
return events;
|
||||
}
|
||||
for (const [id, state] of this.activeSidechains) {
|
||||
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
|
||||
test("background cards retain child actions after the parent finishes (#6253)", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
logger,
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
model: "haiku",
|
||||
modeId: "bypassPermissions",
|
||||
cwd,
|
||||
title: "Background action log reproduction",
|
||||
});
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
'In one message, call Agent twice with run_in_background true: first with subagent_type "general-purpose", description "List files", prompt "Run ls -1, then run sleep 20, then reply with the number of entries."; second with subagent_type "general-purpose", description "Print cwd", prompt "Run pwd, then run sleep 20, then reply with the path." After launching both, reply "launched" and end your turn immediately without waiting.',
|
||||
);
|
||||
await client.waitForFinish(agent.id, 180_000);
|
||||
const atFinish = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { subagents } = await client.listProviderSubagents(agent.id);
|
||||
return subagents.length === 2 && subagents.every((child) => child.status === "completed");
|
||||
},
|
||||
{ timeout: 180_000, interval: 1000 },
|
||||
)
|
||||
.toBe(true);
|
||||
const { subagents } = await client.listProviderSubagents(agent.id);
|
||||
const children = await Promise.all(
|
||||
subagents.map(async (child) => ({
|
||||
description: child.description,
|
||||
timeline: await client.fetchProviderSubagentTimeline(agent.id, child.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
}),
|
||||
})),
|
||||
);
|
||||
const final = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"logs",
|
||||
agent.id,
|
||||
],
|
||||
{ env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 },
|
||||
);
|
||||
expect(stdout).toContain("[Bash] ls -1");
|
||||
expect(stdout).toContain("[Bash] pwd");
|
||||
expect(
|
||||
atFinish.entries.filter(
|
||||
(row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
const cards = final.entries
|
||||
.map((row) => row.item)
|
||||
.filter((item) => item.type === "tool_call" && item.detail?.type === "sub_agent");
|
||||
expect(cards).toHaveLength(2);
|
||||
for (const child of children) {
|
||||
expect(
|
||||
child.timeline.rows.some(
|
||||
(row) => row.item.type === "tool_call" && row.item.name === "Bash",
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
for (const card of cards) {
|
||||
expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]");
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 420_000);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHook } from "node:async_hooks";
|
||||
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
@@ -1066,16 +1066,6 @@ describe("relative typed-entry configuration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
interface SearchProcessExit {
|
||||
code: number | null;
|
||||
signal: string | null;
|
||||
}
|
||||
|
||||
interface SearchProcess {
|
||||
child: ChildProcess;
|
||||
exited: Promise<SearchProcessExit>;
|
||||
}
|
||||
|
||||
// Reading a tree is cheap; re-deriving each entry's path state is not. A scan that asks whether
|
||||
// every ancestor of every entry is inside the root, or is Git-ignored, costs multiples of the
|
||||
// read and is what made large home directories exceed the client request timeout.
|
||||
@@ -1083,7 +1073,6 @@ describe("home-tree scan cost", () => {
|
||||
const DEPTH = 8;
|
||||
const CHAINS = 40;
|
||||
let scanRoot: string;
|
||||
let searchProcess: SearchProcess | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-cost-")));
|
||||
@@ -1094,16 +1083,8 @@ describe("home-tree scan cost", () => {
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
if (searchProcess) {
|
||||
searchProcess.child.kill();
|
||||
await searchProcess.exited;
|
||||
}
|
||||
} finally {
|
||||
searchProcess = null;
|
||||
rmSync(scanRoot, { recursive: true, force: true });
|
||||
}
|
||||
afterEach(() => {
|
||||
rmSync(scanRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Nothing matches, so the scan walks the whole tree instead of stopping at a confident result.
|
||||
@@ -1121,78 +1102,6 @@ describe("home-tree scan cost", () => {
|
||||
return stopPathContainmentMetrics();
|
||||
}
|
||||
|
||||
it("finishes overlapping broad-tree searches within a bounded heap", async () => {
|
||||
const broadRoot = path.join(scanRoot, "broad");
|
||||
mkdirSync(broadRoot);
|
||||
for (let branch = 0; branch < 30; branch += 1) {
|
||||
const branchRoot = path.join(broadRoot, `branch-${branch}`);
|
||||
mkdirSync(branchRoot);
|
||||
for (let group = 0; group < 30; group += 1) {
|
||||
const groupRoot = path.join(branchRoot, `group-${group}`);
|
||||
mkdirSync(groupRoot);
|
||||
for (let leaf = 0; leaf < 30; leaf += 1) {
|
||||
mkdirSync(path.join(groupRoot, `leaf-${leaf}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A smaller real tree and explicit heap budget keep the million-directory repro cheap in CI.
|
||||
const moduleUrl = new URL("./directory-suggestions.ts", import.meta.url).href;
|
||||
const source = `
|
||||
const { searchDirectoryEntries } = await import(${JSON.stringify(moduleUrl)});
|
||||
const results = await Promise.all(Array.from({ length: 14 }, (_, index) =>
|
||||
searchDirectoryEntries({
|
||||
root: ${JSON.stringify(broadRoot)},
|
||||
query: "z".repeat(index + 1),
|
||||
pathFormat: "absolute",
|
||||
includeFiles: false,
|
||||
includeDirectories: true,
|
||||
pathQueryPolicy: "rooted",
|
||||
rootAliases: ["~"],
|
||||
blankQueryBehavior: "none",
|
||||
confidentResultScanThreshold: 5000,
|
||||
maxEntriesScanned: 2000,
|
||||
limit: 30,
|
||||
})
|
||||
));
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
`;
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--max-old-space-size=128",
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
source,
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"], timeout: 20_000 },
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
const exited = new Promise<SearchProcessExit>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
searchProcess = { child, exited };
|
||||
const exit = await exited;
|
||||
expect({ ...exit, outOfMemory: stderr.includes("heap out of memory") }).toEqual({
|
||||
code: 0,
|
||||
signal: null,
|
||||
outOfMemory: false,
|
||||
});
|
||||
expect(JSON.parse(stdout)).toEqual(Array.from({ length: 14 }, () => []));
|
||||
});
|
||||
|
||||
it("derives no containment for a tree that cannot leave the root", async () => {
|
||||
const checks = await countContainmentChecks(scanWholeTree);
|
||||
|
||||
|
||||
@@ -63,13 +63,6 @@ interface WalkStart {
|
||||
resolvedPath: string;
|
||||
}
|
||||
|
||||
interface WalkChildrenOptions {
|
||||
parent: WalkStart;
|
||||
depth: number;
|
||||
input: SearchInput;
|
||||
visited: Set<string>;
|
||||
}
|
||||
|
||||
interface TraversedEntry extends ChildEntry {
|
||||
visiblePath: string;
|
||||
depth: number;
|
||||
@@ -325,18 +318,17 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
|
||||
const discoveryInput = isInsideExcludedPath(parent, input)
|
||||
? { ...input, excludedDiscoveryPaths: new Set<string>() }
|
||||
: input;
|
||||
const ranked: RankedEntry[] = [];
|
||||
for await (const entry of readChildren(parent)) {
|
||||
const entries = await readChildren(parent);
|
||||
return entries.flatMap((entry) => {
|
||||
if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput))
|
||||
continue;
|
||||
return [];
|
||||
const candidate: TraversedEntry = {
|
||||
...entry,
|
||||
visiblePath: path.join(visibleParent, entry.name),
|
||||
depth: 1,
|
||||
};
|
||||
if (shouldSuggest(candidate, input)) ranked.push(rank(candidate, input));
|
||||
}
|
||||
return ranked;
|
||||
return shouldSuggest(candidate, input) ? [rank(candidate, input)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function searchTree(
|
||||
@@ -344,8 +336,21 @@ async function searchTree(
|
||||
start: WalkStart = { visiblePath: input.root, resolvedPath: input.root },
|
||||
): Promise<RankedEntry[]> {
|
||||
if (!(input.maxEntriesScanned > 0)) return [];
|
||||
const roots = (await readChildren(start.resolvedPath)).filter((entry) =>
|
||||
staysInsideRoot(entry, input.root),
|
||||
);
|
||||
const visited = new Set<string>([start.resolvedPath]);
|
||||
const branches = walkChildren({ parent: start, depth: 1, input, visited });
|
||||
const branches = roots.flatMap((entry) =>
|
||||
shouldDiscover(entry, input)
|
||||
? [
|
||||
walkBranch(
|
||||
{ ...entry, visiblePath: path.join(start.visiblePath, entry.name), depth: 1 },
|
||||
input,
|
||||
visited,
|
||||
),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const ranked: RankedEntry[] = [];
|
||||
let scanned = 0;
|
||||
const threshold = input.confidentResultScanThreshold;
|
||||
@@ -376,39 +381,38 @@ async function* walkBranch(
|
||||
)
|
||||
return;
|
||||
visited.add(entry.resolvedPath);
|
||||
yield* roundRobin(walkChildren({ parent: entry, depth: entry.depth + 1, input, visited }));
|
||||
const children = (await readChildren(entry.resolvedPath)).filter((child) =>
|
||||
staysInsideRoot(child, input.root),
|
||||
);
|
||||
const branches = children.flatMap((child) =>
|
||||
shouldDiscover(child, input)
|
||||
? [
|
||||
walkBranch(
|
||||
{
|
||||
...child,
|
||||
visiblePath: path.join(entry.visiblePath, child.name),
|
||||
depth: entry.depth + 1,
|
||||
},
|
||||
input,
|
||||
visited,
|
||||
),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
yield* roundRobin(branches);
|
||||
}
|
||||
|
||||
// Create a child's path and iterator only when its branch gets a turn. Eagerly constructing every
|
||||
// sibling's subtree retained hundreds of thousands of unvisited children beyond the scan budget.
|
||||
async function* walkChildren({
|
||||
parent,
|
||||
depth,
|
||||
input,
|
||||
visited,
|
||||
}: WalkChildrenOptions): AsyncGenerator<AsyncGenerator<TraversedEntry>> {
|
||||
for await (const child of readChildren(parent.resolvedPath)) {
|
||||
if (!staysInsideRoot(child, input.root) || !shouldDiscover(child, input)) continue;
|
||||
yield walkBranch(
|
||||
{ ...child, visiblePath: path.join(parent.visiblePath, child.name), depth },
|
||||
input,
|
||||
visited,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function* roundRobin<T>(branches: AsyncIterable<AsyncGenerator<T>>): AsyncGenerator<T> {
|
||||
let active: AsyncIterable<AsyncGenerator<T>> | Array<AsyncGenerator<T>> = branches;
|
||||
while (true) {
|
||||
async function* roundRobin<T>(branches: Array<AsyncGenerator<T>>): AsyncGenerator<T> {
|
||||
let active = branches;
|
||||
while (active.length) {
|
||||
const nextRound: Array<AsyncGenerator<T>> = [];
|
||||
for await (const branch of active) {
|
||||
for (const branch of active) {
|
||||
const next = await branch.next();
|
||||
if (!next.done) {
|
||||
nextRound.push(branch);
|
||||
yield next.value;
|
||||
}
|
||||
}
|
||||
if (!nextRound.length) return;
|
||||
active = nextRound;
|
||||
}
|
||||
}
|
||||
@@ -738,9 +742,9 @@ async function resolveDirectory(inputPath: string): Promise<string | null> {
|
||||
return info?.isDirectory() ? resolved : null;
|
||||
}
|
||||
|
||||
async function* readChildren(directory: string): AsyncGenerator<ChildEntry> {
|
||||
async function readChildren(directory: string): Promise<ChildEntry[]> {
|
||||
const directoryInfo = await stat(directory).catch(() => null);
|
||||
if (!directoryInfo?.isDirectory()) return;
|
||||
if (!directoryInfo?.isDirectory()) return [];
|
||||
|
||||
const cached = CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA
|
||||
? directoryListCache.get(directory)
|
||||
@@ -770,10 +774,9 @@ async function* readChildren(directory: string): AsyncGenerator<ChildEntry> {
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawEntry of rawEntries) {
|
||||
const entry = await resolveChild(directory, rawEntry);
|
||||
if (entry) yield entry;
|
||||
}
|
||||
return (await Promise.all(rawEntries.map((entry) => resolveChild(directory, entry))))
|
||||
.filter((entry): entry is ChildEntry => entry !== null)
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
async function loadGitIgnoredPaths(root: string): Promise<Set<string>> {
|
||||
|
||||
@@ -19,6 +19,9 @@ export default defineConfig({
|
||||
setupFiles: [path.resolve(__dirname, "./src/test-utils/vitest-setup.ts")],
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
// Even two concurrent Git-heavy suites can exhaust Windows test deadlines.
|
||||
// Run files serially so their subprocesses do not compete across suites.
|
||||
maxWorkers: process.platform === "win32" ? 1 : undefined,
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**", "**/.dev/**"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user