diff --git a/devlog/2026-08-05_debug-nudge-phantom-loop/REQ.md b/devlog/2026-08-05_debug-nudge-phantom-loop/REQ.md new file mode 100644 index 00000000..616e05a7 --- /dev/null +++ b/devlog/2026-08-05_debug-nudge-phantom-loop/REQ.md @@ -0,0 +1,20 @@ +# REQ — Debug Nudge Phantom Turn Loop + +## Problem + +When `config.debug: true`, ACP's debug notifications used `sendIgnoredMessage()` to persist nudge text and compress notifications to the conversation DB as `ignored: true` user messages. opencode's runtime loop detects the last user message by `role == "user"` WITHOUT checking the `ignored` flag, so the phantom user message triggers a new turn → model keeps working → calls compress → another notification → infinite loop. + +## Evidence + +From issue #20 floor 1734 (awork investigation): +- 867 messages in session, 10 compress calls, 10 `ignored: true` user messages +- Each phantom user message made the runtime loop continue +- Session eventually stopped but was stuck in a loop for extended period + +## Fix + +Remove ALL `sendIgnoredMessage` calls from debug-mode notification paths: +1. `hooks.ts:205-232` — Debug nudge callback: removed `sendIgnoredMessage`, kept `logger.debug` + `showToast` +2. `notification.ts:281-285` — Debug compress notification: removed `sendIgnoredMessage`, kept `showToast` + added `logger.debug` + +Both sites now use `logger.debug` (file log at `~/.config/opencode/logs/acp/`) + `showToast` (5-second popup) for debug visibility, without writing to the conversation DB. diff --git a/devlog/2026-08-05_debug-nudge-phantom-loop/WORKLOG.md b/devlog/2026-08-05_debug-nudge-phantom-loop/WORKLOG.md new file mode 100644 index 00000000..b3844387 --- /dev/null +++ b/devlog/2026-08-05_debug-nudge-phantom-loop/WORKLOG.md @@ -0,0 +1,13 @@ +# WORKLOG — Debug Nudge Phantom Turn Loop + +## Changes + +1. `lib/hooks.ts`: Removed `sendIgnoredMessage` from debug nudge callback (lines 205-220). Removed unused import (line 40). Kept `logger.debug` + `showToast`. +2. `lib/ui/notification.ts`: Replaced `sendIgnoredMessage` call with `logger.debug` in debug compress notification block (lines 281-285). + +## Verification + +- TypeScript: 0 errors +- Tests: 954 pass, 0 fail +- Build: 386.21 KB +- Deployed to `~/.cache/opencode/packages/opencode-acp@latest/` diff --git a/lib/hooks.ts b/lib/hooks.ts index 12b4b481..f576d455 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -37,7 +37,6 @@ import { applyMessageFilters } from "./messages/filter/apply" import { ensureBuiltinFiltersRegistered } from "./messages/filter/builtin" import { createSessionState, saveSessionState, syncToolCache, updatePerTurnState, type SessionStateRegistry } from "./state" import { cacheSystemPromptTokens } from "./ui/utils" -import { sendIgnoredMessage } from "./ui/notification" import { runBatchCleanup } from "./gc/merge" import { getCurrentTokenUsage } from "./token-utils" @@ -203,22 +202,11 @@ export function createChatMessageTransformHandler( compressionPriorities, config.debug ? (text: string) => { + // sendIgnoredMessage writes an ignored:true user msg to DB. + // opencode's runtime loop detects it as "last user" (role-only, + // ignores the flag) → phantom turn → compress → notification → + // infinite loop. Use logger.debug + toast instead. logger.debug(`[ACP Debug] Nudge injected:\n${text}`) - if (state.sessionId && lastUserMessage) { - const userInfo = lastUserMessage.info as any - sendIgnoredMessage( - client, - state.sessionId, - `[ACP Debug Nudge]\n${text}`, - { - providerId: userInfo.model?.providerID, - modelId: userInfo.model?.modelID, - agent: userInfo.agent, - variant: userInfo.variant, - }, - logger, - ).catch(() => {}) - } client.tui .showToast({ body: { diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index b1651c89..2676b970 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -278,10 +278,11 @@ export async function sendCompressNotification( toastMessage = config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage) + // [DEBUG] Use toast + logger only. Do NOT use sendIgnoredMessage — it writes + // an ignored:true user msg to DB that opencode's runtime loop detects as + // "last user" (role-only) → phantom turn → infinite loop (issue #20 floor 1734). if (config.debug) { - const chatMessage = - config.pruneNotification === "minimal" ? message : truncateToastBody(message) - await sendIgnoredMessage(client, sessionId, chatMessage, params, logger) + logger.debug(`[ACP Debug] Compress notification:\n${message}`) } await client.tui.showToast({