Files
filip131311 20cb452fd4 feat(remote): artifact transport for files produced by a remote tool-server (#278)
## 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>
2026-06-10 12:04:30 +02:00

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");
});
});