mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
20cb452fd4
## Why
Files produced on the **tool-server host** (screenshots, profiler
exports) were surfaced to the agent as host paths / `127.0.0.1` media
URLs, which the MCP client (`argent-mcp`) then fetched a *second* time
to get the bytes. When the tool-server runs **remotely**, that localhost
URL resolves to the client's own machine — nothing there — so
screenshots fail with `(Screenshot unavailable: no valid PNG …)`.
Profiler exports have the same shape: paths that only exist on the
remote host.
## What
A reusable **artifact boundary layer** so any file-producing tool works
regardless of where the tool-server runs:
**Foundation**
- **tool-server** (`src/artifacts.ts`, `http.ts`): in-process artifact
registry + `GET /artifacts/:id` streaming route.
`registerArtifact(hostPath)` returns a wire handle (`{ __argentArtifact,
id, filename, mimeType, size }`) instead of a raw path.
- **argent-mcp** (`src/artifacts.ts`, `content.ts`, `mcp-server.ts`): a
client materializer deep-walks tool results for handles, downloads each
over the remote-aware `TOOLS_URL`, writes it into a **structured
cache**, and rewrites the result to the **local** path (+ inline image
rendering).
**Cache layout** (override root with `ARGENT_ARTIFACTS_DIR`):
```
~/.argent/artifacts/<project>/<session>/<device>/<filename>
```
- `project` = `basename(cwd)` + short hash of the full path (readable +
collision-safe)
- `session` = minted once per argent-mcp process (groups a run, easy GC)
- `device` = udid/serial when device-scoped, omitted otherwise
**Consumers wired**
- **screenshot** → returns an image artifact; rendered inline from
downloaded bytes with **no second `127.0.0.1` fetch**. Fixes
`auto-screenshot` and `flow-execute` screenshots via the same path.
- **native-profiler-stop** → `exportedFiles` (XML) become downloadable
artifacts materialized client-side.
## Deferred (follow-ups)
- **`.trace` bundle** — it's an Instruments bundle (a directory);
streaming needs archiving (no archiver dep yet). `native-profiler-stop`
now returns a `traceFileNote` explaining it stays on the profiling host.
- **Project source on remote** — `react-profiler-component-source` /
`react-profiler-analyze` read the user's source tree on the tool-server
host. The profiler analysis chains already work remotely
(server-internal); tracked separately.
## Compatibility
Legacy `{ url, path }` screenshot rendering is retained as a fallback
when no artifact context is present (older tool-servers / unit tests).
## Tests
- tool-server: artifact registry (infer/override), `GET /artifacts/:id`
(200/404/410), updated `stop-recovery` assertions.
- argent-mcp: materializer
(download/rewrite/nested/failure/device-scoping), artifact-aware
`toMcpContent`.
- argent-mcp typecheck + full suite green; tool-server artifact/profiler
tests green. (Note: a full local tool-server typecheck trips a
pre-existing zod-v4-vs-v3 mismatch in `run-sequence` that's unrelated to
this change and resolves on this branch's own lockfile.)
## Notes
Routes around the `argent-private` native simulator-server binary (which
emits the `127.0.0.1` media URL) rather than changing it — no submodule
change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: pFornagiel <pawel.fornagiel2@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Paweł Fornagiel <85541429+pFornagiel@users.noreply.github.com>
114 lines
4.1 KiB
TypeScript
114 lines
4.1 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { ArtifactStore } from "@argent/registry";
|
|
import {
|
|
nativeProfilerSessionBlueprint,
|
|
type NativeProfilerSessionApi,
|
|
} from "../../src/blueprints/native-profiler-session";
|
|
|
|
// Mock the platform impl modules so we never shell out to xctrace / adb. We
|
|
// only care that the dispatch picks the correct branch given an api.platform.
|
|
vi.mock("../../src/utils/check-deps", () => ({
|
|
ensureDeps: vi.fn(async () => {}),
|
|
ensureDep: vi.fn(async () => {}),
|
|
}));
|
|
vi.mock("../../src/tools/profiler/native-profiler/platforms/ios", () => ({
|
|
startNativeProfilerIos: vi.fn(async () => ({
|
|
status: "recording",
|
|
pid: 1,
|
|
traceFile: "/ios.trace",
|
|
})),
|
|
stopNativeProfilerIos: vi.fn(async () => ({
|
|
traceFile: "/ios.trace",
|
|
exportedFiles: {},
|
|
exportDiagnostics: { tocSchemas: [], cpuSchemaUsed: null, errors: {} },
|
|
})),
|
|
analyzeNativeProfilerIos: vi.fn(async () => ({
|
|
report: "ios",
|
|
reportFile: null,
|
|
bottlenecksTotal: 0,
|
|
status: "ok",
|
|
exportErrors: {},
|
|
})),
|
|
handleXctraceExit: vi.fn(),
|
|
}));
|
|
vi.mock("../../src/tools/profiler/native-profiler/platforms/android", () => ({
|
|
startNativeProfilerAndroid: vi.fn(async () => ({
|
|
status: "recording",
|
|
pid: 2,
|
|
traceFile: "/android.pftrace",
|
|
})),
|
|
stopNativeProfilerAndroid: vi.fn(async () => ({
|
|
traceFile: "/android.pftrace",
|
|
exportedFiles: {},
|
|
})),
|
|
analyzeNativeProfilerAndroid: vi.fn(async () => ({
|
|
report: "android",
|
|
reportFile: null,
|
|
bottlenecksTotal: 0,
|
|
status: "ok",
|
|
exportErrors: {},
|
|
})),
|
|
}));
|
|
|
|
import { nativeProfilerStartTool } from "../../src/tools/profiler/native-profiler/native-profiler-start";
|
|
import { nativeProfilerStopTool } from "../../src/tools/profiler/native-profiler/native-profiler-stop";
|
|
import { nativeProfilerAnalyzeTool } from "../../src/tools/profiler/native-profiler/native-profiler-analyze";
|
|
import { startNativeProfilerIos } from "../../src/tools/profiler/native-profiler/platforms/ios";
|
|
import { startNativeProfilerAndroid } from "../../src/tools/profiler/native-profiler/platforms/android";
|
|
|
|
async function buildSession(platform: "ios" | "android"): Promise<NativeProfilerSessionApi> {
|
|
const device =
|
|
platform === "ios"
|
|
? {
|
|
id: "11111111-2222-3333-4444-555555555555",
|
|
platform: "ios" as const,
|
|
kind: "simulator" as const,
|
|
}
|
|
: { id: "emulator-5554", platform: "android" as const, kind: "emulator" as const };
|
|
const instance = await nativeProfilerSessionBlueprint.factory({}, device, { device });
|
|
return instance.api;
|
|
}
|
|
|
|
describe("native-profiler-* dispatch by session platform", () => {
|
|
it("routes iOS sessions through platforms/ios", async () => {
|
|
const api = await buildSession("ios");
|
|
const result = await nativeProfilerStartTool.execute({ session: api } as never, {
|
|
device_id: "11111111-2222-3333-4444-555555555555",
|
|
});
|
|
expect(startNativeProfilerIos).toHaveBeenCalled();
|
|
expect(startNativeProfilerAndroid).not.toHaveBeenCalled();
|
|
expect(result.pid).toBe(1);
|
|
});
|
|
|
|
it("routes Android sessions through platforms/android", async () => {
|
|
const api = await buildSession("android");
|
|
const result = await nativeProfilerStartTool.execute({ session: api } as never, {
|
|
device_id: "emulator-5554",
|
|
});
|
|
expect(startNativeProfilerAndroid).toHaveBeenCalled();
|
|
expect(result.pid).toBe(2);
|
|
});
|
|
|
|
it("stop+analyze also route by session platform", async () => {
|
|
const androidApi = await buildSession("android");
|
|
const stop = await nativeProfilerStopTool.execute(
|
|
{ session: androidApi } as never,
|
|
{
|
|
device_id: "emulator-5554",
|
|
},
|
|
{ artifacts: new ArtifactStore() }
|
|
);
|
|
// The stop tool wraps the platform's raw path into a downloadable artifact;
|
|
// the filename still proves it routed through the Android (.pftrace) path.
|
|
expect(stop.traceFile).toMatchObject({
|
|
__argentArtifact: true,
|
|
archive: "tar.gz",
|
|
filename: "android.pftrace",
|
|
});
|
|
const analyze = await nativeProfilerAnalyzeTool.execute({ session: androidApi } as never, {
|
|
device_id: "emulator-5554",
|
|
});
|
|
expect(analyze.report).toBe("android");
|
|
});
|
|
});
|