diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts index 7b3aa8d7a..623b35efd 100644 --- a/src/config/schema/agent-overrides.ts +++ b/src/config/schema/agent-overrides.ts @@ -59,7 +59,9 @@ export const AgentOverridesSchema = z.object({ build: AgentOverrideConfigSchema.optional(), plan: AgentOverrideConfigSchema.optional(), sisyphus: AgentOverrideConfigSchema.optional(), - hephaestus: AgentOverrideConfigSchema.optional(), + hephaestus: AgentOverrideConfigSchema.extend({ + allow_non_gpt_model: z.boolean().optional(), + }).optional(), "sisyphus-junior": AgentOverrideConfigSchema.optional(), "OpenCode-Builder": AgentOverrideConfigSchema.optional(), prometheus: AgentOverrideConfigSchema.optional(), diff --git a/src/hooks/no-hephaestus-non-gpt/hook.ts b/src/hooks/no-hephaestus-non-gpt/hook.ts index a1d08a2a1..e621c6d01 100644 --- a/src/hooks/no-hephaestus-non-gpt/hook.ts +++ b/src/hooks/no-hephaestus-non-gpt/hook.ts @@ -12,12 +12,16 @@ const TOAST_MESSAGE = [ ].join("\n") const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") -function showToast(ctx: PluginInput, sessionID: string): void { +type NoHephaestusNonGptHookOptions = { + allowNonGptModel?: boolean +} + +function showToast(ctx: PluginInput, sessionID: string, variant: "error" | "warning"): void { ctx.client.tui.showToast({ body: { title: TOAST_TITLE, message: TOAST_MESSAGE, - variant: "error", + variant, duration: 10000, }, }).catch((error) => { @@ -28,7 +32,10 @@ function showToast(ctx: PluginInput, sessionID: string): void { }) } -export function createNoHephaestusNonGptHook(ctx: PluginInput) { +export function createNoHephaestusNonGptHook( + ctx: PluginInput, + options?: NoHephaestusNonGptHookOptions, +) { return { "chat.message": async (input: { sessionID: string @@ -40,9 +47,13 @@ export function createNoHephaestusNonGptHook(ctx: PluginInput) { const rawAgent = input.agent ?? getSessionAgent(input.sessionID) ?? "" const agentKey = getAgentConfigKey(rawAgent) const modelID = input.model?.modelID + const allowNonGptModel = options?.allowNonGptModel === true if (agentKey === "hephaestus" && modelID && !isGptModel(modelID)) { - showToast(ctx, input.sessionID) + showToast(ctx, input.sessionID, allowNonGptModel ? "warning" : "error") + if (allowNonGptModel) { + return + } input.agent = SISYPHUS_DISPLAY if (output?.message) { output.message.agent = SISYPHUS_DISPLAY diff --git a/src/hooks/no-hephaestus-non-gpt/index.test.ts b/src/hooks/no-hephaestus-non-gpt/index.test.ts index 51e1f3a0a..3440cccc8 100644 --- a/src/hooks/no-hephaestus-non-gpt/index.test.ts +++ b/src/hooks/no-hephaestus-non-gpt/index.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, expect, spyOn, test } from "bun:test" import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state" import { getAgentDisplayName } from "../../shared/agent-display-names" @@ -8,7 +10,7 @@ const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") function createOutput() { return { - message: {}, + message: {} as { agent?: string; [key: string]: unknown }, parts: [], } } @@ -16,7 +18,7 @@ function createOutput() { describe("no-hephaestus-non-gpt hook", () => { test("shows toast on every chat.message when hephaestus uses non-gpt model", async () => { // given - hephaestus with claude model - const showToast = spyOn({ fn: async () => ({}) }, "fn") + const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") const hook = createNoHephaestusNonGptHook({ client: { tui: { showToast } }, } as any) @@ -49,9 +51,38 @@ describe("no-hephaestus-non-gpt hook", () => { }) }) + test("shows warning and does not switch agent when allow_non_gpt_model is enabled", async () => { + // given - hephaestus with claude model and opt-out enabled + const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") + const hook = createNoHephaestusNonGptHook({ + client: { tui: { showToast } }, + } as any, { + allowNonGptModel: true, + }) + + const output = createOutput() + + // when - chat.message runs + await hook["chat.message"]?.({ + sessionID: "ses_opt_out", + agent: HEPHAESTUS_DISPLAY, + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + }, output) + + // then - warning toast is shown but agent is not switched + expect(showToast).toHaveBeenCalledTimes(1) + expect(output.message.agent).toBeUndefined() + expect(showToast.mock.calls[0]?.[0]).toMatchObject({ + body: { + title: "NEVER Use Hephaestus with Non-GPT", + variant: "warning", + }, + }) + }) + test("does not show toast when hephaestus uses gpt model", async () => { // given - hephaestus with gpt model - const showToast = spyOn({ fn: async () => ({}) }, "fn") + const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") const hook = createNoHephaestusNonGptHook({ client: { tui: { showToast } }, } as any) @@ -72,7 +103,7 @@ describe("no-hephaestus-non-gpt hook", () => { test("does not show toast for non-hephaestus agent", async () => { // given - sisyphus with claude model (non-gpt) - const showToast = spyOn({ fn: async () => ({}) }, "fn") + const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") const hook = createNoHephaestusNonGptHook({ client: { tui: { showToast } }, } as any) @@ -95,7 +126,7 @@ describe("no-hephaestus-non-gpt hook", () => { // given - session agent saved as hephaestus _resetForTesting() updateSessionAgent("ses_4", HEPHAESTUS_DISPLAY) - const showToast = spyOn({ fn: async () => ({}) }, "fn") + const showToast = spyOn({ fn: async (_input: unknown) => ({}) }, "fn") const hook = createNoHephaestusNonGptHook({ client: { tui: { showToast } }, } as any) diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index daa4e12e0..daa5e4ff5 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -232,7 +232,10 @@ export function createSessionHooks(args: { : null const noHephaestusNonGpt = isHookEnabled("no-hephaestus-non-gpt") - ? safeHook("no-hephaestus-non-gpt", () => createNoHephaestusNonGptHook(ctx)) + ? safeHook("no-hephaestus-non-gpt", () => + createNoHephaestusNonGptHook(ctx, { + allowNonGptModel: pluginConfig.agents?.hephaestus?.allow_non_gpt_model, + })) : null const questionLabelTruncator = isHookEnabled("question-label-truncator")