feat(nodes): expose device diagnostics and notification actions

This commit is contained in:
Ayaan Zaidi
2026-02-27 09:40:32 +05:30
committed by Ayaan Zaidi
parent d0ec3de588
commit 29f5da5b2a
3 changed files with 152 additions and 4 deletions

View File

@@ -355,8 +355,8 @@ Core actions:
- `notify` (macOS `system.notify`)
- `run` (macOS `system.run`)
- `camera_list`, `camera_snap`, `camera_clip`, `screen_record`
- `location_get`, `notifications_list`
- `device_status`, `device_info`
- `location_get`, `notifications_list`, `notifications_action`
- `device_status`, `device_info`, `device_permissions`, `device_health`
Notes:

View File

@@ -174,6 +174,40 @@ describe("nodes notifications_list", () => {
});
});
describe("nodes notifications_action", () => {
it("invokes notifications.actions dismiss", async () => {
callGateway.mockImplementation(async ({ method, params }) => {
if (method === "node.list") {
return mockNodeList(["notifications.actions"]);
}
if (method === "node.invoke") {
expect(params).toMatchObject({
nodeId: NODE_ID,
command: "notifications.actions",
params: {
key: "n1",
action: "dismiss",
},
});
return { payload: { ok: true, key: "n1", action: "dismiss" } };
}
return unexpectedGatewayMethod(method);
});
const result = await executeNodes({
action: "notifications_action",
node: NODE_ID,
notificationKey: "n1",
notificationAction: "dismiss",
});
expect(result.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining('"dismiss"'),
});
});
});
describe("nodes device_status and device_info", () => {
it("invokes device.status and returns payload", async () => {
callGateway.mockImplementation(async ({ method, params }) => {
@@ -237,6 +271,71 @@ describe("nodes device_status and device_info", () => {
text: expect.stringContaining('"systemName"'),
});
});
it("invokes device.permissions and returns payload", async () => {
callGateway.mockImplementation(async ({ method, params }) => {
if (method === "node.list") {
return mockNodeList(["device.permissions"]);
}
if (method === "node.invoke") {
expect(params).toMatchObject({
nodeId: NODE_ID,
command: "device.permissions",
params: {},
});
return {
payload: {
permissions: {
camera: { status: "granted", promptable: false },
},
},
};
}
return unexpectedGatewayMethod(method);
});
const result = await executeNodes({
action: "device_permissions",
node: NODE_ID,
});
expect(result.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining('"permissions"'),
});
});
it("invokes device.health and returns payload", async () => {
callGateway.mockImplementation(async ({ method, params }) => {
if (method === "node.list") {
return mockNodeList(["device.health"]);
}
if (method === "node.invoke") {
expect(params).toMatchObject({
nodeId: NODE_ID,
command: "device.health",
params: {},
});
return {
payload: {
memory: { pressure: "normal" },
battery: { chargingType: "usb" },
},
};
}
return unexpectedGatewayMethod(method);
});
const result = await executeNodes({
action: "device_health",
node: NODE_ID,
});
expect(result.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining('"memory"'),
});
});
});
describe("nodes run", () => {

View File

@@ -42,14 +42,18 @@ const NODES_TOOL_ACTIONS = [
"screen_record",
"location_get",
"notifications_list",
"notifications_action",
"device_status",
"device_info",
"device_permissions",
"device_health",
"run",
"invoke",
] as const;
const NOTIFY_PRIORITIES = ["passive", "active", "timeSensitive"] as const;
const NOTIFY_DELIVERIES = ["system", "overlay", "auto"] as const;
const NOTIFICATIONS_ACTIONS = ["open", "dismiss", "reply"] as const;
const CAMERA_FACING = ["front", "back", "both"] as const;
const LOCATION_ACCURACY = ["coarse", "balanced", "precise"] as const;
type GatewayCallOptions = ReturnType<typeof readGatewayCallOptions>;
@@ -117,6 +121,10 @@ const NodesToolSchema = Type.Object({
maxAgeMs: Type.Optional(Type.Number()),
locationTimeoutMs: Type.Optional(Type.Number()),
desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY),
// notifications_action
notificationAction: optionalStringEnum(NOTIFICATIONS_ACTIONS),
notificationKey: Type.Optional(Type.String()),
notificationReplyText: Type.Optional(Type.String()),
// run
command: Type.Optional(Type.Array(Type.String())),
cwd: Type.Optional(Type.String()),
@@ -302,7 +310,9 @@ export function createNodesTool(options?: {
case "camera_list":
case "notifications_list":
case "device_status":
case "device_info": {
case "device_info":
case "device_permissions":
case "device_health": {
const node = readStringParam(params, "node", { required: true });
const command =
action === "camera_list"
@@ -311,7 +321,11 @@ export function createNodesTool(options?: {
? "notifications.list"
: action === "device_status"
? "device.status"
: "device.info";
: action === "device_info"
? "device.info"
: action === "device_permissions"
? "device.permissions"
: "device.health";
const payloadRaw = await invokeNodeCommandPayload({
gatewayOpts,
node,
@@ -321,6 +335,41 @@ export function createNodesTool(options?: {
payloadRaw && typeof payloadRaw === "object" && payloadRaw !== null ? payloadRaw : {};
return jsonResult(payload);
}
case "notifications_action": {
const node = readStringParam(params, "node", { required: true });
const notificationKey = readStringParam(params, "notificationKey", { required: true });
const notificationAction =
typeof params.notificationAction === "string"
? params.notificationAction.trim().toLowerCase()
: "";
if (
notificationAction !== "open" &&
notificationAction !== "dismiss" &&
notificationAction !== "reply"
) {
throw new Error("notificationAction must be open|dismiss|reply");
}
const notificationReplyText =
typeof params.notificationReplyText === "string"
? params.notificationReplyText.trim()
: undefined;
if (notificationAction === "reply" && !notificationReplyText) {
throw new Error("notificationReplyText required when notificationAction=reply");
}
const payloadRaw = await invokeNodeCommandPayload({
gatewayOpts,
node,
command: "notifications.actions",
commandParams: {
key: notificationKey,
action: notificationAction,
replyText: notificationReplyText,
},
});
const payload =
payloadRaw && typeof payloadRaw === "object" && payloadRaw !== null ? payloadRaw : {};
return jsonResult(payload);
}
case "camera_clip": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);