Skip to content

fix(realtime): 连续对话重复回复、结束对话卡死与播放卡顿修复 - #406

Merged
LUPENGHAN merged 21 commits into
1024XEngineer:mainfrom
LUPENGHAN:fix/realtime-duplicate-reply
Aug 28, 2026
Merged

fix(realtime): 连续对话重复回复、结束对话卡死与播放卡顿修复#406
LUPENGHAN merged 21 commits into
1024XEngineer:mainfrom
LUPENGHAN:fix/realtime-duplicate-reply

Conversation

@LUPENGHAN

Copy link
Copy Markdown
Contributor

关联 Issue

Closes #405

改动

  • 后端:转写与回复不再按到达顺序猜配对,改为按 vendor 的 item_idturn_id)精确匹配,从根上解决重复/错位气泡
  • 后端:response.cancel 与 vendor 自然收尾竞态时不再误判为致命错误、挂断整通电话
  • 后端:agent 提前退出时网关也会回收音频流,避免整条 WebSocket 连接被卡死
  • 后端:复用 vendor 会话时清掉上一通电话残留的流状态
  • 后端:一次删除确认覆盖整批删除;消歧提问不再让模型抄一遍候选日程原文(省 7~11 秒)
  • 后端:说了哪天但没说几点时改为主动询问,不再自行套用默认整点
  • 后端:日志补上用户转写内容与工具调用参数,便于后续排查
  • 前端:结束对话时任一原生调用超过 1 秒未响应即放弃等待,避免挂断卡死连带播放链路哑掉
  • 前端:打断后不再把上一句的音频残片写进已经停掉的播放流
  • 前端:连续对话收到服务端报错时真正挂断,不再只显示一条报错却仍占着通话
  • 前端:播放期间合并高频通知,减少与音频写入抢占 JS 线程
  • 前端:开播前攒够约 200ms 音频再交给原生播放器,消除每条回复开头的起跑断音

详细背景、方案取舍见关联 Issue。

验证

  • 后端 pytest(覆盖率 ≥95%)、ruff checkmypy strict 全绿
  • 前端 npm run check(eslint / prettier / tsc)、jestvitest 全绿
  • 真机连续通话多轮人工验证:含主动打断、结束对话、消歧删除;对照后端日志确认时序符合预期

本轮不含(见 Issue Out of Scope)

  • 通话中途主动轮换 vendor 会话
  • 空流回错误信封的静默处理
  • 工具被拒绝时补充诊断日志
  • 前端定位取值的预热与缓存窗口放宽

记录每次回复开始(新 reply_id)、每次为兑现工具调用发出的补发 response.create,以及 vendor 实际报告的 response.created 次数是否超过我们主动发出的次数,方便下次复现时对着日志定位是不是 vendor 自己多起了一次 response。
日志实锤:一次提问里,vendor 在我们没有发 response.create、用户也没有新开口的情况下,自己又起了两次 response,各自带新 reply_id 把答案重新说一遍,客户端于是把它们当成"抢跑的下一轮"开出新气泡,造成回复重复。

现在只有真的检测到用户开口(speech_started)或工具调用要求续答时才认为下一个 response.created 合法,其余一律当场 response.cancel 掉,内容不会传到客户端。PTT 模式的 vendor turn_detection 本来就是关的,不受影响,没有改动。

顺手补了几条被这次改动影响的既有测试(之前没建模真实场景里 response.created 前一定有 speech_started 这一步)。
PTT 里模型会把一次回答拆成"说话→调用工具→说话"好几段独立 response,导致同一个回复气泡的文字被后一段整体顶替、来回跳变。根因是 1024XEngineer#383 加的"这一步不用开口"只绑在改/删前的 schedule_query 定位这一句话末尾,纯查询、新建、搜地点都没管到。现在拆成单独一条规则,覆盖所有工具调用,conservative/aggressive 两个 prompt 变体都改了。
审查完整条链路后发现的两个结构性风险,实测复现后修:

