Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import test from "node:test";

import { describeAgentReadinessFailures } from "./useMentionSendFlow.helpers.ts";

test("describeAgentReadinessFailures: no failures blocks nothing and warns about nothing", () => {
assert.deepEqual(describeAgentReadinessFailures([]), {
blocking: null,
warning: null,
});
});

test("describeAgentReadinessFailures: a launch failure for a channel member only warns", () => {
// #5099: the agent runs on the user's own server, so this desktop holds no
// key for it and the launch can never succeed. It is in the channel and
// answers there, so the mention must still go out.
const result = describeAgentReadinessFailures([
{ blocking: false, message: "Backend · Claude: agent has no private key" },
]);

assert.equal(result.blocking, null);
assert.equal(
result.warning,
"Could not start the mentioned agent; sending the mention anyway: Backend · Claude: agent has no private key",
);
});

test("describeAgentReadinessFailures: the warning never claims the message was sent", () => {
// The notice is emitted before Huddle sync, media upload and the send, each
// of which can still abort. Past-tense copy here would be a false delivery
// confirmation on an error path.
for (const count of [1, 2]) {
const { warning } = describeAgentReadinessFailures(
Array.from({ length: count }, (_, index) => ({
blocking: false,
message: `Agent ${index}: no private key`,
})),
);

assert.match(warning, /sending the mention anyway/);
assert.doesNotMatch(warning, /\bsent\b/i);
assert.doesNotMatch(warning, /\bdelivered\b/i);
}
});

test("describeAgentReadinessFailures: a blocking failure keeps stopping the send", () => {
const result = describeAgentReadinessFailures([
{ blocking: true, message: "Fizz: Mock agent startup failed." },
]);

assert.equal(
result.blocking,
"Could not start agent mention: Fizz: Mock agent startup failed.",
);
assert.equal(result.warning, null);
});

test("describeAgentReadinessFailures: a blocking failure wins over a warning", () => {
const result = describeAgentReadinessFailures([
{ blocking: false, message: "Remote: no private key" },
{ blocking: true, message: "Fizz: startup failed" },
]);

assert.equal(
result.blocking,
"Could not start agent mention: Fizz: startup failed",
);
assert.equal(
result.warning,
"Could not start the mentioned agent; sending the mention anyway: Remote: no private key",
);
});

