From 0e74fd32e68c6f264fbcb10b19499dda53969b68 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 11:52:47 +0000 Subject: [PATCH] fix: agents wait for you to join; the mic always starts a fresh call (0.13.3, qc 0.9.2) Agents left a call 30 s after joining if nobody was there yet, and the mic rejoined any call from the last 30 min, which the agents may already have left; they only join on a new call message, so you could end up talking to nobody. - qc agent call: wait up to 10 min for the first person; once someone has been in the call, leave 30 s after the last person goes. - The mic in a chat with agents always starts a new call (the call message is what brings the agents in). Old call cards still offer Join. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- packages/qryptchat/package.json | 2 +- src/cli/voice-agent.js | 13 +++++++-- src/cli/voice-agent.test.js | 37 ++++++++++++++++++++++++ src/lib/components/chat/MessageInput.jsx | 10 ++----- 5 files changed, 51 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index b602b998..98392129 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.13.2", + "version": "0.13.3", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index d77a8d56..bb0a24c3 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.9.1", + "version": "0.9.2", "description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.", "type": "module", "bin": { diff --git a/src/cli/voice-agent.js b/src/cli/voice-agent.js index 386b05e5..2043f060 100644 --- a/src/cli/voice-agent.js +++ b/src/cli/voice-agent.js @@ -39,7 +39,8 @@ export const e2eeOptions = (key) => ({ }); export const JOIN_RECENT_MS = 30 * 60 * 1000; // on start-up, join calls started this recently -const ALONE_LEAVE_MS = 30 * 1000; // leave once no person has been in the call this long +const ALONE_LEAVE_MS = 30 * 1000; // leave once the people have gone for this long +export const FIRST_WAIT_MS = 10 * 60 * 1000; // wait this long for the first person to join const MAX_CALL_MS = 3 * 60 * 60 * 1000; const RATE = 16000; // what we listen at const SPEAK_RATE = 24000; // what /api/voice/speak returns @@ -131,6 +132,7 @@ export class CallSession { this.closed = false; this.started = Date.now(); this.lastHuman = Date.now(); + this.humanSeen = false; } get id() { @@ -163,8 +165,13 @@ export class CallSession { } checkAlone() { - if (this.humansPresent()) this.lastHuman = Date.now(); - if (Date.now() - this.lastHuman > ALONE_LEAVE_MS) return this.close('everyone left'); + if (this.humansPresent()) { + this.lastHuman = Date.now(); + this.humanSeen = true; + } + // Whoever started the call may still be on their way in: wait for them before giving up. + const limit = this.humanSeen ? ALONE_LEAVE_MS : FIRST_WAIT_MS; + if (Date.now() - this.lastHuman > limit) return this.close(this.humanSeen ? 'everyone left' : 'nobody came'); if (Date.now() - this.started > MAX_CALL_MS) return this.close('call too long'); } diff --git a/src/cli/voice-agent.test.js b/src/cli/voice-agent.test.js index 0f92c3d4..3f86e5ab 100644 --- a/src/cli/voice-agent.test.js +++ b/src/cli/voice-agent.test.js @@ -96,6 +96,43 @@ describe('CallSession', () => { expect(frames).toEqual([]); }); + it('waits for the first person, then leaves 30 s after the last one goes', () => { + const { s } = make('echo PASS'); + const closed = []; + s.close = (why) => closed.push(why); + let people = false; + s.humansPresent = () => people; + const t0 = Date.now(); + const at = (ms) => vi.spyOn(Date, 'now').mockReturnValue(t0 + ms); + s.lastHuman = t0; + at(5 * 60 * 1000); + s.checkAlone(); + expect(closed).toEqual([]); // nobody yet, but well inside 10 minutes + people = true; + s.checkAlone(); + people = false; + at(5 * 60 * 1000 + 20 * 1000); + s.checkAlone(); + expect(closed).toEqual([]); + at(5 * 60 * 1000 + 31 * 1000); + s.checkAlone(); + expect(closed).toEqual(['everyone left']); + vi.restoreAllMocks(); + }); + + it('gives up when nobody comes within 10 minutes', () => { + const { s } = make('echo PASS'); + const closed = []; + s.close = (why) => closed.push(why); + s.humansPresent = () => false; + const t0 = Date.now(); + s.lastHuman = t0; + vi.spyOn(Date, 'now').mockReturnValue(t0 + 10 * 60 * 1000 + 1000); + s.checkAlone(); + expect(closed).toEqual(['nobody came']); + vi.restoreAllMocks(); + }); + it('stays quiet on PASS and stops talking when interrupted', async () => { const { s, frames, sent } = make('echo PASS'); await s.heard(new Float32Array(1600), 'chovy', 'h'); diff --git a/src/lib/components/chat/MessageInput.jsx b/src/lib/components/chat/MessageInput.jsx index d33de88d..fd66a24f 100644 --- a/src/lib/components/chat/MessageInput.jsx +++ b/src/lib/components/chat/MessageInput.jsx @@ -13,7 +13,6 @@ import { newVoiceRoomLink, parseSlashCommand, voiceMessage } from '@/lib/chat/vo import EmojiPicker, { Icon } from './EmojiPicker.jsx'; import { IRC_COMMANDS, listLines, namesLines } from '@/lib/chat/irc.js'; import { agentsIn } from '@/lib/chat/voice-mode.js'; -import { CALL_TYPE, isJoinable, parseCall } from '@/lib/chat/calls.js'; import { useVoiceMode } from '@/lib/hooks/useVoiceMode.js'; import VoiceBar from './VoiceBar.jsx'; import { setBusy } from '@/lib/app-update.js'; @@ -82,15 +81,10 @@ export default function MessageInput({ conversationId, disabled = false }) { const voiceOn = voice.phase !== 'off'; // With AI agents in the chat the mic is a real voice call: they join it, hear - // you and talk back in their own voices (qc agent call). Join the call that is - // already going, if any, else start one. + // you and talk back in their own voices (qc agent call). Always a fresh call: a + // new call message is what brings the agents in, and they may have left an old one. const chatAgents = agentsIn(activeConversation, user?.id); function talkToAgents() { - const recent = [...messages].reverse().find((m) => m.conversation_id === conversationId && m.message_type === CALL_TYPE); - const call = recent && parseCall(recent.content); - if (call && isJoinable(call, recent.created_at) && Date.now() - Date.parse(call.at ?? recent.created_at) < 30 * 60 * 1000) { - return useChatStore.getState().joinCall(conversationId, call.key, false); - } return useChatStore.getState().startCall(conversationId, { video: false }); }