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 HOOK_NAME = "gpt-permission-continuation"
|
||||||
export const CONTINUATION_PROMPT = "continue"
|
export const CONTINUATION_PROMPT = "continue"
|
||||||
|
export const MAX_CONSECUTIVE_AUTO_CONTINUES = 3
|
||||||
|
|
||||||
export const DEFAULT_STALL_PATTERNS = [
|
export const DEFAULT_STALL_PATTERNS = [
|
||||||
"if you want",
|
"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 "."
|
import { createGptPermissionContinuationHook } from "."
|
||||||
|
|
||||||
@@ -38,6 +40,20 @@ function createMockPluginInput(messages: SessionMessage[]) {
|
|||||||
return { ctx, promptCalls }
|
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", () => {
|
describe("gpt-permission-continuation", () => {
|
||||||
test("injects continue when the last GPT assistant reply asks for permission", async () => {
|
test("injects continue when the last GPT assistant reply asks for permission", async () => {
|
||||||
// given
|
// given
|
||||||
@@ -147,4 +163,87 @@ describe("gpt-permission-continuation", () => {
|
|||||||
// then
|
// then
|
||||||
expect(promptCalls).toEqual(["continue"])
|
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,
|
isGptAssistantMessage,
|
||||||
type SessionMessage,
|
type SessionMessage,
|
||||||
} from "./assistant-message"
|
} 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 { detectStallPattern } from "./detector"
|
||||||
import type { SessionStateStore } from "./session-state"
|
import type { SessionStateStore } from "./session-state"
|
||||||
|
|
||||||
|
type SessionState = ReturnType<SessionStateStore["getState"]>
|
||||||
|
|
||||||
async function promptContinuation(
|
async function promptContinuation(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
@@ -33,6 +39,37 @@ async function promptContinuation(
|
|||||||
await ctx.client.session.prompt(payload)
|
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: {
|
export function createGptPermissionContinuationHandler(args: {
|
||||||
ctx: PluginInput
|
ctx: PluginInput
|
||||||
sessionStateStore: SessionStateStore
|
sessionStateStore: SessionStateStore
|
||||||
@@ -78,6 +115,12 @@ export function createGptPermissionContinuationHandler(args: {
|
|||||||
const lastAssistantMessage = getLastAssistantMessage(messages)
|
const lastAssistantMessage = getLastAssistantMessage(messages)
|
||||||
if (!lastAssistantMessage) return
|
if (!lastAssistantMessage) return
|
||||||
|
|
||||||
|
const lastAssistantIndex = messages.lastIndexOf(lastAssistantMessage)
|
||||||
|
const previousUserMessage = getLastUserMessageBefore(messages, lastAssistantIndex)
|
||||||
|
if (previousUserMessage && !isAutoContinuationUserMessage(previousUserMessage)) {
|
||||||
|
resetAutoContinuationState(state)
|
||||||
|
}
|
||||||
|
|
||||||
const messageID = lastAssistantMessage.info?.id
|
const messageID = lastAssistantMessage.info?.id
|
||||||
if (messageID && state.lastHandledMessageID === messageID) {
|
if (messageID && state.lastHandledMessageID === messageID) {
|
||||||
log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID })
|
log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID })
|
||||||
@@ -99,9 +142,39 @@ export function createGptPermissionContinuationHandler(args: {
|
|||||||
return
|
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
|
state.inFlight = true
|
||||||
await promptContinuation(ctx, sessionID)
|
await promptContinuation(ctx, sessionID)
|
||||||
state.lastHandledMessageID = messageID
|
state.lastHandledMessageID = messageID
|
||||||
|
state.consecutiveAutoContinueCount += 1
|
||||||
|
state.lastAutoContinuePermissionPhrase = permissionPhrase
|
||||||
state.lastInjectedAt = Date.now()
|
state.lastInjectedAt = Date.now()
|
||||||
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
|
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
type SessionState = {
|
type SessionState = {
|
||||||
inFlight: boolean
|
inFlight: boolean
|
||||||
|
consecutiveAutoContinueCount: number
|
||||||
lastHandledMessageID?: string
|
lastHandledMessageID?: string
|
||||||
|
lastAutoContinuePermissionPhrase?: string
|
||||||
lastInjectedAt?: number
|
lastInjectedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ export function createSessionStateStore() {
|
|||||||
|
|
||||||
const created: SessionState = {
|
const created: SessionState = {
|
||||||
inFlight: false,
|
inFlight: false,
|
||||||
|
consecutiveAutoContinueCount: 0,
|
||||||
}
|
}
|
||||||
states.set(sessionID, created)
|
states.set(sessionID, created)
|
||||||
return created
|
return created
|
||||||
|
|||||||
Reference in New Issue
Block a user