fix(gpt-permission-continuation): add per-session consecutive auto-continue cap to prevent infinite loops

This commit is contained in:
YeonGyu-Kim
2026-03-13 10:48:00 +09:00
parent 825e854cff
commit 2c8a8eb4f1
4 changed files with 178 additions and 2 deletions

View File

@@ -1,5 +1,6 @@
export const HOOK_NAME = "gpt-permission-continuation"
export const CONTINUATION_PROMPT = "continue"
export const MAX_CONSECUTIVE_AUTO_CONTINUES = 3
export const DEFAULT_STALL_PATTERNS = [
"if you want",

View File

@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test"
/// <reference path="../../../bun-test.d.ts" />
import { describe, expect, it as test } from "bun:test"
import { createGptPermissionContinuationHook } from "."
@@ -38,6 +40,20 @@ function createMockPluginInput(messages: SessionMessage[]) {
return { ctx, promptCalls }
}
function createAssistantMessage(id: string, text: string): SessionMessage {
return {
info: { id, role: "assistant", modelID: "gpt-5.4" },
parts: [{ type: "text", text }],
}
}
function createUserMessage(id: string, text: string): SessionMessage {
return {
info: { id, role: "user" },
parts: [{ type: "text", text }],
}
}
describe("gpt-permission-continuation", () => {
test("injects continue when the last GPT assistant reply asks for permission", async () => {
// given
@@ -147,4 +163,87 @@ describe("gpt-permission-continuation", () => {
// then
expect(promptCalls).toEqual(["continue"])
})
describe("#given repeated GPT permission tails in the same session", () => {
describe("#when the permission phrases keep changing", () => {
test("stops injecting after three consecutive auto-continues", async () => {
// given
const messages: SessionMessage[] = [
createUserMessage("msg-0", "Please continue the fix."),
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
]
const { ctx, promptCalls } = createMockPluginInput(messages)
const hook = createGptPermissionContinuationHook(ctx)
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
messages.push(createUserMessage("msg-2", "continue"))
messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
messages.push(createUserMessage("msg-4", "continue"))
messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
messages.push(createUserMessage("msg-6", "continue"))
messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
// then
expect(promptCalls).toEqual(["continue", "continue", "continue"])
})
})
describe("#when a real user message arrives between auto-continues", () => {
test("resets the consecutive auto-continue counter", async () => {
// given
const messages: SessionMessage[] = [
createUserMessage("msg-0", "Please continue the fix."),
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
]
const { ctx, promptCalls } = createMockPluginInput(messages)
const hook = createGptPermissionContinuationHook(ctx)
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
messages.push(createUserMessage("msg-2", "continue"))
messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
messages.push(createUserMessage("msg-4", "Please keep going and finish the cleanup."))
messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
messages.push(createUserMessage("msg-6", "continue"))
messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
messages.push(createUserMessage("msg-8", "continue"))
messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish."))
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
messages.push(createUserMessage("msg-10", "continue"))
messages.push(createAssistantMessage("msg-11", "Would you like me to ship the final verification?"))
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
// then
expect(promptCalls).toEqual(["continue", "continue", "continue", "continue", "continue"])
})
})
describe("#when the same permission phrase repeats after an auto-continue", () => {
test("stops immediately on stagnation", async () => {
// given
const messages: SessionMessage[] = [
createUserMessage("msg-0", "Please continue the fix."),
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
]
const { ctx, promptCalls } = createMockPluginInput(messages)
const hook = createGptPermissionContinuationHook(ctx)
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
messages.push(createUserMessage("msg-2", "continue"))
messages.push(createAssistantMessage("msg-3", "If you want, I can apply the patch next."))
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
// then
expect(promptCalls).toEqual(["continue"])
})
})
})
})

View File

@@ -9,10 +9,16 @@ import {
isGptAssistantMessage,
type SessionMessage,
} from "./assistant-message"
import { CONTINUATION_PROMPT, HOOK_NAME } from "./constants"
import {
CONTINUATION_PROMPT,
HOOK_NAME,
MAX_CONSECUTIVE_AUTO_CONTINUES,
} from "./constants"
import { detectStallPattern } from "./detector"
import type { SessionStateStore } from "./session-state"
type SessionState = ReturnType<SessionStateStore["getState"]>
async function promptContinuation(
ctx: PluginInput,
sessionID: string,
@@ -33,6 +39,37 @@ async function promptContinuation(
await ctx.client.session.prompt(payload)
}
function getLastUserMessageBefore(
messages: SessionMessage[],
lastAssistantIndex: number,
): SessionMessage | null {
for (let index = lastAssistantIndex - 1; index >= 0; index--) {
if (messages[index].info?.role === "user") {
return messages[index]
}
}
return null
}
function isAutoContinuationUserMessage(message: SessionMessage): boolean {
return extractAssistantText(message).trim().toLowerCase() === CONTINUATION_PROMPT
}
function extractPermissionPhrase(text: string): string | null {
const tail = text.slice(-800)
const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean)
const hotZone = lines.slice(-3).join(" ")
const sentenceParts = hotZone.trim().replace(/\s+/g, " ").split(/(?<=[.!?])\s+/)
const trailingSegment = sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? ""
return trailingSegment || null
}
function resetAutoContinuationState(state: SessionState): void {
state.consecutiveAutoContinueCount = 0
state.lastAutoContinuePermissionPhrase = undefined
}
export function createGptPermissionContinuationHandler(args: {
ctx: PluginInput
sessionStateStore: SessionStateStore
@@ -78,6 +115,12 @@ export function createGptPermissionContinuationHandler(args: {
const lastAssistantMessage = getLastAssistantMessage(messages)
if (!lastAssistantMessage) return
const lastAssistantIndex = messages.lastIndexOf(lastAssistantMessage)
const previousUserMessage = getLastUserMessageBefore(messages, lastAssistantIndex)
if (previousUserMessage && !isAutoContinuationUserMessage(previousUserMessage)) {
resetAutoContinuationState(state)
}
const messageID = lastAssistantMessage.info?.id
if (messageID && state.lastHandledMessageID === messageID) {
log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID })
@@ -99,9 +142,39 @@ export function createGptPermissionContinuationHandler(args: {
return
}
const permissionPhrase = extractPermissionPhrase(assistantText)
if (!permissionPhrase) {
return
}
if (state.consecutiveAutoContinueCount >= MAX_CONSECUTIVE_AUTO_CONTINUES) {
state.lastHandledMessageID = messageID
log(`[${HOOK_NAME}] Skipped: reached max consecutive auto-continues`, {
sessionID,
messageID,
consecutiveAutoContinueCount: state.consecutiveAutoContinueCount,
})
return
}
if (
state.consecutiveAutoContinueCount >= 1
&& state.lastAutoContinuePermissionPhrase === permissionPhrase
) {
state.lastHandledMessageID = messageID
log(`[${HOOK_NAME}] Skipped: repeated permission phrase after auto-continue`, {
sessionID,
messageID,
permissionPhrase,
})
return
}
state.inFlight = true
await promptContinuation(ctx, sessionID)
state.lastHandledMessageID = messageID
state.consecutiveAutoContinueCount += 1
state.lastAutoContinuePermissionPhrase = permissionPhrase
state.lastInjectedAt = Date.now()
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
} catch (error) {

View File

@@ -1,6 +1,8 @@
type SessionState = {
inFlight: boolean
consecutiveAutoContinueCount: number
lastHandledMessageID?: string
lastAutoContinuePermissionPhrase?: string
lastInjectedAt?: number
}
@@ -15,6 +17,7 @@ export function createSessionStateStore() {
const created: SessionState = {
inFlight: false,
consecutiveAutoContinueCount: 0,
}
states.set(sessionID, created)
return created