Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions lib/commands/manual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function applyPendingManualTrigger(
): void {
const pending = state.pendingManualTrigger
if (!pending) {
releaseStaleManualTrigger(state, messages, logger)
return
}

Expand All @@ -118,10 +119,46 @@ 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
}
}

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,
})
}
1 change: 1 addition & 0 deletions lib/compress/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export async function finalizeSession(
): Promise<void> {
if (ctx.state.manualMode === "compress-pending") {
ctx.state.manualMode = false
ctx.state.consumedManualTrigger = null
await refreshManualMode(
ctx.state,
toolCtx.sessionID,
Expand Down
3 changes: 3 additions & 0 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion lib/state/state.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -72,6 +72,7 @@ export function createSessionState(idFormat: IdFormat = "xml"): SessionState {
manualMode: false,
compressPermission: undefined,
pendingManualTrigger: null,
consumedManualTrigger: null,
prune: {
tools: new Map<string, number>(),
messages: createPruneMessagesState(),
Expand Down Expand Up @@ -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<string, number>(),
messages: createPruneMessagesState(),
Expand Down
16 changes: 16 additions & 0 deletions lib/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -92,13 +94,27 @@ export interface Nudges {
iterationNudgeAnchors: Set<string>
}

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
isSubAgent: boolean
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
Expand Down
149 changes: 149 additions & 0 deletions tests/manual-trigger-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<compress triggered manually>\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/)
})