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 {