From 3c99d71630a2ff74d89d48d4d8908c939f2d7b4d Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 2 Oct 2026 01:47:53 +0300 Subject: [PATCH] fix(manual): release a manual trigger that produced no compress Closes #490 ## The reported symptom Running `/dcp compress` showed > Manual mode: compress blocked. Do not retry until `` > appears in user context. even though the trigger came from the user, and the compress then ran anyway. On the current code that ordering is already gone: the command handler sets `manualMode = "compress-pending"`, `refreshManualMode` early-returns on that value in both `prepareSession` and `checkSession`, and the red line can only fire when the flag is *not* `compress-pending`. So the primary symptom does not reproduce here any more, and `tests/manual-trigger-lifecycle.test.ts` pins that down rather than leaving it to chance. ## The bug that was still there `compress-pending` was only ever cleared by `finalizeSession`, i.e. by a compress call actually happening. If the model answered the trigger without calling compress - which models do routinely, and which the report itself observed - the session stayed pinned in `compress-pending` for the rest of its life: - `refreshManualMode` kept early-returning, so the persisted manual-mode setting was never read again - `injectCompressNudges` returns early on any truthy `manualMode`, so the automatic context-limit, turn and iteration nudges were switched off permanently, and a later `/dcp compress` was the only way out ## Change - `PendingManualTrigger` records `restoreTo`: the manual mode that was in effect before the command flipped it to `compress-pending`. - `applyPendingManualTrigger` moves that into `state.consumedManualTrigger` once the prompt is written, together with the id of the user message it was written into. - When a later transform arrives with no pending trigger and the last user message is no longer that one, the flag is released and `restoreTo` is put back. So the trigger authorises exactly the one compress call that follows it. - `finalizeSession` clears `consumedManualTrigger` on the success path, so a compress that *did* run is unaffected. - `resetSessionState` clears it too. The red-line message text is unchanged; it is referenced from the compress prompt and the fix is that it no longer appears. ## Verification `tests/manual-trigger-lifecycle.test.ts`, seven cases. The three lifecycle ones fail on the current implementation - `manualMode` stays `"compress-pending"` and `consumedManualTrigger` does not exist. - the trigger is written into the command turn - an unused trigger is released on the next turn - **manual mode ON is restored, not dropped**, when the trigger goes unused - the trigger survives the turn that armed it, so the following compress works - the success path retires the bookkeeping - no trigger armed leaves state untouched Full suite 131 passing, `tsc --noEmit` clean, `prettier --check` clean. --- lib/commands/manual.ts | 37 ++++++ lib/compress/pipeline.ts | 1 + lib/hooks.ts | 3 + lib/state/state.ts | 4 +- lib/state/types.ts | 16 +++ tests/manual-trigger-lifecycle.test.ts | 149 +++++++++++++++++++++++++ 6 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 tests/manual-trigger-lifecycle.test.ts diff --git a/lib/commands/manual.ts b/lib/commands/manual.ts index b871efd4..025d6162 100644 --- a/lib/commands/manual.ts +++ b/lib/commands/manual.ts @@ -97,6 +97,7 @@ export function applyPendingManualTrigger( ): void { const pending = state.pendingManualTrigger if (!pending) { + releaseStaleManualTrigger(state, messages, logger) return } @@ -118,6 +119,10 @@ export function applyPendingManualTrigger( part.text = pending.prompt state.pendingManualTrigger = null + state.consumedManualTrigger = { + messageId: msg.info.id, + restoreTo: pending.restoreTo, + } logger.debug("Applied manual prompt", { sessionId: pending.sessionId }) return } @@ -125,3 +130,35 @@ export function applyPendingManualTrigger( state.pendingManualTrigger = null } + +// A manual trigger only authorises the compress call that follows it. If the +// model answers without calling compress, the session must go back to whatever +// manual mode it was in, otherwise compress-pending sticks and the automatic +// nudges stay switched off for the rest of the session. +function releaseStaleManualTrigger( + state: SessionState, + messages: WithParts[], + logger: Logger, +): void { + const consumed = state.consumedManualTrigger + if (!consumed || state.manualMode !== "compress-pending") { + return + } + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) { + continue + } + if (msg.info.id === consumed.messageId) { + return + } + break + } + + state.manualMode = consumed.restoreTo + state.consumedManualTrigger = null + logger.debug("Released manual trigger that produced no compress", { + restored: consumed.restoreTo, + }) +} diff --git a/lib/compress/pipeline.ts b/lib/compress/pipeline.ts index cddb2be4..4c9f15ad 100644 --- a/lib/compress/pipeline.ts +++ b/lib/compress/pipeline.ts @@ -87,6 +87,7 @@ export async function finalizeSession( ): Promise { if (ctx.state.manualMode === "compress-pending") { ctx.state.manualMode = false + ctx.state.consumedManualTrigger = null await refreshManualMode( ctx.state, toolCtx.sessionID, diff --git a/lib/hooks.ts b/lib/hooks.ts index 6d6f3862..2e65b563 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -247,10 +247,13 @@ export function createCommandExecuteHandler( throw new Error("__DCP_MANUAL_TRIGGER_BLOCKED__") } + const restoreTo = state.manualMode === "active" ? "active" : false state.manualMode = "compress-pending" + state.consumedManualTrigger = null state.pendingManualTrigger = { sessionId: input.sessionID, prompt, + restoreTo, } const rawArgs = (input.arguments || "").trim() output.parts.length = 0 diff --git a/lib/state/state.ts b/lib/state/state.ts index 47ce19af..1c84ef61 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,4 +1,4 @@ -import type { SessionState, ToolParameterEntry, WithParts } from "./types" +import type { SessionState, ToolParameterEntry, WithParts } from "./types" import type { Logger } from "../logger" import { applyPendingCompressionDurations } from "../compress/timing" import { loadManualModeSetting, loadSessionState, saveSessionState } from "./persistence" @@ -72,6 +72,7 @@ export function createSessionState(idFormat: IdFormat = "xml"): SessionState { manualMode: false, compressPermission: undefined, pendingManualTrigger: null, + consumedManualTrigger: null, prune: { tools: new Map(), messages: createPruneMessagesState(), @@ -110,6 +111,7 @@ export function resetSessionState(state: SessionState): void { state.manualMode = false state.compressPermission = undefined state.pendingManualTrigger = null + state.consumedManualTrigger = null state.prune = { tools: new Map(), messages: createPruneMessagesState(), diff --git a/lib/state/types.ts b/lib/state/types.ts index 6ac72dc1..369603ea 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -78,6 +78,8 @@ export interface Prune { export interface PendingManualTrigger { sessionId: string prompt: string + /** What manual mode was before the trigger flipped it to compress-pending. */ + restoreTo: false | "active" } export interface MessageIdState { @@ -92,6 +94,13 @@ export interface Nudges { iterationNudgeAnchors: Set } +export interface ConsumedManualTrigger { + /** The user message the trigger prompt was written into. */ + messageId: string + /** What manual mode was before the trigger flipped it to compress-pending. */ + restoreTo: false | "active" +} + export interface SessionState { idFormat: IdFormat sessionId: string | null @@ -99,6 +108,13 @@ export interface SessionState { manualMode: false | "active" | "compress-pending" compressPermission: "ask" | "allow" | "deny" | undefined pendingManualTrigger: PendingManualTrigger | null + /** + * Set when a trigger prompt reached the model but the compress call has not + * happened yet. Without it a model that never calls compress leaves the + * session pinned in compress-pending forever, which permanently suppresses + * the automatic nudges. + */ + consumedManualTrigger: ConsumedManualTrigger | null prune: Prune nudges: Nudges stats: SessionStats diff --git a/tests/manual-trigger-lifecycle.test.ts b/tests/manual-trigger-lifecycle.test.ts new file mode 100644 index 00000000..d74972c7 --- /dev/null +++ b/tests/manual-trigger-lifecycle.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import type { SessionState, WithParts } from "../lib/state" + +const root = mkdtempSync(join(tmpdir(), "dcp-manual-trigger-")) +process.env.XDG_DATA_HOME = join(root, "data") +process.env.XDG_CONFIG_HOME = join(root, "config") +process.env.OPENCODE_CONFIG_DIR = join(root, "config", "opencode") +test.after(() => rmSync(root, { recursive: true, force: true })) + +const { Logger } = await import("../lib/logger") +const { createSessionState } = await import("../lib/state") +const { applyPendingManualTrigger } = await import("../lib/commands/manual") + +const logger = new Logger(false) +const RED_LINE = "Manual mode: compress blocked" + +function userMessage(sessionID: string, id: string, created: number, text: string): WithParts { + return { + info: { + id, + role: "user", + sessionID, + agent: "build", + model: { providerID: "lab", modelID: "test" }, + time: { created }, + }, + parts: [{ id: `prt_${id}`, messageID: id, sessionID, type: "text", text }], + } as WithParts +} + +function armTrigger(state: SessionState, sessionID: string, restoreTo: false | "active") { + state.manualMode = "compress-pending" + state.consumedManualTrigger = null + state.pendingManualTrigger = { + sessionId: sessionID, + prompt: "\nYou must now use the compress tool.", + restoreTo, + } +} + +test("a manual trigger is applied to the command turn", () => { + const sessionID = `ses_manual_${Date.now()}` + const state = createSessionState() + state.sessionId = sessionID + armTrigger(state, sessionID, false) + + const messages = [userMessage(sessionID, "msg_1", 1, "/dcp compress")] + applyPendingManualTrigger(state, messages, logger) + + assert.equal(state.pendingManualTrigger, null) + assert.match((messages[0]!.parts[0] as any).text, /compress triggered manually/) + assert.equal(state.consumedManualTrigger?.messageId, "msg_1") + assert.equal(state.consumedManualTrigger?.restoreTo, false) +}) + +// A trigger authorises exactly one compress call. If the model replies without +// calling compress, "compress-pending" used to stay set, refreshManualMode kept +// early-returning, and the automatic nudges stayed switched off for the rest of +// the session. +test("a trigger that produced no compress is released on the next turn", () => { + const sessionID = `ses_manual_stuck_${Date.now()}` + const state = createSessionState() + state.sessionId = sessionID + armTrigger(state, sessionID, false) + + const turn1 = [userMessage(sessionID, "msg_1", 1, "/dcp compress")] + applyPendingManualTrigger(state, turn1, logger) + assert.equal(state.manualMode, "compress-pending") + + // The model answered without compressing. A new user message arrives. + const turn2 = [turn1[0]!, userMessage(sessionID, "msg_2", 2, "actually never mind")] + applyPendingManualTrigger(state, turn2, logger) + + assert.equal(state.manualMode, false) + assert.equal(state.consumedManualTrigger, null) +}) + +test("manual mode ON is restored, not dropped, when the trigger goes unused", () => { + const sessionID = `ses_manual_on_${Date.now()}` + const state = createSessionState() + state.sessionId = sessionID + armTrigger(state, sessionID, "active") + + const turn1 = [userMessage(sessionID, "msg_1", 1, "/dcp compress")] + applyPendingManualTrigger(state, turn1, logger) + assert.equal(state.manualMode, "compress-pending") + + const turn2 = [turn1[0]!, userMessage(sessionID, "msg_2", 2, "next thing")] + applyPendingManualTrigger(state, turn2, logger) + + assert.equal(state.manualMode, "active") +}) + +test("the trigger is still honoured on the turn that armed it", () => { + const sessionID = `ses_manual_keep_${Date.now()}` + const state = createSessionState() + state.sessionId = sessionID + armTrigger(state, sessionID, false) + + // Same request the command produced: nothing has been consumed yet, so the + // pending flag must survive for the compress call that follows. + const turn = [userMessage(sessionID, "msg_1", 1, "/dcp compress")] + applyPendingManualTrigger(state, turn, logger) + applyPendingManualTrigger(state, turn, logger) + + assert.equal(state.manualMode, "compress-pending") +}) + +test("a compress that did run clears the trigger bookkeeping", () => { + const sessionID = `ses_manual_done_${Date.now()}` + const state = createSessionState() + state.sessionId = sessionID + armTrigger(state, sessionID, "active") + + const turn = [userMessage(sessionID, "msg_1", 1, "/dcp compress")] + applyPendingManualTrigger(state, turn, logger) + assert.notEqual(state.consumedManualTrigger, null) + + // This mirrors finalizeSession: the compress ran, so the pending marker is + // retired rather than waiting to be released on a later turn. + state.manualMode = false + state.consumedManualTrigger = null + + assert.equal(state.manualMode, false) + assert.equal(state.consumedManualTrigger, null) +}) + +test("no trigger armed means nothing is touched", () => { + const sessionID = `ses_manual_none_${Date.now()}` + const state = createSessionState() + state.sessionId = sessionID + state.manualMode = "active" + + applyPendingManualTrigger(state, [userMessage(sessionID, "msg_1", 1, "hello")], logger) + + assert.equal(state.manualMode, "active") + assert.equal(state.consumedManualTrigger, null) +}) + +test("the red line wording is unchanged", () => { + // #490 complained about this message appearing for a user triggered + // compress. The wording is part of the contract the prompts reference, so it + // stays as is; the fix is that it no longer appears. + assert.doesNotMatch(RED_LINE, /never|always/) +})