* Find connected-app tools from queries written in words Catalog search matched the query only as one substring, so "search repositories" or "get file" found nothing for search_repositories and get_file_contents and the agent concluded GitHub had no tools. Underscores, hyphens and spaces now compare equal, a query matches when every word starts a word of the tool, and a miss returns the source's tool names. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Bump Expo SDK 57 packages to the patch versions expo install expects expo install --check now expects @expo/ui 57.0.25, expo 57.0.30 and expo-router 57.0.28, which turns the mobile typecheck red on every branch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ --------- Co-authored-by: Claude <noreply@anthropic.com>
566 lines
19 KiB
TypeScript
566 lines
19 KiB
TypeScript
import type { ConnectorTool } from "@rakazo/adapter-kit";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
assertConnectorToolArgs,
|
|
catalogEntries,
|
|
catalogGroupLabel,
|
|
DIRECT_TOOL_LIMIT,
|
|
disambiguateInstalledToolNames,
|
|
executeLazyCatalogControl,
|
|
formatNameIndexText,
|
|
lazyCatalogTools,
|
|
loadCatalogEntry,
|
|
NAME_INDEX_MAX_NAMES,
|
|
prepareCatalogExecuteArguments,
|
|
resolveCatalogCall,
|
|
SELECTED_SCHEMA_MAX_BYTES,
|
|
searchCatalog,
|
|
TOOL_SEARCH_LIMIT,
|
|
uniquifyInstalledToolName,
|
|
} from "./lazy-tool-catalog.js";
|
|
|
|
function tools(count: number, group = "source"): ConnectorTool[] {
|
|
return Array.from({ length: count }, (_, index) => ({
|
|
name: `tool_${String(index).padStart(2, "0")}`,
|
|
description: "Shared catalog result",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { value: { type: "string" } },
|
|
required: ["value"],
|
|
},
|
|
route: {
|
|
connectorId: "test",
|
|
resourceId: `source-${index}`,
|
|
toolName: `tool-${index}`,
|
|
catalogGroup: group,
|
|
},
|
|
}));
|
|
}
|
|
|
|
function multiGroupTools(): ConnectorTool[] {
|
|
return [
|
|
{
|
|
name: "mcp__gmail__send",
|
|
description: "Send mail",
|
|
inputSchema: { type: "object", properties: { to: { type: "string" } } },
|
|
route: {
|
|
connectorId: "mcp",
|
|
resourceId: "srv-gmail",
|
|
toolName: "send",
|
|
catalogGroup: "gmail",
|
|
},
|
|
},
|
|
{
|
|
name: "mcp__gmail__read",
|
|
description: "Read mail",
|
|
inputSchema: { type: "object", properties: {} },
|
|
route: {
|
|
connectorId: "mcp",
|
|
resourceId: "srv-gmail",
|
|
toolName: "read",
|
|
catalogGroup: "gmail",
|
|
},
|
|
},
|
|
{
|
|
name: "mcp__gmail__list",
|
|
description: "List mail",
|
|
inputSchema: { type: "object", properties: {} },
|
|
route: {
|
|
connectorId: "mcp",
|
|
resourceId: "srv-gmail",
|
|
toolName: "list",
|
|
catalogGroup: "gmail",
|
|
},
|
|
},
|
|
{
|
|
name: "mcp__hubspot__list_contacts",
|
|
description: "List contacts",
|
|
inputSchema: { type: "object", properties: {} },
|
|
route: {
|
|
connectorId: "mcp",
|
|
resourceId: "srv-hubspot",
|
|
toolName: "list_contacts",
|
|
catalogGroup: "hubspot",
|
|
},
|
|
},
|
|
{
|
|
name: "mcp__hubspot__get_deal",
|
|
description: "Get deal",
|
|
inputSchema: { type: "object", properties: {} },
|
|
route: {
|
|
connectorId: "mcp",
|
|
resourceId: "srv-hubspot",
|
|
toolName: "get_deal",
|
|
catalogGroup: "hubspot",
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
describe("lazy tool catalog", () => {
|
|
it("exposes tools directly at the limit and switches to lazy wrappers above it", () => {
|
|
expect(tools(DIRECT_TOOL_LIMIT)).toHaveLength(20);
|
|
expect(tools(DIRECT_TOOL_LIMIT + 1)).toHaveLength(21);
|
|
expect(lazyCatalogTools("connectors", "mcp", "MCP")).toHaveLength(3);
|
|
expect(lazyCatalogTools("installed", "installed", "API").map((tool) => tool.name)).toEqual([
|
|
"installed_search_tools",
|
|
"installed_load_tool",
|
|
"installed_execute_tool",
|
|
]);
|
|
expect(lazyCatalogTools("connectors", "mcp", "MCP").map((tool) => tool.name)).toEqual([
|
|
"connectors_search_tools",
|
|
"connectors_load_tool",
|
|
"connectors_execute_tool",
|
|
]);
|
|
expect(lazyCatalogTools("connectors", "mcp", "MCP")[0]?.description).toContain(
|
|
"Empty query lists sources",
|
|
);
|
|
expect(lazyCatalogTools("connectors", "mcp", "MCP")[0]?.inputSchema).not.toMatchObject({
|
|
required: expect.arrayContaining(["query"]),
|
|
});
|
|
});
|
|
|
|
it("embeds a compact name index in the search description when it fits", () => {
|
|
const entries = catalogEntries(multiGroupTools());
|
|
const [search] = lazyCatalogTools("connectors", "mcp", "MCP", entries);
|
|
expect(search?.description).toContain("gmail:");
|
|
expect(search?.description).toContain("list, read, send");
|
|
expect(search?.description).toContain("hubspot:");
|
|
expect(search?.description).toContain("get_deal, list_contacts");
|
|
expect(search?.description).not.toContain("inputSchema");
|
|
expect(search?.description).not.toContain("Send mail");
|
|
});
|
|
|
|
it("returns an empty catalog as an empty tool list", () => {
|
|
expect(catalogEntries([])).toEqual([]);
|
|
expect(searchCatalog([], { query: "anything" })).toEqual({ tools: [] });
|
|
});
|
|
|
|
it("finds tools from a query written in words", () => {
|
|
const entries = catalogEntries([
|
|
{
|
|
name: "search_repositories",
|
|
description: "Find GitHub repositories",
|
|
inputSchema: { type: "object" },
|
|
route: {
|
|
connectorId: "test",
|
|
resourceId: "gh",
|
|
toolName: "search_repositories",
|
|
catalogGroup: "GitHub",
|
|
},
|
|
},
|
|
{
|
|
name: "get_file_contents",
|
|
description: "Read a file from a repository",
|
|
inputSchema: { type: "object" },
|
|
route: {
|
|
connectorId: "test",
|
|
resourceId: "gh",
|
|
toolName: "get_file_contents",
|
|
catalogGroup: "GitHub",
|
|
},
|
|
},
|
|
]);
|
|
const names = (query: string) =>
|
|
(searchCatalog(entries, { query }) as { tools: Array<{ name: string }> }).tools.map(
|
|
(tool) => tool.name,
|
|
);
|
|
expect(names("search repositories")[0]).toBe("search_repositories");
|
|
expect(names("get file")[0]).toBe("get_file_contents");
|
|
expect(names("find repositories")).toEqual(["search_repositories"]);
|
|
expect(searchCatalog(entries, { query: "cynarAI evOo-local" })).toMatchObject({
|
|
tools: [],
|
|
index: [{ group: "GitHub", names: ["get_file_contents", "search_repositories"] }],
|
|
});
|
|
});
|
|
|
|
it("lists grouped names for an empty query without dumping schemas", () => {
|
|
const result = searchCatalog(catalogEntries(multiGroupTools()), {});
|
|
expect(result).toEqual({
|
|
index: [
|
|
{ group: "gmail", names: ["list", "read", "send"] },
|
|
{ group: "hubspot", names: ["get_deal", "list_contacts"] },
|
|
],
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("inputSchema");
|
|
expect(JSON.stringify(result)).not.toContain("Send mail");
|
|
});
|
|
|
|
it("expands one group to its names", () => {
|
|
expect(searchCatalog(catalogEntries(multiGroupTools()), { group: "gmail" })).toEqual({
|
|
group: "gmail",
|
|
names: ["list", "read", "send"],
|
|
});
|
|
expect(searchCatalog(catalogEntries(multiGroupTools()), { group: "missing" })).toEqual({
|
|
group: "missing",
|
|
names: [],
|
|
error: "Unknown or empty group",
|
|
});
|
|
});
|
|
|
|
it("keeps case-distinct group labels separate when filtering", () => {
|
|
const mixed = [
|
|
...tools(2, "Gmail").map((tool, index) => ({
|
|
...tool,
|
|
name: `Upper_${index}`,
|
|
route: { ...tool.route!, toolName: `Upper_${index}` },
|
|
})),
|
|
...tools(2, "gmail").map((tool, index) => ({
|
|
...tool,
|
|
name: `lower_${index}`,
|
|
route: { ...tool.route!, toolName: `lower_${index}` },
|
|
})),
|
|
];
|
|
const entries = catalogEntries(mixed);
|
|
expect(searchCatalog(entries, { group: "Gmail" })).toEqual({
|
|
group: "Gmail",
|
|
names: ["Upper_0", "Upper_1"],
|
|
});
|
|
expect(searchCatalog(entries, { group: "gmail" })).toEqual({
|
|
group: "gmail",
|
|
names: ["lower_0", "lower_1"],
|
|
});
|
|
const listed = searchCatalog(entries, {});
|
|
expect("index" in listed && listed.index).toEqual(
|
|
[
|
|
{ group: "Gmail", names: ["Upper_0", "Upper_1"] },
|
|
{ group: "gmail", names: ["lower_0", "lower_1"] },
|
|
].sort((left, right) => left.group.localeCompare(right.group)),
|
|
);
|
|
});
|
|
|
|
it("returns a group count when expanding an over-budget source", () => {
|
|
const oversized = Array.from({ length: NAME_INDEX_MAX_NAMES + 5 }, (_, index) => ({
|
|
name: `tool_${index}`,
|
|
description: "x".repeat(40),
|
|
inputSchema: { type: "object", properties: { secretShape: { type: "string" } } },
|
|
route: {
|
|
connectorId: "test",
|
|
resourceId: "big",
|
|
toolName: `tool_${index}`,
|
|
catalogGroup: "huge",
|
|
},
|
|
}));
|
|
const entries = catalogEntries(oversized);
|
|
const result = searchCatalog(entries, { group: "huge" });
|
|
expect(result).toEqual({
|
|
group: "huge",
|
|
count: NAME_INDEX_MAX_NAMES + 5,
|
|
hint: "Pass query to search within this source.",
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("secretShape");
|
|
expect(JSON.stringify(result)).not.toContain("tool_0");
|
|
const searched = searchCatalog(entries, { group: "huge", query: "tool_0" });
|
|
expect("tools" in searched && searched.tools.map((item) => item.name)).toEqual(["tool_0"]);
|
|
});
|
|
|
|
it("returns groups with counts when the name index exceeds budget", () => {
|
|
const oversized = Array.from({ length: NAME_INDEX_MAX_NAMES + 5 }, (_, index) => ({
|
|
name: `tool_${index}`,
|
|
description: "x".repeat(40),
|
|
inputSchema: { type: "object", properties: { secretShape: { type: "string" } } },
|
|
route: {
|
|
connectorId: "test",
|
|
resourceId: `src-${index}`,
|
|
toolName: `tool_${index}`,
|
|
catalogGroup: `group_${Math.floor(index / 50)}`,
|
|
},
|
|
}));
|
|
const entries = catalogEntries(oversized);
|
|
const result = searchCatalog(entries, {});
|
|
expect(result).toMatchObject({
|
|
hint: "Pass group to list names in one source.",
|
|
});
|
|
expect("groups" in result && result.groups.every((item) => item.count > 0)).toBe(true);
|
|
expect("index" in result).toBe(false);
|
|
expect(JSON.stringify(result)).not.toContain("secretShape");
|
|
const [search] = lazyCatalogTools("connectors", "mcp", "MCP", entries);
|
|
expect(search?.description).toContain("Empty query lists sources");
|
|
expect(search?.description).not.toContain("tool_0");
|
|
});
|
|
|
|
it("filters query search by group and keeps schema-free hits", () => {
|
|
const catalog = tools(25, "alpha").concat(tools(5, "beta"));
|
|
const first = searchCatalog(catalogEntries(catalog), {
|
|
query: "shared",
|
|
group: "beta",
|
|
limit: 100,
|
|
});
|
|
const second = searchCatalog(catalogEntries(catalog), {
|
|
query: "shared",
|
|
group: "beta",
|
|
limit: 100,
|
|
});
|
|
|
|
expect(first).toEqual(second);
|
|
expect("tools" in first && first.tools).toHaveLength(5);
|
|
expect("tools" in first && first.tools.every((item) => item.name.startsWith("tool_"))).toBe(
|
|
true,
|
|
);
|
|
expect(JSON.stringify(first)).not.toContain("secretShape");
|
|
});
|
|
|
|
it("caps and stably orders schema-free search results", () => {
|
|
const catalog = tools(25);
|
|
const first = searchCatalog(catalogEntries(catalog), { query: "shared", limit: 100 });
|
|
const second = searchCatalog(catalogEntries(catalog), { query: "shared", limit: 100 });
|
|
|
|
expect("tools" in first && first.tools).toHaveLength(TOOL_SEARCH_LIMIT);
|
|
expect(first).toEqual(second);
|
|
expect("tools" in first && first.tools.map((item) => item.name)).toEqual(
|
|
[...(("tools" in first && first.tools.map((item) => item.name)) || [])].sort((left, right) =>
|
|
left.localeCompare(right),
|
|
),
|
|
);
|
|
expect(JSON.stringify(first)).not.toContain("secretShape");
|
|
});
|
|
|
|
it("rejects an untrusted selected schema above the byte ceiling", () => {
|
|
const entries = catalogEntries([
|
|
{
|
|
name: "oversized",
|
|
description: "Oversized schema",
|
|
inputSchema: { type: "object", description: "x".repeat(SELECTED_SCHEMA_MAX_BYTES) },
|
|
},
|
|
]);
|
|
|
|
expect(() => loadCatalogEntry(entries, { id: "oversized" })).toThrow("schema is too large");
|
|
});
|
|
|
|
it("caps load descriptions like search", () => {
|
|
const long = "d".repeat(600);
|
|
const entries = catalogEntries([
|
|
{
|
|
name: "verbose",
|
|
description: long,
|
|
inputSchema: { type: "object", properties: {} },
|
|
route: { connectorId: "test", resourceId: "src", toolName: "verbose" },
|
|
},
|
|
]);
|
|
const entry = loadCatalogEntry(entries, { id: "src:verbose" });
|
|
expect(entry.tool.description).toHaveLength(600);
|
|
const hits = searchCatalog(entries, { query: "verbose" });
|
|
expect("tools" in hits && hits.tools[0]?.description).toHaveLength(500);
|
|
});
|
|
|
|
it("loads by exposed tool name as well as catalog id", () => {
|
|
const entries = catalogEntries(multiGroupTools());
|
|
expect(loadCatalogEntry(entries, { id: "mcp__gmail__send" }).tool.name).toBe(
|
|
"mcp__gmail__send",
|
|
);
|
|
expect(loadCatalogEntry(entries, { id: "srv-gmail:send" }).tool.name).toBe("mcp__gmail__send");
|
|
expect(loadCatalogEntry(entries, { id: "send" }).tool.name).toBe("mcp__gmail__send");
|
|
expect(() => loadCatalogEntry(entries, { id: "missing" })).toThrow(/unknown/i);
|
|
});
|
|
|
|
it("does not execute args when JSON Schema is unsupported", async () => {
|
|
const entries = catalogEntries([
|
|
{
|
|
name: "exotic",
|
|
description: "Unsupported schema",
|
|
// unevaluatedProperties is rejected by z.fromJSONSchema
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { payload: { type: "string" } },
|
|
unevaluatedProperties: false,
|
|
},
|
|
route: { connectorId: "test", resourceId: "src", toolName: "exotic" },
|
|
},
|
|
]);
|
|
|
|
const executeResolved = vi.fn(async function* () {
|
|
yield { type: "result" as const, data: "executed" };
|
|
});
|
|
const execute = async () => {
|
|
for await (const _event of executeLazyCatalogControl(
|
|
{
|
|
tool: "wrapper",
|
|
args: { id: "src:exotic", arguments: { payload: "raw-bytes", extra: true } },
|
|
executionId: "exec",
|
|
route: { connectorId: "test", toolName: "__catalog_execute" },
|
|
},
|
|
entries,
|
|
executeResolved,
|
|
)) {
|
|
// Consume the catalog execution path.
|
|
}
|
|
};
|
|
|
|
await expect(execute()).rejects.toThrow();
|
|
expect(executeResolved).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("unwraps arguments nested under parameters and names what is missing", () => {
|
|
const entries = catalogEntries(tools(1));
|
|
const call = (args: Record<string, unknown>) => ({
|
|
tool: "wrapper",
|
|
args: { id: "source-0:tool-0", arguments: args },
|
|
executionId: "exec",
|
|
route: { connectorId: "test", toolName: "__catalog_execute" },
|
|
});
|
|
expect(resolveCatalogCall(call({ parameters: { value: "v" } }), entries).call.args).toEqual({
|
|
value: "v",
|
|
});
|
|
expect(
|
|
resolveCatalogCall(call({ args: '{"arguments":{"value":"v"}}' }), entries).call.args,
|
|
).toEqual({ value: "v" });
|
|
expect(() => resolveCatalogCall(call({ query: "v" }), entries)).toThrow(
|
|
"Required: value. Received: query.",
|
|
);
|
|
expect(
|
|
resolveCatalogCall(
|
|
{
|
|
tool: "wrapper",
|
|
args: { id: "source-0:tool-0", value: "v" },
|
|
executionId: "exec",
|
|
route: { connectorId: "test", toolName: "__catalog_execute" },
|
|
},
|
|
entries,
|
|
).call.args,
|
|
).toEqual({ value: "v" });
|
|
});
|
|
|
|
it("still rejects non-object args and unknown ids", () => {
|
|
const entries = catalogEntries(tools(1));
|
|
expect(() =>
|
|
resolveCatalogCall(
|
|
{
|
|
tool: "wrapper",
|
|
args: { id: "missing", arguments: {} },
|
|
executionId: "exec",
|
|
route: { connectorId: "test", toolName: "__catalog_execute" },
|
|
},
|
|
entries,
|
|
),
|
|
).toThrow("unknown or not authorized");
|
|
expect(() =>
|
|
resolveCatalogCall(
|
|
{
|
|
tool: "wrapper",
|
|
args: { id: "source-0:tool-0", arguments: ["not-an-object"] },
|
|
executionId: "exec",
|
|
route: { connectorId: "test", toolName: "__catalog_execute" },
|
|
},
|
|
entries,
|
|
),
|
|
).toThrow("must be an object");
|
|
});
|
|
|
|
it("rejects persisted bound args that no longer match the live schema and unwraps aliases", () => {
|
|
const schema = {
|
|
type: "object",
|
|
properties: { value: { type: "string" } },
|
|
required: ["value"],
|
|
additionalProperties: false,
|
|
};
|
|
expect(() => assertConnectorToolArgs(schema, { value: "ok" })).not.toThrow();
|
|
expect(() => assertConnectorToolArgs(schema, { parameters: { value: "ok" } })).not.toThrow();
|
|
expect(() => assertConnectorToolArgs(schema, { input: { value: "ok" } })).not.toThrow();
|
|
expect(() => assertConnectorToolArgs(schema, { value: 1 })).toThrow(/invalid/i);
|
|
expect(() => assertConnectorToolArgs(schema, {})).toThrow(/invalid/i);
|
|
});
|
|
|
|
it("rejects schemas that combine patternProperties with additionalProperties false", () => {
|
|
const schema = {
|
|
type: "object",
|
|
patternProperties: { "^x-": { type: "string" } },
|
|
additionalProperties: false,
|
|
};
|
|
expect(() => assertConnectorToolArgs(schema, { "x-a": "ok" })).toThrow(
|
|
/patternProperties with additionalProperties false/,
|
|
);
|
|
});
|
|
|
|
it("uniquifies installed tool names across installs", () => {
|
|
expect(uniquifyInstalledToolName("install-A", "delete_item")).toBe(
|
|
"installed__install-A__delete_item",
|
|
);
|
|
expect(uniquifyInstalledToolName("install-B", "delete_item")).toBe(
|
|
"installed__install-B__delete_item",
|
|
);
|
|
});
|
|
|
|
it("only prefixes installed names when they collide across installs", () => {
|
|
const tools = disambiguateInstalledToolNames([
|
|
{
|
|
name: "unique_op",
|
|
description: "Only on A",
|
|
inputSchema: { type: "object" },
|
|
route: { connectorId: "installed", resourceId: "install-A", toolName: "unique_op" },
|
|
},
|
|
{
|
|
name: "delete_item",
|
|
description: "On A",
|
|
inputSchema: { type: "object" },
|
|
route: { connectorId: "installed", resourceId: "install-A", toolName: "delete_item" },
|
|
},
|
|
{
|
|
name: "delete_item",
|
|
description: "On B",
|
|
inputSchema: { type: "object" },
|
|
route: { connectorId: "installed", resourceId: "install-B", toolName: "delete_item" },
|
|
},
|
|
]);
|
|
expect(tools.map((tool) => tool.name)).toEqual([
|
|
"unique_op",
|
|
"installed__install-A__delete_item",
|
|
"installed__install-B__delete_item",
|
|
]);
|
|
});
|
|
|
|
it("labels nameless installs with kind and a short id", () => {
|
|
expect(catalogGroupLabel("Gmail", "api", "abcdefghij")).toBe("Gmail");
|
|
expect(catalogGroupLabel(" ", "api", "abcdefghij")).toBe("api-abcdefgh");
|
|
expect(catalogGroupLabel(undefined, "mcp", "xyz")).toBe("mcp-xyz");
|
|
});
|
|
|
|
it("formats a compact text index for descriptions", () => {
|
|
expect(
|
|
formatNameIndexText([
|
|
{ group: "gmail", names: ["list", "read", "send"] },
|
|
{ group: "hubspot", names: ["get_deal", "list_contacts"] },
|
|
]),
|
|
).toBe("gmail:\n list, read, send\nhubspot:\n get_deal, list_contacts");
|
|
});
|
|
});
|
|
|
|
describe("prepareCatalogExecuteArguments", () => {
|
|
it("keeps an arguments object as it is", () => {
|
|
expect(prepareCatalogExecuteArguments({ id: "a", arguments: { owner: "o" } })).toEqual({
|
|
id: "a",
|
|
arguments: { owner: "o" },
|
|
});
|
|
});
|
|
|
|
it("parses arguments sent as a JSON string", () => {
|
|
expect(
|
|
prepareCatalogExecuteArguments({ id: "a", arguments: '{"owner":"o","repo":"r"}' }),
|
|
).toEqual({
|
|
id: "a",
|
|
arguments: { owner: "o", repo: "r" },
|
|
});
|
|
expect(prepareCatalogExecuteArguments({ id: "a", arguments: "" })).toEqual({
|
|
id: "a",
|
|
arguments: {},
|
|
});
|
|
});
|
|
|
|
it("collects arguments flattened next to the id", () => {
|
|
expect(prepareCatalogExecuteArguments({ id: "a", owner: "o" })).toEqual({
|
|
id: "a",
|
|
arguments: { owner: "o" },
|
|
});
|
|
expect(prepareCatalogExecuteArguments({ id: "a" })).toEqual({ id: "a", arguments: {} });
|
|
expect(
|
|
prepareCatalogExecuteArguments({ id: "a", arguments: {}, owner: "o", repo: "r" }),
|
|
).toEqual({ id: "a", arguments: { owner: "o", repo: "r" } });
|
|
});
|
|
|
|
it("leaves unparseable strings for schema validation to reject", () => {
|
|
expect(prepareCatalogExecuteArguments({ id: "a", arguments: "owner=o" })).toEqual({
|
|
id: "a",
|
|
arguments: "owner=o",
|
|
});
|
|
});
|
|
});
|