- canceledAudioId 原来是单个变量,连续两次打断前后脚发生、第一条还没等到自己的 tts.end 就来了第二条,第一条会被覆盖掉——它自己迟到的 tts.end 就会被误判成正常收尾,多余触发一次 endStream()。跟 PTT 的 abandonedAudioId 是同一个 bug,改成 Set 同款修法。顺带发现 voice.tts.start 会把这个记录清空,同样会提前冲掉还没等到 tts.end 的取消记录,一并改掉。
- writeTurnReply() 判断"是不是新一轮"完全靠到达顺序(lastTurnReplyDone 这个标志位),没有身份可查。加一道防御:新消息的 reply_id 如果跟刚收尾那条完全一样,判定为迟到/重复投递,直接丢弃,不开占位轮——防的是跟 vendor 凭空多起 response 那次实测一样的重复气泡症状,即使后端那次的根因已经堵了,这里也不该只靠到达顺序这一个依据。

两条改动都补了探针测试,改前先跑失败确认复现,再改代码让它通过。
cancelTurn()/dispose() 只调用 connection.close(),但语音这条连接是共享的 AuthenticatedWebSocketClient,close() 只解绑本地监听,不会真正断开 WebSocket。服务端只认 voice.stream.end 来清掉这个 session 的活跃流标记,收不到就会把它当成永远活跃,后续同一条连接上的每一次 voice.stream.start 都会被拒绝——而且这条拒绝路径原来完全不打日志。上滑取消手势最容易触发(先录音一段再取消,流已经在服务端注册成活跃),实测复现:上滑取消一次后,之后每次按住说话都完全没反应。

改法:cancelTurn()/dispose() 在真的有一条活跃流时先发 voice.stream.end 再收尾;给"已有活跃流"这条拒绝加日志,带上 session_id 和卡住的 stream_id,方便以后直接从日志定位。
1024XEngineer#383 为了压 token 成本删掉的说明句/示例(end_conversation 触发词全文、几处"不要反问/不要凭空加"的兜底说明、地点消歧那句"不能只说一句就完事")实测下来是真的会影响模型表现,不是可以白删的冗余。恢复全部内容,只保留这次会话里新加的那条规则(调用工具前不开口,已扩到所有工具调用)。顺手删掉 _AGGRESSIVE/_ROLE_AGGRESSIVE 那套 A/B 精简实验脚手架——方向已经反过来了,用不上。
审查 composed 模式(intelligence/conversation/agent.py)后发现 realtime 少了两处硬性拦截,之前完全靠 prompt 善意提醒,模型不遵守就真的执行了:

- 删除授权:schedule_delete 之前必须紧跟一次 confirmation 或 recurrence_scope 类型的 request_user_input 才允许执行,否则代码直接拒绝退回提示。问了别的类型问题会让授权失效,执行一次即消耗,不会跨多次删除复用。跟 composed 的 _delete_authorized 同一个思路,但拿不到用户回答本身(vendor 自己处理对话理解),只能保证"确实问过",没法核实回答是不是肯定。
- 忘记挂断兜底:一段回复全程没调用任何工具、又说了"再见/拜拜/先这样/就这样"这类道别词,即使模型忘了调 end_conversation 也强制挂断,跟 composed 的同名兜底逻辑一致。

补了对应的探针测试。
用户说「闯姐明天的会议」,模型直接建了明天早上 9 点的日程——「没说开始时间就
用下一个整点」这条规则本来只为「连哪天都没说」准备,被套用到了指定了日期的
场景上。改成只有完全没提日期时才用下一个整点;说了具体哪天却没说几点,调
request_user_input 问清楚再建。
删除授权闸门原来在每次 schedule_delete 成功后就把授权清掉,于是「把这三条都
删了」只有第一条能删,后两条被拒绝并要求重新确认。composed 那边的
_delete_authorized 是按轮判定、不逐条消耗的,这里对齐:授权只在问了别的
kind 的问题时才失效。
拦截 vendor 凭空多起的 response 时会发一条 response.cancel。如果 vendor 恰好
已经自己把那条 response 收尾了,它回的是一条 error 事件(Conversation has no
active response)——而 _pump_continuous 把任何 error 都当致命失败,连续模式的
failed() 又会强制 _ends_conversation,于是一次本该无害的空取消把整通电话挂掉。

