Skip to content
6 changes: 6 additions & 0 deletions .changeset/convai-widget-queue-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@elevenlabs/convai-widget-core": minor
"@elevenlabs/convai-widget-embed": patch
---

Support concurrency wait-queue (`queue_status`) server events: show a waiting status while all agents are busy, hide the typing indicator and block text/attachment sending while queued, and render a friendly non-error message when the queue wait times out.
103 changes: 80 additions & 23 deletions packages/convai-widget-core/src/contexts/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export type TranscriptEntry =
type: "mode_toggle";
mode: ConversationMode;
conversationIndex: number;
}
| {
type: "queue_timeout";
conversationIndex: number;
};

export function ConversationProvider({ children }: ConversationProviderProps) {
Expand Down Expand Up @@ -189,6 +193,16 @@ function useConversationSetup() {
const conversationTextOnly = signal<boolean | null>(null);
const isAgentTyping = signal(false);
const isExternalAgentMode = signal(false);
const queueStatus = signal<string | null>(null);
// Unknown statuses mean "not held" so a new backend status cannot lock the UI.
const isHeldInQueue = computed(
() => queueStatus.value === "waiting" || queueStatus.value === "timed_out"
);
// "timed_out" still counts as held: the server sends it just before
// closing, so the UI keeps showing waiting until the disconnect lands.
const isWaitingForAgent = computed(
() => isHeldInQueue.value && !isDisconnected.value
);

const setAgentTyping = (typing: boolean, durationMs?: number | null) => {
clearTypingTimer();
Expand All @@ -214,6 +228,8 @@ function useConversationSetup() {
transcript,
isAgentTyping,
isExternalAgentMode,
queueStatus,
isWaitingForAgent,
startSession: async (
element: HTMLElement,
initialMessage?: string,
Expand Down Expand Up @@ -259,6 +275,7 @@ function useConversationSetup() {
}

conversationTextOnly.value = processedConfig.textOnly ?? false;
queueStatus.value = null;
transcript.value = [
...firstMessageEntries(),
...(initialMessage
Expand Down Expand Up @@ -432,7 +449,27 @@ function useConversationSetup() {
setAgentTyping(false);
isExternalAgentMode.value = false;
},
// The SDK forwards unhandled server events to onDebug.
// TODO: drop this narrowing once the SDK handles queue_status
// explicitly (planned after the queue protocol is finalized).
onDebug: (props: unknown) => {
const event = props as {
type?: string;
queue_status_event?: { status?: unknown };
};
if (
event?.type === "queue_status" &&
typeof event.queue_status_event?.status === "string"
) {
queueStatus.value = event.queue_status_event.status;
}
},
onDisconnect: details => {
// A queue timeout closes with an error; show friendly copy
// instead of the raw close reason.
const queueTimedOut =
details.reason === "error" &&
queueStatus.peek() === "timed_out";
receivedFirstMessageRef.current = false;
conversationTextOnly.value = null;
streamingMessageIndexRef.current = null;
Expand All @@ -442,20 +479,25 @@ function useConversationSetup() {
isExternalAgentMode.value = false;
transcript.value = [
...transcript.peek(),
details.reason === "error"
queueTimedOut
? {
type: "error",
message: details.message,
type: "queue_timeout",
conversationIndex: conversationIndex.peek(),
}
: {
type: "disconnection",
role: details.reason === "user" ? "user" : "agent",
conversationIndex: conversationIndex.peek(),
},
: details.reason === "error"
? {
type: "error",
message: details.message,
conversationIndex: conversationIndex.peek(),
}
: {
type: "disconnection",
role: details.reason === "user" ? "user" : "agent",
conversationIndex: conversationIndex.peek(),
},
];
conversationIndex.value++;
if (details.reason === "error") {
if (details.reason === "error" && !queueTimedOut) {
error.value = details.message;
console.error(
"[ConversationalAI] Disconnected due to an error:",
Expand All @@ -481,21 +523,33 @@ function useConversationSetup() {
error.value = null;
return id;
} catch (e) {
let message = "Could not start a conversation.";
if (e instanceof CloseEvent) {
message = e.reason || message;
} else if (e instanceof Error) {
message = e.message || message;
// A queue timeout can close the connection before startSession
// resolves.
if (queueStatus.peek() === "timed_out") {
transcript.value = [
...transcript.value,
{
type: "queue_timeout",
conversationIndex: conversationIndex.peek(),
},
];
} else {
let message = "Could not start a conversation.";
if (e instanceof CloseEvent) {
message = e.reason || message;
} else if (e instanceof Error) {
message = e.message || message;
}
error.value = message;
transcript.value = [
...transcript.value,
{
type: "error",
message,
conversationIndex: conversationIndex.peek(),
},
];
Comment thread
cursor[bot] marked this conversation as resolved.
}
error.value = message;
transcript.value = [
...transcript.value,
{
type: "error",
message,
conversationIndex: conversationIndex.peek(),
},
];
} finally {
lockRef.current = null;
}
Expand All @@ -521,6 +575,8 @@ function useConversationSetup() {
conversationRef.current?.sendFeedback(like);
},
sendUserMessage: (text: string, options?: SendUserMessageOptions) => {
// The orchestrator discards messages sent while held in the queue.
if (isWaitingForAgent.peek()) return;
conversationRef.current?.sendUserMessage(text, options);
transcript.value = [
...transcript.value,
Expand All @@ -537,6 +593,7 @@ function useConversationSetup() {
text?: string;
file: TranscriptFileInput & { fileId: string };
}) => {
if (isWaitingForAgent.peek()) return;
const trimmed = input.text?.trim() ?? "";
const { fileId, ...fileInput } = input.file;
conversationRef.current?.sendMultimodalMessage({
Expand Down
200 changes: 200 additions & 0 deletions packages/convai-widget-core/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,206 @@ describe("elevenlabs-convai", () => {
}
);

describe("concurrency wait queue", () => {
it("shows the waiting status and blocks sending while queued", async () => {
setupWebComponent({
"agent-id": "queued",
transcript: "true",
"text-input": "true",
});

const startButton = page.getByRole("button", { name: "Start a call" });
await startButton.click();
const acceptButton = page.getByRole("button", { name: "Accept" });
await acceptButton.click();

await expect
.element(page.getByText("Waiting for an available agent"))
.toBeInTheDocument();

// The typing indicator stays hidden while queued.
await expect
.element(page.getByText("Agent is typing ..."))
.not.toBeInTheDocument();

// Sending is blocked while held in the queue.
const textInput = page.getByRole("textbox", {
name: "Text message input",
});
await textInput.fill("Queued message");
await expect
.element(page.getByRole("button", { name: "Send", exact: true }))
.toBeDisabled();
await userEvent.keyboard("{Enter}");
await expect
.element(page.getByText("Queued message"))
.not.toBeInTheDocument();

// Hanging up while queued goes through the regular disconnect flow.
const endButton = page.getByRole("button", { name: "End", exact: true });
await endButton.click();
await expect
.element(page.getByText("You ended the conversation"))
.toBeInTheDocument();
await expect
.element(page.getByText("Waiting for an available agent"))
.not.toBeInTheDocument();

// No residual gating: a new conversation can start from the textarea.
await textInput.fill("New text message");
await userEvent.keyboard("{Enter}");
await expect
.element(page.getByText("New text message"))
.toBeInTheDocument();
});

it("disables rich-content buttons and drops bridged sends while queued", async () => {
const widget = setupWebComponent({
"agent-id": "queued",
transcript: "true",
"text-input": "true",
"allow-events": "true",
"default-expanded": "true",
});

// Start via the textarea so the conversation is text-only and rich
// content renders.
const textInput = page.getByRole("textbox", {
name: "Text message input",
});
await textInput.fill("Hello");
await userEvent.keyboard("{Enter}");
const acceptButton = page.getByRole("button", { name: "Accept" });
await acceptButton.click();

await expect
.element(page.getByText("Waiting for an available agent"))
.toBeInTheDocument();

// Rich-content buttons received while queued render disabled.
await expect
.element(page.getByRole("button", { name: "Quick reply" }))
.toBeDisabled();

// Programmatic sends via the event bridge are dropped while queued.
widget.dispatchEvent(
new CustomEvent("elevenlabs-agent:user-message", {
detail: { message: "Bridged message" },
})
);
await expect
.element(page.getByText("Bridged message"))
.not.toBeInTheDocument();
});

it("shows the full waiting message on the avatar overlay", async () => {
// The expanded sheet without a transcript shows the avatar overlay,
// which renders the full waiting copy.
setupWebComponent({ "agent-id": "queued_overlay" });

const startButton = page.getByRole("button", { name: "Start a call" });
await startButton.click();
const acceptButton = page.getByRole("button", { name: "Accept" });
await acceptButton.click();

await expect
.element(page.getByText("All agents are busy right now"))
.toBeInTheDocument();
});

it("keeps the waiting status inside the full trigger", async () => {
// Regression: the long waiting copy must size the trigger, not get
// clipped at the card edge.
setupWebComponent({ "agent-id": "queued", variant: "full" });

const startButton = page.getByRole("button", { name: "Start a call" });
await startButton.click();
const acceptButton = page.getByRole("button", { name: "Accept" });
await acceptButton.click();

const label = page.getByText("Waiting for an available agent");
await expect.element(label).toBeInTheDocument();

const labelElement = label.element() as HTMLElement;
// The full copy is visible, not ellipsized...
expect(labelElement.scrollWidth).toBeLessThanOrEqual(
labelElement.clientWidth + 1
);
// ...and the label stays within the trigger card.
const card = labelElement.closest(".rounded-sheet")!;
const labelRect = labelElement.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
expect(labelRect.right).toBeLessThanOrEqual(cardRect.right);
expect(labelRect.left).toBeGreaterThanOrEqual(cardRect.left);
});

it("returns to the regular flow when admitted from the queue", async () => {
setupWebComponent({
"agent-id": "queue_admit",
transcript: "true",
"text-input": "true",
});

const startButton = page.getByRole("button", { name: "Start a call" });
await startButton.click();
const acceptButton = page.getByRole("button", { name: "Accept" });
await acceptButton.click();

await expect
.element(page.getByText("Waiting for an available agent"))
.toBeInTheDocument();

// Admitted: the waiting status clears and the agent responds.
await expect
.element(page.getByText("Queue cleared response"))
.toBeInTheDocument();
await expect
.element(page.getByText("Waiting for an available agent"))
.not.toBeInTheDocument();

// Sending works again.
const textInput = page.getByRole("textbox", {
name: "Text message input",
});
await textInput.fill("Hello after queue");
await userEvent.keyboard("{Enter}");
await expect
.element(page.getByText("Hello after queue"))
.toBeInTheDocument();
});

it("shows a friendly message when the queue wait times out", async () => {
setupWebComponent({
"agent-id": "queue_timeout",
transcript: "true",
"text-input": "true",
});

const startButton = page.getByRole("button", { name: "Start a call" });
await startButton.click();
const acceptButton = page.getByRole("button", { name: "Accept" });
await acceptButton.click();

await expect
.element(page.getByText("Waiting for an available agent"))
.toBeInTheDocument();

// Friendly copy instead of the raw close reason or error styling.
await expect
.element(
page.getByText("All agents are still busy. Please try again later.")
)
.toBeInTheDocument();
await expect
.element(page.getByText("An error occurred"))
.not.toBeInTheDocument();
await expect
.element(page.getByText("too many concurrent connections"))
.not.toBeInTheDocument();
await expect.element(startButton).toBeInTheDocument();
});
});

describe("expansion events", () => {
it.each(["document", "widget"])(
"should expand and collapse widget when elevenlabs-agent:expand event is dispatched (%s)",
Expand Down
2 changes: 2 additions & 0 deletions packages/convai-widget-core/src/markdown.dev.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ function MockConversationProvider({
transcript: mockTranscript,
isAgentTyping: signal(false),
isExternalAgentMode: signal(false),
queueStatus: signal<string | null>(null),
isWaitingForAgent: signal(false),
startSession: async () => "",
endSession: async () => {},
getInputVolume: () => 0,
Expand Down
Loading
Loading