diff --git a/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts b/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts
index c4068566f..98e2e2ae8 100644
--- a/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts
+++ b/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts
@@ -1,5 +1,6 @@
///
+import { createOpencodeClient } from "@opencode-ai/sdk"
import { describe, expect, it as test } from "bun:test"
import { createGptPermissionContinuationHook } from "."
@@ -17,25 +18,79 @@ type SessionMessage = {
parts?: Array<{ type: string; text?: string }>
}
-function createMockPluginInput(messages: SessionMessage[]) {
- const promptCalls: string[] = []
+type GptPermissionContext = Parameters[0]
- 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 {}
- },
+function isRecord(value: unknown): value is Record {
+ 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
},
},
- } as any
+ )
+ 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 }
}
@@ -245,5 +300,35 @@ describe("gpt-permission-continuation", () => {
expect(promptCalls).toEqual(["continue"])
})
})
+
+ 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
+ expect(promptCalls).toEqual(["continue", "continue", "continue", "continue"])
+ })
+ })
})
})
diff --git a/src/hooks/gpt-permission-continuation/handler.ts b/src/hooks/gpt-permission-continuation/handler.ts
index 252f8e357..27f28530f 100644
--- a/src/hooks/gpt-permission-continuation/handler.ts
+++ b/src/hooks/gpt-permission-continuation/handler.ts
@@ -67,6 +67,7 @@ function extractPermissionPhrase(text: string): string | null {
function resetAutoContinuationState(state: SessionState): void {
state.consecutiveAutoContinueCount = 0
+ state.awaitingAutoContinuationResponse = false
state.lastAutoContinuePermissionPhrase = undefined
}
@@ -117,8 +118,17 @@ export function createGptPermissionContinuationHandler(args: {
const lastAssistantIndex = messages.lastIndexOf(lastAssistantMessage)
const previousUserMessage = getLastUserMessageBefore(messages, lastAssistantIndex)
- if (previousUserMessage && !isAutoContinuationUserMessage(previousUserMessage)) {
+ 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
@@ -174,6 +184,7 @@ export function createGptPermissionContinuationHandler(args: {
await promptContinuation(ctx, sessionID)
state.lastHandledMessageID = messageID
state.consecutiveAutoContinueCount += 1
+ state.awaitingAutoContinuationResponse = true
state.lastAutoContinuePermissionPhrase = permissionPhrase
state.lastInjectedAt = Date.now()
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
diff --git a/src/hooks/gpt-permission-continuation/session-state.ts b/src/hooks/gpt-permission-continuation/session-state.ts
index fc3497235..9414692e4 100644
--- a/src/hooks/gpt-permission-continuation/session-state.ts
+++ b/src/hooks/gpt-permission-continuation/session-state.ts
@@ -1,6 +1,7 @@
type SessionState = {
inFlight: boolean
consecutiveAutoContinueCount: number
+ awaitingAutoContinuationResponse: boolean
lastHandledMessageID?: string
lastAutoContinuePermissionPhrase?: string
lastInjectedAt?: number
@@ -18,6 +19,7 @@ export function createSessionStateStore() {
const created: SessionState = {
inFlight: false,
consecutiveAutoContinueCount: 0,
+ awaitingAutoContinuationResponse: false,
}
states.set(sessionID, created)
return created
diff --git a/src/plugin-handlers/agent-config-handler.test.ts b/src/plugin-handlers/agent-config-handler.test.ts
index c0d13016c..064e86116 100644
--- a/src/plugin-handlers/agent-config-handler.test.ts
+++ b/src/plugin-handlers/agent-config-handler.test.ts
@@ -13,6 +13,8 @@ import { applyAgentConfig } from "./agent-config-handler"
import type { PluginComponents } from "./plugin-components-loader"
const BUILTIN_SISYPHUS_DISPLAY_NAME = getAgentDisplayName("sisyphus")
+const BUILTIN_SISYPHUS_JUNIOR_DISPLAY_NAME = getAgentDisplayName("sisyphus-junior")
+const BUILTIN_MULTIMODAL_LOOKER_DISPLAY_NAME = getAgentDisplayName("multimodal-looker")
function createPluginComponents(): PluginComponents {
return {
@@ -66,6 +68,12 @@ describe("applyAgentConfig builtin override protection", () => {
mode: "subagent",
}
+ const builtinMultimodalLookerConfig: AgentConfig = {
+ name: "multimodal-looker",
+ prompt: "multimodal prompt",
+ mode: "subagent",
+ }
+
const sisyphusJuniorConfig: AgentConfig = {
name: "Sisyphus-Junior",
prompt: "junior prompt",
@@ -76,6 +84,7 @@ describe("applyAgentConfig builtin override protection", () => {
createBuiltinAgentsSpy = spyOn(agents, "createBuiltinAgents").mockResolvedValue({
sisyphus: builtinSisyphusConfig,
oracle: builtinOracleConfig,
+ "multimodal-looker": builtinMultimodalLookerConfig,
})
createSisyphusJuniorAgentSpy = spyOn(
@@ -194,4 +203,56 @@ describe("applyAgentConfig builtin override protection", () => {
// then
expect(result[BUILTIN_SISYPHUS_DISPLAY_NAME]).toEqual(builtinSisyphusConfig)
})
+
+ describe("#given protected builtin agents use hyphenated names", () => {
+ describe("#when a user agent uses the underscored multimodal looker alias", () => {
+ test("filters the override", async () => {
+ // given
+ loadUserAgentsSpy.mockReturnValue({
+ multimodal_looker: {
+ name: "multimodal_looker",
+ prompt: "user multimodal alias prompt",
+ mode: "subagent",
+ },
+ })
+
+ // when
+ const result = await applyAgentConfig({
+ config: createBaseConfig(),
+ pluginConfig: createPluginConfig(),
+ ctx: { directory: "/tmp" },
+ pluginComponents: createPluginComponents(),
+ })
+
+ // then
+ expect(result[BUILTIN_MULTIMODAL_LOOKER_DISPLAY_NAME]).toEqual(builtinMultimodalLookerConfig)
+ expect(result.multimodal_looker).toBeUndefined()
+ })
+ })
+
+ describe("#when a user agent uses the underscored sisyphus junior alias", () => {
+ test("filters the override", async () => {
+ // given
+ loadUserAgentsSpy.mockReturnValue({
+ sisyphus_junior: {
+ name: "sisyphus_junior",
+ prompt: "user junior alias prompt",
+ mode: "subagent",
+ },
+ })
+
+ // when
+ const result = await applyAgentConfig({
+ config: createBaseConfig(),
+ pluginConfig: createPluginConfig(),
+ ctx: { directory: "/tmp" },
+ pluginComponents: createPluginComponents(),
+ })
+
+ // then
+ expect(result[BUILTIN_SISYPHUS_JUNIOR_DISPLAY_NAME]).toEqual(sisyphusJuniorConfig)
+ expect(result.sisyphus_junior).toBeUndefined()
+ })
+ })
+ })
})
diff --git a/src/plugin-handlers/agent-override-protection.ts b/src/plugin-handlers/agent-override-protection.ts
index 389a54af9..1954b6529 100644
--- a/src/plugin-handlers/agent-override-protection.ts
+++ b/src/plugin-handlers/agent-override-protection.ts
@@ -1,7 +1,12 @@
const PARENTHETICAL_SUFFIX_PATTERN = /\s*(\([^)]*\)\s*)+$/u
export function normalizeProtectedAgentName(agentName: string): string {
- return agentName.trim().toLowerCase().replace(PARENTHETICAL_SUFFIX_PATTERN, "").trim()
+ return agentName
+ .trim()
+ .toLowerCase()
+ .replace(PARENTHETICAL_SUFFIX_PATTERN, "")
+ .replace(/[-_]/g, "")
+ .trim()
}
export function createProtectedAgentNameSet(agentNames: Iterable): Set {