fix(hooks): remove gpt permission continuation hook

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-03-19 11:37:30 +09:00
parent 521a1f76a9
commit ccaf759b6b
31 changed files with 45 additions and 928 deletions
+3 -3
View File
@@ -1,10 +1,10 @@
# src/hooks/ — 46 Lifecycle Hooks
# src/hooks/ — 45 Lifecycle Hooks
**Generated:** 2026-03-06
## OVERVIEW
46 hooks across 45 directories + 11 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
45 hooks across 44 directories + 11 standalone files. Three-tier composition: Core(37) + Continuation(6) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
## HOOK TIERS
@@ -109,7 +109,7 @@ hooks/
| contextInjectorMessagesTransform | messages.transform | Inject AGENTS.md/README.md into context |
| thinkingBlockValidator | messages.transform | Validate thinking block structure |
### Tier 4: Continuation Hooks (7) — `create-continuation-hooks.ts`
### Tier 4: Continuation Hooks (6) — `create-continuation-hooks.ts`
| Hook | Event | Purpose |
|------|-------|---------|
-6
View File
@@ -88,7 +88,6 @@ function scheduleRetry(input: {
const currentProgress = getPlanProgress(currentBoulder.active_plan)
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
if (options?.shouldSkipContinuation?.(sessionID)) return
if (hasRunningBackgroundTasks(sessionID, options)) return
await injectContinuation({
@@ -177,11 +176,6 @@ export async function handleAtlasSessionIdle(input: {
return
}
if (options?.shouldSkipContinuation?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID })
return
}
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
scheduleRetry({ ctx, sessionID, sessionState, options })
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
-31
View File
@@ -1464,37 +1464,6 @@ session_id: ses_untrusted_999
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should skip when another continuation hook already injected", async () => {
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput, {
directory: TEST_DIR,
shouldSkipContinuation: (sessionID: string) => sessionID === MAIN_SESSION_ID,
})
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - should not call prompt because another continuation already handled it
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should clear abort state on message.updated", async () => {
// given - boulder with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
-1
View File
@@ -8,7 +8,6 @@ export interface AtlasHookOptions {
directory: string
backgroundManager?: BackgroundManager
isContinuationStopped?: (sessionID: string) => boolean
shouldSkipContinuation?: (sessionID: string) => boolean
agentOverrides?: AgentOverrides
/** Enable auto-commit after each atomic task completion (default: true) */
autoCommit?: boolean
@@ -1,44 +0,0 @@
type TextPart = {
type?: string
text?: string
}
type MessageInfo = {
id?: string
role?: string
error?: unknown
model?: {
providerID?: string
modelID?: string
}
providerID?: string
modelID?: string
}
export type SessionMessage = {
info?: MessageInfo
parts?: TextPart[]
}
export function getLastAssistantMessage(messages: SessionMessage[]): SessionMessage | null {
for (let index = messages.length - 1; index >= 0; index--) {
if (messages[index].info?.role === "assistant") {
return messages[index]
}
}
return null
}
export function extractAssistantText(message: SessionMessage): string {
return (message.parts ?? [])
.filter((part) => part.type === "text" && typeof part.text === "string")
.map((part) => part.text?.trim() ?? "")
.filter(Boolean)
.join("\n")
}
export function isGptAssistantMessage(message: SessionMessage): boolean {
const modelID = message.info?.model?.modelID ?? message.info?.modelID
return typeof modelID === "string" && modelID.toLowerCase().includes("gpt")
}
@@ -1,11 +0,0 @@
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",
"would you like",
"shall i",
"do you want me to",
"let me know if",
] as const
@@ -1,32 +0,0 @@
import { DEFAULT_STALL_PATTERNS } from "./constants"
function getTrailingSegment(text: string): string {
const normalized = text.trim().replace(/\s+/g, " ")
if (!normalized) return ""
const sentenceParts = normalized.split(/(?<=[.!?])\s+/)
return sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? ""
}
export function detectStallPattern(
text: string,
patterns: readonly string[] = DEFAULT_STALL_PATTERNS,
): boolean {
if (!text.trim()) return false
const tail = text.slice(-800)
const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean)
const hotZone = lines.slice(-3).join(" ")
const trailingSegment = getTrailingSegment(hotZone)
return patterns.some((pattern) => trailingSegment.startsWith(pattern.toLowerCase()))
}
export 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
}
@@ -1,384 +0,0 @@
/// <reference path="../../../bun-test.d.ts" />
import { createOpencodeClient } from "@opencode-ai/sdk"
import { afterEach, describe, expect, it as test } from "bun:test"
import { subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
import { createGptPermissionContinuationHook } from "."
type SessionMessage = {
info: {
id: string
role: "user" | "assistant"
model?: {
providerID?: string
modelID?: string
}
modelID?: string
}
parts?: Array<{ type: string; text?: string }>
}
type GptPermissionContext = Parameters<typeof createGptPermissionContinuationHook>[0]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function extractPromptText(input: unknown): string {
if (!isRecord(input)) return ""
const body = input.body
if (!isRecord(body)) return ""
const parts = body.parts
if (!Array.isArray(parts)) return ""
const firstPart = parts[0]
if (!isRecord(firstPart)) return ""
return typeof firstPart.text === "string" ? firstPart.text : ""
}
function createMockPluginInput(messages: SessionMessage[]): {
ctx: GptPermissionContext
promptCalls: string[]
} {
const promptCalls: string[] = []
const client = createOpencodeClient({ directory: "/tmp/test" })
const shell = Object.assign(
() => {
throw new Error("$ is not used in this test")
},
{
braces: () => [],
escape: (input: string) => input,
env() {
return shell
},
cwd() {
return shell
},
nothrow() {
return shell
},
throws() {
return shell
},
},
)
const request = new Request("http://localhost")
const response = new Response()
Reflect.set(client.session, "messages", async () => ({ data: messages, error: undefined, request, response }))
Reflect.set(client.session, "prompt", async (input: unknown) => {
promptCalls.push(extractPromptText(input))
return { data: undefined, error: undefined, request, response }
})
Reflect.set(client.session, "promptAsync", async (input: unknown) => {
promptCalls.push(extractPromptText(input))
return { data: undefined, error: undefined, request, response }
})
const ctx: GptPermissionContext = {
client,
project: {
id: "test-project",
worktree: "/tmp/test",
time: { created: Date.now() },
},
directory: "/tmp/test",
worktree: "/tmp/test",
serverUrl: new URL("http://localhost"),
$: shell,
}
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 }],
}
}
function expectContinuationPrompts(promptCalls: string[], count: number): void {
expect(promptCalls).toHaveLength(count)
for (const call of promptCalls) {
expect(call.startsWith("continue")).toBe(true)
}
}
describe("gpt-permission-continuation", () => {
afterEach(() => {
_resetForTesting()
})
test("injects continue when the last GPT assistant reply asks for permission", async () => {
// given
const { ctx, promptCalls } = createMockPluginInput([
{
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
parts: [{ type: "text", text: "I finished the analysis. If you want, I can apply the changes next." }],
},
])
const hook = createGptPermissionContinuationHook(ctx)
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
// then
expectContinuationPrompts(promptCalls, 1)
})
test("does not inject when the last assistant model is not GPT", async () => {
// given
const { ctx, promptCalls } = createMockPluginInput([
{
info: {
id: "msg-1",
role: "assistant",
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
},
parts: [{ type: "text", text: "If you want, I can keep going." }],
},
])
const hook = createGptPermissionContinuationHook(ctx)
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
// then
expect(promptCalls).toEqual([])
})
test("does not inject when the last assistant reply is not a stall pattern", async () => {
// given
const { ctx, promptCalls } = createMockPluginInput([
{
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
parts: [{ type: "text", text: "I completed the refactor and all tests pass." }],
},
])
const hook = createGptPermissionContinuationHook(ctx)
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
// then
expect(promptCalls).toEqual([])
})
test("does not inject when a permission phrase appears before the final sentence", async () => {
// given
const { ctx, promptCalls } = createMockPluginInput([
{
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
parts: [{ type: "text", text: "If you want, I can keep going. The current work is complete." }],
},
])
const hook = createGptPermissionContinuationHook(ctx)
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
// then
expect(promptCalls).toEqual([])
})
test("does not inject when continuation is stopped for the session", async () => {
// given
const { ctx, promptCalls } = createMockPluginInput([
{
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
parts: [{ type: "text", text: "If you want, I can continue with the fix." }],
},
])
const hook = createGptPermissionContinuationHook(ctx, {
isContinuationStopped: (sessionID) => sessionID === "ses-1",
})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
// then
expect(promptCalls).toEqual([])
})
test("does not inject twice for the same assistant message", async () => {
// given
const { ctx, promptCalls } = createMockPluginInput([
{
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
parts: [{ type: "text", text: "Would you like me to continue with the fix?" }],
},
])
const hook = createGptPermissionContinuationHook(ctx)
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
// then
expectContinuationPrompts(promptCalls, 1)
})
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
expectContinuationPrompts(promptCalls, 3)
})
})
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
expectContinuationPrompts(promptCalls, 5)
})
})
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
expectContinuationPrompts(promptCalls, 1)
})
})
describe("#when a user manually types continue after the cap is reached", () => {
test("resets the cap and allows another auto-continue", 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" } } })
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" } } })
// then
expectContinuationPrompts(promptCalls, 4)
})
})
})
test("does not inject when the session is a subagent session", async () => {
// given
const { ctx, promptCalls } = createMockPluginInput([
{
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
parts: [{ type: "text", text: "If you want, I can continue with the fix." }],
},
])
subagentSessions.add("ses-subagent")
const hook = createGptPermissionContinuationHook(ctx)
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-subagent" } } })
// then
expect(promptCalls).toEqual([])
})
test("includes assistant text context in the continuation prompt", async () => {
// given
const assistantText = "I finished the analysis. If you want, I can apply the changes next."
const { ctx, promptCalls } = createMockPluginInput([
{
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
parts: [{ type: "text", text: assistantText }],
},
])
const hook = createGptPermissionContinuationHook(ctx)
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
// then
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0].startsWith("continue")).toBe(true)
expect(promptCalls[0]).toContain("If you want, I can apply the changes next.")
})
})
@@ -1,200 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { subagentSessions } from "../../features/claude-code-session-state"
import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger"
import {
extractAssistantText,
getLastAssistantMessage,
isGptAssistantMessage,
type SessionMessage,
} from "./assistant-message"
import {
CONTINUATION_PROMPT,
HOOK_NAME,
MAX_CONSECUTIVE_AUTO_CONTINUES,
} from "./constants"
import { detectStallPattern, extractPermissionPhrase } from "./detector"
import { buildContextualContinuationPrompt } from "./prompt-builder"
import type { SessionStateStore } from "./session-state"
type SessionState = ReturnType<SessionStateStore["getState"]>
async function promptContinuation(
ctx: PluginInput,
sessionID: string,
assistantText: string,
): Promise<void> {
const prompt = buildContextualContinuationPrompt(assistantText)
const payload = {
path: { id: sessionID },
body: {
parts: [{ type: "text" as const, text: prompt }],
},
query: { directory: ctx.directory },
}
if (typeof ctx.client.session.promptAsync === "function") {
await ctx.client.session.promptAsync(payload)
return
}
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 {
const text = extractAssistantText(message).trim().toLowerCase()
return text === CONTINUATION_PROMPT || text.startsWith(`${CONTINUATION_PROMPT}\n`)
}
function resetAutoContinuationState(state: SessionState): void {
state.consecutiveAutoContinueCount = 0
state.awaitingAutoContinuationResponse = false
state.lastAutoContinuePermissionPhrase = undefined
}
export function createGptPermissionContinuationHandler(args: {
ctx: PluginInput
sessionStateStore: SessionStateStore
isContinuationStopped?: (sessionID: string) => boolean
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
const { ctx, sessionStateStore, isContinuationStopped } = args
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const properties = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") {
const sessionID = (properties?.info as { id?: string } | undefined)?.id
if (sessionID) {
sessionStateStore.cleanup(sessionID)
}
return
}
if (event.type !== "session.idle") return
const sessionID = properties?.sessionID as string | undefined
if (!sessionID) return
if (subagentSessions.has(sessionID)) {
log(`[${HOOK_NAME}] Skipped: session is a subagent`, { sessionID })
return
}
if (isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
return
}
const state = sessionStateStore.getState(sessionID)
if (state.inFlight) {
log(`[${HOOK_NAME}] Skipped: prompt already in flight`, { sessionID })
return
}
try {
const messagesResponse = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const messages = normalizeSDKResponse(messagesResponse, [] as SessionMessage[], {
preferResponseOnMissingData: true,
})
const lastAssistantMessage = getLastAssistantMessage(messages)
if (!lastAssistantMessage) return
const lastAssistantIndex = messages.lastIndexOf(lastAssistantMessage)
const previousUserMessage = getLastUserMessageBefore(messages, lastAssistantIndex)
const previousUserMessageWasAutoContinuation =
previousUserMessage !== null
&& state.awaitingAutoContinuationResponse
&& isAutoContinuationUserMessage(previousUserMessage)
if (previousUserMessageWasAutoContinuation) {
state.awaitingAutoContinuationResponse = false
} else if (previousUserMessage) {
resetAutoContinuationState(state)
} else {
state.awaitingAutoContinuationResponse = false
}
const messageID = lastAssistantMessage.info?.id
if (messageID && state.lastHandledMessageID === messageID) {
log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID })
return
}
if (lastAssistantMessage.info?.error) {
log(`[${HOOK_NAME}] Skipped: last assistant message has error`, { sessionID, messageID })
return
}
if (!isGptAssistantMessage(lastAssistantMessage)) {
log(`[${HOOK_NAME}] Skipped: last assistant model is not GPT`, { sessionID, messageID })
return
}
const assistantText = extractAssistantText(lastAssistantMessage)
if (!detectStallPattern(assistantText)) {
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, assistantText)
state.lastHandledMessageID = messageID
state.consecutiveAutoContinueCount += 1
state.awaitingAutoContinuationResponse = true
state.lastAutoContinuePermissionPhrase = permissionPhrase
state.lastInjectedAt = Date.now()
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
} catch (error) {
log(`[${HOOK_NAME}] Failed to inject continuation prompt`, {
sessionID,
error: String(error),
})
} finally {
state.inFlight = false
}
}
}
@@ -1,29 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { createGptPermissionContinuationHandler } from "./handler"
import { createSessionStateStore } from "./session-state"
export type GptPermissionContinuationHook = {
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
wasRecentlyInjected: (sessionID: string) => boolean
}
export function createGptPermissionContinuationHook(
ctx: PluginInput,
options?: {
isContinuationStopped?: (sessionID: string) => boolean
},
): GptPermissionContinuationHook {
const sessionStateStore = createSessionStateStore()
return {
handler: createGptPermissionContinuationHandler({
ctx,
sessionStateStore,
isContinuationStopped: options?.isContinuationStopped,
}),
wasRecentlyInjected(sessionID: string): boolean {
return sessionStateStore.wasRecentlyInjected(sessionID, 5_000)
},
}
}
@@ -1,14 +0,0 @@
import { CONTINUATION_PROMPT } from "./constants"
const CONTEXT_LINE_COUNT = 5
export function buildContextualContinuationPrompt(assistantText: string): string {
const lines = assistantText.split("\n").map((line) => line.trim()).filter(Boolean)
const contextLines = lines.slice(-CONTEXT_LINE_COUNT)
if (contextLines.length === 0) {
return CONTINUATION_PROMPT
}
return `${CONTINUATION_PROMPT}\n\n[Your last response ended with:]\n${contextLines.join("\n")}`
}
@@ -1,39 +0,0 @@
type SessionState = {
inFlight: boolean
consecutiveAutoContinueCount: number
awaitingAutoContinuationResponse: boolean
lastHandledMessageID?: string
lastAutoContinuePermissionPhrase?: string
lastInjectedAt?: number
}
export type SessionStateStore = ReturnType<typeof createSessionStateStore>
export function createSessionStateStore() {
const states = new Map<string, SessionState>()
const getState = (sessionID: string): SessionState => {
const existing = states.get(sessionID)
if (existing) return existing
const created: SessionState = {
inFlight: false,
consecutiveAutoContinueCount: 0,
awaitingAutoContinuationResponse: false,
}
states.set(sessionID, created)
return created
}
return {
getState,
wasRecentlyInjected(sessionID: string, windowMs: number): boolean {
const state = states.get(sessionID)
if (!state?.lastInjectedAt) return false
return Date.now() - state.lastInjectedAt <= windowMs
},
cleanup(sessionID: string): void {
states.delete(sessionID)
},
}
}
@@ -1,64 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createTodoContinuationEnforcer } from "../todo-continuation-enforcer"
import { createGptPermissionContinuationHook } from "."
describe("gpt-permission-continuation coordination", () => {
test("injects only once when GPT permission continuation and todo continuation are both eligible", async () => {
// given
const promptCalls: string[] = []
const toastCalls: string[] = []
const sessionID = "ses-dual-continuation"
const ctx = {
directory: "/tmp/test",
client: {
session: {
messages: async () => ({
data: [
{
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
parts: [{ type: "text", text: "If you want, I can implement the fix next." }],
},
],
}),
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
prompt: async (input: { body: { parts: Array<{ text: string }> } }) => {
promptCalls.push(input.body.parts[0]?.text ?? "")
return {}
},
promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => {
promptCalls.push(input.body.parts[0]?.text ?? "")
return {}
},
},
tui: {
showToast: async (input: { body: { title: string } }) => {
toastCalls.push(input.body.title)
return {}
},
},
},
} as any
const gptPermissionContinuation = createGptPermissionContinuationHook(ctx)
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx, {
shouldSkipContinuation: (id) => gptPermissionContinuation.wasRecentlyInjected(id),
})
// when
await gptPermissionContinuation.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await todoContinuationEnforcer.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// then
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0].startsWith("continue")).toBe(true)
expect(promptCalls[0]).toContain("If you want, I can implement the fix next.")
expect(toastCalls).toEqual([])
})
})
-1
View File
@@ -30,7 +30,6 @@ export { createCategorySkillReminderHook } from "./category-skill-reminder";
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
export { createNoSisyphusGptHook } from "./no-sisyphus-gpt";
export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt";
export { createGptPermissionContinuationHook, type GptPermissionContinuationHook } from "./gpt-permission-continuation"
export { createAutoSlashCommandHook } from "./auto-slash-command";
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
@@ -17,7 +17,6 @@ export function createTodoContinuationHandler(args: {
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
shouldSkipContinuation?: (sessionID: string) => boolean
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
const {
ctx,
@@ -25,7 +24,6 @@ export function createTodoContinuationHandler(args: {
backgroundManager,
skipAgents = DEFAULT_SKIP_AGENTS,
isContinuationStopped,
shouldSkipContinuation,
} = args
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
@@ -58,7 +56,6 @@ export function createTodoContinuationHandler(args: {
backgroundManager,
skipAgents,
isContinuationStopped,
shouldSkipContinuation,
})
return
}
@@ -30,7 +30,6 @@ export async function handleSessionIdle(args: {
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
shouldSkipContinuation?: (sessionID: string) => boolean
}): Promise<void> {
const {
ctx,
@@ -39,7 +38,6 @@ export async function handleSessionIdle(args: {
backgroundManager,
skipAgents = DEFAULT_SKIP_AGENTS,
isContinuationStopped,
shouldSkipContinuation,
} = args
log(`[${HOOK_NAME}] session.idle`, { sessionID })
@@ -174,11 +172,6 @@ export async function handleSessionIdle(args: {
return
}
if (shouldSkipContinuation?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID })
return
}
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount, todos)
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
return
@@ -17,7 +17,6 @@ export function createTodoContinuationEnforcer(
backgroundManager,
skipAgents = DEFAULT_SKIP_AGENTS,
isContinuationStopped,
shouldSkipContinuation,
} = options
const sessionStateStore = createSessionStateStore()
@@ -43,7 +42,6 @@ export function createTodoContinuationEnforcer(
backgroundManager,
skipAgents,
isContinuationStopped,
shouldSkipContinuation,
})
const cancelAllCountdowns = (): void => {
@@ -1706,27 +1706,6 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(0)
})
test("should not inject when shouldSkipContinuation returns true", async () => {
// given - session already handled by another continuation hook
const sessionID = "main-skip-other-continuation"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
shouldSkipContinuation: (id) => id === sessionID,
})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no countdown toast or continuation injection
expect(toastCalls).toHaveLength(0)
expect(promptCalls).toHaveLength(0)
})
test("should not inject when isContinuationStopped becomes true during countdown", async () => {
// given - session where continuation is not stopped at idle time but stops during countdown
const sessionID = "main-race-condition"
@@ -5,7 +5,6 @@ export interface TodoContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
shouldSkipContinuation?: (sessionID: string) => boolean
}
export interface TodoContinuationEnforcer {