这条错误只可能是 response.cancel 的回执,语义就是"没东西可取消",不代表会话
坏了。豁免一直保持到真的有新 response 开始才清:vendor 已经收尾正是让它的
response.done 夹在 cancel 和 error 中间的原因,只管紧邻下一个事件的话,豁免
会被那条 response.done 吃掉,永远够不着它本来要挡的那条 error。

无关的 error 照旧致命,测试里单独钉住。
一条 vendor 会话会被同一个 conversation 的下一次开麦复用,但 _pump_continuous
只重置了局部变量,几个实例字段带着上一通电话的答案进入新一轮:

- _expecting_response 残留 true,新通话第一个不请自来的 response 直接放行,
  正是这道闸门要拦的重复回复,偏偏在没人盯的那一轮溜过去。
- _playable_until / _reply_bytes 残留,新通话第一次开口被当成对上一通电话某条
  回复的打断,一接通就给客户端发一条 voice.tts.canceled。

一通电话内部 suppressed 会把过期的播放估算挡住,跨电话没有任何东西挡。
已经请求、还没拿到的 follow-up 是唯一可以合法跨过这个循环的期待,按它派生。
_drain_to_sink 原来只在 consume() 抛异常时回收流。但 agent 也可能安静地返回:
realtime 在开不出 vendor 会话时就是 logger.exception 之后直接 return,pump 因
vendor 报错提前结束时从网关看也是一样的。这两种情况下 consume() 成功返回、异常
分支不会跑,可队列同样没人取了。

于是这条流还挂在 _active_streams 里:客户端继续推流,接收循环在队列满 32 块
(100ms 一块 = 3.2 秒)之后阻塞,整条连接再也读不到任何帧——包括客户端唯一的
出路 voice.stream.end。表现是按住说话按下去完全没反应、连续通话一直"正在听"
却连结束对话都点不动。

改成不管 consume() 怎么结束都回收;正常路径下 voice.stream.end 早就把流摘走了,
这时是空跑。真的回收掉一条时打 warning,日志里能看见。
pushChunk 把一块 PCM 切成若干片依次喂给原生播放器,每片都重新读 this.streamId。
打断的真实时序是 stop() 在上一片还卡在原生桥上时到达:stop() 把 streamId 置 null
并清空播放器,随后那个循环继续把剩下的片写进去,而且带的是 null——等于用一条野生
的流,把刚被打断那句的尾巴(最长约 400ms)放出来。

改成开头捕获这条流的 id,每片之前确认它还是当前那条,不是就停手。

顺带修一个让这道判断形同虚设的问题:流 id 原来是 assistant-tts-${Date.now()},
两条回复前后脚开流会拿到同一个毫秒、同一个 id,原生播放器也没法靠它把两条流分开。
加自增后缀。
原来只把 phase 设成 error 就完事:麦克风还开着、音频帧还在往一条服务端已经回收
掉的流上发,服务端就一帧回一条错误(100ms 一条),用户看到"出错了"却发现通话还
在继续,只能自己想起来去点结束对话。而且不发 voice.stream.end 的话服务端那条流
还挂着,下一次 voice.stream.start 会被当成"已有活跃流"直接拒绝。

endTurn 抽出 hangUp(finalState, stopPlayback),新增的 abortCall 走完全一样的收尾
(关麦、放掉服务端的流、断监听),只是终态是带原因的 error 而不是悄悄归 idle——
让用户看得见为什么断的。收尾途中到达的报错被 endTurnInFlight 挡掉,不会把已经
归位的状态再掰回去。
排查这条链路时日志里只有 tool=schedule_create ms=5.3 和 token 数:用户到底说了
什么、模型建的是几点的日程,全都看不到,只能靠 vad_ms、output_text 这些间接
数字反推,连续几次排查都卡在这里。

两条 INFO:
- heard():正常转写原样打出来(原来只有空转写才打一行)。一条回复没有转写跟
  在前面时,现在能分清是用户没说话、vendor 漏发了转写事件、还是这条回复根本
  不属于这一轮——以前这三种在日志里长得一模一样。
- tool_requested():调用前打参数,不并进后面那条 executed 行。run() 会抛,也
  可能卡住,而这两种情况恰恰最需要参数;只有 requested 没有 executed 的那一对
  就指出是哪一次调用挂了。

