Files
software-mansion__argent/packages/tool-server/test/http-tools-meta.test.ts
filip131311 c573e9c524 feat(telemetry): report device_kind next to platform on tool events (#1049)
## Why

Telemetry cannot tell a physical iPhone from a simulator: `platform` is
`ios` for both, and the tool-server's `kind` never reaches the events.
Physical-iOS support shipped in 0.24.0 with no way to count its adoption
except through `ios_device_*` failure stages on `tool:fail`. Physical
Android phones have the same gap.

## What

`tool:invoke` / `tool:complete` / `tool:fail` gain `device_kind:
simulator | emulator | vvd | device | app`, derived from the same device
id as `platform` (single classifier in `telemetry-platform.ts`, which
`debugger:tool_outcome` and `lens:*` also go through for their
platform). `platform: ios` + `device_kind: device` is a physical iPhone.

The kind is emitted only for positively recognised id shapes, so the
metric is not polluted by ids that are not devices:

| id shape | platform | device_kind |
|---|---|---|
| simulator UUID / `remote:` | ios / ios-remote (tvos when the cache is
warm) | simulator |
| physical iPhone UDID | ios | device |
| `emulator-NNNN` | android (android-tv when warm) | emulator |
| USB serial, 6–20 alnum with a digit and a letter | android | device |
| non-loopback `ip:port` | android | device (best effort) |
| loopback `127.0.0.1:port` / `localhost:port` / `::1:port` | android |
emulator |
| anything else non-iOS-shaped (Metro 40-hex handle, `booted`,
`Pixel_7`, IPv6, mDNS) | android | omitted |
| `chromium-cdp-<port>` / `amazon-…` | chromium / vega | app / vvd |
| `avdName`-only boot | android | omitted |

A child sub-tool that names its own device replaces the parent's
platform, kind and provider label wholesale, so an `avdName`-only child
never reports the parent's `simulator` next to its own `android`. A
logical-keyed Metro session strips the kind.

`unknown` (in the registry `DeviceKind`) is rejected by the sanitizer
like `platform: "unknown"`; no registry change.

## Querying

- Physical iPhone adoption: `tool:invoke` where `platform = 'ios' and
device_kind = 'device'`, distinct users over it. `isIosPhysicalUdid` is
a positive match, so invoke is clean.
- Physical Android: count on `tool:complete` (the device answered).
`tool:invoke` / `tool:fail` still carry residual mistakes that happen to
look like a serial (`Pixel7`), and a non-loopback `ip:port` may be a
networked virtual device.

## Docs

- `Telemetry.md` → 1.04, effective date 7 September 2026 (adjust to the
release date on merge). Discloses the new category and, while touching
that section, the external-provider label that #735 added without a
notice line — happy to drop that second bullet if it should ship
separately.
- No docs-site change: `docs/reference/telemetry.mdx` links to
`Telemetry.md` and has no per-property list.

## Notes

- Pre-existing, unchanged: HTTP-edge failures carry no
`tool_invocation_id`; `stop-all-simulator-servers` declares no
capability, so its invoke row has neither field while its fail row has
both. `device_kind` follows `platform` in each site.
- Legacy 40-hex iPhone UDIDs already classify as `android` today; they
now get no kind rather than a wrong one.
- Design went through three adversarial review rounds before
implementation and one on the diff; the fallback-bucket and
child-attribution rules above came out of those.

## Verification

- `packages/telemetry`: 319 tests, `packages/tool-server`: full suite
5670 passed / 1 skipped; `typecheck:tests` both packages; `eslint
--max-warnings 0`; `knip`; prettier.
- New: `test/telemetry-device-kind.test.ts` (the shape table above),
plus cases in `http-tools-meta`, `http-platform-alias`, `sanitize`,
`registry-listener`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01BSGpeAwvoCv2JAhbLsuto2


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Telemetry now records a device category—such as simulator, emulator,
physical device, virtual device, or desktop app—alongside the platform.
* Device categories are inferred from device information and may include
a short label for external device providers.
* Device category information is included in tool invocation,
completion, and failure telemetry when available.
* Device identifiers themselves continue to be excluded from telemetry.
* **Documentation**
* Updated the Argent Privacy Notice (Telemetry) to version 1.04,
effective 7 September 2026.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-07 11:55:57 +02:00

673 lines
26 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import supertest from "supertest";
import { z } from "zod";
import { createHttpApp, type HttpAppHandle } from "../src/http";
import type { Registry } from "@argent/registry";
vi.mock("../src/utils/update-checker", () => ({
getUpdateState: vi.fn(() => ({
updateAvailable: false,
latestVersion: null,
currentVersion: "1.0.0",
})),
isUpdateNoteSuppressed: vi.fn(() => true),
suppressUpdateNote: vi.fn(),
}));
// Drive the synchronous, cache-only runtime-kind readers that http.ts consults
// to split a TV target out of its base mobile platform. Real code warms these
// caches via simctl/adb; here we mock only the two getters so a case can pretend
// the cache is cold ("mobile"/undefined → coarse platform) or warm for TV.
const tvKinds = vi.hoisted(() => ({
ios: undefined as "mobile" | "tv" | undefined,
android: undefined as "mobile" | "tv" | undefined,
}));
vi.mock("../src/utils/ios-devices", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/utils/ios-devices")>();
return { ...actual, getCachedSimulatorRuntimeKind: () => tvKinds.ios };
});
vi.mock("../src/utils/adb", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/utils/adb")>();
return { ...actual, getCachedAndroidRuntimeKind: () => tvKinds.android };
});
function stubRegistry(): Registry {
return {
getSnapshot: vi.fn(() => ({
services: new Map(),
namespaces: [],
tools: ["always-tool", "hinted-tool", "plain-tool", "device-tool", "boot-tool"],
})),
getTool: vi.fn((name: string) => {
if (name === "always-tool") {
return {
id: "always-tool",
description: "Always loaded",
inputSchema: { type: "object", properties: {} },
alwaysLoad: true,
searchHint: "always loaded discovery",
services: () => ({}),
execute: async () => ({}),
};
}
if (name === "hinted-tool") {
return {
id: "hinted-tool",
description: "Deferred but hinted",
inputSchema: { type: "object", properties: {} },
searchHint: "profiling hotspots cpu",
services: () => ({}),
execute: async () => ({}),
};
}
if (name === "plain-tool") {
return {
id: "plain-tool",
description: "Plain tool",
inputSchema: { type: "object", properties: {} },
services: () => ({}),
execute: async () => ({}),
};
}
if (name === "device-tool") {
return {
id: "device-tool",
description: "Device tool",
inputSchema: { type: "object", properties: {} },
capability: { apple: { simulator: true }, android: { emulator: true } },
services: () => ({}),
execute: async () => ({}),
};
}
if (name === "boot-tool") {
return {
id: "boot-tool",
description: "Boot tool",
inputSchema: { type: "object", properties: {} },
capability: { apple: { simulator: true }, android: { emulator: true } },
services: () => ({}),
execute: async () => ({}),
};
}
return undefined;
}),
invokeTool: vi.fn(),
} as unknown as Registry;
}
describe("GET /tools progressive-loading metadata", () => {
let handle: HttpAppHandle;
const request = supertest;
beforeEach(async () => {
tvKinds.ios = undefined;
tvKinds.android = undefined;
handle = createHttpApp(stubRegistry());
});
afterEach(() => {
handle?.dispose();
vi.clearAllMocks();
});
it("passes alwaysLoad and searchHint through on /tools response", async () => {
const res = await request(handle.app).get("/tools").expect(200);
const byName = new Map<string, Record<string, unknown>>(
(res.body.tools as Record<string, unknown>[]).map((t) => [t.name as string, t])
);
expect(byName.get("always-tool")).toMatchObject({
alwaysLoad: true,
searchHint: "always loaded discovery",
});
expect(byName.get("hinted-tool")).toMatchObject({
searchHint: "profiling hotspots cpu",
});
expect(byName.get("hinted-tool")).not.toHaveProperty("alwaysLoad");
expect(byName.get("plain-tool")).not.toHaveProperty("alwaysLoad");
expect(byName.get("plain-tool")).not.toHaveProperty("searchHint");
});
it("does not pass bundleId into telemetry invocation metadata", async () => {
const release = vi.fn();
let seenMeta: Record<string, unknown> | undefined;
const recordInvocation = vi.fn((_toolInvocationId: string, meta: Record<string, unknown>) => {
seenMeta = meta;
return release;
});
const registry = stubRegistry();
handle.dispose();
handle = createHttpApp(registry, { recordInvocation });
await request(handle.app)
.post("/tools/device-tool")
.send({
udid: "11111111-1111-1111-1111-111111111111",
bundleId: "com.example.app",
})
.expect(200);
expect(recordInvocation).toHaveBeenCalledWith(expect.any(String), {
platform: "ios",
device_kind: "simulator",
});
expect(seenMeta).not.toHaveProperty("bundleId");
expect(seenMeta).not.toHaveProperty("deviceId");
expect(release).toHaveBeenCalledOnce();
expect(registry.invokeTool).toHaveBeenCalledWith(
"device-tool",
expect.any(Object),
expect.objectContaining({ toolInvocationId: recordInvocation.mock.calls[0]![0] })
);
});
it("does not record platform metadata for non-device tools", async () => {
const recordInvocation = vi.fn(() => vi.fn());
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
await request(handle.app)
.post("/tools/plain-tool")
.send({
udid: "11111111-1111-1111-1111-111111111111",
bundleId: "com.example.app",
})
.expect(200);
expect(recordInvocation).not.toHaveBeenCalled();
});
it("records Android platform for avdName device-management calls without a device hash", async () => {
const recordInvocation = vi.fn((_toolInvocationId: string, meta: Record<string, unknown>) => {
expect(meta).toEqual({ platform: "android" });
return vi.fn();
});
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
await request(handle.app).post("/tools/boot-tool").send({ avdName: "Pixel_9" }).expect(200);
expect(recordInvocation).toHaveBeenCalledWith(expect.any(String), { platform: "android" });
});
it("records the platform of a scoped teardown, whose device arg is a LIST", async () => {
// `devices` is the third device-arg spelling and the only one that is an
// array — `stop-all-simulator-servers`' scope. The other two spellings are
// pinned above; deleting the `devices` branch of `extractDeviceArg` left
// the whole suite green, so a scoped teardown silently lost its platform.
// Driven through `device-tool` because `extractInvocationMeta` derives a
// platform only for a tool that declares a capability, and
// `stop-all-simulator-servers` — the sole tool spelling `devices` today —
// declares none. That is a fact about THIS consumer: `extractDeviceArg`'s
// other two are ungated and read `devices` in production (a failure
// classified from `req.body`, and a replayed teardown step attributed
// through `deriveChildInvocationMeta`).
let seenMeta: Record<string, unknown> | undefined;
const recordInvocation = vi.fn((_id: string, meta: Record<string, unknown>) => {
seenMeta = meta;
return vi.fn();
});
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
await request(handle.app)
.post("/tools/device-tool")
.send({ devices: ["emulator-5554", "11111111-1111-1111-1111-111111111111"] })
.expect(200);
// The first id is enough for the coarse platform; a mixed-platform scope
// is not something this dimension tries to represent.
expect(seenMeta).toEqual({ platform: "android", device_kind: "emulator" });
});
it("ignores a devices list that holds no usable id", async () => {
// `devices: []` and `devices: [123]` must both yield no device arg. The
// empty case alone was a tautology — deleting the
// `typeof record.devices[0] === "string"` guard left the whole suite green,
// because a non-string element was never sent. The schema rejects such a
// call in production, so this is the guard's only exercise.
const recordInvocation = vi.fn(() => vi.fn());
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
for (const devices of [[], [123], [null]]) {
recordInvocation.mockClear();
await request(handle.app).post("/tools/device-tool").send({ devices }).expect(200);
// No device arg, so no platform — and with nothing else to record, no
// invocation metadata at all.
expect(recordInvocation, `devices: ${JSON.stringify(devices)}`).not.toHaveBeenCalled();
}
});
it("classifies a FAILED call from its devices scope, with no capability in play", async () => {
// `emitHttpFailure` is one of `extractDeviceArg`'s two UNGATED consumers,
// and the one that makes the `devices` branch live in production: a
// rejected `stop-all-simulator-servers` call is classified straight from
// `req.body`, which carries the scope. Driven through a tool that declares
// no capability, exactly like the real one.
const recordFailure = vi.fn();
const registry = stubRegistry();
// The real shape: `stop-all-simulator-servers` is `.strict()` precisely so
// the `udids` slip cannot be stripped down to a machine-wide sweep, and
// that rejection is a 400 classified from `req.body` — which carries
// `devices`. No capability anywhere in the path.
(registry.getTool as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
id: "strict-teardown",
description: "Scoped teardown",
inputSchema: { type: "object", properties: { devices: {} } },
zodSchema: z.object({ devices: z.array(z.string()).optional() }).strict(),
services: () => ({}),
execute: async () => ({}),
});
handle.dispose();
handle = createHttpApp(registry, { recordFailure });
await request(handle.app)
.post("/tools/strict-teardown")
.send({ devices: ["emulator-5554"], udids: ["oops"] })
.expect(400);
expect(recordFailure).toHaveBeenCalled();
expect(recordFailure.mock.calls[0][1]).toEqual({
platform: "android",
device_kind: "emulator",
invalid_params: ["unrecognized_keys"],
});
});
it("refines an iOS device to `tvos` when its cached runtime kind is tv", async () => {
tvKinds.ios = "tv";
let seenMeta: Record<string, unknown> | undefined;
const recordInvocation = vi.fn((_id: string, meta: Record<string, unknown>) => {
seenMeta = meta;
return vi.fn();
});
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
await request(handle.app)
.post("/tools/device-tool")
.send({ udid: "11111111-1111-1111-1111-111111111111" })
.expect(200);
// Same UDID shape as an iPhone sim, but the warm cache splits it out as tvOS.
// The kind never depends on the cache: a tvOS target is still a simulator.
expect(seenMeta).toEqual({ platform: "tvos", device_kind: "simulator" });
});
it("refines an Android device to `android-tv` when its cached runtime kind is tv", async () => {
tvKinds.android = "tv";
let seenMeta: Record<string, unknown> | undefined;
const recordInvocation = vi.fn((_id: string, meta: Record<string, unknown>) => {
seenMeta = meta;
return vi.fn();
});
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
await request(handle.app)
.post("/tools/device-tool")
.send({ udid: "emulator-5554" })
.expect(200);
expect(seenMeta).toEqual({ platform: "android-tv", device_kind: "emulator" });
});
it("keeps the coarse `ios` platform when the cached kind is mobile (not tv)", async () => {
tvKinds.ios = "mobile";
let seenMeta: Record<string, unknown> | undefined;
const recordInvocation = vi.fn((_id: string, meta: Record<string, unknown>) => {
seenMeta = meta;
return vi.fn();
});
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
await request(handle.app)
.post("/tools/device-tool")
.send({ udid: "11111111-1111-1111-1111-111111111111" })
.expect(200);
expect(seenMeta).toEqual({ platform: "ios", device_kind: "simulator" });
});
it("keeps the coarse platform when the cache is cold (first call before warm-up)", async () => {
// tvKinds default to undefined → the reader can't yet tell TV from mobile, so
// the first tool call on a device reports the base platform. A later call,
// once describe/streaming has warmed the cache, would report the TV variant.
let seenMeta: Record<string, unknown> | undefined;
const recordInvocation = vi.fn((_id: string, meta: Record<string, unknown>) => {
seenMeta = meta;
return vi.fn();
});
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
await request(handle.app)
.post("/tools/device-tool")
.send({ udid: "11111111-1111-1111-1111-111111111111" })
.expect(200);
expect(seenMeta).toEqual({ platform: "ios", device_kind: "simulator" });
});
it("re-derives a child sub-tool's TV platform from its own device arg", async () => {
tvKinds.android = "tv";
const recordInvocation = vi.fn((_id: string, _meta: Record<string, unknown>) => vi.fn());
const registry = stubRegistry();
handle.dispose();
handle = createHttpApp(registry, { recordInvocation });
// Parent targets an Android TV device.
await request(handle.app)
.post("/tools/device-tool")
.send({ udid: "emulator-5554" })
.expect(200);
const { recordChildInvocation } = vi.mocked(registry.invokeTool).mock.calls[0]![2] as {
recordChildInvocation: (id: string, childArgs?: unknown) => () => void;
};
// A sub-tool targeting the same TV device is attributed to android-tv too.
recordChildInvocation("tv-child", { udid: "emulator-5554" });
expect(recordInvocation).toHaveBeenCalledWith("tv-child", {
platform: "android-tv",
device_kind: "emulator",
});
});
it("records the AI client from request headers alongside platform", async () => {
let seenMeta: Record<string, unknown> | undefined;
const recordInvocation = vi.fn((_id: string, meta: Record<string, unknown>) => {
seenMeta = meta;
return vi.fn();
});
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
await request(handle.app)
.post("/tools/device-tool")
.set("X-Argent-AI-Client", "codex")
.send({ udid: "11111111-1111-1111-1111-111111111111" })
.expect(200);
expect(seenMeta).toEqual({ platform: "ios", device_kind: "simulator", ai_client: "codex" });
});
it("forwards a child-invocation recorder bound to the request's attribution", async () => {
const release = vi.fn();
const recordInvocation = vi.fn((_id: string, _meta: Record<string, unknown>) => release);
const registry = stubRegistry();
handle.dispose();
handle = createHttpApp(registry, { recordInvocation });
await request(handle.app)
.post("/tools/device-tool")
.set("X-Argent-AI-Client", "codex")
.send({ udid: "11111111-1111-1111-1111-111111111111" })
.expect(200);
// The parent invocation is recorded with the resolved attribution.
expect(recordInvocation).toHaveBeenCalledTimes(1);
expect(recordInvocation.mock.calls[0]![1]).toEqual({
platform: "ios",
device_kind: "simulator",
ai_client: "codex",
});
// A recorder is threaded into the tool context so orchestrator tools can
// attribute the sub-tools they dispatch. The AI client is inherited; a child
// with no device arg of its own falls back to the request's platform.
const opts = vi.mocked(registry.invokeTool).mock.calls[0]![2] as {
recordChildInvocation?: (id: string, childArgs?: unknown) => () => void;
};
expect(opts.recordChildInvocation).toBeTypeOf("function");
const childRelease = opts.recordChildInvocation!("child-id");
expect(recordInvocation).toHaveBeenCalledWith("child-id", {
platform: "ios",
device_kind: "simulator",
ai_client: "codex",
});
expect(childRelease).toBe(release);
});
it("re-derives each child's platform from its own device arg", async () => {
const recordInvocation = vi.fn((_id: string, _meta: Record<string, unknown>) => vi.fn());
const registry = stubRegistry();
handle.dispose();
handle = createHttpApp(registry, { recordInvocation });
// Parent request targets iOS.
await request(handle.app)
.post("/tools/device-tool")
.set("X-Argent-AI-Client", "codex")
.send({ udid: "11111111-1111-1111-1111-111111111111" })
.expect(200);
const { recordChildInvocation } = vi.mocked(registry.invokeTool).mock.calls[0]![2] as {
recordChildInvocation: (id: string, childArgs?: unknown) => () => void;
};
// A sub-tool that targets an Android device keeps the inherited ai_client but
// is attributed to ITS OWN platform — not the parent's iOS. This is what lets
// a flow-execute (no platform of its own) attribute each gesture correctly.
recordChildInvocation("android-child", { udid: "emulator-5554" });
expect(recordInvocation).toHaveBeenCalledWith("android-child", {
ai_client: "codex",
platform: "android",
device_kind: "emulator",
});
// A child with no device arg falls back to the parent's platform and kind.
recordChildInvocation("no-device-child", { message: "hi" });
expect(recordInvocation).toHaveBeenCalledWith("no-device-child", {
ai_client: "codex",
platform: "ios",
device_kind: "simulator",
});
// A child that names a physical phone reports hardware, never the parent's
// simulator kind. `R5CT12345678` is the serial the adb tests use.
recordChildInvocation("phone-child", { udid: "R5CT12345678" });
expect(recordInvocation).toHaveBeenCalledWith("phone-child", {
ai_client: "codex",
platform: "android",
device_kind: "device",
});
// An `avdName`-only child has a platform but no serial to derive a kind
// from: it must not report the parent's `simulator` next to `android`.
recordChildInvocation("avd-child", { avdName: "Pixel_9" });
expect(recordInvocation).toHaveBeenCalledWith("avd-child", {
ai_client: "codex",
platform: "android",
});
});
it("records `device` for a physical iPhone UDID, `simulator` for a simulator one", async () => {
// The whole point of `device_kind`: the two share `platform: "ios"`, and only
// the UDID shape (`isIosPhysicalUdid`) tells hardware from a simulator.
let seenMeta: Record<string, unknown> | undefined;
const recordInvocation = vi.fn((_id: string, meta: Record<string, unknown>) => {
seenMeta = meta;
return vi.fn();
});
const registry = stubRegistry();
// `device-tool` above is simulator-only, so the HTTP capability gate would
// reject the phone before attribution; this one supports both.
(registry.getTool as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
id: "phone-tool",
description: "Phone tool",
inputSchema: { type: "object", properties: {} },
capability: { apple: { simulator: true, device: true } },
services: () => ({}),
execute: async () => ({}),
});
handle.dispose();
handle = createHttpApp(registry, { recordInvocation });
await request(handle.app)
.post("/tools/phone-tool")
.send({ udid: "00008030-000A1B2C3D4E5F60" })
.expect(200);
expect(seenMeta).toEqual({ platform: "ios", device_kind: "device" });
await request(handle.app)
.post("/tools/phone-tool")
.send({ udid: "11111111-1111-1111-1111-111111111111" })
.expect(200);
expect(seenMeta).toEqual({ platform: "ios", device_kind: "simulator" });
});
it("drops the parent's external-provider label when a child names a native device", async () => {
const recordInvocation = vi.fn((_id: string, _meta: Record<string, unknown>) => vi.fn());
const registry = stubRegistry();
handle.dispose();
handle = createHttpApp(registry, { recordInvocation });
// Parent targets a device an external provider attached.
await request(handle.app)
.post("/tools/device-tool")
.send({ udid: "ext:acme-3f2a9c:11111111-1111-1111-1111-111111111111" })
.expect(200);
expect(recordInvocation.mock.calls[0]![1]).toEqual({
device_provider: "acme",
platform: "ios",
device_kind: "simulator",
});
const { recordChildInvocation } = vi.mocked(registry.invokeTool).mock.calls[0]![2] as {
recordChildInvocation: (id: string, childArgs?: unknown) => () => void;
};
// The child named its own, non-external device: the provider label is
// re-derived from it (to nothing), not inherited from the parent.
recordChildInvocation("native-child", { udid: "emulator-5554" });
expect(recordInvocation).toHaveBeenCalledWith("native-child", {
platform: "android",
device_kind: "emulator",
});
});
it("classifies a FAILED call's platform but not its kind when the udid is garbage", async () => {
// `emitHttpFailure` reads the raw body before validation, so a typo'd udid
// reaches the classifier. It still shape-classifies as `android` (the
// pre-existing fallback), but it must not count as physical hardware.
const recordFailure = vi.fn();
const registry = stubRegistry();
(registry.getTool as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
id: "strict-tool",
description: "Strict tool",
inputSchema: { type: "object", properties: { udid: {} } },
zodSchema: z.object({ udid: z.string() }).strict(),
services: () => ({}),
execute: async () => ({}),
});
handle.dispose();
handle = createHttpApp(registry, { recordFailure });
await request(handle.app)
.post("/tools/strict-tool")
.send({ udid: "booted", extra: 1 })
.expect(400);
expect(recordFailure.mock.calls[0]![1]).toMatchObject({ platform: "android" });
expect(recordFailure.mock.calls[0]![1]).not.toHaveProperty("device_kind");
});
it("does not forward a child recorder when there is no attribution to propagate", async () => {
const recordInvocation = vi.fn(() => vi.fn());
const registry = stubRegistry();
handle.dispose();
handle = createHttpApp(registry, { recordInvocation });
// plain-tool has no capability and no AI-client header → no metadata at all,
// so nothing is recorded and no child recorder is handed downstream.
await request(handle.app).post("/tools/plain-tool").send({}).expect(200);
expect(recordInvocation).not.toHaveBeenCalled();
const opts = vi.mocked(registry.invokeTool).mock.calls[0]![2] as {
recordChildInvocation?: unknown;
};
expect(opts.recordChildInvocation).toBeUndefined();
});
it("records the coarse `other` bucket for an unknown tool, never its name", async () => {
let seenMeta: Record<string, unknown> | undefined;
const recordInvocation = vi.fn((_id: string, meta: Record<string, unknown>) => {
seenMeta = meta;
return vi.fn();
});
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
// A non-device tool with no platform context still records because the
// coarse `other` slug is present. The name header is ignored entirely.
await request(handle.app)
.post("/tools/plain-tool")
.set("X-Argent-AI-Client", "other")
.set("X-Argent-AI-Client-Name", "some-new-tool")
.send({})
.expect(200);
expect(seenMeta).toEqual({ ai_client: "other" });
});
it("drops unregistered AI client slugs", async () => {
const recordInvocation = vi.fn(() => vi.fn());
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
// Unknown slug + unsafe name → nothing usable → no metadata → not recorded
// for a non-device tool.
await request(handle.app)
.post("/tools/plain-tool")
.set("X-Argent-AI-Client", "evil-client")
.set("X-Argent-AI-Client-Name", "/Users/alice/secret")
.send({})
.expect(200);
expect(recordInvocation).not.toHaveBeenCalled();
});
it("never records a client-name header, even for a recognized client", async () => {
let seenMeta: Record<string, unknown> | undefined;
const recordInvocation = vi.fn((_id: string, meta: Record<string, unknown>) => {
seenMeta = meta;
return vi.fn();
});
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
// The raw client name is never recorded; only the coarse slug (and platform)
// survive, regardless of what name header the MCP server forwarded.
await request(handle.app)
.post("/tools/device-tool")
.set("X-Argent-AI-Client", "codex")
.set("X-Argent-AI-Client-Name", "claude-code")
.send({ udid: "11111111-1111-1111-1111-111111111111" })
.expect(200);
expect(seenMeta).toEqual({ platform: "ios", device_kind: "simulator", ai_client: "codex" });
});
it("drops a client name sent with no ai_client header", async () => {
const recordInvocation = vi.fn(() => vi.fn());
handle.dispose();
handle = createHttpApp(stubRegistry(), { recordInvocation });
// Name-only, no slug → nothing usable → no metadata → not recorded for a
// non-device tool.
await request(handle.app)
.post("/tools/plain-tool")
.set("X-Argent-AI-Client-Name", "some-new-tool")
.send({})
.expect(200);
expect(recordInvocation).not.toHaveBeenCalled();
});
});