fix(gpt-permission-continuation): add per-session consecutive auto-continue cap to prevent infinite loops
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user