From 2c8a8eb4f1a43c3dd03a65d1baec67183cc91a8a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 13 Mar 2026 10:48:00 +0900 Subject: [PATCH] fix(gpt-permission-continuation): add per-session consecutive auto-continue cap to prevent infinite loops --- .../gpt-permission-continuation/constants.ts | 1 + .../gpt-permission-continuation.test.ts | 101 +++++++++++++++++- .../gpt-permission-continuation/handler.ts | 75 ++++++++++++- .../session-state.ts | 3 + 4 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/hooks/gpt-permission-continuation/constants.ts b/src/hooks/gpt-permission-continuation/constants.ts index 690a0f0bd..04eda9a72 100644 --- a/src/hooks/gpt-permission-continuation/constants.ts +++ b/src/hooks/gpt-permission-continuation/constants.ts @@ -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", diff --git a/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts b/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts index dfb8b4591..c4068566f 100644 --- a/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts +++ b/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, test } from "bun:test" +/// + +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"]) + }) + }) + }) }) diff --git a/src/hooks/gpt-permission-continuation/handler.ts b/src/hooks/gpt-permission-continuation/handler.ts index 0db3142dc..252f8e357 100644 --- a/src/hooks/gpt-permission-continuation/handler.ts +++ b/src/hooks/gpt-permission-continuation/handler.ts @@ -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 + 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) { diff --git a/src/hooks/gpt-permission-continuation/session-state.ts b/src/hooks/gpt-permission-continuation/session-state.ts index 132675086..fc3497235 100644 --- a/src/hooks/gpt-permission-continuation/session-state.ts +++ b/src/hooks/gpt-permission-continuation/session-state.ts @@ -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