test("describeAgentReadinessFailures: several failures of one kind are joined and pluralised", () => {
const result = describeAgentReadinessFailures([
{ blocking: true, message: "A: one" },
{ blocking: true, message: "B: two" },
{ blocking: false, message: "C: three" },
{ blocking: false, message: "D: four" },
]);

assert.equal(
result.blocking,
"Could not start agent mentions: A: one; B: two",
);
assert.equal(
result.warning,
"Could not start the mentioned agents; sending the mention anyway: C: three; D: four",
);
});
55 changes: 55 additions & 0 deletions desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,58 @@ export function isManagedAgentRunning(agent: ManagedAgent) {
export function isProviderBackedAgent(agent: ManagedAgent) {
return agent.backend.type === "provider";
}

/**
* A mentioned agent that could not be prepared for the send.
*
* `blocking` is false only when the agent is already a member of the channel
* the message is going to. Launching it on this desktop is then an
* optimization: an agent that runs elsewhere — a container on the user's own
* server — has no private key on this machine and never will, so its launch
* fails every time, yet it is in the channel and still answers. Refusing to
* publish loses a valid mention and fixes nothing.
*
* Everything else blocks, because nothing else would put the agent in the
* channel: a failed attach leaves it outside, and a failed launch for an agent
* that is only being prepared for a channel this send creates or expands
* leaves that channel without the participant it was expanded for.
*/
export type AgentReadinessFailure = {
blocking: boolean;
message: string;
};

/**
* Build the send-blocking message and the send-anyway notice, or null.
*
* The non-blocking copy is deliberately pre-send tense. It is shown before
* Huddle sync, media upload and the send itself, any of which can still fail
* and abort, so it must not claim the message was delivered.
*/
export function describeAgentReadinessFailures(
failures: readonly AgentReadinessFailure[],
) {
const describe = (
singular: string,
plural: string,
selected: readonly AgentReadinessFailure[],
) =>
selected.length === 0
? null
: `${selected.length === 1 ? singular : plural}: ${selected
.map((failure) => failure.message)
.join("; ")}`;

return {
blocking: describe(
"Could not start agent mention",
"Could not start agent mentions",
failures.filter((failure) => failure.blocking),
),
warning: describe(
"Could not start the mentioned agent; sending the mention anyway",
"Could not start the mentioned agents; sending the mention anyway",
failures.filter((failure) => !failure.blocking),
),
};
}
28 changes: 14 additions & 14 deletions desktop/src/features/messages/ui/useMentionSendFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags";
import {
type AgentReadinessFailure,
describeAgentReadinessFailures,
getErrorMessage,
isManagedAgentRunning,
isProviderBackedAgent,
Expand Down Expand Up @@ -183,7 +185,7 @@ export function useMentionSendFlow({
) => {
if (!capturedChannelId || mentionPubkeys.length === 0) {
return {
errors: [] as string[],
errors: [] as AgentReadinessFailure[],
pubkeys: [] as string[],
};
}
Expand All @@ -195,13 +197,14 @@ export function useMentionSendFlow({
...mentions.memberPubkeys,
...preparedParticipantPubkeys.map(normalizePubkey),
]);
const errors: string[] = [];
const errors: AgentReadinessFailure[] = [];
const pubkeys: string[] = [];
for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) {
const agent = managedAgentsByPubkey.get(pubkey);
if (!agent) {
continue;
}
const isExistingMember = mentions.memberPubkeys.has(pubkey);
try {
if (participantPubkeys.has(pubkey)) {
if (isProviderBackedAgent(agent)) {
Expand All @@ -220,12 +223,13 @@ export function useMentionSendFlow({
}
pubkeys.push(pubkey);
} catch (error) {
errors.push(
`${agent.name}: ${getErrorMessage(
errors.push({
blocking: !isExistingMember,
message: `${agent.name}: ${getErrorMessage(
error,
"Could not prepare agent.",
)}`,
);
});
}
}
return {
Expand Down Expand Up @@ -452,17 +456,13 @@ export function useMentionSendFlow({
persistPreflightDraft();
return;
}
if (agentReadiness.errors.length > 0) {
const message =
agentReadiness.errors.length === 1
? `Could not start agent mention: ${agentReadiness.errors[0]}`
: `Could not start agent mentions: ${agentReadiness.errors.join(
"; ",
)}`;
setNonMemberPromptError(message);
toast.error(message);
const readiness = describeAgentReadinessFailures(agentReadiness.errors);
if (readiness.blocking) {
setNonMemberPromptError(readiness.blocking);
toast.error(readiness.blocking);
return;
}
if (readiness.warning) toast.warning(readiness.warning);

if (preparedAgentPubkeys.length > 0 && sendChannelId) {
try {
Expand Down
98 changes: 98 additions & 0 deletions desktop/tests/e2e/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,104 @@ test("mentioning an in-channel provider managed agent deploys it before sending"
await expect(mentionChip).toBeVisible();
});

test("mentioning an in-channel agent still sends when its start fails", async ({
page,
}) => {
// #5099: the agent runs on the user's own server, so this desktop holds no
// key for it and the launch can never succeed. It is a member of the channel
// and answers there, so the mention has to be published anyway.
const startError = "agent has no private key available";
await installMockBridge(page, {
managedAgents: [
{
pubkey: OUT_OF_CHANNEL_PROVIDER_AGENT_PUBKEY,
name: "portal",
status: "not_deployed",
channelNames: ["general"],
backend: {
type: "provider",
id: "portal",
config: { region: "test" },
},
},
],
startManagedAgentErrors: [startError],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");

const input = page.getByTestId("message-input");
await input.fill("Hey @portal");

const dropdown = autocomplete(page);
await expect(dropdown.getByText("portal")).toBeVisible();
await input.press("Enter");
await page.keyboard.type(" can you help?");
await page.getByTestId("send-message").click();

await expect(
page.getByText(startError, { exact: false }).first(),
).toBeVisible();
const mentionChip = page
.getByTestId("message-row")
.last()
.locator("[data-mention].agent-mention-highlight", { hasText: "portal" });
await expect(mentionChip).toBeVisible();
});

test("a start failure never reports delivery when the send itself fails", async ({
page,
}) => {
// The start notice is emitted before Huddle sync, media upload and the send,
// so it has to stay pre-send tense: a later abort must not leave the user
// with a message that claims the mention went out.
const startError = "agent has no private key available";
const sendError = "Mock send failed.";
await installMockBridge(page, {
managedAgents: [
{
pubkey: OUT_OF_CHANNEL_PROVIDER_AGENT_PUBKEY,
name: "portal",
status: "not_deployed",
channelNames: ["general"],
backend: {
type: "provider",
id: "portal",
config: { region: "test" },
},
},
],
startManagedAgentErrors: [startError],
sendMessageErrors: [sendError],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");

const input = page.getByTestId("message-input");
await input.fill("Hey @portal");

const dropdown = autocomplete(page);
await expect(dropdown.getByText("portal")).toBeVisible();
await input.press("Enter");
await page.keyboard.type(" can you help?");
await page.getByTestId("send-message").click();

await expect(
page.getByText("sending the mention anyway", { exact: false }).first(),
).toBeVisible();
// The send aborted: the composer keeps the draft and nothing reached the
// timeline, so the notice above must not have claimed delivery.
await expect(input).toContainText("portal");
await expect(
page
.getByTestId("message-row")
.locator("[data-mention].agent-mention-highlight", { hasText: "portal" }),
).toHaveCount(0);
await expect(page.getByText(/message sent/i)).toHaveCount(0);
});

test("mentioning a non-member managed agent adds and starts it before sending", async ({
page,
}) => {
Expand Down