From f6447319a3765a20b312efbde28ba351e17fe412 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:55:33 -0400 Subject: [PATCH] Q95: a run names the capabilities it could not load Co-Authored-By: Claude Opus 5.5 --- .../migrations/080-run-capability-warnings.ts | 6 ++ src/main/database/schema.ts | 2 + .../services/composio/composio-tool-bridge.ts | 38 ++++++++--- src/renderer/src/i18n/zh.ts | 1 + src/renderer/src/pages/RunsPage.tsx | 7 ++ .../src/pages/work/TaskDetailDialog.tsx | 3 + src/shared/domain.ts | 2 + tests/unit/capability-warnings.test.ts | 66 +++++++++++++++++++ tests/unit/domain-services.test.ts | 2 +- tests/unit/message-readers.test.ts | 2 +- 10 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 src/main/database/migrations/080-run-capability-warnings.ts create mode 100644 tests/unit/capability-warnings.test.ts diff --git a/src/main/database/migrations/080-run-capability-warnings.ts b/src/main/database/migrations/080-run-capability-warnings.ts new file mode 100644 index 00000000..8c43dff5 --- /dev/null +++ b/src/main/database/migrations/080-run-capability-warnings.ts @@ -0,0 +1,6 @@ +export const runCapabilityWarningsSchema = ` + alter table task_runs add column capability_warnings text; + + insert into schema_migrations(version, name, applied_at) + values (80, 'run_capability_warnings', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index d1778b89..266cb5ce 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -78,6 +78,7 @@ import { runPinsSchema } from "./migrations/076-run-pins"; import { modelChecksSchema } from "./migrations/077-model-checks"; import { requestErrorCodesSchema } from "./migrations/078-request-error-codes"; import { eraseRetiredCredentialsSchema } from "./migrations/079-erase-retired-credentials"; +import { runCapabilityWarningsSchema } from "./migrations/080-run-capability-warnings"; interface Migration { version: number; @@ -165,6 +166,7 @@ const MIGRATIONS: Migration[] = [ { version: 77, name: "model_checks", sql: modelChecksSchema }, { version: 78, name: "request_error_codes", sql: requestErrorCodesSchema }, { version: 79, name: "erase_retired_credentials", sql: eraseRetiredCredentialsSchema }, + { version: 80, name: "run_capability_warnings", sql: runCapabilityWarningsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/composio/composio-tool-bridge.ts b/src/main/services/composio/composio-tool-bridge.ts index ee660005..6542cebf 100644 --- a/src/main/services/composio/composio-tool-bridge.ts +++ b/src/main/services/composio/composio-tool-bridge.ts @@ -3,6 +3,7 @@ import type { ToolDefinition, ToolCall } from "../provider-tool-turn"; import { ComposioClient, type ComposioToolSchema } from "./composio-client"; import type { IntegrationKeyStore } from "../integration-key-store"; import { ConnectorGrantsService } from "./connector-grants"; +import { currentRunId } from "../run-control"; const COMPOSIO_TOOL_PREFIX = "composio_"; export const FIND_ACTIONS_TOOL = "composio_find_actions"; @@ -66,7 +67,7 @@ export async function executeComposioTool( 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 === FIND_ACTIONS_TOOL) return await findActions(db, 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) { @@ -74,12 +75,12 @@ export async function executeComposioTool( } } -async function findActions(apiKey: string, toolkits: string[], args: Record): Promise { +async function findActions(db: DatabaseSync, 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 actions = (await actionCatalog(db, apiKey, toolkits)).filter((action) => !toolkit || action.toolkitSlug?.toLowerCase() === toolkit); const words = query.split(/[^a-z0-9]+/).filter(Boolean); const scored = actions .map((action) => { @@ -99,7 +100,7 @@ async function findActions(apiKey: 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); + const action = (await actionCatalog(db, 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. @@ -117,14 +118,33 @@ async function runAction(db: DatabaseSync, apiKey: string, orgId: string, agentI 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 { +/** The full action list of the granted toolkits, every page of it; a failed listing reaches the agent and is named on the run. */ +async function actionCatalog(db: DatabaseSync, 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; + try { + const actions = await new ComposioClient(apiKey).getTools(toolkits); + catalogCache.set(cacheKey, { actions, fetchedAt: Date.now() }); + return actions; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + noteMissingCapability(db, `The actions of ${toolkits.join(", ")} could not be listed: ${reason}`); + throw error; + } +} + +/** Names a capability the current run went without, once, on the run the user looks at; also logged. */ +function noteMissingCapability(db: DatabaseSync, text: string): void { + // One line per gap: error bodies (JSON, HTML) arrive with their own line breaks. + const warning = text.replace(/\s+/g, " ").trim(); + console.warn(`[connectors] ${warning}`); + const runId = currentRunId(); + if (!runId) return; + const row = db.prepare("select capability_warnings from task_runs where id = ?").get(runId) as { capability_warnings: string | null } | undefined; + const lines = row?.capability_warnings ? row.capability_warnings.split("\n") : []; + if (lines.includes(warning)) return; + db.prepare("update task_runs set capability_warnings = ? where id = ?").run([...lines, warning].join("\n"), runId); } export function getComposioUserId(db: DatabaseSync, orgId: string): string { diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index efc8b492..6051f6a4 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -32,6 +32,7 @@ export const zh: Record = { "superseded": "已被取代", "stalled": "已停滞", "Resume": "恢复", + "Missing this run": "本次运行缺少的能力", "Disconnect": "断开连接", "Revokes the sign-in at {provider} and erases it from this computer.": "在 {provider} 撤销登录授权,并从本机删除。", "Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.": "仅从本机删除密钥。在你于 {provider} 账号中撤销之前,该密钥仍然有效。", diff --git a/src/renderer/src/pages/RunsPage.tsx b/src/renderer/src/pages/RunsPage.tsx index 9075b684..a21b7eef 100644 --- a/src/renderer/src/pages/RunsPage.tsx +++ b/src/renderer/src/pages/RunsPage.tsx @@ -160,6 +160,13 @@ function RunDetail({ run, tasks, agents, providers, providerRequests, actions }: )} + {run.capabilityWarnings && ( +
+
{t("Missing this run")}
+
{run.capabilityWarnings}
+
+ )} + {request && (
{t("Provider request")}
diff --git a/src/renderer/src/pages/work/TaskDetailDialog.tsx b/src/renderer/src/pages/work/TaskDetailDialog.tsx index 4f9b28c3..66cb4322 100644 --- a/src/renderer/src/pages/work/TaskDetailDialog.tsx +++ b/src/renderer/src/pages/work/TaskDetailDialog.tsx @@ -169,6 +169,9 @@ function OverviewTab({ task, assignee, project, goal, workspace, provider, runs {run.state === "failed" && run.errorMessage && ( {run.errorMessage} )} + {run.capabilityWarnings && ( + {t("Missing this run")}: {run.capabilityWarnings} + )}
))} diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 97eca699..10e56896 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -163,6 +163,8 @@ export interface TaskRunRecord { errorMessage: string | null; /** JSON RunContextManifest: which messages the run read. */ contextJson: string | null; + /** Capabilities the run could not load, one per line, so the user sees what it worked without. */ + capabilityWarnings: string | null; } export interface ApprovalRecord { diff --git a/tests/unit/capability-warnings.test.ts b/tests/unit/capability-warnings.test.ts new file mode 100644 index 00000000..5fd95f6e --- /dev/null +++ b/tests/unit/capability-warnings.test.ts @@ -0,0 +1,66 @@ +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"; + +vi.mock("electron", () => ({ + net: { + request: () => { + const request = new EventEmitter() as EventEmitter & { setHeader: () => void; write: () => void; end: () => void; abort: () => void }; + request.setHeader = () => undefined; + request.write = () => undefined; + request.abort = () => undefined; + request.end = () => { + const response = new EventEmitter() as EventEmitter & { statusCode: number }; + response.statusCode = 503; + request.emit("response", response); + response.emit("data", Buffer.from(JSON.stringify({ error: "Service Unavailable", detail: "busy" }, null, 2))); + response.emit("end"); + }; + return request; + }, + }, +})); + +const { DatabaseConnection } = await import("../../src/main/database/connection"); +const { OnboardingService } = await import("../../src/main/services/onboarding-service"); +const { ProviderService } = await import("../../src/main/services/provider-service"); +const { providerAdapters } = await import("../../src/main/services/provider-adapters"); +const { IntegrationKeyStore } = await import("../../src/main/services/integration-key-store"); +const { claimNextQueuedRun } = await import("../../src/main/app/task-queue-worker"); +const { executeComposioTool } = await import("../../src/main/services/composio/composio-tool-bridge"); +const { withRunControl } = await import("../../src/main/services/run-control"); + +const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") }; + +describe("a run tells the user which capabilities it lacked", () => { + test("a connector whose actions cannot be listed is named on the run, as well as told to the agent", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-capability-${randomUUID()}.db`)); + const providers = new ProviderService(connection.db, providerAdapters, vault); + const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); + let orgId = ""; + let ceoId = ""; + connection.transaction(() => { + const summary = onboarding.bootstrap({ + companyName: "FlowPilot", companyDescription: "d", employeeName: "Yang", workspacePath: "", executionMode: "ai_ceo", autoApproveHires: true, + reviewDeliverables: false, goalTitle: "Ship.", goalDescription: "d", providerKey: "openai", providerSecret: "sk-test", providerModel: "gpt-5.4", + }); + orgId = summary.orgId; + ceoId = summary.ceoAgentId; + 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", "ak_capability"); + const run = claimNextQueuedRun(connection)!; + const output = await withRunControl(run.id, () => executeComposioTool(connection.db, keys, orgId, ceoId, { id: "c1", name: "composio_find_actions", arguments: { query: "create pull request" } }), 60_000); + expect(output).toMatch(/^ERROR: .*503/); + const warnings = (connection.db.prepare("select capability_warnings from task_runs where id = ?").get(run.id) as { capability_warnings: string }).capability_warnings; + expect(warnings).toMatch(/^The actions of github could not be listed: .*503/); + expect(warnings.split("\n")).toHaveLength(1); + await withRunControl(run.id, () => executeComposioTool(connection.db, keys, orgId, ceoId, { id: "c2", name: "composio_find_actions", arguments: { query: "again" } }), 60_000); + expect((connection.db.prepare("select capability_warnings from task_runs where id = ?").get(run.id) as { capability_warnings: string }).capability_warnings).toBe(warnings); + connection.close(); + }); +}); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 208a86be..975e9c1a 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80]); connection.close(); }); diff --git a/tests/unit/message-readers.test.ts b/tests/unit/message-readers.test.ts index b31d6755..80fc912a 100644 --- a/tests/unit/message-readers.test.ts +++ b/tests/unit/message-readers.test.ts @@ -6,7 +6,7 @@ function run(agentId: string | null, contextJson: string | null): TaskRunRecord return { id: `run_${Math.random()}`, orgId: "org", taskId: "tsk", agentId, providerId: null, providerRequestId: null, executionContextId: null, serviceIdentityId: null, workspaceMountJson: "[]", secretScopeJson: "{}", - trigger: "chat_mention", state: "completed", createdAt: "", startedAt: null, completedAt: null, errorMessage: null, contextJson, + trigger: "chat_mention", state: "completed", createdAt: "", startedAt: null, completedAt: null, errorMessage: null, contextJson, capabilityWarnings: null, }; }