fix(hooks): select prompt provider and model

This commit is contained in:
ntduc committed 2026-07-22 19:49:15 +07:00
1 parent 876104950b
commit fe5a3c7f92
16 files changed
+335 -132

No files matched your search

+3 -1
View File
@@ -107,7 +107,8 @@ function handle(event) {
"matcher": "^(exec|shell|write_file)$",
"config": {
"prompt_template": "Evaluate safety of this tool call.",
"model": "haiku",
"provider": "openai",
"model": "gpt-4.1-mini",
"max_invocations_per_turn": 5
}
}
@@ -116,6 +117,7 @@ function handle(event) {
Required:
- `prompt_template` — system-level instruction the evaluator receives.
- `matcher` or `if_expr` — runaway-cost guard; prevents firing the LLM on every event.
- `provider` and `model` — required for prompt hooks created or edited in the dashboard; select the pair from the configured provider catalog. Legacy model-only hooks remain supported at runtime.
Safeguards:
- **Structured output**: evaluator MUST call a `decide(decision, reason, injection_detected, updated_input)` tool. Free-text responses fail-closed.
+18 -7
View File
@@ -24,11 +24,11 @@ import (
// ── Public surface ──────────────────────────────────────────────────────────
// ProviderResolver returns a provider + resolved model name for a given
// (tenantID, preferredModel). preferredModel is the UI/config-specified
// model (e.g. "haiku"); resolver may expand aliases or fall back to the
// tenant's default when the alias is unknown.
// tenant. preferredProvider and preferredModel come from hook config. Legacy
// hooks may omit preferredProvider, in which case the resolver applies its
// backward-compatible alias/config fallback chain.
type ProviderResolver interface {
ResolveForHook(ctx context.Context, tenantID uuid.UUID, preferredModel string) (providers.Provider, string, error)
ResolveForHook(ctx context.Context, tenantID uuid.UUID, preferredProvider, preferredModel string) (providers.Provider, string, error)
}
// PromptHandler implements hooks.Handler via an LLM structured-output call.
@@ -150,8 +150,9 @@ func (h *PromptHandler) Execute(ctx context.Context, cfg hooks.HookConfig, ev ho
}
// 4. Resolve provider
model := h.modelFor(cfg)
provider, resolvedModel, err := h.Resolver.ResolveForHook(ctx, ev.TenantID, model)
providerName := h.providerFor(cfg)
model := h.modelFor(cfg, providerName != "")
provider, resolvedModel, err := h.Resolver.ResolveForHook(ctx, ev.TenantID, providerName, model)
if err != nil || provider == nil {
return hooks.DecisionError, fmt.Errorf("hook: prompt handler: resolve provider: %w", err)
}
@@ -233,10 +234,20 @@ func (h *PromptHandler) maxInvocations(cfg hooks.HookConfig) int {
return defaultPromptMaxInvocations
}
func (h *PromptHandler) modelFor(cfg hooks.HookConfig) string {
func (h *PromptHandler) providerFor(cfg hooks.HookConfig) string {
provider, _ := cfg.Config["provider"].(string)
return strings.TrimSpace(provider)
}
func (h *PromptHandler) modelFor(cfg hooks.HookConfig, hasExplicitProvider bool) string {
if m, _ := cfg.Config["model"].(string); m != "" {
return m
}
// An explicit provider with no model means "use that provider's default".
// Only legacy model-only hooks inherit the historical Haiku fallback.
if hasExplicitProvider {
return ""
}
if h.DefaultModel != "" {
return h.DefaultModel
}
+33 -13
View File
@@ -3,6 +3,7 @@ package handlers
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
@@ -15,17 +16,18 @@ import (
// the ProviderResolver interface consumed by PromptHandler. It applies a
// simple fallback chain:
//
// 1. Explicit model alias → map to provider by convention (haiku/sonnet/opus → anthropic).
// 2. System config `hooks.prompt.provider` / `hooks.prompt.model`.
// 3. System config `background.provider` / `background.model`.
// 4. First registered provider for the tenant.
// 1. Explicit provider + model from the hook config.
// 2. Legacy explicit model alias → map to provider by convention.
// 3. System config `hooks.prompt.provider` / `hooks.prompt.model`.
// 4. System config `background.provider` / `background.model`.
// 5. First registered provider for the tenant.
//
// Keeping this adapter in `handlers` rather than the higher-level
// `providerresolve` package avoids a new import cycle (providerresolve →
// store → hooks would introduce a diamond).
type RegistryResolver struct {
Registry *providers.Registry
SysConfig store.SystemConfigStore
Registry *providers.Registry
SysConfig store.SystemConfigStore
DefaultProviderForAlias func(alias string) string
}
@@ -33,19 +35,36 @@ type RegistryResolver struct {
// registry MUST be non-nil. sysConfig may be nil (fallback to step 4 only).
func NewRegistryResolver(registry *providers.Registry, sysConfig store.SystemConfigStore) *RegistryResolver {
return &RegistryResolver{
Registry: registry,
SysConfig: sysConfig,
Registry: registry,
SysConfig: sysConfig,
DefaultProviderForAlias: defaultProviderForAlias,
}
}
// ResolveForHook implements ProviderResolver.
func (r *RegistryResolver) ResolveForHook(ctx context.Context, tenantID uuid.UUID, preferredModel string) (providers.Provider, string, error) {
func (r *RegistryResolver) ResolveForHook(ctx context.Context, tenantID uuid.UUID, preferredProvider, preferredModel string) (providers.Provider, string, error) {
if r == nil || r.Registry == nil {
return nil, "", errors.New("hook resolver: nil registry")
}
// Step 1: explicit alias → provider name.
// Step 1: an explicit provider is authoritative. Never silently route its
// model to another provider when a hook is misconfigured.
if providerName := strings.TrimSpace(preferredProvider); providerName != "" {
p, err := r.Registry.GetForTenant(tenantID, providerName)
if err != nil {
return nil, "", fmt.Errorf("hook resolver: configured provider %q: %w", providerName, err)
}
if p == nil {
return nil, "", fmt.Errorf("hook resolver: configured provider %q returned nil", providerName)
}
model := strings.TrimSpace(preferredModel)
if model == "" {
model = p.DefaultModel()
}
return p, model, nil
}
// Step 2: legacy explicit alias → provider name.
if preferredModel != "" {
if name := r.providerForAlias(preferredModel); name != "" {
if p, err := r.Registry.GetForTenant(tenantID, name); err == nil && p != nil {
@@ -54,18 +73,19 @@ func (r *RegistryResolver) ResolveForHook(ctx context.Context, tenantID uuid.UUI
}
}
// Step 2: system config hooks.prompt.*
// Step 3: system config hooks.prompt.*
configs := r.loadConfigs(ctx, tenantID)
if p, m, ok := r.tryConfig(tenantID, configs["hooks.prompt.provider"], configs["hooks.prompt.model"], preferredModel); ok {
return p, m, nil
}
// Step 3: fall back to background.*
// Step 4: fall back to background.*
if p, m, ok := r.tryConfig(tenantID, configs["background.provider"], configs["background.model"], preferredModel); ok {
return p, m, nil
}
// Step 4: first registered provider for the tenant.
// Step 5: first registered provider for the tenant. This path remains only
// for backward compatibility with hooks that predate explicit providers.
names := r.Registry.ListForTenant(tenantID)
if len(names) == 0 {
return nil, "", errors.New("hook resolver: no providers registered for tenant")
@@ -0,0 +1,72 @@
package handlers_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/hooks/handlers"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
func TestRegistryResolver_ExplicitProviderIsAuthoritative(t *testing.T) {
tenantID := uuid.New()
registry := providers.NewRegistry(nil)
registry.RegisterForTenant(tenantID, &fakeProvider{name: "cppai", defaultModel: "gpt-default"})
registry.RegisterForTenant(tenantID, &fakeProvider{name: "anthropic", defaultModel: "claude-default"})
resolver := handlers.NewRegistryResolver(registry, nil)
provider, model, err := resolver.ResolveForHook(context.Background(), tenantID, "cppai", "gpt-5.6-terra")
if err != nil {
t.Fatalf("ResolveForHook() error: %v", err)
}
if provider.Name() != "cppai" || model != "gpt-5.6-terra" {
t.Fatalf("resolved=(%q, %q), want (cppai, gpt-5.6-terra)", provider.Name(), model)
}
}
func TestRegistryResolver_ExplicitProviderUsesItsDefaultModel(t *testing.T) {
tenantID := uuid.New()
registry := providers.NewRegistry(nil)
registry.RegisterForTenant(tenantID, &fakeProvider{name: "bailian", defaultModel: "qwen3.7-plus"})
resolver := handlers.NewRegistryResolver(registry, nil)
provider, model, err := resolver.ResolveForHook(context.Background(), tenantID, "bailian", "")
if err != nil {
t.Fatalf("ResolveForHook() error: %v", err)
}
if provider.Name() != "bailian" || model != "qwen3.7-plus" {
t.Fatalf("resolved=(%q, %q), want (bailian, qwen3.7-plus)", provider.Name(), model)
}
}
func TestRegistryResolver_MissingExplicitProviderDoesNotFallback(t *testing.T) {
tenantID := uuid.New()
registry := providers.NewRegistry(nil)
registry.RegisterForTenant(tenantID, &fakeProvider{name: "bailian", defaultModel: "qwen3.7-plus"})
resolver := handlers.NewRegistryResolver(registry, nil)
provider, model, err := resolver.ResolveForHook(context.Background(), tenantID, "missing", "gpt-5.6-terra")
if err == nil {
t.Fatal("ResolveForHook() error=nil, want configured-provider error")
}
if provider != nil || model != "" {
t.Fatalf("resolved=(%v, %q), want nil provider and empty model", provider, model)
}
}
func TestRegistryResolver_LegacyModelAliasStillResolves(t *testing.T) {
tenantID := uuid.New()
registry := providers.NewRegistry(nil)
registry.RegisterForTenant(tenantID, &fakeProvider{name: "anthropic", defaultModel: "claude-default"})
resolver := handlers.NewRegistryResolver(registry, nil)
provider, model, err := resolver.ResolveForHook(context.Background(), tenantID, "", "haiku")
if err != nil {
t.Fatalf("ResolveForHook() error: %v", err)
}
if provider.Name() != "anthropic" || model != "haiku" {
t.Fatalf("resolved=(%q, %q), want (anthropic, haiku)", provider.Name(), model)
}
}
+45 -5
View File
@@ -17,14 +17,18 @@ import (
// fakeResolver returns a static provider + model. Counts resolve calls for
// cache-hit assertions.
type fakeResolver struct {
prov providers.Provider
model string
calls atomic.Int32
resolveErr error
prov providers.Provider
model string
calls atomic.Int32
resolveErr error
preferredProvider string
preferredModel string
}
func (f *fakeResolver) ResolveForHook(_ context.Context, _ uuid.UUID, _ string) (providers.Provider, string, error) {
func (f *fakeResolver) ResolveForHook(_ context.Context, _ uuid.UUID, preferredProvider, preferredModel string) (providers.Provider, string, error) {
f.calls.Add(1)
f.preferredProvider = preferredProvider
f.preferredModel = preferredModel
if f.resolveErr != nil {
return nil, "", f.resolveErr
}
@@ -115,6 +119,42 @@ func TestPrompt_Allow_StructuredOutput(t *testing.T) {
}
}
func TestPrompt_ExplicitProviderPassedToResolver(t *testing.T) {
prov := &fakeProvider{nextResp: okResp("allow")}
resolver := &fakeResolver{prov: prov, model: "gpt-5.6-terra"}
h := &handlers.PromptHandler{Resolver: resolver}
cfg := makePromptCfg(t)
cfg.Config["provider"] = "cppai"
cfg.Config["model"] = "gpt-5.6-terra"
dec, err := h.Execute(context.Background(), cfg, makePromptEv())
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if dec != hooks.DecisionAllow {
t.Fatalf("decision=%q, want allow", dec)
}
if resolver.preferredProvider != "cppai" || resolver.preferredModel != "gpt-5.6-terra" {
t.Fatalf("resolver preferences=(%q, %q), want (cppai, gpt-5.6-terra)", resolver.preferredProvider, resolver.preferredModel)
}
}
func TestPrompt_ExplicitProviderWithoutModelUsesProviderDefault(t *testing.T) {
prov := &fakeProvider{nextResp: okResp("allow")}
resolver := &fakeResolver{prov: prov, model: "provider-default"}
h := &handlers.PromptHandler{Resolver: resolver, DefaultModel: "haiku"}
cfg := makePromptCfg(t)
cfg.Config["provider"] = "cppai"
delete(cfg.Config, "model")
if _, err := h.Execute(context.Background(), cfg, makePromptEv()); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if resolver.preferredModel != "" {
t.Fatalf("preferred model=%q, want empty provider-default sentinel", resolver.preferredModel)
}
}
func TestPrompt_Block_StructuredOutput(t *testing.T) {
prov := &fakeProvider{nextResp: okResp("block")}
h := &handlers.PromptHandler{Resolver: &fakeResolver{prov: prov, model: "m"}}
+4 -1
View File
@@ -72,6 +72,7 @@
"headers": "Headers",
"bodyTemplate": "Body Template",
"promptTemplate": "Prompt Template",
"provider": "Provider",
"model": "Model",
"maxInvocationsPerTurn": "Max Invocations per Turn",
"scriptSource": "Script source (ES5.1 JavaScript)",
@@ -144,6 +145,8 @@
"promptRequiresMatcher": "Prompt hooks require a matcher or if_expr",
"invalidRegex": "Invalid regex",
"promptTemplateRequired": "Prompt template is required",
"promptProviderRequired": "Provider is required",
"promptModelRequired": "Model is required",
"scriptSourceRequired": "Script source is required"
},
"decision": {
@@ -152,4 +155,4 @@
"error": "Error",
"timeout": "Timeout"
}
}
}
+3
View File
@@ -72,6 +72,7 @@
"headers": "Заголовки",
"bodyTemplate": "Шаблон тела",
"promptTemplate": "Шаблон промпта",
"provider": "Провайдер",
"model": "Модель",
"maxInvocationsPerTurn": "Макс. вызовов за ход",
"scriptSource": "Исходный код скрипта (ES5.1 JavaScript)",
@@ -138,6 +139,8 @@
"promptRequiresMatcher": "Хуки prompt требуют матчер или if_expr",
"invalidRegex": "Недопустимый regex",
"promptTemplateRequired": "Шаблон промпта обязателен",
"promptProviderRequired": "Провайдер обязателен",
"promptModelRequired": "Модель обязательна",
"scriptSourceRequired": "Исходный код скрипта обязателен"
},
"decision": {
+4 -1
View File
@@ -72,6 +72,7 @@
"headers": "Headers",
"bodyTemplate": "Mẫu body",
"promptTemplate": "Mẫu prompt",
"provider": "Nhà cung cấp",
"model": "Mô hình",
"maxInvocationsPerTurn": "Số lần gọi tối đa mỗi lượt",
"scriptSource": "Mã nguồn script (ES5.1 JavaScript)",
@@ -144,6 +145,8 @@
"promptRequiresMatcher": "Hook prompt yêu cầu matcher hoặc if_expr",
"invalidRegex": "Regex không hợp lệ",
"promptTemplateRequired": "Mẫu prompt là bắt buộc",
"promptProviderRequired": "Nhà cung cấp là bắt buộc",
"promptModelRequired": "Mô hình là bắt buộc",
"scriptSourceRequired": "Mã nguồn script là bắt buộc"
},
"decision": {
@@ -152,4 +155,4 @@
"error": "Lỗi",
"timeout": "Hết thời gian"
}
}
}
+4 -1
View File
@@ -72,6 +72,7 @@
"headers": "请求头",
"bodyTemplate": "请求体模板",
"promptTemplate": "Prompt 模板",
"provider": "提供商",
"model": "模型",
"maxInvocationsPerTurn": "每轮最大调用次数",
"scriptSource": "脚本源码(ES5.1 JavaScript)",
@@ -144,6 +145,8 @@
"promptRequiresMatcher": "Prompt 钩子需要 matcher 或 if_expr",
"invalidRegex": "无效的正则表达式",
"promptTemplateRequired": "Prompt 模板为必填项",
"promptProviderRequired": "提供商为必填项",
"promptModelRequired": "模型为必填项",
"scriptSourceRequired": "脚本源码为必填项"
},
"decision": {
@@ -152,4 +155,4 @@
"error": "错误",
"timeout": "超时"
}
}
}
@@ -15,6 +15,7 @@ import {
import { HookListRow } from "@/pages/hooks/components/hook-list-row";
import { HookFormDialog } from "@/pages/hooks/components/hook-form-dialog";
import { HookTestPanel } from "@/pages/hooks/components/hook-test-panel";
import { buildHookConfig } from "@/pages/hooks/hook-form-config";
import type { HookFormData } from "@/schemas/hooks.schema";
interface AgentHooksTabProps {
@@ -23,42 +24,6 @@ interface AgentHooksTabProps {
onCreateOpenChange?: (open: boolean) => void;
}
function parseHeaders(raw: string | undefined): Record<string, unknown> {
const trimmed = (raw ?? "").trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
throw new Error("headers must be a JSON object");
} catch (err) {
// eslint-disable-next-line preserve-caught-error -- JSON.parse error message already captured verbatim in thrown message
throw new Error(
"Invalid headers JSON: " + (err instanceof Error ? err.message : String(err)),
);
}
}
function buildConfig(data: HookFormData): Record<string, unknown> {
if (data.handler_type === "http") {
return {
url: data.url ?? "",
method: data.method ?? "POST",
headers: parseHeaders(data.headers),
body_template: data.body_template ?? "",
};
}
if (data.handler_type === "script") {
return { source: data.script_source ?? "" };
}
return {
prompt_template: data.prompt_template ?? "",
model: data.model ?? "haiku",
max_invocations_per_turn: data.max_invocations_per_turn ?? 5,
};
}
export function AgentHooksTab({ agentId, initialCreateOpen, onCreateOpenChange }: AgentHooksTabProps) {
const { t } = useTranslation("agents");
const { t: th } = useTranslation("hooks");
@@ -89,7 +54,7 @@ export function AgentHooksTab({ agentId, initialCreateOpen, onCreateOpenChange }
const handleCreate = async (data: HookFormData) => {
let config: Record<string, unknown>;
try {
config = buildConfig(data);
config = buildHookConfig(data);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
return;
@@ -118,7 +83,7 @@ export function AgentHooksTab({ agentId, initialCreateOpen, onCreateOpenChange }
if (!editTarget) return;
let config: Record<string, unknown>;
try {
config = buildConfig(data);
config = buildHookConfig(data);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
return;
@@ -6,6 +6,7 @@
*/
import { describe, it, expect } from "vitest";
import { hookFormSchema } from "@/schemas/hooks.schema";
import { buildHookConfig } from "@/pages/hooks/hook-form-config";
// --- Zod schema validation ---
@@ -33,6 +34,8 @@ describe("hookFormSchema — base cases", () => {
handler_type: "prompt",
matcher: "^bash$",
prompt_template: "Evaluate the tool call.",
provider: "cppai",
model: "gpt-5.6-terra",
});
expect(result.success).toBe(true);
});
@@ -42,6 +45,8 @@ describe("hookFormSchema — base cases", () => {
...base,
handler_type: "prompt",
prompt_template: "Evaluate the tool call.",
provider: "cppai",
model: "gpt-5.6-terra",
matcher: "",
if_expr: "",
});
@@ -57,6 +62,8 @@ describe("hookFormSchema — base cases", () => {
...base,
handler_type: "prompt",
matcher: "^bash$",
provider: "cppai",
model: "gpt-5.6-terra",
});
expect(result.success).toBe(false);
if (!result.success) {
@@ -65,6 +72,21 @@ describe("hookFormSchema — base cases", () => {
}
});
it("rejects prompt hook without provider or model", () => {
const result = hookFormSchema.safeParse({
...base,
handler_type: "prompt",
matcher: "^bash$",
prompt_template: "Evaluate the tool call.",
});
expect(result.success).toBe(false);
if (!result.success) {
const paths = result.error.issues.map((e) => e.path.join("."));
expect(paths).toContain("provider");
expect(paths).toContain("model");
}
});
it("rejects invalid regex in matcher", () => {
const result = hookFormSchema.safeParse({
...base,
@@ -132,6 +154,17 @@ describe("hooks i18n key contracts", () => {
}
});
it("prompt provider/model keys exist in all locales", async () => {
for (const locale of ["en", "vi", "zh", "ru"]) {
const mod = await import(`@/i18n/locales/${locale}/hooks.json`);
const data = mod as unknown as Record<string, Record<string, string>>;
expect(data.form?.provider).toBeTruthy();
expect(data.form?.model).toBeTruthy();
expect(data.validation?.promptProviderRequired).toBeTruthy();
expect(data.validation?.promptModelRequired).toBeTruthy();
}
});
it("toast keys exist in en", async () => {
const en = await import("@/i18n/locales/en/hooks.json");
const data = en as unknown as Record<string, Record<string, string>>;
@@ -144,14 +177,24 @@ describe("hooks i18n key contracts", () => {
// --- buildConfig helper (re-tested inline) ---
describe("hook config builder logic", () => {
it("prompt type config has prompt_template and model", () => {
const config = {
it("prompt type config persists provider and model", () => {
const config = buildHookConfig({
event: "pre_tool_use",
handler_type: "prompt",
scope: "tenant",
matcher: "^exec$",
timeout_ms: 5000,
on_timeout: "block",
priority: 100,
enabled: true,
prompt_template: "Evaluate this.",
model: "haiku",
provider: "cppai",
model: "gpt-5.6-terra",
max_invocations_per_turn: 5,
};
});
expect(config.prompt_template).toBeTruthy();
expect(config.model).toBe("haiku");
expect(config.provider).toBe("cppai");
expect(config.model).toBe("gpt-5.6-terra");
expect(config.max_invocations_per_turn).toBe(5);
});
@@ -16,6 +16,7 @@ import { hookFormSchema, type HookFormData } from "@/schemas/hooks.schema";
import type { HookConfig } from "@/hooks/use-hooks";
import { useAuthStore } from "@/stores/use-auth-store";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
import { ScriptEditor } from "./script-editor";
const HOOK_EVENTS = [
@@ -39,7 +40,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
: (["tenant", "agent"] as const);
const {
register, control, handleSubmit, watch, reset,
register, control, handleSubmit, watch, reset, setValue,
formState: { errors, isSubmitting },
} = useForm<HookFormData>({
resolver: zodResolver(hookFormSchema),
@@ -54,12 +55,16 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
priority: 100,
enabled: true,
method: "POST",
provider: "",
model: "",
max_invocations_per_turn: 5,
},
});
const handlerType = watch("handler_type");
const scope = watch("scope");
const promptProvider = watch("provider") ?? "";
const promptModel = watch("model") ?? "";
const { agents } = useAgents();
// Builtin rows (Phase 04/05) ship with source='builtin'. UI + backend agree:
// only `enabled` is mutable. All other inputs render as read-only, and the
@@ -94,6 +99,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
headers: cfg.headers ? JSON.stringify(cfg.headers) : "",
body_template: (cfg.body_template as string) ?? "",
prompt_template: (cfg.prompt_template as string) ?? "",
provider: (cfg.provider as string) ?? "",
model: (cfg.model as string) ?? "",
max_invocations_per_turn: (cfg.max_invocations_per_turn as number) ?? 5,
script_source: (cfg.source as string) ?? "",
@@ -326,19 +332,29 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
)}
</div>
<div className="space-y-1.5">
<Label>{t("form.model")}</Label>
<Controller control={control} name="model" render={({ field }) => (
<Select value={field.value ?? "haiku"} onValueChange={field.onChange} disabled={isBuiltin}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="haiku">haiku</SelectItem>
<SelectItem value="sonnet">sonnet</SelectItem>
<SelectItem value="opus">opus</SelectItem>
</SelectContent>
</Select>
)} />
<ProviderModelSelect
provider={promptProvider}
onProviderChange={(value) => {
setValue("provider", value, { shouldDirty: true, shouldValidate: true });
setValue("model", "", { shouldDirty: true, shouldValidate: true });
}}
model={promptModel}
onModelChange={(value) => setValue("model", value, { shouldDirty: true, shouldValidate: true })}
providerLabel={t("form.provider")}
modelLabel={t("form.model")}
showVerify
disabled={isBuiltin}
/>
{errors.provider && (
<p className="text-xs text-destructive">
{t(errors.provider.message ?? "validation.promptProviderRequired")}
</p>
)}
{errors.model && (
<p className="text-xs text-destructive">
{t(errors.model.message ?? "validation.promptModelRequired")}
</p>
)}
</div>
<div className="space-y-1.5">
<Label>{t("form.maxInvocationsPerTurn")}</Label>
@@ -163,6 +163,7 @@ function HttpConfigCard({ cfg, t }: { cfg: Record<string, unknown>; t: TFn }) {
function PromptConfigCard({ cfg, t }: { cfg: Record<string, unknown>; t: TFn }) {
const promptTemplate = typeof cfg.prompt_template === "string" ? cfg.prompt_template : "";
const provider = typeof cfg.provider === "string" ? cfg.provider : "";
const model = typeof cfg.model === "string" ? cfg.model : "";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
@@ -173,10 +174,17 @@ function PromptConfigCard({ cfg, t }: { cfg: Record<string, unknown>; t: TFn })
<pre className="overflow-x-auto rounded bg-muted px-2 py-1 text-xs whitespace-pre-wrap">{promptTemplate}</pre>
</div>
)}
{model && (
{(provider || model) && (
<div className="space-y-1">
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.model")}</p>
<Badge variant="outline">{model}</Badge>
<p className="text-2xs uppercase tracking-wide text-muted-foreground">
{provider && model
? `${t("form.provider")} / ${t("form.model")}`
: t(provider ? "form.provider" : "form.model")}
</p>
<div className="flex flex-wrap gap-2">
{provider && <Badge variant="outline">{provider}</Badge>}
{model && <Badge variant="outline">{model}</Badge>}
</div>
</div>
)}
</div>
@@ -0,0 +1,39 @@
import type { HookFormData } from "@/schemas/hooks.schema";
function parseHeaders(raw: string | undefined): Record<string, unknown> {
const trimmed = (raw ?? "").trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
throw new Error("headers must be a JSON object");
} catch (err) {
// The original parser message is included verbatim in the user-facing error.
// eslint-disable-next-line preserve-caught-error
throw new Error(
"Invalid headers JSON: " + (err instanceof Error ? err.message : String(err)),
);
}
}
export function buildHookConfig(data: HookFormData): Record<string, unknown> {
if (data.handler_type === "http") {
return {
url: data.url ?? "",
method: data.method ?? "POST",
headers: parseHeaders(data.headers),
body_template: data.body_template ?? "",
};
}
if (data.handler_type === "script") {
return { source: data.script_source ?? "" };
}
return {
prompt_template: data.prompt_template ?? "",
provider: data.provider ?? "",
model: data.model ?? "",
max_invocations_per_turn: data.max_invocations_per_turn ?? 5,
};
}
+3 -43
View File
@@ -23,6 +23,7 @@ import { HookTestPanel } from "./components/hook-test-panel";
import { HookOverviewTab } from "./components/hook-overview-tab";
import { HookHistoryTable } from "./components/hook-history-table";
import { BetaInfoCard } from "./components/beta-info-card";
import { buildHookConfig } from "./hook-form-config";
import type { HookFormData } from "@/schemas/hooks.schema";
const HOOK_EVENTS = [
@@ -30,47 +31,6 @@ const HOOK_EVENTS = [
"post_tool_use", "post_model_response", "stop", "subagent_start", "subagent_stop",
] as const;
// parseHeaders accepts an empty string, an empty object string, or a JSON
// object. Returns {} for empty/whitespace-only input. Throws a typed Error
// with a friendly message on malformed JSON so the caller can surface via toast.
function parseHeaders(raw: string | undefined): Record<string, unknown> {
const trimmed = (raw ?? "").trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
throw new Error("headers must be a JSON object");
} catch (err) {
// eslint-disable-next-line preserve-caught-error -- JSON.parse error message already captured verbatim in thrown message
throw new Error(
"Invalid headers JSON: " + (err instanceof Error ? err.message : String(err)),
);
}
}
function buildConfig(data: HookFormData): Record<string, unknown> {
if (data.handler_type === "http") {
return {
url: data.url ?? "",
method: data.method ?? "POST",
headers: parseHeaders(data.headers),
body_template: data.body_template ?? "",
};
}
if (data.handler_type === "script") {
// Backend goja handler reads cfg.Config.source (Phase 03). Zod caps at 32 KiB.
return { source: data.script_source ?? "" };
}
// prompt
return {
prompt_template: data.prompt_template ?? "",
model: data.model ?? "haiku",
max_invocations_per_turn: data.max_invocations_per_turn ?? 5,
};
}
export function HooksPage() {
// Route params — single source of truth (CLAUDE.md)
const { id: detailId } = useParams<{ id: string }>();
@@ -114,7 +74,7 @@ export function HooksPage() {
const handleCreate = async (data: HookFormData) => {
let config: Record<string, unknown>;
try {
config = buildConfig(data);
config = buildHookConfig(data);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
return;
@@ -139,7 +99,7 @@ export function HooksPage() {
if (!editTarget) return;
let config: Record<string, unknown>;
try {
config = buildConfig(data);
config = buildHookConfig(data);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
return;
+15
View File
@@ -38,6 +38,7 @@ export const hookFormSchema = z
headers: z.string().optional(), // JSON string
body_template: z.string().optional(),
prompt_template: z.string().optional(),
provider: z.string().optional(),
model: z.string().optional(),
max_invocations_per_turn: z.number().int().min(1).max(20).optional(),
// Script handler source (ES5.1 JavaScript). Cap mirrors backend 32 KiB
@@ -73,6 +74,20 @@ export const hookFormSchema = z
message: "validation.promptTemplateRequired",
});
}
if (!data.provider?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["provider"],
message: "validation.promptProviderRequired",
});
}
if (!data.model?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["model"],
message: "validation.promptModelRequired",
});
}
}
if (data.handler_type === "script" && !data.script_source?.trim()) {
ctx.addIssue({