两条都用字符串插值而不是 extra=:本项目的日志格式串不引用 extra 字段,走那条
路会一个字都打不出来(这个坑本模块已经踩过两次,见 failed 和 usage_reported
的注释)。测试把这一点钉住了——改回 extra= 就会红。

注意:转写内容和日程标题从此会进日志。
新加的转写日志把这个坐实了:vendor 的 transcription.completed 就是在回复开始
之后才发的,有时甚至晚于 response.done。

  02:19:31,103 realtime turn started a new spoken reply: reply_id=reply_45e9...
  02:19:31,338 realtime response usage: ...            ← response.done
  02:19:31,499 realtime heard the user say: '明天我要去看电影。'

也就是说"回复先于任何 transcript 到达"根本不是代码注释里写的"正常流程不会
发生",而是每通电话第一轮的常态。原来这种回复被挂进一个单独的零散列表,那个
列表 getMessages() 里永远拼在最后、也永远不会被随后到达的转写认领,于是:

- 回复的头几个分片进零散列表、转写到达后剩下的分片进新开的那一轮,同一句话
  变成两个气泡;
- 零散列表只在 startTurn() 清空,那条气泡整通电话都钉在对话最底下——用户描述
  的"有一条一直存在",实测截图里它排在后面好几轮之后。

改成走已有的占位轮:turns 为空时开一个 transcript 为空的轮次,转写到了由
voice.asr.completed 现成的补全分支填进同一轮。这条路径本来就是为"转写晚到"
写的,只是入口少判了一种情况。零散列表连同两个 upsert 方法一起删掉。

顺带把空白判定从零散列表那条路提到 getMessages() 里统一做:流式回复的第一条
分片可能只有空白,渲染出来是个空气泡。
抓了一通真实通话的原始 vendor 帧,确认它给了可以关联的 id:每段用户语音有一个
item_id,speech_started / input_audio_buffer.committed / 转写事件上都带着它。

  speech_started            item_id = item_p7jyQlJ...
  input_audio_buffer.committed  item_id = item_p7jyQlJ...   ← 紧挨着 response.created
  response.created
  response.audio_transcript.delta ...                       ← 回复先开始了
  transcription.completed   item_id = item_p7jyQlJ...       ← 转写晚 200ms 才到

前端原来只能按到达顺序归属轮次,而这个顺序在真实时序下经常反过来。已经为此打过
两轮补丁,每次都只挡住其中一种排列。实测还剩一种挡不住的:两轮的回复都赶在任何
一条转写之前完成时,第一条转写会被填进较新的那一轮——问题A配上回复B、回复A丢掉
提问、问题B丢掉回复,三条全错位。这个顺序没有任何启发式能猜对。

改成把 vendor 这个 id 一路带到客户端:voice.asr.completed / voice.dialogue.reply /
voice.dialogue.question 三个 payload 各加一个可选 turn_id,前端按它 upsert 轮次,
两半谁先到都能找回同一轮。

工具调用那条路单独传 id 而不是复用 spoke() 记下的:一次工具调用发生在这一轮说出
任何字之前,沿用上一次记下的值会把 request_user_input 的追问归到上一轮去——那比
不带 id 更糟。

协议上是三个可选字段,默认 null,老客户端忽略。composed 后端拿不到 vendor 的 id,
字段恒为 null,前端按到达顺序那套原样保留作为兜底,并有测试钉住它没被改坏。
原生播放器写完一块 PCM 会空等这块时长的 50%(见 ExpoAudioPlayback 的注释),
100ms 的块就是 50ms 空窗,而 AudioTrack 的流式缓冲只有几十毫秒。也就是说 JS
线程只要卡住超过大约 50ms,缓冲就被抽干,就能听见一个空隙。

播放期间同一条线程上同时在跑的:

- vendor 的回复文字逐词下发,抓到的原始帧里一句话约 20 条、间隔 12~16ms,每条
  都通知一次 = 每条都整屏重渲染(getMessages() 重建数组 + 铺一遍 ScrollView)
- 麦克风音量 100ms 一次,连续模式放 TTS 时麦克风也开着,又是一次整屏重渲染
- 音频帧本身要切块、base64、过桥——这才是必须准时的活

