Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 3 additions & 16 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

113 changes: 113 additions & 0 deletions packages/chat/src/chat-orchestrator.test.ts
Original file line number Diff line number Diff line change
@@ -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",
},
]);
});
});
43 changes: 41 additions & 2 deletions packages/chat/src/chat-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions packages/connections/src/credential-error.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
});
27 changes: 27 additions & 0 deletions packages/connections/src/credential-error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
6 changes: 6 additions & 0 deletions packages/connections/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions packages/connections/src/missing-credential-detail.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
30 changes: 30 additions & 0 deletions packages/connections/src/missing-credential-detail.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions packages/connections/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading