diff --git a/agent/agent.py b/agent/agent.py index 24a3b9c..5f00453 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -758,6 +758,17 @@ async def handle_job(ctx: JobContext) -> None: ) await ctx.connect() + # The participant can appear in the browser slightly before AgentSession is + # ready to accept lk.chat streams. Explicitly signal readiness so the first + # typed message is never raced against session startup. + try: + await ctx.room.local_participant.publish_data( + json.dumps({"type": "agent_ready"}), + reliable=True, + topic="conversation-control", + ) + except Exception as exc: # noqa: BLE001 + logger.warning("failed to publish agent readiness: %s", exc) logger.info("Agent connected to room %s", ctx.room.name) diff --git a/web/app.js b/web/app.js index 20ac5e5..d84f6e3 100644 --- a/web/app.js +++ b/web/app.js @@ -31,6 +31,7 @@ const MODE_KEY = "hope_conversation_mode"; // ── State ─────────────────────────────────────────────────────────────────── let room = null; let agentParticipant = null; +let agentReady = false; let uiState = "idle"; // idle | connecting | listening | thinking | speaking let micLevel = 0; // 0..1, smoothed let agentAudioLevel = 0; // 0..1, smoothed @@ -518,7 +519,9 @@ function registerTranscriptionStream(rm) { function handleDataPacket(payload, participant, topic) { try { const msg = JSON.parse(new TextDecoder().decode(payload)); - if (msg.type === "transcript") { + if (msg.type === "agent_ready") { + agentReady = true; + } else if (msg.type === "transcript") { addMessage(msg.role || "agent", msg.text); if (conversationMode === "text" && (msg.role || "agent") === "agent") { setState("listening"); @@ -721,6 +724,7 @@ function createRoom(token) { startBtn.disabled = false; stopBtn.disabled = true; agentParticipant = null; + agentReady = false; }); return newRoom.connect(LIVEKIT_URL, token, { @@ -733,6 +737,22 @@ function createRoom(token) { }); } +function waitForAgentReady(timeoutMs) { + return new Promise((resolve) => { + if (agentReady) return resolve(true); + const deadline = Date.now() + timeoutMs; + const timer = setInterval(() => { + if (agentReady) { + clearInterval(timer); + resolve(true); + } else if (Date.now() > deadline) { + clearInterval(timer); + resolve(false); + } + }, 100); + }); +} + function waitForAgent(timeoutMs) { return new Promise((resolve) => { if (findAgent(room)) return resolve(true); @@ -773,6 +793,7 @@ async function connectConversation() { const stale = room; room = null; agentParticipant = null; + agentReady = false; await stale.disconnect(); const freshToken = await fetchToken(ROOM_NAME); room = await createRoom(freshToken); @@ -780,6 +801,10 @@ async function connectConversation() { throw new Error("Assistant did not join — reload the page and try again"); } } + statusEl.textContent = "Hope joined · Finishing setup…"; + if (!(await waitForAgentReady(10000))) { + throw new Error("Assistant joined but was not ready — try again"); + } sendModeMessage(conversationMode); if (conversationMode === "voice") { @@ -870,6 +895,7 @@ stopBtn.addEventListener("click", () => { room.disconnect(); room = null; agentParticipant = null; + agentReady = false; } teardownAudio(); stopVisualLoop();