而且文字分片和音频帧走同一条 WebSocket、同一个接收回调,那 20 条 JSON 正好插在
音频帧中间到达。

两处改动,都不增加任何延迟:

- 流式分片合并到最多每 50ms 通知一次。合并的只是"通知界面重画",状态本身仍是
  同步更新的(getMessages()/getReplyText() 立刻就是新值),所以没人会读到旧数据,
  文字看起来照样是流式的。收尾那条 done=true 不合并,最终文案立刻落地。
- 正在放 TTS 时不再为麦克风音量刷界面:那时候光球显示的是麦克风能量,本来就没有
  意义。音量照常记着,下一次真正的状态变化会把它一起带出去。

没有先做"播放前攒一段缓冲"那条:它能造出缓冲垫,但要拿首字延迟去换,跟另一个
"第一句回答慢"的问题直接冲突。这两处是纯赚的,先做完再看还剩多少。
实测症状:结束对话按了没反应,而且那之后 TTS 也不播了。两件事是同一个根。

hangUp 里清 endTurnInFlight 的 finally 只盖住最后一个 try,前面还有两个裸 await
(capture.stop、stopPlaybackImmediately)。原生桥调用失败会抛、catch 得住,卡住
不返回不会——一卡住就永远走不到后面的收尾,endTurnInFlight 一直是 true,之后每
一次按都被 hangUp 开头那道门槛直接挡掉。按钮从此失效。

同一次卡住还会把 playbackChain 换成一个永远不会 settle 的 promise,之后每一块
音频都排在它后面,这通电话再不出声。一个卡住的 stopAudio 同时解释两个症状。

至于为什么会卡住:连续模式的 stopPlaybackImmediately 是绕过队列直接调
playback.stop() 的,跟在途 pushChunk 正在进行的原生写入并发。按住说话那边的同名
方法特意排了队,注释里点名过这个危险;连续模式这边没排。原来绕过队列的理由写的
是"否则已排队的 PCM 会先继续喂给播放器"——那个理由不成立,代次检查早就把排队的
都作废了。

三处改动:

- hangUp 整段收进一个 try/finally,endTurnInFlight 必定清掉。
- 两个原生调用都用 settleWithin 限时 1 秒。超时不代表原生真的结束了,只代表我们
  不再等它、把控制权还给用户。
- stopPlaybackImmediately 排回 playbackChain 末尾,不再跟在途写入并发。排队的成本
  现在很低:pushChunk 每块之前会确认这条流还在不在,代次一变就当场停手。

三条新测试分别钉住:播放器不应答 stop 时仍能挂断、录音器不应答 stop 时仍能挂断、
一次卡住的 stop 不会让后面的 TTS 全部哑掉。两条既有测试跟着改了——它们钉的是
"stop 绕过队列立刻执行"这个正是要改掉的行为,保证没变,时机变了。
实测一次「删除这个日程」的链条:

  08:32:12,809  听到「删除这个日程。」
  08:32:12,851  schedule_query          执行 3.1ms
  08:32:20,830  request_user_input      response_ms=7438,输出 477 token
  08:32:25,243  request_user_input 又一次 response_ms=3978,输出 259 token
  08:32:25,530  才开始说话

从用户说完到模型开口 12.7 秒,其中 11.4 秒花在生成 candidates 上。它把
schedule_query 刚返回给它的两条快照一个字段不落地又逐 token 写了一遍,包括
revision、reminder_trigger_at、starts_at_local 这些跟「你要删哪个」毫无关系的字段。

生成两遍是因为第一次写成了 JSON 字符串而不是数组,_candidates 只认 list,走了
「ambiguous_target 必须提供非空 candidates」的拒绝分支,模型重来一遍又花 4 秒。

而这个字段没有任何客户端读:全前端搜下来 candidates 只出现在类型定义里,
voice.dialogue.question 的处理只用 speech_text。prompt 里那句「客户端要靠它把选项
列出来给用户点」是错的,而且跟地点那一节自己写的「客户端不弹候选卡片,用户只能靠
听你说的话来选」直接矛盾。

