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 "status_end" -> _statusText.value = null
"tool_request" -> handleToolRequest(dto.content) "tool_request" -> handleToolRequest(dto.content)
else -> { else -> {
_statusText.value = null
_messages.emit(AssistantMessage(content = dto.content, type = dto.type)) _messages.emit(AssistantMessage(content = dto.content, type = dto.type))
} }
} }

View file

@ -190,13 +190,14 @@ class AssistantManager(
val statusJob = launch { val statusJob = launch {
connection.statusText.collect { label -> connection.statusText.collect { label ->
if (label != null) heartbeat.trySend(Unit) heartbeat.trySend(Unit)
} }
} }
try { try {
_state.update { it.copy(phase = VoicePhase.THINKING, statusText = null) } _state.update { it.copy(phase = VoicePhase.THINKING, statusText = null) }
var hasSpoken = false
while (true) { while (true) {
val result = select<WaitResult> { val result = select<WaitResult> {
speechQueue.onReceive { WaitResult.Message(it) } speechQueue.onReceive { WaitResult.Message(it) }
@ -206,15 +207,21 @@ class AssistantManager(
when (result) { when (result) {
WaitResult.Timeout -> { WaitResult.Timeout -> {
_state.update { if (!hasSpoken) {
it.copy(phase = VoicePhase.ERROR, errorMessage = "Response timed out") _state.update {
it.copy(phase = VoicePhase.ERROR, errorMessage = "Response timed out")
}
delay(2000)
} }
delay(2000)
return@coroutineScope return@coroutineScope
} }
WaitResult.Heartbeat -> continue WaitResult.Heartbeat -> {
if (hasSpoken && connection.statusText.value == null) return@coroutineScope
continue
}
is WaitResult.Message -> { is WaitResult.Message -> {
speakAndDrain(result.content, speechQueue) speakAndDrain(result.content, speechQueue)
hasSpoken = true
val currentStatus = connection.statusText.value val currentStatus = connection.statusText.value
if (currentStatus == null) return@coroutineScope if (currentStatus == null) return@coroutineScope
_state.update { _state.update {
@ -231,7 +238,7 @@ class AssistantManager(
private suspend fun speakAndDrain(firstContent: String, speechQueue: Channel<String>) { private suspend fun speakAndDrain(firstContent: String, speechQueue: Channel<String>) {
val chunks = mutableListOf(firstContent) val chunks = mutableListOf(firstContent)
try { try {
_state.update { it.copy(phase = VoicePhase.SPEAKING, responseText = firstContent) } _state.update { it.copy(phase = VoicePhase.SPEAKING, responseText = firstContent, statusText = null) }
ttsWrapper.speak(firstContent) ttsWrapper.speak(firstContent)
while (true) { while (true) {
val next = speechQueue.tryReceive().getOrNull() ?: break val next = speechQueue.tryReceive().getOrNull() ?: break

View file

@ -296,17 +296,9 @@ func (al *AgentLoop) Run(ctx context.Context) error {
return return
} }
if response != "" { if response != "" {
alreadySent := false al.bus.PublishOutbound(bus.OutboundMessage{
if tool, ok := al.tools.Get("message"); ok { Channel: m.Channel, ChatID: m.ChatID, Content: response,
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,
})
}
} }
}(msg, sessionKey) }(msg, sessionKey)
} }

View file

@ -18,7 +18,6 @@ type MessageTool struct {
sendCallback SendCallback sendCallback SendCallback
defaultChannel string defaultChannel string
defaultChatID string defaultChatID string
sentInRound bool // Tracks whether a message was sent in the current processing round
enabledChannels []string enabledChannels []string
stateResolver StateResolver stateResolver StateResolver
} }
@ -74,12 +73,6 @@ func (t *MessageTool) SetStateResolver(sr StateResolver) {
func (t *MessageTool) SetContext(channel, chatID string) { func (t *MessageTool) SetContext(channel, chatID string) {
t.defaultChannel = channel t.defaultChannel = channel
t.defaultChatID = chatID 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) { 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 // Silent: user already received the message directly
return &ToolResult{ return &ToolResult{
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),