diff --git a/bun.lock b/bun.lock index 46ca9c954..404df42ab 100644 --- a/bun.lock +++ b/bun.lock @@ -701,6 +701,7 @@ "@intx/types": "0.3.0", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", + "@workbench/connections": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -3373,19 +3374,7 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], - - "@corbits/bench-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/tasks-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -3409,9 +3398,7 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], - - "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], diff --git a/packages/chat/src/chat-orchestrator.test.ts b/packages/chat/src/chat-orchestrator.test.ts new file mode 100644 index 000000000..c66ae2ded --- /dev/null +++ b/packages/chat/src/chat-orchestrator.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; + +import { createReplyPartsAccumulator } from "./chat-orchestrator"; + +const AGENT_ADDRESS = "agent@example.com"; + +function toolCallBlock(callId: string) { + return { + kind: "tool-call" as const, + callId, + name: "giphy_search", + input: {}, + }; +} + +describe("createReplyPartsAccumulator", () => { + test("a normal successful tool result is unaffected", () => { + const acc = createReplyPartsAccumulator(); + acc.onInferenceDone(AGENT_ADDRESS, [toolCallBlock("call_1")]); + acc.onToolDone(AGENT_ADDRESS, { + callId: "call_1", + content: "3 results found", + isError: false, + }); + + const parts = acc.take(AGENT_ADDRESS); + expect(parts).toEqual([ + { + kind: "tool-trace", + name: "giphy_search", + input: {}, + status: "success", + output: "3 results found", + }, + ]); + }); + + test("a failed tool result with no structured detail is unaffected", () => { + const acc = createReplyPartsAccumulator(); + acc.onInferenceDone(AGENT_ADDRESS, [toolCallBlock("call_1")]); + acc.onToolDone(AGENT_ADDRESS, { + callId: "call_1", + content: "timed out", + isError: true, + }); + + const parts = acc.take(AGENT_ADDRESS); + expect(parts).toEqual([ + { + kind: "tool-trace", + name: "giphy_search", + input: {}, + status: "error", + output: "timed out", + }, + ]); + }); + + test("a missing-credential detail renders the connect-service block naming the connector", () => { + const acc = createReplyPartsAccumulator(); + acc.onInferenceDone(AGENT_ADDRESS, [toolCallBlock("call_1")]); + acc.onToolDone(AGENT_ADDRESS, { + callId: "call_1", + content: "GitHub is not connected for this run.", + isError: true, + detail: { kind: "missing-credential", connectorId: "github" }, + }); + + const parts = acc.take(AGENT_ADDRESS); + expect(parts).toEqual([ + { + kind: "tool-trace", + name: "giphy_search", + input: {}, + status: "error", + output: "GitHub is not connected for this run.", + }, + { + kind: "block", + block: { + type: "connect-service", + data: { + connectorId: "github", + displayName: "GitHub", + reason: "GitHub is not connected for this run.", + }, + }, + }, + ]); + }); + + test("a missing-credential detail on a non-error result is ignored", () => { + const acc = createReplyPartsAccumulator(); + acc.onInferenceDone(AGENT_ADDRESS, [toolCallBlock("call_1")]); + acc.onToolDone(AGENT_ADDRESS, { + callId: "call_1", + content: "3 results found", + isError: false, + detail: { kind: "missing-credential", connectorId: "github" }, + }); + + const parts = acc.take(AGENT_ADDRESS); + expect(parts).toEqual([ + { + kind: "tool-trace", + name: "giphy_search", + input: {}, + status: "success", + output: "3 results found", + }, + ]); + }); +}); diff --git a/packages/chat/src/chat-orchestrator.ts b/packages/chat/src/chat-orchestrator.ts index f32e25ed3..6fea41019 100644 --- a/packages/chat/src/chat-orchestrator.ts +++ b/packages/chat/src/chat-orchestrator.ts @@ -42,6 +42,10 @@ import { type ClassifiedInferenceFailureCategory, type ProviderHealthPort, } from "@workbench/connections/provider-health"; +import { + CONNECTOR_REGISTRY, + parseMissingCredentialDetail, +} from "@workbench/connections/registry"; import { artifactPartsForFinalizedTurn } from "./artifact-delivery"; import type { ApproveBlockData } from "./blocks"; import { encodeParts } from "./codec"; @@ -204,11 +208,16 @@ function gateBlockedCorrelationId(event: unknown): string | undefined { * matching `repliedAddresses`' own per-address bookkeeping above); reset * the moment a turn's `connector.reply` or turn-drop notice consumes it. */ -function createReplyPartsAccumulator(): { +export function createReplyPartsAccumulator(): { onInferenceDone(agentAddress: string, blocks: ReplyContentBlock[]): void; onToolDone( agentAddress: string, - result: { callId: string; content: unknown; isError: boolean }, + result: { + callId: string; + content: unknown; + isError: boolean; + detail?: unknown; + }, ): void; /** Returns and clears the address's accumulated parts, or undefined if * nothing was ever accumulated for it this turn. */ @@ -251,6 +260,36 @@ function createReplyPartsAccumulator(): { status: result.isError ? "error" : "success", output: result.content, }; + // A tool that stopped rather than guessing because a connector's + // credential isn't connected (CL-6495's mid-turn halt) carries + // that fact structurally in `detail`, not just as prose in + // `content`. When it does, append the same `connect-service` card + // `request_connection` already posts for the agent-initiated path + // — same block type, same render path, same live actions port — + // so the person sees a real "Connect X" button in this turn + // instead of a dead-end error. + const missingCredential = result.isError + ? parseMissingCredentialDetail(result.detail) + : undefined; + if (missingCredential !== undefined) { + const displayName = + CONNECTOR_REGISTRY[missingCredential.connectorId]?.displayName ?? + missingCredential.connectorId; + parts.push({ + kind: "block", + block: { + type: "connect-service", + data: { + connectorId: missingCredential.connectorId, + displayName, + reason: + typeof result.content === "string" && result.content.length > 0 + ? result.content + : `${displayName} isn't connected, so this couldn't run.`, + }, + }, + }); + } }, take(agentAddress) { const parts = partsByAddress.get(agentAddress); diff --git a/packages/connections/src/credential-error.test.ts b/packages/connections/src/credential-error.test.ts new file mode 100644 index 000000000..a653cfa3d --- /dev/null +++ b/packages/connections/src/credential-error.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; + +import { MissingCredentialError } from "./credential-error"; + +describe("MissingCredentialError", () => { + test("names the connector by its consumer-facing display name", () => { + const error = new MissingCredentialError("github"); + + expect(error.name).toBe("MissingCredentialError"); + expect(error.connectorId).toBe("github"); + expect(error.displayName).toBe("GitHub"); + expect(error.message).toBe("GitHub is not connected."); + }); + + test("falls back to the raw connector id when it has no registry entry", () => { + const error = new MissingCredentialError("not-a-real-connector"); + + expect(error.connectorId).toBe("not-a-real-connector"); + expect(error.displayName).toBe("not-a-real-connector"); + expect(error.message).toBe("not-a-real-connector is not connected."); + }); +}); diff --git a/packages/connections/src/credential-error.ts b/packages/connections/src/credential-error.ts new file mode 100644 index 000000000..387221db0 --- /dev/null +++ b/packages/connections/src/credential-error.ts @@ -0,0 +1,27 @@ +// The one typed signal for "this connector's credential is missing" — +// the pop-up that lets someone connect it can't target the right +// connector unless that identity survives past the failure. Today it +// doesn't: `packages/folded-runs/src/launch.ts` discards +// `buildCredentialDelivery`'s own `reason.binding.provider` into a +// generic `Error` string, and every tool package bakes its own +// hardcoded "not connected" prose instead of naming the connector +// structurally. This class is the shared, identifiable shape a thrower +// and a catcher can agree on. `displayName` comes straight from +// `CONNECTOR_REGISTRY` — the one place a connector's consumer-facing +// name lives — so nothing downstream re-derives or hand-writes it, and +// a caller never has to fall back to showing the raw connector id. +import { CONNECTOR_REGISTRY } from "./registry"; + +export class MissingCredentialError extends Error { + readonly connectorId: string; + readonly displayName: string; + + constructor(connectorId: string) { + const displayName = + CONNECTOR_REGISTRY[connectorId]?.displayName ?? connectorId; + super(`${displayName} is not connected.`); + this.name = "MissingCredentialError"; + this.connectorId = connectorId; + this.displayName = displayName; + } +} diff --git a/packages/connections/src/index.ts b/packages/connections/src/index.ts index adb7a8fde..5e4718585 100644 --- a/packages/connections/src/index.ts +++ b/packages/connections/src/index.ts @@ -20,6 +20,12 @@ export type { OAuthExchangeResult, } from "./descriptor"; export { CONNECTOR_REGISTRY, connectorDescriptors } from "./registry"; +export { MissingCredentialError } from "./credential-error"; +export { + missingCredentialDetail, + parseMissingCredentialDetail, + type MissingCredentialDetail, +} from "./missing-credential-detail"; export { testExaCredential, testGitHubCredential, diff --git a/packages/connections/src/missing-credential-detail.test.ts b/packages/connections/src/missing-credential-detail.test.ts new file mode 100644 index 000000000..82172dfed --- /dev/null +++ b/packages/connections/src/missing-credential-detail.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; + +import { + missingCredentialDetail, + parseMissingCredentialDetail, +} from "./missing-credential-detail"; + +describe("missingCredentialDetail / parseMissingCredentialDetail", () => { + test("round-trips a connector id through the wire shape", () => { + const detail = missingCredentialDetail("github"); + expect(parseMissingCredentialDetail(detail)).toEqual({ + kind: "missing-credential", + connectorId: "github", + }); + }); + + test("rejects a tool result's detail that isn't this shape", () => { + expect(parseMissingCredentialDetail(undefined)).toBeUndefined(); + expect(parseMissingCredentialDetail("timed out")).toBeUndefined(); + expect( + parseMissingCredentialDetail({ kind: "something-else" }), + ).toBeUndefined(); + expect( + parseMissingCredentialDetail({ kind: "missing-credential" }), + ).toBeUndefined(); + }); +}); diff --git a/packages/connections/src/missing-credential-detail.ts b/packages/connections/src/missing-credential-detail.ts new file mode 100644 index 000000000..b52267de7 --- /dev/null +++ b/packages/connections/src/missing-credential-detail.ts @@ -0,0 +1,30 @@ +// The wire shape a `ToolResult.detail` carries when a tool call didn't +// run because a connector's credential isn't connected — the mid-turn +// counterpart to `MissingCredentialError`'s launch-time halt. A plain, +// `kind`-discriminated value rather than the error class itself: this +// travels the same sidecar event wire every other `ToolResult` does, so +// it's parsed here rather than trusted, matching every other external +// boundary in this repo. A tool package that wants the chat to render +// the connect-service card writes this shape onto its `ToolResult` +// literally (no dependency on this package needed to produce it — only +// the reader, `@corbits/chat`'s orchestrator, needs to parse it). +import { type } from "arktype"; + +export const MissingCredentialDetail = type({ + kind: "'missing-credential'", + connectorId: "string > 0", +}); +export type MissingCredentialDetail = typeof MissingCredentialDetail.infer; + +export function missingCredentialDetail( + connectorId: string, +): MissingCredentialDetail { + return { kind: "missing-credential", connectorId }; +} + +export function parseMissingCredentialDetail( + detail: unknown, +): MissingCredentialDetail | undefined { + const parsed = MissingCredentialDetail(detail); + return parsed instanceof type.errors ? undefined : parsed; +} diff --git a/packages/connections/src/registry.ts b/packages/connections/src/registry.ts index 656a0a06d..aafdf1dc4 100644 --- a/packages/connections/src/registry.ts +++ b/packages/connections/src/registry.ts @@ -21,6 +21,11 @@ import { testProviderCredential, type SupportedCredentialProvider, } from "@workbench/hub-client/credential-test"; +export { + missingCredentialDetail, + parseMissingCredentialDetail, + type MissingCredentialDetail, +} from "./missing-credential-detail"; export type { ConnectorAuthKind, ConnectorDescriptor, diff --git a/packages/folded-runs/package.json b/packages/folded-runs/package.json index 1e7b13e7a..e62b8247f 100644 --- a/packages/folded-runs/package.json +++ b/packages/folded-runs/package.json @@ -25,6 +25,7 @@ "@intx/types": "0.3.0", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", + "@workbench/connections": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/folded-runs/src/agent-events.test.ts b/packages/folded-runs/src/agent-events.test.ts index 52f59f365..aed507073 100644 --- a/packages/folded-runs/src/agent-events.test.ts +++ b/packages/folded-runs/src/agent-events.test.ts @@ -128,6 +128,28 @@ describe("toolDoneResult", () => { expect(toolDoneResult({ type: "connector.reply" })).toBeUndefined(); expect(toolDoneResult(undefined)).toBeUndefined(); }); + + test("carries a failed tool's structured detail alongside its content", () => { + expect( + toolDoneResult({ + type: "tool.done", + seq: 1, + data: { + result: { + callId: "call_1", + content: "GitHub is not connected.", + isError: true, + detail: { kind: "missing-credential", connectorId: "github" }, + }, + }, + }), + ).toEqual({ + callId: "call_1", + content: "GitHub is not connected.", + isError: true, + detail: { kind: "missing-credential", connectorId: "github" }, + }); + }); }); describe("messageRunEnded", () => { diff --git a/packages/folded-runs/src/agent-events.ts b/packages/folded-runs/src/agent-events.ts index 1322e399a..d72dfb53f 100644 --- a/packages/folded-runs/src/agent-events.ts +++ b/packages/folded-runs/src/agent-events.ts @@ -98,7 +98,9 @@ export function inferenceDoneBlocks( * entries to fill in a tool-trace part's outcome once its call settles. */ export function toolDoneResult( event: unknown, -): { callId: string; content: unknown; isError: boolean } | undefined { +): + | { callId: string; content: unknown; isError: boolean; detail?: unknown } + | undefined { if ( typeof event !== "object" || event === null || @@ -112,7 +114,13 @@ export function toolDoneResult( if (typeof callId !== "string") return undefined; const content = (result as { content?: unknown }).content; const isError = (result as { isError?: unknown }).isError === true; - return { callId, content, isError }; + // `detail` rides alongside `content` on the underlying `ToolResult` + // (`@intx/types/runtime`) as the side channel for structured metadata + // that isn't meant for the model's own eyes — a missing-credential + // signal (`@workbench/connections`' `parseMissingCredentialDetail`) + // being the one caller today. + const detail = (result as { detail?: unknown }).detail; + return { callId, content, isError, detail }; } export type MessageRunEnded = { diff --git a/packages/folded-runs/src/credential-delivery-error.test.ts b/packages/folded-runs/src/credential-delivery-error.test.ts new file mode 100644 index 000000000..c1e68fe84 --- /dev/null +++ b/packages/folded-runs/src/credential-delivery-error.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { MissingCredentialError } from "@workbench/connections"; +import type { CredentialDeliveryFailure } from "@intx/db"; + +import { credentialDeliveryError } from "./credential-delivery-error"; + +describe("credentialDeliveryError", () => { + test("names the missing connector instead of a generic failure", () => { + const reason: CredentialDeliveryFailure = { + code: "unresolved", + binding: { + provider: "github", + package: "@corbits/github-tools", + handle: "github", + }, + message: "no credential resolves this binding", + }; + + const error = credentialDeliveryError("the agent", reason); + + expect(error).toBeInstanceOf(MissingCredentialError); + expect((error as MissingCredentialError).connectorId).toBe("github"); + expect((error as MissingCredentialError).displayName).toBe("GitHub"); + }); + + test("keeps a generic error for a non-missing-credential failure", () => { + const reason: CredentialDeliveryFailure = { + code: "ambiguous", + binding: { + provider: "github", + package: "@corbits/github-tools", + handle: "github", + }, + message: "more than one candidate credential resolves this binding", + }; + + const error = credentialDeliveryError("the agent", reason); + + expect(error).not.toBeInstanceOf(MissingCredentialError); + expect(error.message).toContain( + "more than one candidate credential resolves this binding", + ); + }); +}); diff --git a/packages/folded-runs/src/credential-delivery-error.ts b/packages/folded-runs/src/credential-delivery-error.ts new file mode 100644 index 000000000..c4145faca --- /dev/null +++ b/packages/folded-runs/src/credential-delivery-error.ts @@ -0,0 +1,23 @@ +// Classifies a failed `buildCredentialDelivery` call into the error a +// launch should throw. `code: "unresolved"` is the one case a person can +// actually act on — the credential simply isn't connected yet — so it +// becomes a `MissingCredentialError` naming the connector, instead of the +// generic `Error` every failure used to collapse into (CL-6495: a launch +// that halts on a missing credential is only useful if something upstream +// can tell that apart from every other launch failure). `"no_origin"` and +// `"ambiguous"` are configuration faults, not a missing connection, so +// they stay generic. +import type { CredentialDeliveryFailure } from "@intx/db"; +import { MissingCredentialError } from "@workbench/connections"; + +export function credentialDeliveryError( + launchLabel: string, + reason: CredentialDeliveryFailure, +): Error { + if (reason.code === "unresolved") { + return new MissingCredentialError(reason.binding.provider); + } + return new Error( + `${launchLabel}: credential binding resolution failed: ${reason.message}`, + ); +} diff --git a/packages/folded-runs/src/launch.ts b/packages/folded-runs/src/launch.ts index 519ec84f8..1fa51710b 100644 --- a/packages/folded-runs/src/launch.ts +++ b/packages/folded-runs/src/launch.ts @@ -22,6 +22,7 @@ import { workflowDefinition, workflowRun, } from "@intx/db/schema"; +import { credentialDeliveryError } from "./credential-delivery-error"; import { foldedRun } from "./schema"; import { SessionLaunchError } from "@intx/hub-sessions"; import { resolveDefinitionSources } from "@intx/hub-api"; @@ -341,9 +342,7 @@ export async function deployAtHead( credentialCipher: deps.credentialCipher, }); if (!delivery.ok) { - throw new Error( - `${params.launchLabel}: credential binding resolution failed: ${delivery.reason.message}`, - ); + throw credentialDeliveryError(params.launchLabel, delivery.reason); } for (const descriptor of delivery.delivery?.bindings ?? []) { grants.push({ diff --git a/packages/github-tools/src/pull-request-tools.test.ts b/packages/github-tools/src/pull-request-tools.test.ts index 13248a9ac..2df306359 100644 --- a/packages/github-tools/src/pull-request-tools.test.ts +++ b/packages/github-tools/src/pull-request-tools.test.ts @@ -135,6 +135,25 @@ test("an unbound credential is reported as not connected, not as a crash", async expect(String(result.content)).toContain("not connected"); }); +test("an unbound credential names GitHub structurally, for the chat's connect prompt", async () => { + const result = await bundle(false).run( + call(GITHUB_PULL_REQUEST_DIFF_TOOL, { pullRequestUrl: PULL_URL }), + new AbortController().signal, + ); + expect(result.detail).toEqual({ + kind: "missing-credential", + connectorId: "github", + }); +}); + +test("a resolved credential carries no missing-credential detail", async () => { + const result = await bundle(true).run( + call(GITHUB_PULL_REQUEST_DIFF_TOOL, { pullRequestUrl: PULL_URL }), + new AbortController().signal, + ); + expect(result.detail).toBeUndefined(); +}); + test("a URL that is not a pull request comes back as a tool error", async () => { const result = await bundle(true).run( call(GITHUB_PULL_REQUEST_DIFF_TOOL, { diff --git a/packages/github-tools/src/pull-request-tools.ts b/packages/github-tools/src/pull-request-tools.ts index 3677564d5..bc57466ce 100644 --- a/packages/github-tools/src/pull-request-tools.ts +++ b/packages/github-tools/src/pull-request-tools.ts @@ -43,8 +43,28 @@ const ReviewArguments = type({ }).array(), }); -function errorResult(callId: string, message: string): ToolResult { - return { callId, content: message, isError: true }; +function errorResult( + callId: string, + message: string, + detail?: unknown, +): ToolResult { + return { + callId, + content: message, + isError: true, + ...(detail !== undefined ? { detail } : {}), + }; +} + +// The wire shape `@corbits/chat`'s orchestrator parses +// (`@workbench/connections`' `parseMissingCredentialDetail`) to render the +// live connect-service card. Written literally rather than imported: a +// sandboxed tool package stays free of a dependency on the hub-side +// connections package for one constant shape both sides already agree on +// by convention, the same way `toolDoneResult` narrows `event.type` +// without importing a shared literal. +function missingCredentialDetail(connectorId: string) { + return { kind: "missing-credential", connectorId } as const; } function failureMessage(err: unknown): string { @@ -83,7 +103,13 @@ async function runDiff( ); } const config = await resolveConfig(env); - if (config === null) return errorResult(call.id, NOT_CONNECTED); + if (config === null) { + return errorResult( + call.id, + NOT_CONNECTED, + missingCredentialDetail(GITHUB_CREDENTIAL_HANDLE), + ); + } try { const ref = parsePullRequestUrl(args.pullRequestUrl); const diff = await fetchPullRequestDiff(config, ref); @@ -105,7 +131,13 @@ async function runPostReview( ); } const config = await resolveConfig(env); - if (config === null) return errorResult(call.id, NOT_CONNECTED); + if (config === null) { + return errorResult( + call.id, + NOT_CONNECTED, + missingCredentialDetail(GITHUB_CREDENTIAL_HANDLE), + ); + } try { const ref = parsePullRequestUrl(args.pullRequestUrl); const posted = await postPullRequestReview(config, ref, args.headSha, {