改动:request_user_input 的 schema 去掉 candidates 参数,_ask 不再要求也不再读它,
_candidates helper 一并删掉。prompt 两处(日程消歧、地点消歧)改成统一按地点那套
已经验证有效的写法——把选项在 speech_text 里说清楚、报出顺序。实测用户回答「第二个」
本来就是靠听来选的,这条路一直在正常工作。

协议字段保留不动:composed 那条路仍然在填它,而且那是 5 号的范围。这个 agent 从此
恒定发空数组。
读了原生模块的源码(AudioPlaybackManager)才看明白卡在哪,之前两轮都在猜:

- 它的队列是 Channel.UNLIMITED,数据一旦交过去就跟 JS 无关了。所以「JS 线程被
  渲染占满导致断音」这个判断是高估的——只要队列里有存货,JS 卡多久都不断。
- 但播放循环是「阻塞写一块 → 空等这块时长的 50% → 再取下一块」。稳态下供给和
  消费刚好打平,而 AudioTrack 的缓冲(minBufferSize*2)只有几十毫秒,余量薄到
  几乎贴着底线。队列一见底就是一个听得见的空隙。

队列什么时候最薄:每条回复刚开始那几百毫秒。第一帧到了就立刻开播,下一帧稍微
晚一点就断;往后 vendor 生成快于播放,队列攒起来就再也断不了。这解释了为什么
症状是「开头」「有时候」——每条回复开头都是脆弱窗口,只是不一定每次都撞上。

所以要补的是起跑余量,不是在 JS 里囤数据。开播前先攒够 200ms 再一次性交过去,
播放循环一开始就有存货。代价是首字音频晚 200ms,相对整轮 ~2 秒约 10%;只有
PREBUFFER_MS 一个常量,还听得到断音就调大,嫌慢就调小。

两个边界必须处理,否则会丢音:
- 「好的。」这种短回复整条都可能不到门槛,endStream 时要把攒着的交出去,不然
  这句一个字都不会响。
- 打断时攒着的那段属于被放弃的那条回复,stop 要丢掉,不能留到下一条流补播。
@LUPENGHAN
LUPENGHAN marked this pull request as ready for review August 28, 2026 02:20
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.66667% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ackend/src/timeflow/intelligence/realtime/agent.py 89.47% 1 Missing and 1 partial ⚠️
...low/infrastructure/external/realtime/qwen_audio.py 97.95% 0 Missing and 1 partial ⚠️
Flag Coverage Δ
backend 96.57% <96.73%> (-0.03%) ⬇️
frontend 90.71% <100.00%> (+0.32%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...kend/src/timeflow/gateway/websocket/agent_ports.py 100.00% <100.00%> (ø)
...imeflow/gateway/websocket/handlers/agent_result.py 100.00% <ø> (ø)
...imeflow/gateway/websocket/handlers/voice_stream.py 96.08% <100.00%> (+0.60%) ⬆️
...d/src/timeflow/gateway/websocket/messages/agent.py 100.00% <100.00%> (ø)
...rc/timeflow/gateway/websocket/messages/dialogue.py 100.00% <100.00%> (ø)
backend/src/timeflow/intelligence/ports.py 100.00% <100.00%> (ø)
...src/timeflow/intelligence/realtime/instructions.py 60.00% <100.00%> (-9.24%) ⬇️
...ackend/src/timeflow/intelligence/realtime/ports.py 100.00% <100.00%> (ø)
...c/timeflow/intelligence/realtime/schedule_tools.py 94.64% <100.00%> (-0.10%) ⬇️
frontend/src/contracts/conversation.ts 100.00% <ø> (ø)
... and 5 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gac0812 gac0812 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the complete fixed diff across the realtime backend, websocket protocol, and continuous/press-to-talk audio lifecycle. The turn pairing and teardown changes are coherent, but the realtime delete guard is not scoped to the user turn that authorized it, leaving an unsafe path for later deletes in the same held session. Targeted automated validation was not runnable in this workspace because the backend test runner and frontend dependencies are unavailable.

Comment thread backend/src/timeflow/intelligence/realtime/schedule_tools.py
@LUPENGHAN
LUPENGHAN merged commit 59fd59d into 1024XEngineer:main Aug 28, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

连续语音对话的重复回复、结束对话卡死与音频卡顿排查修复

2 participants