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 <noreply@anthropic.com>
This commit is contained in:
KoheiYamashita 2026-02-22 16:42:24 +09:00
parent 0524dc7b65
commit bf8b433812
4 changed files with 16 additions and 31 deletions

View file

@ -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))
}
}

View file

@ -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<WaitResult> {
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<String>) {
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

View file

@ -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)
}

View file

@ -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),