···1212 summarizeConversationViaLLM,
1313} from "./util.js"
1414import { addAssistantMessage, applyUsage, configuredSummaryProvider, emitThinking, fetchCompletion } from "./loop-completion.js"
1515-import { isFunctionToolCall } from "./loop-content.js"
1515+import { assistantContentText, isFunctionToolCall } from "./loop-content.js"
1616import { buildTurnSignature, hasIncomingUserMessage } from "./loop-signatures.js"
1717import { processToolCalls } from "./loop-tools.js"
1818import type { LoopHooks, LoopState } from "./types.js"
···7676 * conversational text but did not call discord_send. Injects a system
7777 * nudge so the next turn actually delivers the message.
7878 */
7979-function applyDiscordSendNudge(state: LoopState, turnMessages: OpenAI.Chat.ChatCompletionMessage[]): void {
7979+function applyDiscordSendNudge(state: LoopState, turnMessages: OpenAI.Chat.ChatCompletionMessage[]): boolean {
8080 // Check if any incoming user message in this turn came from Discord
8181 const hasDiscordInput = turnMessages.some(
8282 (m) => m.role === "user" && typeof m.content === "string" && /\[discord\/(?:dm|batch|channel)\]/i.test(m.content),
8383 )
8484- if (!hasDiscordInput) return
8484+ if (!hasDiscordInput) return false
85858686 // Check if the assistant called discord_send in this turn
8787 const hasDiscordSend = turnMessages.some(
···9090 Array.isArray(m.tool_calls) &&
9191 m.tool_calls.some((tc) => tc.type === "function" && tc.function.name === "discord_send"),
9292 )
9393- if (hasDiscordSend) return
9393+ if (hasDiscordSend) return false
94949595 // Also check if a tool result from discord_send exists (edge case: tool result is separate message)
9696 const hasDiscordSendResult = turnMessages.some(
···100100 m.content.includes('"ok":true') &&
101101 m.content.includes("discord_send"),
102102 )
103103- if (hasDiscordSendResult) return
103103+ if (hasDiscordSendResult) return false
104104105105 // Find the assistant's text content in this turn
106106- const assistantText = turnMessages.find(
107107- (m) => m.role === "assistant" && typeof m.content === "string" && m.content.trim().length > 0,
108108- )
109109- if (!assistantText || typeof assistantText !== "object") return
106106+ const assistantText = turnMessages.find((m) => m.role === "assistant" && assistantContentText(m.content).length > 0)
107107+ if (!assistantText) return false
110108111109 // The assistant wrote something in response to a Discord message but
112110 // never actually sent it. Nudge.
113111 const nudge = `[system] you wrote a response to a Discord message but did not call discord_send. your message was not delivered. call discord_send now or explicitly decide not to reply.`
114112 console.warn("[runner] discord_send nudge: assistant responded to Discord input without calling discord_send")
115113 state.conversation.push({ role: "user", content: nudge })
114114+ return true
116115}
117116118117function applyContextNudge(state: LoopState): void {
···197196 // a Discord message but forgets to call discord_send. This is a common
198197 // hallucination pattern — the model writes a reply "in its head" and
199198 // then calls wait/rest, leaving the Discord user in silence.
199199+ let discordSendNudged = false
200200 if (outcome !== CycleOutcome.Rest) {
201201- applyDiscordSendNudge(state, turnMessages)
201201+ discordSendNudged = applyDiscordSendNudge(state, turnMessages)
202202 }
203203204204 if (interruptedByUserEvent || !turnSignature) {
···213213214214 if (outcome === CycleOutcome.Rest) return "rest"
215215 if (outcome === CycleOutcome.NoTools) {
216216+ if (discordSendNudged) continue
216217 await hooks.saveSession()
217218 return "silent_complete"
218219 }
···234235 await hooks.saveSession()
235236 }
236237}
238238+239239+export const __loopTest = {
240240+ applyDiscordSendNudge,
241241+}