From bf8b433812bb57eeb0c07c926cbc788ae65075d7 Mon Sep 17 00:00:00 2001 From: KoheiYamashita Date: Sun, 22 Feb 2026 16:42:24 +0900 Subject: [PATCH] fix: send final response after message tool use and improve end-of-turn detection The message tool's sentInRound flag was suppressing the LLM's final response when an intermediate message had been sent on the same channel. On the client side, statusText was cleared on every incoming message, making it impossible to distinguish regular messages from status_end (turn completion signal). Server: remove sentInRound suppression so final response is always sent. Client: only clear statusText on status_end, detect end-of-turn via hasSpoken + statusText==null in both Heartbeat and Message handlers. Co-Authored-By: Claude Opus 4.6 --- .../repository/AssistantConnectionImpl.kt | 1 - .../chat/assistant/AssistantManager.kt | 19 +++++++++++++------ pkg/agent/loop.go | 14 +++----------- pkg/tools/message.go | 13 ------------- 4 files changed, 16 insertions(+), 31 deletions(-) diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/AssistantConnectionImpl.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/AssistantConnectionImpl.kt index 3640da070..d4e5e09af 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/AssistantConnectionImpl.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/AssistantConnectionImpl.kt @@ -51,7 +51,6 @@ class AssistantConnectionImpl( "status_end" -> _statusText.value = null "tool_request" -> handleToolRequest(dto.content) else -> { - _statusText.value = null _messages.emit(AssistantMessage(content = dto.content, type = dto.type)) } } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantManager.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantManager.kt index 72a427499..26921e7fe 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantManager.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/assistant/AssistantManager.kt @@ -190,13 +190,14 @@ class AssistantManager( val statusJob = launch { connection.statusText.collect { label -> - if (label != null) heartbeat.trySend(Unit) + heartbeat.trySend(Unit) } } try { _state.update { it.copy(phase = VoicePhase.THINKING, statusText = null) } + var hasSpoken = false while (true) { val result = select { speechQueue.onReceive { WaitResult.Message(it) } @@ -206,15 +207,21 @@ class AssistantManager( when (result) { WaitResult.Timeout -> { - _state.update { - it.copy(phase = VoicePhase.ERROR, errorMessage = "Response timed out") + if (!hasSpoken) { + _state.update { + it.copy(phase = VoicePhase.ERROR, errorMessage = "Response timed out") + } + delay(2000) } - delay(2000) return@coroutineScope } - WaitResult.Heartbeat -> continue + WaitResult.Heartbeat -> { + if (hasSpoken && connection.statusText.value == null) return@coroutineScope + continue + } is WaitResult.Message -> { speakAndDrain(result.content, speechQueue) + hasSpoken = true val currentStatus = connection.statusText.value if (currentStatus == null) return@coroutineScope _state.update { @@ -231,7 +238,7 @@ class AssistantManager( private suspend fun speakAndDrain(firstContent: String, speechQueue: Channel) { val chunks = mutableListOf(firstContent) try { - _state.update { it.copy(phase = VoicePhase.SPEAKING, responseText = firstContent) } + _state.update { it.copy(phase = VoicePhase.SPEAKING, responseText = firstContent, statusText = null) } ttsWrapper.speak(firstContent) while (true) { val next = speechQueue.tryReceive().getOrNull() ?: break diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 17552b545..7a9cd73b3 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -296,17 +296,9 @@ func (al *AgentLoop) Run(ctx context.Context) error { return } if response != "" { - alreadySent := false - if tool, ok := al.tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { - alreadySent = mt.HasSentInRound() - } - } - if !alreadySent { - al.bus.PublishOutbound(bus.OutboundMessage{ - Channel: m.Channel, ChatID: m.ChatID, Content: response, - }) - } + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: m.Channel, ChatID: m.ChatID, Content: response, + }) } }(msg, sessionKey) } diff --git a/pkg/tools/message.go b/pkg/tools/message.go index 21dd332a4..20e1c1a8d 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -18,7 +18,6 @@ type MessageTool struct { sendCallback SendCallback defaultChannel string defaultChatID string - sentInRound bool // Tracks whether a message was sent in the current processing round enabledChannels []string stateResolver StateResolver } @@ -74,12 +73,6 @@ func (t *MessageTool) SetStateResolver(sr StateResolver) { func (t *MessageTool) SetContext(channel, chatID string) { t.defaultChannel = channel t.defaultChatID = chatID - t.sentInRound = false // Reset send tracking for new processing round -} - -// HasSentInRound returns true if the message tool sent a message during the current round. -func (t *MessageTool) HasSentInRound() bool { - return t.sentInRound } func (t *MessageTool) SetSendCallback(callback SendCallback) { @@ -146,12 +139,6 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) } } - // Only mark as "sent in round" when the message went to the originating channel. - // Cross-channel sends (e.g. WS→Discord) must NOT suppress the response - // back to the sender's channel. - if channel == t.defaultChannel { - t.sentInRound = true - } // Silent: user already received the message directly return &ToolResult{ ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),