From d92bdbad90701d655a09b5ec807401a5efdb7b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 15:48:19 -0400 Subject: [PATCH] Q94: agents search and run every connector action instead of the first twenty Co-Authored-By: Claude Opus 5.5 --- src/main/services/composio/composio-client.ts | 17 +- .../services/composio/composio-tool-bridge.ts | 180 +++++++++--------- src/main/services/run-journal.ts | 2 +- src/main/services/task-execution-service.ts | 2 +- src/main/services/task-prompts.ts | 2 +- tests/unit/agent-instructions.test.ts | 2 +- tests/unit/composio-tool-bridge.test.ts | 4 +- tests/unit/composio-tool-pages.test.ts | 112 +++++++++++ 8 files changed, 224 insertions(+), 97 deletions(-) create mode 100644 tests/unit/composio-tool-pages.test.ts diff --git a/src/main/services/composio/composio-client.ts b/src/main/services/composio/composio-client.ts index 9cc63871..eb385b2a 100644 --- a/src/main/services/composio/composio-client.ts +++ b/src/main/services/composio/composio-client.ts @@ -63,15 +63,20 @@ export class ComposioClient { return configs; } + /** Every tool of each toolkit, page by page; an agent's actions must not depend on where a list happens to stop. */ async getTools(toolkits: string[]): Promise { const all: ComposioToolSchema[] = []; for (const toolkit of toolkits) { - const params = new URLSearchParams({ toolkit_slug: toolkit, limit: "20" }); - const url = `${BASE_URL}/tools?${params.toString()}`; - const response = await this.request("GET", url); - if (Array.isArray(response.items)) { - all.push(...(response.items as Array>).map(normalizeToolSchema)); - } + let cursor: string | null = null; + do { + const params = new URLSearchParams({ toolkit_slug: toolkit, limit: "100" }); + if (cursor) params.set("cursor", cursor); + const response = await this.request("GET", `${BASE_URL}/tools?${params.toString()}`); + const items = Array.isArray(response.items) ? response.items as Array> : []; + all.push(...items.map(normalizeToolSchema)); + cursor = typeof response.next_cursor === "string" && response.next_cursor ? response.next_cursor : null; + if (items.length === 0) break; + } while (cursor); } return all; } diff --git a/src/main/services/composio/composio-tool-bridge.ts b/src/main/services/composio/composio-tool-bridge.ts index e6d837f7..ee660005 100644 --- a/src/main/services/composio/composio-tool-bridge.ts +++ b/src/main/services/composio/composio-tool-bridge.ts @@ -5,79 +5,53 @@ import type { IntegrationKeyStore } from "../integration-key-store"; import { ConnectorGrantsService } from "./connector-grants"; const COMPOSIO_TOOL_PREFIX = "composio_"; -const CACHE_TTL_MS = 5 * 60 * 1000; +export const FIND_ACTIONS_TOOL = "composio_find_actions"; +export const RUN_ACTION_TOOL = "composio_run_action"; +const CATALOG_TTL_MS = 5 * 60 * 1000; +const MATCHES_SHOWN = 10; -interface CacheEntry { - tools: ToolDefinition[]; - fetchedAt: number; - toolkitsKey: string; -} - -const toolCache = new Map(); -/** tool slug (upper case) → toolkit slug, learned from the tools listing. */ -const toolkitByTool = new Map(); +/** Every action of the granted toolkits, by key and toolkit set, refreshed after the TTL. */ +const catalogCache = new Map(); export function isComposioTool(toolName: string): boolean { return toolName.startsWith(COMPOSIO_TOOL_PREFIX); } -export function composioToolName(composioName: string): string { - return `${COMPOSIO_TOOL_PREFIX}${composioName.toLowerCase()}`; -} - -function originalToolName(prefixedName: string): string { - return prefixedName.slice(COMPOSIO_TOOL_PREFIX.length).toUpperCase(); -} - -/** Toolkit a tool belongs to: learned mapping first, slug prefix as contract. */ -function toolkitForTool(toolSlug: string): string { - return toolkitByTool.get(toolSlug) ?? toolSlug.split("_")[0].toLowerCase(); -} - -export function composioSchemaToToolDefinition(schema: ComposioToolSchema): ToolDefinition { - return { - name: composioToolName(schema.name), - description: schema.description, - parameters: { - type: "object", - additionalProperties: false, - properties: schema.parameters.properties, - required: schema.parameters.required ?? [], - }, - }; -} - -/** - * The connector tools one agent is allowed to carry into a run: the - * intersection of the company's active connections and the toolkits the CEO - * granted this agent. - */ -export async function loadComposioTools(db: DatabaseSync, keys: IntegrationKeyStore, orgId: string, agentId: string): Promise { - const grants = new ConnectorGrantsService(db); - const toolkits = grants.toolkitsForAgent(orgId, agentId); +/** An agent's connector tools: search every action of its granted toolkits, then run one; hundreds of actions would not fit as tools. */ +export function loadComposioTools(db: DatabaseSync, keys: IntegrationKeyStore, orgId: string, agentId: string): ToolDefinition[] { + const toolkits = new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId); if (toolkits.length === 0) return []; - const apiKey = keys.read("composio"); - if (!apiKey) { + if (!keys.read("composio")) { throw new Error(`Agent ${agentId} holds connector grants (${toolkits.join(", ")}) but no Composio API key is saved in Settings > Integrations.`); } - const toolkitsKey = toolkits.join(","); - const cacheKey = `${apiKey}:${orgId}:${toolkitsKey}`; - const cached = toolCache.get(cacheKey); - if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { - return cached.tools; - } - try { - const client = new ComposioClient(apiKey); - const schemas = await client.getTools(toolkits); - for (const schema of schemas) { - if (schema.toolkitSlug) toolkitByTool.set(schema.name.toUpperCase(), schema.toolkitSlug.toLowerCase()); - } - const tools = schemas.map(composioSchemaToToolDefinition); - toolCache.set(cacheKey, { tools, fetchedAt: Date.now(), toolkitsKey }); - return tools; - } catch { - return cached?.tools ?? []; - } + return [ + { + name: FIND_ACTIONS_TOOL, + description: `Search every action of your connected services (${toolkits.join(", ")}). Returns the best matches with the exact input each takes; then call ${RUN_ACTION_TOOL}.`, + parameters: { + type: "object", + additionalProperties: false, + properties: { + query: { type: "string", description: "What you want to do, in a few words, e.g. \"create pull request\"." }, + toolkit: { type: "string", enum: toolkits, description: "Search one service only." }, + }, + required: ["query"], + }, + }, + { + name: RUN_ACTION_TOOL, + description: `Run one action found with ${FIND_ACTIONS_TOOL}.`, + parameters: { + type: "object", + additionalProperties: false, + properties: { + action: { type: "string", description: "The action's name exactly as found, e.g. GITHUB_CREATE_A_PULL_REQUEST." }, + arguments: { type: "object", description: "The action's input, following the schema the search returned." }, + }, + required: ["action", "arguments"], + }, + }, + ]; } export async function executeComposioTool( @@ -89,32 +63,68 @@ export async function executeComposioTool( ): Promise { const apiKey = keys.read("composio"); if (!apiKey) return "ERROR: No Composio API key is saved. Ask the board to add one in Settings > Integrations."; - const toolSlug = originalToolName(call.name); - const toolkit = toolkitForTool(toolSlug); - const grants = new ConnectorGrantsService(db); - const allowed = grants.toolkitsForAgent(orgId, agentId); - if (!allowed.includes(toolkit)) { + const toolkits = new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId); + if (toolkits.length === 0) return "ERROR: You hold no connector grants. Ask the CEO to grant you one (grant_connector)."; + try { + if (call.name === FIND_ACTIONS_TOOL) return await findActions(apiKey, toolkits, call.arguments); + if (call.name === RUN_ACTION_TOOL) return await runAction(db, apiKey, orgId, agentId, toolkits, call.arguments); + return `ERROR: ${call.name} is not a connector tool; use ${FIND_ACTIONS_TOOL} and ${RUN_ACTION_TOOL}.`; + } catch (error) { + return `ERROR: ${error instanceof Error ? error.message : String(error)}`; + } +} + +async function findActions(apiKey: string, toolkits: string[], args: Record): Promise { + const query = typeof args.query === "string" ? args.query.trim().toLowerCase() : ""; + if (!query) return "ERROR: query is required: say in a few words what you want to do."; + const toolkit = typeof args.toolkit === "string" ? args.toolkit.toLowerCase() : null; + if (toolkit && !toolkits.includes(toolkit)) return `ERROR: You hold no grant for "${toolkit}". Your services: ${toolkits.join(", ")}.`; + const actions = (await actionCatalog(apiKey, toolkits)).filter((action) => !toolkit || action.toolkitSlug?.toLowerCase() === toolkit); + const words = query.split(/[^a-z0-9]+/).filter(Boolean); + const scored = actions + .map((action) => { + const text = `${action.name} ${action.description}`.toLowerCase(); + return { action, score: words.filter((word) => text.includes(word)).length }; + }) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score || a.action.name.length - b.action.name.length) + .slice(0, MATCHES_SHOWN); + if (scored.length === 0) return `No action of ${toolkit ?? toolkits.join(", ")} matches "${query}" among ${actions.length}. Try other words.`; + return JSON.stringify({ + matches: scored.map(({ action }) => ({ action: action.name, toolkit: action.toolkitSlug, description: action.description, input: action.parameters })), + searched: actions.length, + }); +} + +async function runAction(db: DatabaseSync, apiKey: string, orgId: string, agentId: string, toolkits: string[], args: Record): Promise { + const name = typeof args.action === "string" ? args.action.trim().toUpperCase() : ""; + if (!name) return `ERROR: action is required; find one with ${FIND_ACTIONS_TOOL}.`; + const action = (await actionCatalog(apiKey, toolkits)).find((candidate) => candidate.name.toUpperCase() === name); + if (!action?.toolkitSlug) return `ERROR: ${name} is not an action of your connected services (${toolkits.join(", ")}); find one with ${FIND_ACTIONS_TOOL}.`; + const toolkit = action.toolkitSlug.toLowerCase(); + // Grants are read again after the catalog load: one revoked meanwhile must stop the action. + if (!new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId).includes(toolkit)) { return `ERROR: You have no grant for the "${toolkit}" connector. Ask the CEO to grant it to you (grant_connector).`; } const account = db.prepare( "select id from connector_accounts where org_id = ? and toolkit_slug = ? and status = 'ACTIVE'", ).get(orgId, toolkit) as { id: string } | undefined; - if (!account) { - return `ERROR: The "${toolkit}" connector is no longer connected. Ask the board to reconnect it on the Connectors page.`; - } - const client = new ComposioClient(apiKey); - try { - const result = await client.executeTool({ - toolName: toolSlug, - arguments: call.arguments, - userId: getComposioUserId(db, orgId), - connectedAccountId: account.id, - }); - if (result.error) return `ERROR: ${result.error}`; - return JSON.stringify(result.data); - } catch (error) { - return `ERROR: ${error instanceof Error ? error.message : String(error)}`; - } + if (!account) return `ERROR: The "${toolkit}" connector is no longer connected. Ask the board to reconnect it on the Connectors page.`; + const input = typeof args.arguments === "object" && args.arguments !== null && !Array.isArray(args.arguments) ? args.arguments as Record : null; + if (!input) return "ERROR: arguments must be an object matching the action's input."; + const result = await new ComposioClient(apiKey).executeTool({ toolName: action.name, arguments: input, userId: getComposioUserId(db, orgId), connectedAccountId: account.id }); + if (result.error) return `ERROR: ${result.error}`; + return JSON.stringify(result.data); +} + +/** The full action list of the granted toolkits, every page of it; a failed listing is an error the agent sees. */ +async function actionCatalog(apiKey: string, toolkits: string[]): Promise { + const cacheKey = `${apiKey}:${[...toolkits].sort().join(",")}`; + const cached = catalogCache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < CATALOG_TTL_MS) return cached.actions; + const actions = await new ComposioClient(apiKey).getTools(toolkits); + catalogCache.set(cacheKey, { actions, fetchedAt: Date.now() }); + return actions; } export function getComposioUserId(db: DatabaseSync, orgId: string): string { diff --git a/src/main/services/run-journal.ts b/src/main/services/run-journal.ts index a78b3a75..f8b3a28f 100644 --- a/src/main/services/run-journal.ts +++ b/src/main/services/run-journal.ts @@ -4,7 +4,7 @@ import type { ToolCall } from "./provider-tool-turn"; /** Tools that only look; everything else changes the company or the world and is journaled. */ const READ_ONLY_TOOLS = new Set([ - "company_status", "fetch_url", "list_connectors", "list_documents", "list_files", "list_skills", + "company_status", "composio_find_actions", "fetch_url", "list_connectors", "list_documents", "list_files", "list_skills", "read_attachment", "read_document", "read_file", "read_messages", "search_knowledge", "web_search", ]); const JOURNAL_LINES = 40; diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index 38366a0c..77ee68dd 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -207,7 +207,7 @@ export class TaskExecutionService { const language = this.appLanguage(); // Baseline taken before any await, so a goal closed while tools load still reaches the session. const goalChanges = createGoalChangePoller(this.db, task.goal_id); - const externalTools = await loadComposioTools(this.db, this.providers.integrationKeys, orgId, agent.id); + const externalTools = loadComposioTools(this.db, this.providers.integrationKeys, orgId, agent.id); const result = await runAgentLoop( { providers: this.providers, diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index 9ceac0f3..2112369b 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -128,7 +128,7 @@ function capabilityBriefing(input: AgentInstructionContext): string { const lines: string[] = []; if (input.connectorNames.length > 0) { lines.push( - `Connected external services you carry in this run: ${input.connectorNames.join(", ")}. Their tools arrive with the composio_ prefix — use them directly whenever the work touches those platforms instead of describing what you would do.`, + `Connected external services you carry in this run: ${input.connectorNames.join(", ")}. Reach every action they offer with composio_find_actions (search in a few words) and composio_run_action (run the one you found) whenever the work touches those platforms, instead of describing what you would do.`, ); } lines.push( diff --git a/tests/unit/agent-instructions.test.ts b/tests/unit/agent-instructions.test.ts index 96bea4d5..d7fa180c 100644 --- a/tests/unit/agent-instructions.test.ts +++ b/tests/unit/agent-instructions.test.ts @@ -79,7 +79,7 @@ describe("agent instructions", () => { language: "en", }); expect(text).toContain("Connected external services you carry in this run: Stripe"); - expect(text).toContain("composio_ prefix"); + expect(text).toContain("composio_find_actions"); expect(text).toContain("list_connectors"); expect(text).toContain("list_skills"); expect(text).not.toContain("You run this company"); diff --git a/tests/unit/composio-tool-bridge.test.ts b/tests/unit/composio-tool-bridge.test.ts index e64d3b15..86d91c8b 100644 --- a/tests/unit/composio-tool-bridge.test.ts +++ b/tests/unit/composio-tool-bridge.test.ts @@ -31,7 +31,7 @@ function ceoHarness() { describe("composio tool loading", () => { test("an agent without connector access loads no tools and needs no key", async () => { const { connection, orgId, ceoId } = ceoHarness(); - await expect(loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).resolves.toEqual([]); + expect(loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).toEqual([]); connection.close(); }); @@ -42,7 +42,7 @@ describe("composio tool loading", () => { insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) values ('ca_1', ?, 'gmail', 'Gmail', 'ac_1', 'ACTIVE', ?, ?) `).run(orgId, now, now); - await expect(loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).rejects.toThrow("no Composio API key is saved"); + expect(() => loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).toThrow("no Composio API key is saved"); connection.close(); }); }); diff --git a/tests/unit/composio-tool-pages.test.ts b/tests/unit/composio-tool-pages.test.ts new file mode 100644 index 00000000..84ec1271 --- /dev/null +++ b/tests/unit/composio-tool-pages.test.ts @@ -0,0 +1,112 @@ +import { EventEmitter } from "node:events"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test, vi } from "vitest"; + +const requested: string[] = []; +const written: string[] = []; +const pages = new Map>(); + +vi.mock("electron", () => ({ + net: { + request: ({ url }: { url: string }) => { + const request = new EventEmitter() as EventEmitter & { setHeader: () => void; write: (body: string) => void; end: () => void; abort: () => void }; + request.setHeader = () => undefined; + request.write = (body: string) => { written.push(`${url} ${body}`); }; + request.abort = () => undefined; + request.end = () => { + requested.push(url); + const query = new URL(url).searchParams; + const page = url.includes("/tools/execute/") ? { data: { number: 42 }, successful: true } : pages.get(`${query.get("toolkit_slug")}:${query.get("cursor") ?? ""}`); + const response = new EventEmitter() as EventEmitter & { statusCode: number }; + response.statusCode = page ? 200 : 404; + request.emit("response", response); + response.emit("data", Buffer.from(JSON.stringify(page ?? { error: "no such page" }))); + response.emit("end"); + }; + return request; + }, + }, +})); + +const { ComposioClient } = await import("../../src/main/services/composio/composio-client"); +const { executeComposioTool, loadComposioTools } = await import("../../src/main/services/composio/composio-tool-bridge"); +const { DatabaseConnection } = await import("../../src/main/database/connection"); +const { OrganizationService } = await import("../../src/main/services/organization-service"); +const { IntegrationKeyStore } = await import("../../src/main/services/integration-key-store"); +const { ConnectorGrantsService } = await import("../../src/main/services/composio/connector-grants"); +const { recordRunAction } = await import("../../src/main/services/run-journal"); + +const tool = (slug: string) => ({ slug, name: slug, description: "", input_parameters: {}, toolkit: { slug: "github" } }); + +describe("a toolkit's tools are all listed", () => { + test("every page of a toolkit's tools is read, not just the first twenty", async () => { + const first = Array.from({ length: 100 }, (_, index) => tool(`GITHUB_A${index}`)); + pages.set("github:", { items: first, next_cursor: "page2", total_items: 101 }); + pages.set("github:page2", { items: [tool("GITHUB_CREATE_PULL_REQUEST")], next_cursor: null, total_items: 101 }); + pages.set("slack:", { items: [tool("SLACK_SEND_MESSAGE")], next_cursor: null, total_items: 1 }); + const tools = await new ComposioClient("ak_test").getTools(["github", "slack"]); + expect(tools).toHaveLength(102); + expect(tools.map((schema) => schema.name)).toContain("GITHUB_CREATE_PULL_REQUEST"); + expect(requested.map((url) => new URL(url).searchParams.get("limit"))).toEqual(["100", "100", "100"]); + }); +}); + +describe("agents reach every action through search and run", () => { + const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") }; + + function company(apiKey = "ak_actions") { + const connection = new DatabaseConnection(join(tmpdir(), `ac-actions-${randomUUID()}.db`)); + let orgId = ""; + let ceoId = ""; + connection.transaction(() => { + orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + ceoId = new OrganizationService(connection.db).createAgent({ orgId, name: "CEO", role: "CEO", department: "Executive", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: null, roleLevel: "c_suite" }); + const now = new Date().toISOString(); + connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) values ('ca_gh', ?, 'github', 'GitHub', 'ac_gh', 'ACTIVE', ?, ?)").run(orgId, now, now); + }); + const keys = new IntegrationKeyStore(connection.db, vault); + keys.write("composio", apiKey); + const call = (name: string, args: Record) => executeComposioTool(connection.db, keys, orgId, ceoId, { id: "c1", name, arguments: args }); + return { connection, keys, orgId, ceoId, call }; + } + + test("the agent carries a search and a runner, not a slice of the list", () => { + const h = company(); + expect(loadComposioTools(h.connection.db, h.keys, h.orgId, h.ceoId).map((tool) => tool.name)).toEqual(["composio_find_actions", "composio_run_action"]); + h.connection.close(); + }); + + test("an action past the first page is found with its input and runs on the connected account", async () => { + pages.set("github:", { items: Array.from({ length: 100 }, (_, index) => tool(`GITHUB_LIST_THING_${index}`)), next_cursor: "page2" }); + pages.set("github:page2", { items: [{ ...tool("GITHUB_CREATE_A_PULL_REQUEST"), description: "Create a pull request", input_parameters: { properties: { title: { type: "string" } }, required: ["title"] } }], next_cursor: null }); + const h = company(); + const found = JSON.parse(await h.call("composio_find_actions", { query: "create pull request" })) as { matches: Array<{ action: string; input: unknown }>; searched: number }; + expect(found.searched).toBe(101); + expect(found.matches[0]).toMatchObject({ action: "GITHUB_CREATE_A_PULL_REQUEST", input: { properties: { title: { type: "string" } }, required: ["title"] } }); + expect(await h.call("composio_run_action", { action: "GITHUB_CREATE_A_PULL_REQUEST", arguments: { title: "Fix" } })).toBe(JSON.stringify({ data: { number: 42 }, successful: true })); + expect(written.at(-1)).toContain("/tools/execute/GITHUB_CREATE_A_PULL_REQUEST"); + expect(written.at(-1)).toContain("\"connected_account_id\":\"ca_gh\""); + expect(await h.call("composio_run_action", { action: "SLACK_SEND_MESSAGE", arguments: {} })).toContain("is not an action of your connected services (github)"); + h.connection.close(); + }); + + test("a grant revoked while the catalog loads stops the action, and a search is not journaled as work", async () => { + pages.set("github:", { items: [tool("GITHUB_STAR_A_REPOSITORY")], next_cursor: null }); + const h = company("ak_revoke"); + const staff = h.connection.transaction(() => { + const providerId = (h.connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(h.orgId) as { id: string }).id; + const id = new OrganizationService(h.connection.db).createAgent({ orgId: h.orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: h.ceoId }); + new ConnectorGrantsService(h.connection.db).grant({ orgId: h.orgId, agentId: id, toolkitSlug: "github", grantedByType: "agent", grantedById: h.ceoId }); + return id; + }); + const running = executeComposioTool(h.connection.db, h.keys, h.orgId, staff, { id: "c2", name: "composio_run_action", arguments: { action: "GITHUB_STAR_A_REPOSITORY", arguments: {} } }); + new ConnectorGrantsService(h.connection.db).revoke({ orgId: h.orgId, agentId: staff, toolkitSlug: "github" }); + expect(await running).toContain('You have no grant for the "github" connector'); + recordRunAction(h.connection.db, { orgId: h.orgId, runId: "run_x", taskId: "task_x", call: { id: "c3", name: "composio_find_actions", arguments: { query: "star" } }, output: "{}" }); + expect(h.connection.db.prepare("select count(*) as n from run_actions").get()).toEqual({ n: 0 }); + h.connection.close(); + }); +});