fix(continuation): auto-continue GPT permission-seeking replies
Resume GPT sessions when the last assistant reply ends in a permission-seeking tail, while honoring stop-continuation and avoiding duplicate continuation across todo and atlas flows. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -43,7 +43,57 @@
|
|||||||
"disabled_hooks": {
|
"disabled_hooks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"gpt-permission-continuation",
|
||||||
|
"todo-continuation-enforcer",
|
||||||
|
"context-window-monitor",
|
||||||
|
"session-recovery",
|
||||||
|
"session-notification",
|
||||||
|
"comment-checker",
|
||||||
|
"tool-output-truncator",
|
||||||
|
"question-label-truncator",
|
||||||
|
"directory-agents-injector",
|
||||||
|
"directory-readme-injector",
|
||||||
|
"empty-task-response-detector",
|
||||||
|
"think-mode",
|
||||||
|
"model-fallback",
|
||||||
|
"anthropic-context-window-limit-recovery",
|
||||||
|
"preemptive-compaction",
|
||||||
|
"rules-injector",
|
||||||
|
"background-notification",
|
||||||
|
"auto-update-checker",
|
||||||
|
"startup-toast",
|
||||||
|
"keyword-detector",
|
||||||
|
"agent-usage-reminder",
|
||||||
|
"non-interactive-env",
|
||||||
|
"interactive-bash-session",
|
||||||
|
"thinking-block-validator",
|
||||||
|
"ralph-loop",
|
||||||
|
"category-skill-reminder",
|
||||||
|
"compaction-context-injector",
|
||||||
|
"compaction-todo-preserver",
|
||||||
|
"claude-code-hooks",
|
||||||
|
"auto-slash-command",
|
||||||
|
"edit-error-recovery",
|
||||||
|
"json-error-recovery",
|
||||||
|
"delegate-task-retry",
|
||||||
|
"prometheus-md-only",
|
||||||
|
"sisyphus-junior-notepad",
|
||||||
|
"no-sisyphus-gpt",
|
||||||
|
"no-hephaestus-non-gpt",
|
||||||
|
"start-work",
|
||||||
|
"atlas",
|
||||||
|
"unstable-agent-babysitter",
|
||||||
|
"task-resume-info",
|
||||||
|
"stop-continuation-guard",
|
||||||
|
"tasks-todowrite-disabler",
|
||||||
|
"runtime-fallback",
|
||||||
|
"write-existing-file-guard",
|
||||||
|
"anthropic-effort",
|
||||||
|
"hashline-read-enhancer",
|
||||||
|
"read-image-resizer"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"disabled_commands": {
|
"disabled_commands": {
|
||||||
@@ -3742,11 +3792,16 @@
|
|||||||
"include_co_authored_by": {
|
"include_co_authored_by": {
|
||||||
"default": true,
|
"default": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"git_env_prefix": {
|
||||||
|
"default": "GIT_MASTER=1",
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"commit_footer",
|
"commit_footer",
|
||||||
"include_co_authored_by"
|
"include_co_authored_by",
|
||||||
|
"git_env_prefix"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
export const HookNameSchema = z.enum([
|
export const HookNameSchema = z.enum([
|
||||||
|
"gpt-permission-continuation",
|
||||||
"todo-continuation-enforcer",
|
"todo-continuation-enforcer",
|
||||||
"context-window-monitor",
|
"context-window-monitor",
|
||||||
"session-recovery",
|
"session-recovery",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { CommentCheckerConfigSchema } from "./comment-checker"
|
|||||||
import { BuiltinCommandNameSchema } from "./commands"
|
import { BuiltinCommandNameSchema } from "./commands"
|
||||||
import { ExperimentalConfigSchema } from "./experimental"
|
import { ExperimentalConfigSchema } from "./experimental"
|
||||||
import { GitMasterConfigSchema } from "./git-master"
|
import { GitMasterConfigSchema } from "./git-master"
|
||||||
|
import { HookNameSchema } from "./hooks"
|
||||||
import { NotificationConfigSchema } from "./notification"
|
import { NotificationConfigSchema } from "./notification"
|
||||||
import { RalphLoopConfigSchema } from "./ralph-loop"
|
import { RalphLoopConfigSchema } from "./ralph-loop"
|
||||||
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
|
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
|
||||||
@@ -30,7 +31,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
|||||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||||
disabled_agents: z.array(z.string()).optional(),
|
disabled_agents: z.array(z.string()).optional(),
|
||||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||||
disabled_hooks: z.array(z.string()).optional(),
|
disabled_hooks: z.array(HookNameSchema).optional(),
|
||||||
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
||||||
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
|
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
|
||||||
disabled_tools: z.array(z.string()).optional(),
|
disabled_tools: z.array(z.string()).optional(),
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ function scheduleRetry(input: {
|
|||||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||||
if (currentProgress.isComplete) return
|
if (currentProgress.isComplete) return
|
||||||
if (options?.isContinuationStopped?.(sessionID)) return
|
if (options?.isContinuationStopped?.(sessionID)) return
|
||||||
|
if (options?.shouldSkipContinuation?.(sessionID)) return
|
||||||
if (hasRunningBackgroundTasks(sessionID, options)) return
|
if (hasRunningBackgroundTasks(sessionID, options)) return
|
||||||
|
|
||||||
await injectContinuation({
|
await injectContinuation({
|
||||||
@@ -192,6 +193,11 @@ export async function handleAtlasSessionIdle(input: {
|
|||||||
return
|
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) {
|
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
||||||
scheduleRetry({ ctx, sessionID, sessionState, options })
|
scheduleRetry({ ctx, sessionID, sessionState, options })
|
||||||
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
||||||
|
|||||||
@@ -1042,6 +1042,37 @@ describe("atlas hook", () => {
|
|||||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
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 () => {
|
test("should clear abort state on message.updated", async () => {
|
||||||
// given - boulder with incomplete plan
|
// given - boulder with incomplete plan
|
||||||
const planPath = join(TEST_DIR, "test-plan.md")
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface AtlasHookOptions {
|
|||||||
directory: string
|
directory: string
|
||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
isContinuationStopped?: (sessionID: string) => boolean
|
isContinuationStopped?: (sessionID: string) => boolean
|
||||||
|
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||||
agentOverrides?: AgentOverrides
|
agentOverrides?: AgentOverrides
|
||||||
/** Enable auto-commit after each atomic task completion (default: true) */
|
/** Enable auto-commit after each atomic task completion (default: true) */
|
||||||
autoCommit?: boolean
|
autoCommit?: boolean
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const HOOK_NAME = "gpt-permission-continuation"
|
||||||
|
export const CONTINUATION_PROMPT = "continue"
|
||||||
|
|
||||||
|
export const DEFAULT_STALL_PATTERNS = [
|
||||||
|
"if you want",
|
||||||
|
"would you like",
|
||||||
|
"shall i",
|
||||||
|
"do you want me to",
|
||||||
|
"let me know if",
|
||||||
|
] as const
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { createGptPermissionContinuationHook } from "."
|
||||||
|
|
||||||
|
type SessionMessage = {
|
||||||
|
info: {
|
||||||
|
id: string
|
||||||
|
role: "user" | "assistant"
|
||||||
|
model?: {
|
||||||
|
providerID?: string
|
||||||
|
modelID?: string
|
||||||
|
}
|
||||||
|
modelID?: string
|
||||||
|
}
|
||||||
|
parts?: Array<{ type: string; text?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockPluginInput(messages: SessionMessage[]) {
|
||||||
|
const promptCalls: string[] = []
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
directory: "/tmp/test",
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
messages: async () => ({ data: messages }),
|
||||||
|
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 {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
|
||||||
|
return { ctx, promptCalls }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gpt-permission-continuation", () => {
|
||||||
|
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
|
||||||
|
expect(promptCalls).toEqual(["continue"])
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(promptCalls).toEqual(["continue"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
import { normalizeSDKResponse } from "../../shared"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractAssistantText,
|
||||||
|
getLastAssistantMessage,
|
||||||
|
isGptAssistantMessage,
|
||||||
|
type SessionMessage,
|
||||||
|
} from "./assistant-message"
|
||||||
|
import { CONTINUATION_PROMPT, HOOK_NAME } from "./constants"
|
||||||
|
import { detectStallPattern } from "./detector"
|
||||||
|
import type { SessionStateStore } from "./session-state"
|
||||||
|
|
||||||
|
async function promptContinuation(
|
||||||
|
ctx: PluginInput,
|
||||||
|
sessionID: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = {
|
||||||
|
path: { id: sessionID },
|
||||||
|
body: {
|
||||||
|
parts: [{ type: "text" as const, text: CONTINUATION_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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
state.inFlight = true
|
||||||
|
await promptContinuation(ctx, sessionID)
|
||||||
|
state.lastHandledMessageID = messageID
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
type SessionState = {
|
||||||
|
inFlight: boolean
|
||||||
|
lastHandledMessageID?: 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,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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).toEqual(["continue"])
|
||||||
|
expect(toastCalls).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,6 +30,7 @@ export { createCategorySkillReminderHook } from "./category-skill-reminder";
|
|||||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||||
export { createNoSisyphusGptHook } from "./no-sisyphus-gpt";
|
export { createNoSisyphusGptHook } from "./no-sisyphus-gpt";
|
||||||
export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt";
|
export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt";
|
||||||
|
export { createGptPermissionContinuationHook, type GptPermissionContinuationHook } from "./gpt-permission-continuation"
|
||||||
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
||||||
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function createTodoContinuationHandler(args: {
|
|||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
skipAgents?: string[]
|
skipAgents?: string[]
|
||||||
isContinuationStopped?: (sessionID: string) => boolean
|
isContinuationStopped?: (sessionID: string) => boolean
|
||||||
|
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||||
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||||
const {
|
const {
|
||||||
ctx,
|
ctx,
|
||||||
@@ -24,6 +25,7 @@ export function createTodoContinuationHandler(args: {
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||||
isContinuationStopped,
|
isContinuationStopped,
|
||||||
|
shouldSkipContinuation,
|
||||||
} = args
|
} = args
|
||||||
|
|
||||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||||
@@ -56,6 +58,7 @@ export function createTodoContinuationHandler(args: {
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
skipAgents,
|
skipAgents,
|
||||||
isContinuationStopped,
|
isContinuationStopped,
|
||||||
|
shouldSkipContinuation,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export async function handleSessionIdle(args: {
|
|||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
skipAgents?: string[]
|
skipAgents?: string[]
|
||||||
isContinuationStopped?: (sessionID: string) => boolean
|
isContinuationStopped?: (sessionID: string) => boolean
|
||||||
|
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const {
|
const {
|
||||||
ctx,
|
ctx,
|
||||||
@@ -37,6 +38,7 @@ export async function handleSessionIdle(args: {
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||||
isContinuationStopped,
|
isContinuationStopped,
|
||||||
|
shouldSkipContinuation,
|
||||||
} = args
|
} = args
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||||
@@ -186,6 +188,11 @@ export async function handleSessionIdle(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldSkipContinuation?.(sessionID)) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount, todos)
|
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount, todos)
|
||||||
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
|
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function createTodoContinuationEnforcer(
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||||
isContinuationStopped,
|
isContinuationStopped,
|
||||||
|
shouldSkipContinuation,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const sessionStateStore = createSessionStateStore()
|
const sessionStateStore = createSessionStateStore()
|
||||||
@@ -42,6 +43,7 @@ export function createTodoContinuationEnforcer(
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
skipAgents,
|
skipAgents,
|
||||||
isContinuationStopped,
|
isContinuationStopped,
|
||||||
|
shouldSkipContinuation,
|
||||||
})
|
})
|
||||||
|
|
||||||
const cancelAllCountdowns = (): void => {
|
const cancelAllCountdowns = (): void => {
|
||||||
|
|||||||
@@ -1706,6 +1706,27 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(0)
|
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 () => {
|
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
|
// given - session where continuation is not stopped at idle time but stops during countdown
|
||||||
const sessionID = "main-race-condition"
|
const sessionID = "main-race-condition"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface TodoContinuationEnforcerOptions {
|
|||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
skipAgents?: string[]
|
skipAgents?: string[]
|
||||||
isContinuationStopped?: (sessionID: string) => boolean
|
isContinuationStopped?: (sessionID: string) => boolean
|
||||||
|
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TodoContinuationEnforcer {
|
export interface TodoContinuationEnforcer {
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export function createEventHandler(args: {
|
|||||||
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
|
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
|
||||||
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
|
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
|
||||||
await Promise.resolve(hooks.sessionNotification?.(input));
|
await Promise.resolve(hooks.sessionNotification?.(input));
|
||||||
|
await Promise.resolve(hooks.gptPermissionContinuation?.handler?.(input));
|
||||||
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
|
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
|
||||||
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
|
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
|
||||||
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
|
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BackgroundManager } from "../../features/background-agent"
|
|||||||
import type { PluginContext } from "../types"
|
import type { PluginContext } from "../types"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createGptPermissionContinuationHook,
|
||||||
createTodoContinuationEnforcer,
|
createTodoContinuationEnforcer,
|
||||||
createBackgroundNotificationHook,
|
createBackgroundNotificationHook,
|
||||||
createStopContinuationGuardHook,
|
createStopContinuationGuardHook,
|
||||||
@@ -14,6 +15,7 @@ import { safeCreateHook } from "../../shared/safe-create-hook"
|
|||||||
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
|
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
|
||||||
|
|
||||||
export type ContinuationHooks = {
|
export type ContinuationHooks = {
|
||||||
|
gptPermissionContinuation: ReturnType<typeof createGptPermissionContinuationHook> | null
|
||||||
stopContinuationGuard: ReturnType<typeof createStopContinuationGuardHook> | null
|
stopContinuationGuard: ReturnType<typeof createStopContinuationGuardHook> | null
|
||||||
compactionContextInjector: ReturnType<typeof createCompactionContextInjector> | null
|
compactionContextInjector: ReturnType<typeof createCompactionContextInjector> | null
|
||||||
compactionTodoPreserver: ReturnType<typeof createCompactionTodoPreserverHook> | null
|
compactionTodoPreserver: ReturnType<typeof createCompactionTodoPreserverHook> | null
|
||||||
@@ -55,6 +57,13 @@ export function createContinuationHooks(args: {
|
|||||||
}))
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const gptPermissionContinuation = isHookEnabled("gpt-permission-continuation")
|
||||||
|
? safeHook("gpt-permission-continuation", () =>
|
||||||
|
createGptPermissionContinuationHook(ctx, {
|
||||||
|
isContinuationStopped: stopContinuationGuard?.isStopped,
|
||||||
|
}))
|
||||||
|
: null
|
||||||
|
|
||||||
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
||||||
? safeHook("compaction-context-injector", () =>
|
? safeHook("compaction-context-injector", () =>
|
||||||
createCompactionContextInjector({ ctx, backgroundManager }))
|
createCompactionContextInjector({ ctx, backgroundManager }))
|
||||||
@@ -66,9 +75,11 @@ export function createContinuationHooks(args: {
|
|||||||
|
|
||||||
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
||||||
? safeHook("todo-continuation-enforcer", () =>
|
? safeHook("todo-continuation-enforcer", () =>
|
||||||
createTodoContinuationEnforcer(ctx, {
|
createTodoContinuationEnforcer(ctx, {
|
||||||
backgroundManager,
|
backgroundManager,
|
||||||
isContinuationStopped: stopContinuationGuard?.isStopped,
|
isContinuationStopped: stopContinuationGuard?.isStopped,
|
||||||
|
shouldSkipContinuation: (sessionID: string) =>
|
||||||
|
gptPermissionContinuation?.wasRecentlyInjected(sessionID) ?? false,
|
||||||
}))
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
@@ -111,12 +122,15 @@ export function createContinuationHooks(args: {
|
|||||||
backgroundManager,
|
backgroundManager,
|
||||||
isContinuationStopped: (sessionID: string) =>
|
isContinuationStopped: (sessionID: string) =>
|
||||||
stopContinuationGuard?.isStopped(sessionID) ?? false,
|
stopContinuationGuard?.isStopped(sessionID) ?? false,
|
||||||
|
shouldSkipContinuation: (sessionID: string) =>
|
||||||
|
gptPermissionContinuation?.wasRecentlyInjected(sessionID) ?? false,
|
||||||
agentOverrides: pluginConfig.agents,
|
agentOverrides: pluginConfig.agents,
|
||||||
autoCommit: pluginConfig.start_work?.auto_commit,
|
autoCommit: pluginConfig.start_work?.auto_commit,
|
||||||
}))
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
gptPermissionContinuation,
|
||||||
stopContinuationGuard,
|
stopContinuationGuard,
|
||||||
compactionContextInjector,
|
compactionContextInjector,
|
||||||
compactionTodoPreserver,
|
compactionTodoPreserver,
|
||||||
|
|||||||
Reference in New Issue
Block a user