From 3dba1c49d4b8f62f2ab786d4933462f64ca01439 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 20 Feb 2026 10:49:04 +0900 Subject: [PATCH] feat(hooks): add no-hephaestus-non-gpt hook to enforce GPT-only for Hephaestus --- src/config/schema/hooks.ts | 1 + src/hooks/index.ts | 1 + src/hooks/no-hephaestus-non-gpt/hook.ts | 54 ++++++++ src/hooks/no-hephaestus-non-gpt/index.test.ts | 115 ++++++++++++++++++ src/hooks/no-hephaestus-non-gpt/index.ts | 1 + src/plugin/chat-message.ts | 1 + src/plugin/hooks/create-session-hooks.ts | 7 ++ 7 files changed, 180 insertions(+) create mode 100644 src/hooks/no-hephaestus-non-gpt/hook.ts create mode 100644 src/hooks/no-hephaestus-non-gpt/index.test.ts create mode 100644 src/hooks/no-hephaestus-non-gpt/index.ts diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index f769d5e12..d6574df99 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -38,6 +38,7 @@ export const HookNameSchema = z.enum([ "prometheus-md-only", "sisyphus-junior-notepad", "no-sisyphus-gpt", + "no-hephaestus-non-gpt", "start-work", "atlas", "unstable-agent-babysitter", diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 37f99df81..72845f671 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -28,6 +28,7 @@ export { createThinkingBlockValidatorHook } from "./thinking-block-validator"; 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 { createAutoSlashCommandHook } from "./auto-slash-command"; export { createEditErrorRecoveryHook } from "./edit-error-recovery"; export { createJsonErrorRecoveryHook } from "./json-error-recovery"; diff --git a/src/hooks/no-hephaestus-non-gpt/hook.ts b/src/hooks/no-hephaestus-non-gpt/hook.ts new file mode 100644 index 000000000..f7e1bdb05 --- /dev/null +++ b/src/hooks/no-hephaestus-non-gpt/hook.ts @@ -0,0 +1,54 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { isGptModel } from "../../agents/types" +import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state" +import { log } from "../../shared" +import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names" + +const TOAST_TITLE = "NEVER Use Hephaestus with Non-GPT" +const TOAST_MESSAGE = [ + "Hephaestus is designed exclusively for GPT models.", + "Hephaestus + non-GPT performs worse than vanilla Sisyphus.", + "For Claude/Kimi/GLM models, always use Sisyphus.", +].join("\n") +const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") + +function showToast(ctx: PluginInput, sessionID: string): void { + ctx.client.tui.showToast({ + body: { + title: TOAST_TITLE, + message: TOAST_MESSAGE, + variant: "error", + duration: 10000, + }, + }).catch((error) => { + log("[no-hephaestus-non-gpt] Failed to show toast", { + sessionID, + error, + }) + }) +} + +export function createNoHephaestusNonGptHook(ctx: PluginInput) { + return { + "chat.message": async (input: { + sessionID: string + agent?: string + model?: { providerID: string; modelID: string } + }, output?: { + message?: { agent?: string; [key: string]: unknown } + }): Promise => { + const rawAgent = input.agent ?? getSessionAgent(input.sessionID) ?? "" + const agentKey = getAgentConfigKey(rawAgent) + const modelID = input.model?.modelID + + if (agentKey === "hephaestus" && modelID && !isGptModel(modelID)) { + showToast(ctx, input.sessionID) + input.agent = SISYPHUS_DISPLAY + if (output?.message) { + output.message.agent = SISYPHUS_DISPLAY + } + updateSessionAgent(input.sessionID, SISYPHUS_DISPLAY) + } + }, + } +} diff --git a/src/hooks/no-hephaestus-non-gpt/index.test.ts b/src/hooks/no-hephaestus-non-gpt/index.test.ts new file mode 100644 index 000000000..3f606d33f --- /dev/null +++ b/src/hooks/no-hephaestus-non-gpt/index.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, spyOn, test } from "bun:test" +import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state" +import { getAgentDisplayName } from "../../shared/agent-display-names" +import { createNoHephaestusNonGptHook } from "./index" + +const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus") +const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") + +function createOutput() { + return { + message: {}, + parts: [], + } +} + +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 hook = createNoHephaestusNonGptHook({ + client: { tui: { showToast } }, + } as any) + + const output1 = createOutput() + const output2 = createOutput() + + // when - chat.message is called repeatedly + await hook["chat.message"]?.({ + sessionID: "ses_1", + agent: HEPHAESTUS_DISPLAY, + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + }, output1) + await hook["chat.message"]?.({ + sessionID: "ses_1", + agent: HEPHAESTUS_DISPLAY, + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + }, output2) + + // then - toast is shown and agent is switched to sisyphus + expect(showToast).toHaveBeenCalledTimes(2) + expect(output1.message.agent).toBe(SISYPHUS_DISPLAY) + expect(output2.message.agent).toBe(SISYPHUS_DISPLAY) + expect(showToast.mock.calls[0]?.[0]).toMatchObject({ + body: { + title: "NEVER Use Hephaestus with Non-GPT", + message: expect.stringContaining("For Claude/Kimi/GLM models, always use Sisyphus."), + variant: "error", + }, + }) + }) + + test("does not show toast when hephaestus uses gpt model", async () => { + // given - hephaestus with gpt model + const showToast = spyOn({ fn: async () => ({}) }, "fn") + const hook = createNoHephaestusNonGptHook({ + client: { tui: { showToast } }, + } as any) + + const output = createOutput() + + // when - chat.message runs + await hook["chat.message"]?.({ + sessionID: "ses_2", + agent: HEPHAESTUS_DISPLAY, + model: { providerID: "openai", modelID: "gpt-5.3-codex" }, + }, output) + + // then - no toast, agent unchanged + expect(showToast).toHaveBeenCalledTimes(0) + expect(output.message.agent).toBeUndefined() + }) + + test("does not show toast for non-hephaestus agent", async () => { + // given - sisyphus with claude model (non-gpt) + const showToast = spyOn({ fn: async () => ({}) }, "fn") + const hook = createNoHephaestusNonGptHook({ + client: { tui: { showToast } }, + } as any) + + const output = createOutput() + + // when - chat.message runs + await hook["chat.message"]?.({ + sessionID: "ses_3", + agent: SISYPHUS_DISPLAY, + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + }, output) + + // then - no toast + expect(showToast).toHaveBeenCalledTimes(0) + expect(output.message.agent).toBeUndefined() + }) + + test("uses session agent fallback when input agent is missing", async () => { + // given - session agent saved as hephaestus + _resetForTesting() + updateSessionAgent("ses_4", HEPHAESTUS_DISPLAY) + const showToast = spyOn({ fn: async () => ({}) }, "fn") + const hook = createNoHephaestusNonGptHook({ + client: { tui: { showToast } }, + } as any) + + const output = createOutput() + + // when - chat.message runs without input.agent + await hook["chat.message"]?.({ + sessionID: "ses_4", + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + }, output) + + // then - toast shown via session-agent fallback, switched to sisyphus + expect(showToast).toHaveBeenCalledTimes(1) + expect(output.message.agent).toBe(SISYPHUS_DISPLAY) + }) +}) diff --git a/src/hooks/no-hephaestus-non-gpt/index.ts b/src/hooks/no-hephaestus-non-gpt/index.ts new file mode 100644 index 000000000..65cebcbb5 --- /dev/null +++ b/src/hooks/no-hephaestus-non-gpt/index.ts @@ -0,0 +1 @@ +export { createNoHephaestusNonGptHook } from "./hook" diff --git a/src/plugin/chat-message.ts b/src/plugin/chat-message.ts index 69340fa1f..cc296a7d6 100644 --- a/src/plugin/chat-message.ts +++ b/src/plugin/chat-message.ts @@ -83,6 +83,7 @@ export function createChatMessageHandler(args: { await hooks.claudeCodeHooks?.["chat.message"]?.(input, output) await hooks.autoSlashCommand?.["chat.message"]?.(input, output) await hooks.noSisyphusGpt?.["chat.message"]?.(input, output) + await hooks.noHephaestusNonGpt?.["chat.message"]?.(input, output) if (hooks.startWork && isStartWorkHookOutput(output)) { await hooks.startWork["chat.message"]?.(input, output) } diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index f7047a90a..e2596011c 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -21,6 +21,7 @@ import { createPrometheusMdOnlyHook, createSisyphusJuniorNotepadHook, createNoSisyphusGptHook, + createNoHephaestusNonGptHook, createQuestionLabelTruncatorHook, createPreemptiveCompactionHook, } from "../../hooks" @@ -52,6 +53,7 @@ export type SessionHooks = { prometheusMdOnly: ReturnType | null sisyphusJuniorNotepad: ReturnType | null noSisyphusGpt: ReturnType | null + noHephaestusNonGpt: ReturnType | null questionLabelTruncator: ReturnType taskResumeInfo: ReturnType anthropicEffort: ReturnType | null @@ -162,6 +164,10 @@ export function createSessionHooks(args: { ? safeHook("no-sisyphus-gpt", () => createNoSisyphusGptHook(ctx)) : null + const noHephaestusNonGpt = isHookEnabled("no-hephaestus-non-gpt") + ? safeHook("no-hephaestus-non-gpt", () => createNoHephaestusNonGptHook(ctx)) + : null + const questionLabelTruncator = createQuestionLabelTruncatorHook() const taskResumeInfo = createTaskResumeInfoHook() @@ -188,6 +194,7 @@ export function createSessionHooks(args: { prometheusMdOnly, sisyphusJuniorNotepad, noSisyphusGpt, + noHephaestusNonGpt, questionLabelTruncator, taskResumeInfo, anthropicEffort,