Files
Aether/aether-vscodex/test/adapter-safety.test.js
T

746 lines
26 KiB
JavaScript

"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { CodexAgentAdapter } = require("../vscode-extension/dist/codexAgentAdapter.js");
const { RelayHost } = require("../vscode-extension/dist/relayHost.js");
class FakeRpc {
responses = [];
requests = [];
notificationListener;
requestListener;
exitListener;
overrides;
constructor(overrides = {}) {
this.overrides = overrides;
}
get running() {
return true;
}
async start() {}
async request(method, params) {
this.requests.push({ method, params });
if (Object.prototype.hasOwnProperty.call(this.overrides, method)) {
const override = this.overrides[method];
return typeof override === "function" ? override(params) : override;
}
if (method === "initialize") return { userAgent: "test", codexHome: "/tmp/codex" };
if (method === "thread/start") return { thread: { id: "thread-test" }, cwd: "/tmp" };
if (method === "turn/start") return { turn: { id: "turn-test" } };
if (method === "turn/steer") return { turn: { id: "turn-test" } };
if (method === "turn/interrupt") return {};
throw new Error(`unexpected request ${method}`);
}
notify() {}
respond(id, result) {
this.responses.push({ id, result });
}
respondError(id, code, message) {
this.responses.push({ id, error: { code, message } });
}
onNotification(listener) {
this.notificationListener = listener;
return { dispose: () => undefined };
}
onServerRequest(listener) {
this.requestListener = listener;
return { dispose: () => undefined };
}
onExit(listener) {
this.exitListener = listener;
return { dispose: () => undefined };
}
close() {}
emitRequest(request) {
this.requestListener(request);
}
emitNotification(notification) {
this.notificationListener(notification);
}
}
class FakeRelay {
frames = [];
listeners = new Set();
async connect() {}
send(frame) {
this.frames.push(frame);
}
onMessage(listener) {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
close() {}
}
test("CodexAgentAdapter keeps numeric and string approval ids distinct", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
rpc.emitRequest({
id: 1,
method: "item/commandExecution/requestApproval",
params: { threadId: "t", turnId: "u", itemId: "n", command: "echo number" },
});
rpc.emitRequest({
id: "1",
method: "item/commandExecution/requestApproval",
params: { threadId: "t", turnId: "u", itemId: "s", command: "echo string" },
});
const snapshot = await adapter.snapshot();
assert.deepEqual(snapshot.pendingApprovals.map((entry) => entry.requestId), [1, "1"]);
await adapter.respondApproval(1, "deny");
await adapter.respondApproval("1", "deny");
assert.deepEqual(rpc.responses.map((entry) => entry.id), [1, "1"]);
assert.equal((await adapter.snapshot()).pendingApprovals.length, 0);
await adapter.dispose();
});
test("commandActions are included in high-risk approval classification", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
rpc.emitRequest({
id: 2,
method: "item/commandExecution/requestApproval",
params: {
threadId: "t",
turnId: "u",
itemId: "actions",
command: null,
commandActions: [{ type: "unknown", command: "sudo rm -rf /" }],
},
});
const snapshot = await adapter.snapshot();
assert.equal(snapshot.pendingApprovals[0].risk, "high");
await adapter.respondApproval(2, "deny");
await adapter.dispose();
});
test("output snapshots stay redacted and interrupt clears the active turn", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
await adapter.startThread({});
await adapter.startTurn({ text: "hello" });
assert.equal((await adapter.snapshot()).turnId, "turn-test");
rpc.emitNotification({
method: "item/agentMessage/delta",
params: { delta: "credential Bearer abcdefghijklmnop" },
});
const snapshot = await adapter.snapshot();
assert.equal(snapshot.outputTail.includes("Bearer abcdefghijklmnop"), false);
assert.match(snapshot.outputTail, /\[REDACTED\]/);
await adapter.interruptTurn({});
const afterInterrupt = await adapter.snapshot();
assert.equal(afterInterrupt.turnId, null);
assert.equal(afterInterrupt.state, "idle");
await adapter.dispose();
});
test("async adapter lists app-server threads and exposes the model catalog", async () => {
const rpc = new FakeRpc({
"model/list": {
data: [{ id: "model-1", model: "gpt-5.6-sol", displayName: "5.6 Sol", hidden: false }],
nextCursor: null,
},
"thread/list": {
data: [
{
id: "thread-recent",
name: null,
preview: "Inspect the workspace\nwith detail",
cwd: "/tmp/workspace",
createdAt: 1_700_000_000,
updatedAt: 1_700_000_100,
status: { type: "idle" },
source: "vscode",
},
],
nextCursor: "next-page",
backwardsCursor: null,
},
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
const result = await adapter.listSessions({ limit: 500, query: "workspace", sortKey: "invalid" });
assert.equal(result.sessions[0].threadId, "thread-recent");
assert.equal(result.sessions[0].title, "Inspect the workspace with detail");
assert.equal(result.sessions[0].updatedAtMs, 1_700_000_100_000);
assert.equal(result.nextCursor, "next-page");
const listRequest = rpc.requests.find((entry) => entry.method === "thread/list");
assert.deepEqual(listRequest.params, {
limit: 100,
sortKey: "updated_at",
sortDirection: "desc",
searchTerm: "workspace",
});
const snapshot = await adapter.snapshot();
assert.equal(snapshot.metadata.mode, "async");
assert.equal(snapshot.metadata.availableModels[0].model, "gpt-5.6-sol");
await adapter.dispose();
});
test("async adapter projects live token usage notifications into metadata and snapshots", async () => {
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
const events = [];
adapter.onEvent((event) => events.push(event));
await adapter.start();
await adapter.startThread({});
rpc.emitNotification({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-test",
// A usage update may arrive after the turn has completed. It must not
// make the adapter report that historical turn as active again.
turnId: "turn-finished",
tokenUsage: {
total: {
totalTokens: 1_200,
inputTokens: 800,
cachedInputTokens: 100,
cacheWriteInputTokens: 20,
outputTokens: 300,
reasoningOutputTokens: 80,
},
last: {
totalTokens: 450,
inputTokens: 300,
cachedInputTokens: 40,
cacheWriteInputTokens: 10,
outputTokens: 100,
reasoningOutputTokens: 40,
},
modelContextWindow: 128_000,
},
},
});
const expected = {
total: {
totalTokens: 1_200,
inputTokens: 800,
cachedInputTokens: 100,
cacheWriteInputTokens: 20,
outputTokens: 300,
reasoningOutputTokens: 80,
},
last: {
totalTokens: 450,
inputTokens: 300,
cachedInputTokens: 40,
cacheWriteInputTokens: 10,
outputTokens: 100,
reasoningOutputTokens: 40,
},
modelContextWindow: 128_000,
};
const snapshot = await adapter.snapshot();
assert.deepEqual(snapshot.metadata.tokenUsage, expected);
assert.deepEqual(snapshot.metadata.latestTokenUsageInfo, expected);
assert.equal(snapshot.turnId, null);
const usageEvent = events.find((event) => event.raw?.method === "thread/tokenUsage/updated");
assert.ok(usageEvent);
assert.deepEqual(usageEvent.payload.tokenUsage, expected);
assert.deepEqual(usageEvent.payload.latestTokenUsageInfo, expected);
// Keep the raw diagnostic envelope redacted while exposing only the safe
// numeric projection to the browser.
assert.equal(usageEvent.raw.params.tokenUsage, "[REDACTED]");
rpc.emitNotification({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-test",
turnId: "turn-finished",
tokenUsage: { total: { inputTokens: -1 } },
},
});
assert.deepEqual((await adapter.snapshot()).metadata.tokenUsage, expected);
await adapter.dispose();
});
test("async adapter resumes a thread with structured history and ignores late notifications", async () => {
const thread = {
id: "thread-selected",
name: "Selected thread",
preview: "hello",
cwd: "/tmp/selected",
createdAt: 1_700_000_000,
updatedAt: 1_700_000_010,
status: { type: "idle" },
turns: [{
id: "turn-history",
status: "completed",
startedAt: 1_700_000_001,
completedAt: 1_700_000_004,
durationMs: 3_000,
items: [
{ type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] },
{ type: "reasoning", id: "reason-1", summary: ["Checking files"], content: [] },
{ type: "commandExecution", id: "command-1", command: "pwd", cwd: "/tmp/selected", status: "completed", aggregatedOutput: "/tmp/selected\n", exitCode: 0, durationMs: 50, commandActions: [] },
{ type: "agentMessage", id: "agent-1", text: "Done", phase: "final_answer" },
],
}],
};
const rpc = new FakeRpc({
"model/list": { data: [{ id: "model-1", model: "gpt-5.6-sol" }], nextCursor: null },
"thread/resume": {
thread,
model: "gpt-5.6-sol",
modelProvider: "openai",
serviceTier: null,
cwd: "/tmp/selected",
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandbox: { type: "workspaceWrite" },
reasoningEffort: "high",
},
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
const events = [];
adapter.onEvent((event) => events.push(event));
await adapter.start();
const result = await adapter.selectSession({ threadId: "thread-selected" });
assert.equal(result.threadId, "thread-selected");
let snapshot = await adapter.snapshot();
assert.equal(snapshot.messages.length, 4);
assert.deepEqual(snapshot.messages.map((message) => message.kind), ["user", "reasoning", "tool", "assistant"]);
assert.equal(snapshot.messages[2].output, "/tmp/selected\n");
assert.equal(snapshot.metadata.title, "Selected thread");
assert.equal(snapshot.metadata.threadSettings.effort, "high");
assert.equal(snapshot.metadata.historyComplete, true);
assert.equal(snapshot.status.turnStatus, "completed");
assert.match(snapshot.outputTail, /Done/);
assert.ok(events.some((event) => event.type === "output.snapshot" && event.payload.historyComplete === true));
const authoritative = events.find((event) => event.type === "session.snapshot");
assert.equal(authoritative.payload.threadId, "thread-selected");
assert.equal(authoritative.payload.metadata.model, "gpt-5.6-sol");
assert.equal(authoritative.payload.messages.length, 4);
rpc.emitNotification({
method: "item/completed",
params: {
threadId: "thread-old",
turnId: "turn-old",
completedAtMs: Date.now(),
item: { type: "agentMessage", id: "late-old", text: "wrong thread" },
},
});
rpc.emitNotification({
method: "item/completed",
params: {
threadId: "thread-selected",
turnId: "turn-live",
completedAtMs: Date.now(),
item: { type: "agentMessage", id: "current-item", text: "current thread" },
},
});
snapshot = await adapter.snapshot();
assert.equal(snapshot.messages.some((message) => message.itemId === "late-old"), false);
assert.equal(snapshot.messages.some((message) => message.itemId === "current-item"), true);
await adapter.dispose();
});
test("async adapter hydrates paginated turns and items into chronological complete history", async () => {
const threadId = "thread-paged-history";
const userItem = (id, text) => ({
type: "userMessage",
id,
clientId: null,
content: [{ type: "text", text, text_elements: [] }],
});
const assistantItem = (id, text) => ({
type: "agentMessage",
id,
text,
phase: "final_answer",
});
const earlyUser = userItem("early-user", "first question");
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
"thread/resume": {
thread: {
id: threadId,
name: "Paged history",
preview: "first question",
cwd: "/tmp/paged",
createdAt: 50,
updatedAt: 350,
historyMode: "paginated",
status: { type: "idle" },
turns: [],
},
model: "gpt-5.6-sol",
cwd: "/tmp/paged",
initialTurnsPage: {
data: [{
id: "turn-late",
status: "completed",
startedAt: 300,
completedAt: 310,
itemsView: "full",
items: [userItem("late-user", "third question"), assistantItem("late-agent", "third answer")],
}],
nextCursor: "turn-page-2",
backwardsCursor: null,
},
},
"thread/turns/list": (params) => {
if (params.cursor === "turn-page-2") {
return {
data: [{
id: "turn-early",
status: "completed",
startedAt: 100,
completedAt: 110,
itemsView: "summary",
items: [earlyUser],
}],
nextCursor: "turn-page-3",
backwardsCursor: null,
};
}
assert.equal(params.cursor, "turn-page-3");
return {
data: [{
id: "turn-middle",
status: "completed",
startedAt: 200,
completedAt: 210,
itemsView: "full",
items: [userItem("middle-user", "second question"), assistantItem("middle-agent", "second answer")],
}],
nextCursor: null,
backwardsCursor: null,
};
},
"thread/items/list": (params) => {
assert.equal(params.turnId, "turn-early");
return {
data: [
// The summary row is repeated by the full item page; hydration must
// de-duplicate it while adding the omitted assistant response.
{ turnId: "turn-early", item: earlyUser },
{ turnId: "turn-early", item: assistantItem("early-agent", "first answer") },
],
nextCursor: null,
backwardsCursor: null,
};
},
});
const events = [];
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
adapter.onEvent((event) => events.push(event));
await adapter.start();
await adapter.selectSession({ threadId });
const resume = rpc.requests.find((entry) => entry.method === "thread/resume");
assert.deepEqual(resume.params, {
threadId,
excludeTurns: true,
initialTurnsPage: { limit: 100, sortDirection: "asc", itemsView: "full" },
});
const turnPages = rpc.requests.filter((entry) => entry.method === "thread/turns/list");
assert.deepEqual(turnPages.map((entry) => entry.params.cursor), ["turn-page-2", "turn-page-3"]);
assert.ok(turnPages.every((entry) => entry.params.threadId === threadId
&& entry.params.limit === 100
&& entry.params.sortDirection === "asc"
&& entry.params.itemsView === "full"));
const itemPages = rpc.requests.filter((entry) => entry.method === "thread/items/list");
assert.deepEqual(itemPages.map((entry) => entry.params), [{
threadId,
turnId: "turn-early",
limit: 100,
sortDirection: "asc",
}]);
assert.equal(rpc.requests.some((entry) => entry.method === "thread/read"), false);
const snapshot = await adapter.snapshot();
assert.deepEqual(snapshot.messages.map((message) => [message.turnId, message.text]), [
["turn-early", "first question"],
["turn-early", "first answer"],
["turn-middle", "second question"],
["turn-middle", "second answer"],
["turn-late", "third question"],
["turn-late", "third answer"],
]);
assert.equal(snapshot.metadata.historyComplete, true);
const outputSnapshot = events.find((event) => event.type === "output.snapshot");
assert.equal(outputSnapshot.payload.historyComplete, true);
assert.deepEqual(outputSnapshot.payload.messages.map((message) => message.text), [
"first question",
"first answer",
"second question",
"second answer",
"third question",
"third answer",
]);
await adapter.dispose();
});
test("async adapter falls back to thread/read when resume omits existing history", async () => {
const metadataThread = {
id: "thread-paginated",
preview: "existing conversation",
cwd: "/tmp/project",
createdAt: 1_700_000_000,
updatedAt: 1_700_000_100,
status: { type: "idle" },
turns: [],
};
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
"thread/resume": { thread: metadataThread, model: "gpt-5.6-sol", cwd: "/tmp/project" },
"thread/read": {
thread: {
...metadataThread,
turns: [{
id: "turn-read",
status: "completed",
items: [{ type: "agentMessage", id: "read-agent", text: "hydrated history" }],
}],
},
},
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
await adapter.selectSession({ threadId: "thread-paginated" });
const read = rpc.requests.find((entry) => entry.method === "thread/read");
assert.deepEqual(read.params, { threadId: "thread-paginated", includeTurns: true });
assert.equal((await adapter.snapshot()).messages[0].text, "hydrated history");
await adapter.dispose();
});
test("async adapter starts new sessions and sends flat durable thread settings", async () => {
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
"thread/start": {
thread: { id: "thread-new", preview: "", cwd: "/tmp/new", status: { type: "idle" }, turns: [] },
model: "gpt-5.6-sol",
cwd: "/tmp/new",
reasoningEffort: "medium",
},
"thread/settings/update": { ok: true },
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0, defaultCwd: "/tmp/default" }, rpc);
await adapter.start();
await adapter.newSession({});
await adapter.updateThreadSettings({
threadSettings: {
model: "gpt-5.6-terra",
effort: "high",
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandboxPolicy: "workspace-write",
permissions: ":workspace",
},
});
const start = rpc.requests.find((entry) => entry.method === "thread/start");
assert.equal(start.params.cwd, "/tmp/default");
const update = rpc.requests.find((entry) => entry.method === "thread/settings/update");
assert.deepEqual(update.params, {
threadId: "thread-new",
model: "gpt-5.6-terra",
effort: "high",
approvalPolicy: "on-request",
approvalsReviewer: "user",
permissions: ":workspace",
});
assert.equal(Object.prototype.hasOwnProperty.call(update.params, "sandboxPolicy"), false);
const snapshot = await adapter.snapshot();
assert.equal(snapshot.metadata.model, "gpt-5.6-terra");
assert.equal(snapshot.metadata.latestReasoningEffort, "high");
assert.equal(snapshot.metadata.sandboxPolicy, "workspace-write");
await adapter.dispose();
});
test("thread settings updates require the send_task_input capability", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
const relay = new FakeRelay();
const host = new RelayHost({
adapter,
relay,
capabilities: ["read_output"],
sessionId: "test-session",
});
await host.handleFrame({
kind: "command",
type: "thread.settings.update",
commandId: "settings-without-capability",
actor: { role: "operator" },
payload: { threadSettings: { model: "gpt-5.6-sol", effort: "high" } },
});
const result = relay.frames.find((frame) => frame.payload?.commandId === "settings-without-capability");
assert.equal(result.type, "command.rejected");
assert.match(result.payload.error, /missing capability: send_task_input/);
await adapter.dispose();
});
test("RelayHost exposes session list as read-only and protects session selection", async () => {
const relay = new FakeRelay();
const calls = [];
const adapter = {
async start() {},
async sendInput() { return {}; },
async cancel() { return {}; },
async respondApproval() { return {}; },
async snapshot() { return { threadId: "thread-a", turnId: null, state: "idle", pendingApprovals: [], outputTail: "" }; },
onEvent() { return { dispose() {} }; },
async dispose() {},
async listSessions(params) {
calls.push({ method: "listSessions", params });
return { sessions: [{ threadId: "thread-a", title: "A", updatedAtMs: null, active: true, available: true }], activeThreadId: "thread-a" };
},
async selectSession(params) {
calls.push({ method: "selectSession", params });
return { threadId: params.threadId, previousThreadId: "thread-a", switched: true, available: true };
},
async newSession(params) {
calls.push({ method: "newSession", params });
return { opened: true, command: "chatgpt.newCodexPanel" };
},
getControlMode() { return "sync"; },
async setControlMode(params) {
calls.push({ method: "setControlMode", params });
return { changed: true, controlMode: params.mode, previousControlMode: "sync", modeEpoch: 1 };
},
};
const host = new RelayHost({
adapter,
relay,
capabilities: ["read_output", "send_task_input"],
sessionId: "test-session",
});
await host.handleFrame({ kind: "command", type: "session/list", commandId: "list-1", actor: { role: "viewer" }, payload: {} });
const listed = relay.frames.find((frame) => frame.payload?.commandId === "list-1");
assert.equal(listed.type, "command.accepted");
assert.equal(listed.payload.result.activeThreadId, "thread-a");
assert.equal(calls[0].method, "listSessions");
await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-viewer", actor: { role: "viewer" }, payload: { threadId: "thread-b" } });
const denied = relay.frames.find((frame) => frame.payload?.commandId === "select-viewer");
assert.equal(denied.type, "command.rejected");
await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-operator", actor: { role: "operator" }, payload: { threadId: "thread-b" } });
const selected = relay.frames.find((frame) => frame.payload?.commandId === "select-operator");
assert.equal(selected.type, "command.accepted");
assert.equal(selected.payload.result.threadId, "thread-b");
assert.equal(calls.at(-1).method, "selectSession");
await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-viewer", actor: { role: "viewer" }, payload: {} });
const deniedNew = relay.frames.find((frame) => frame.payload?.commandId === "new-viewer");
assert.equal(deniedNew.type, "command.rejected");
await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-operator", actor: { role: "operator" }, payload: {} });
const opened = relay.frames.find((frame) => frame.payload?.commandId === "new-operator");
assert.equal(opened.type, "command.accepted");
assert.equal(opened.payload.result.command, "chatgpt.newCodexPanel");
assert.equal(calls.at(-1).method, "newSession");
await host.handleFrame({ kind: "command", type: "control/mode/get", commandId: "mode-get-viewer", actor: { role: "viewer" }, payload: {} });
const mode = relay.frames.find((frame) => frame.payload?.commandId === "mode-get-viewer");
assert.equal(mode.type, "command.accepted");
assert.equal(mode.payload.result.mode, "sync");
await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-viewer", actor: { role: "viewer" }, payload: { mode: "async" } });
const deniedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-viewer");
assert.equal(deniedMode.type, "command.rejected");
await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-operator", actor: { role: "operator" }, payload: { mode: "async" } });
const changedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-operator");
assert.equal(changedMode.type, "command.accepted");
assert.equal(changedMode.payload.result.controlMode, "async");
assert.equal(calls.at(-1).method, "setControlMode");
});
test("approval decision conflicts and unknown tagged objects fail closed", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
const relay = new FakeRelay();
const host = new RelayHost({
adapter,
relay,
capabilities: ["read_output", "send_task_input", "cancel_task", "approve_low_risk"],
sessionId: "test-session",
});
rpc.emitRequest({
id: 3,
method: "execCommandApproval",
params: { conversationId: "thread-test", callId: "call-3", command: ["echo", "safe"] },
});
await host.handleFrame({
kind: "command",
type: "approval.respond",
commandId: "conflicting-response",
actor: { role: "operator" },
payload: {
requestId: 3,
decision: "deny",
response: { decision: "approved_mcp_policy_amendment" },
},
});
assert.deepEqual(rpc.responses[0], {
id: 3,
result: { decision: { denied: { rejection: "approval response implies allow, but decision is deny" } } },
});
rpc.emitRequest({
id: 4,
method: "item/commandExecution/requestApproval",
params: { threadId: "thread-test", turnId: "turn-test", itemId: "item-4", command: "echo safe" },
});
await host.handleFrame({
kind: "command",
type: "approval.respond",
commandId: "unknown-tagged-response",
actor: { role: "operator" },
payload: {
requestId: 4,
decision: "allow",
response: { decision: { futurePolicyGrant: { scope: "all" } } },
},
});
assert.deepEqual(rpc.responses[1], {
id: 4,
result: { decision: "decline" },
});
await adapter.dispose();
});