From ac9ce3a0166289144d7f91a424cfc6497a385d9b Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:19:36 +0800 Subject: [PATCH 1/4] feat: add hooks cli and trace tooling --- README.md | 1 + cmd/neocode-gateway/main.go | 8 +- cmd/neocode/main.go | 8 +- docs/guides/hooks-cli.md | 107 +++ internal/app/bootstrap.go | 6 + internal/app/bootstrap_test.go | 89 ++ internal/cli/exit_code.go | 46 + internal/cli/gateway_commands.go | 5 +- internal/cli/gateway_runtime_bridge.go | 16 +- internal/cli/gateway_runtime_bridge_test.go | 2 +- internal/cli/hook_command.go | 711 ++++++++++++++ internal/cli/hook_command_test.go | 864 ++++++++++++++++++ internal/cli/root.go | 1 + internal/cli/root_test.go | 6 +- internal/runtime/event_emitter.go | 3 + internal/runtime/hook_trace.go | 205 +++++ internal/runtime/hook_trace_test.go | 66 ++ internal/runtime/hooks/fixture/fixture.go | 111 +++ .../runtime/hooks/fixture/fixture_test.go | 63 ++ internal/runtime/repo_hooks.go | 13 +- internal/runtime/runtime.go | 11 + internal/runtime/user_hooks.go | 37 +- 22 files changed, 2354 insertions(+), 25 deletions(-) create mode 100644 docs/guides/hooks-cli.md create mode 100644 internal/cli/exit_code.go create mode 100644 internal/cli/hook_command.go create mode 100644 internal/cli/hook_command_test.go create mode 100644 internal/runtime/hook_trace.go create mode 100644 internal/runtime/hook_trace_test.go create mode 100644 internal/runtime/hooks/fixture/fixture.go create mode 100644 internal/runtime/hooks/fixture/fixture_test.go diff --git a/README.md b/README.md index ac6e2626b..d3cdb5630 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Feishu: 更多端侧截图与交互说明: - [Web UI 使用指南](https://neocode-docs.pages.dev/guide/web-ui) - [飞书远程接入配置](https://neocode-docs.pages.dev/guide/feishu-remote-setup) +- [Hooks CLI 指南](docs/guides/hooks-cli.md) --- diff --git a/cmd/neocode-gateway/main.go b/cmd/neocode-gateway/main.go index e0046af7e..e48369b10 100644 --- a/cmd/neocode-gateway/main.go +++ b/cmd/neocode-gateway/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" @@ -11,6 +12,11 @@ import ( func main() { if err := cli.ExecuteGatewayServer(context.Background(), os.Args[1:]); err != nil { fmt.Fprintf(os.Stderr, "neocode-gateway: %v\n", err) - os.Exit(1) + exitCode := 1 + var exitCoder interface{ ExitCode() int } + if errors.As(err, &exitCoder) { + exitCode = exitCoder.ExitCode() + } + os.Exit(exitCode) } } diff --git a/cmd/neocode/main.go b/cmd/neocode/main.go index dfaea80c9..b1d662401 100644 --- a/cmd/neocode/main.go +++ b/cmd/neocode/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" @@ -11,7 +12,12 @@ import ( func main() { if err := cli.Execute(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "neocode: %v\n", err) - os.Exit(1) + exitCode := 1 + var exitCoder interface{ ExitCode() int } + if errors.As(err, &exitCoder) { + exitCode = exitCoder.ExitCode() + } + os.Exit(exitCode) } if notice := cli.ConsumeUpdateNotice(); notice != "" { fmt.Fprintln(os.Stdout, notice) diff --git a/docs/guides/hooks-cli.md b/docs/guides/hooks-cli.md new file mode 100644 index 000000000..69f302e1d --- /dev/null +++ b/docs/guides/hooks-cli.md @@ -0,0 +1,107 @@ +# Hooks CLI + +`neocode hook` 提供面向 Runtime Hooks 的本地调试入口: + +- `neocode hook lint [path]` +- `neocode hook dry-run [path] --hook --fixture ` +- `neocode hook trace --run-id ` + +## lint + +默认扫描两处: + +- `~/.neocode/config.yaml` 中的 `runtime.hooks.items` +- `/.neocode/hooks.yaml` 中的 `hooks.items` + +可显式传入单个文件路径: + +```bash +neocode hook lint +neocode hook lint .neocode/hooks.yaml +``` + +退出码: + +- `0`:无问题 +- `1`:存在 lint findings +- `2`:读取、解析或内部错误 + +## dry-run + +使用 fixture 驱动单个 hook: + +```bash +neocode hook dry-run --hook warn-bash --fixture fixture.yaml +neocode hook dry-run --hook repo-guard --fixture fixture.json --repo +neocode hook dry-run .neocode/hooks.yaml --hook repo-guard --fixture fixture.json +``` + +fixture 支持 YAML / JSON,字段以 hook payload schema 为准,最小示例: + +```yaml +payload_version: "1" +point: before_tool_call +run_id: run-1 +session_id: session-1 +metadata: + tool_name: bash + tool_call_id: call-1 + tool_arguments_preview: echo hello + workdir: /workspace +``` + +查找 hook 时的默认行为: + +- 默认同时扫描 user hooks 与 repo hooks。 +- 同名 hook 同时存在时,优先选择 user hook。 +- 若要强制执行 repo hook,可使用 `--repo`,或直接把 repo hooks 文件路径作为 `[path]` 传入。 +- fixture 的 `point` 必须与目标 hook 的 `point` 完全一致,否则 `dry-run` 直接失败。 + +输出中会固定打印: + +- `status: pass|block|failed` +- `block: true|false` +- `duration_ms: ` +- 命中的 `message` / `annotations` + +退出码: + +- `0`:结果为 `pass` +- `3`:结果为 `block` +- `4`:结果为 `failed` +- `2`:fixture / hook 解析失败 + +## trace + +在真实 runtime 路径上打开 `--trace-hooks` 后,hook 相关 runtime 事件会持久化到当前 workspace: + +```bash +neocode gateway --trace-hooks +neocode hook trace --run-id run_123 +``` + +trace 文件位置: + +```text +~/.neocode/projects//hook-traces/.jsonl +``` + +`hook trace` 会按时间顺序回放: + +- `hook_started` +- `hook_finished` +- `hook_failed` +- `hook_blocked` + +并在末尾输出按 `hook_id` 聚合的简单耗时统计。 + +补充说明: + +- `hook trace --run-id` 只读取当前 workspace 对应目录下的 trace 文件,不做跨项目全局搜索。 +- workspace 优先取 `--workdir`,未传时回退到当前进程工作目录。 + +退出码: + +- `0`:成功输出 trace +- `1`:未找到 trace +- `2`:trace 文件损坏或读取失败 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 45fcd3c17..6509bbdb7 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -64,6 +64,7 @@ type BootstrapOptions struct { Workdir string SessionID string WakeInputB64 string + TraceHooks bool } type memoExtractorScheduler interface { @@ -269,6 +270,11 @@ func BuildGatewayServerDeps(ctx context.Context, opts BootstrapOptions) (Runtime runtimeImpl := agentruntime.Runtime(runtimeSvc) closeFns := []func() error{toolsCleanup, checkpointStore.Close, sessionStore.Close} + if opts.TraceHooks { + recorder := agentruntime.NewHookTraceRecorder(sharedDeps.ConfigManager.BaseDir(), cfg.Workdir) + runtimeSvc.SetRuntimeEventRecorder(recorder) + closeFns = append(closeFns, recorder.Close) + } needCleanup = false diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 11dc335c1..5e6f40b5b 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -24,6 +24,7 @@ import ( "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" + runtimehooks "neo-code/internal/runtime/hooks" agentsession "neo-code/internal/session" "neo-code/internal/skills" "neo-code/internal/tools" @@ -918,6 +919,94 @@ func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) { } } +func TestBuildRuntimeTraceHooksPersistsHookEvents(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + workdir := t.TempDir() + bundle, err := BuildGatewayServerDeps(context.Background(), BootstrapOptions{ + Workdir: workdir, + TraceHooks: true, + }) + if err != nil { + t.Fatalf("BuildGatewayServerDeps() error = %v", err) + } + t.Cleanup(func() { + if bundle.Close != nil { + if closeErr := bundle.Close(); closeErr != nil { + t.Fatalf("bundle.Close() error = %v", closeErr) + } + } + }) + + service, ok := bundle.Runtime.(*agentruntime.Service) + if !ok { + t.Fatalf("bundle.Runtime type = %T, want *runtime.Service", bundle.Runtime) + } + service.SetHookExecutor(traceBlockingHookExecutor{}) + session, err := service.CreateSession(context.Background(), "trace-session") + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + runID := "trace-run-1" + _, err = service.Compact(context.Background(), agentruntime.CompactInput{ + SessionID: session.ID, + RunID: runID, + }) + if err == nil || !strings.Contains(err.Error(), "trace guard blocked compact") { + t.Fatalf("Compact() error = %v, want hook block error", err) + } + + tracePath, err := agentruntime.HookTracePath(bundle.ConfigManager.BaseDir(), bundle.Config.Workdir, runID) + if err != nil { + t.Fatalf("HookTracePath() error = %v", err) + } + data, err := os.ReadFile(tracePath) + if err != nil { + t.Fatalf("ReadFile(trace) error = %v", err) + } + text := string(data) + if !strings.Contains(text, `"event_type":"hook_blocked"`) { + t.Fatalf("trace file missing hook_blocked: %s", text) + } + if !strings.Contains(text, `"hook_id":"trace-pre-compact"`) { + t.Fatalf("trace file missing hook id: %s", text) + } +} + +type traceBlockingHookExecutor struct{} + +func (traceBlockingHookExecutor) Run( + ctx context.Context, + point runtimehooks.HookPoint, + input runtimehooks.HookContext, +) runtimehooks.RunOutput { + _ = ctx + _ = input + if point != runtimehooks.HookPointPreCompact { + return runtimehooks.RunOutput{} + } + return runtimehooks.RunOutput{ + Blocked: true, + BlockedBy: "trace-pre-compact", + BlockedSource: runtimehooks.HookSourceUser, + Results: []runtimehooks.HookResult{ + { + HookID: "trace-pre-compact", + Point: runtimehooks.HookPointPreCompact, + Scope: runtimehooks.HookScopeUser, + Source: runtimehooks.HookSourceUser, + Status: runtimehooks.HookResultBlock, + Message: "trace guard blocked compact", + }, + }, + } +} + func TestBuildRuntimeSucceedsWhenSkillsRootMissing(t *testing.T) { disableBuiltinProviderAPIKeys(t) diff --git a/internal/cli/exit_code.go b/internal/cli/exit_code.go new file mode 100644 index 000000000..2bec94631 --- /dev/null +++ b/internal/cli/exit_code.go @@ -0,0 +1,46 @@ +package cli + +import "fmt" + +// ExitCoder 描述 CLI 错误可携带的进程退出码。 +type ExitCoder interface { + error + ExitCode() int +} + +type commandExitError struct { + code int + err error +} + +// Error 返回底层错误文案,供主入口统一打印。 +func (e *commandExitError) Error() string { + if e == nil || e.err == nil { + return "" + } + return e.err.Error() +} + +// Unwrap 暴露底层错误,便于测试和 errors.Is/As 复用。 +func (e *commandExitError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + +// ExitCode 返回命令预期的进程退出码。 +func (e *commandExitError) ExitCode() int { + if e == nil || e.code <= 0 { + return 1 + } + return e.code +} + +// newCommandExitError 构造带退出码的 CLI 错误。 +func newCommandExitError(code int, format string, args ...any) error { + return &commandExitError{ + code: code, + err: fmt.Errorf(format, args...), + } +} diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index 0ffb71feb..fcd8770bf 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -45,6 +45,7 @@ type gatewayCommandOptions struct { TokenFile string ACLMode string Workdir string + TraceHooks bool MaxFrameBytes int IPCMaxConnections int @@ -108,6 +109,7 @@ func newGatewayServerCommand(use, short string, readWorkdir func(*cobra.Command) TokenFile: strings.TrimSpace(options.TokenFile), ACLMode: strings.TrimSpace(options.ACLMode), Workdir: normalizedWorkdir, + TraceHooks: options.TraceHooks, MaxFrameBytes: options.MaxFrameBytes, IPCMaxConnections: options.IPCMaxConnections, @@ -161,6 +163,7 @@ func newGatewayServerCommand(use, short string, readWorkdir func(*cobra.Command) "gateway http shutdown timeout seconds override", ) cmd.Flags().BoolVar(&options.MetricsEnabled, "metrics-enabled", false, "gateway metrics enable override") + cmd.Flags().BoolVar(&options.TraceHooks, "trace-hooks", false, "persist hook runtime trace events for this workspace") return cmd } @@ -253,7 +256,7 @@ func startGatewayServer(ctx context.Context, options gatewayCommandOptions, stat logger, ) - runtimePort, closeRuntimePort, err := buildGatewayRuntimePort(signalContext, options.Workdir) + runtimePort, closeRuntimePort, err := buildGatewayRuntimePort(signalContext, options.Workdir, options.TraceHooks) if err != nil { return fmt.Errorf("initialize gateway runtime: %w", err) } diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index 9d77d9926..5096858bd 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -100,11 +100,18 @@ type bridgeSessionLoader interface { // defaultBuildGatewayRuntimePort 构建网关运行时 RuntimePort 适配器,并返回对应资源清理函数。 // 当启用多工作区时,返回 MultiWorkspaceRuntime 路由代理,每个工作区拥有独立的 RuntimeBundle。 -func defaultBuildGatewayRuntimePort(ctx context.Context, workdir string) (gateway.RuntimePort, func() error, error) { +func defaultBuildGatewayRuntimePort( + ctx context.Context, + workdir string, + traceHooks bool, +) (gateway.RuntimePort, func() error, error) { trimmedWorkdir := strings.TrimSpace(workdir) // 先构建默认工作区的 bundle,用于获取 baseDir 和共享组件。 - bundle, err := app.BuildGatewayServerDeps(ctx, app.BootstrapOptions{Workdir: trimmedWorkdir}) + bundle, err := app.BuildGatewayServerDeps(ctx, app.BootstrapOptions{ + Workdir: trimmedWorkdir, + TraceHooks: traceHooks, + }) if err != nil { return nil, nil, err } @@ -140,7 +147,10 @@ func defaultBuildGatewayRuntimePort(ctx context.Context, workdir string) (gatewa if trimmedWd != "" { _ = os.MkdirAll(trimmedWd, 0o755) } - b, err := app.BuildGatewayServerDeps(ctx, app.BootstrapOptions{Workdir: trimmedWd}) + b, err := app.BuildGatewayServerDeps(ctx, app.BootstrapOptions{ + Workdir: trimmedWd, + TraceHooks: traceHooks, + }) if err != nil { return nil, nil, err } diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index 01c6b92d7..9000f8ac5 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -3909,7 +3909,7 @@ func TestDefaultBuildGatewayRuntimePortListSessionsWithoutExplicitWorkdir(t *tes t.Setenv("USERPROFILE", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) - port, cleanup, err := defaultBuildGatewayRuntimePort(context.Background(), "") + port, cleanup, err := defaultBuildGatewayRuntimePort(context.Background(), "", false) if err != nil { t.Fatalf("defaultBuildGatewayRuntimePort() error = %v", err) } diff --git a/internal/cli/hook_command.go b/internal/cli/hook_command.go new file mode 100644 index 000000000..def38efee --- /dev/null +++ b/internal/cli/hook_command.go @@ -0,0 +1,711 @@ +package cli + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" + runtimehooks "neo-code/internal/runtime/hooks" + hookfixture "neo-code/internal/runtime/hooks/fixture" + agentsession "neo-code/internal/session" +) + +const ( + hookExitLintFindings = 1 + hookExitSystemError = 2 + hookExitHookBlocked = 3 + hookExitHookFailed = 4 +) + +type hookLintDiagnostic struct { + Path string + Line int + Severity string + Message string + Hint string +} + +type hookCandidate struct { + Path string + Scope string + Source string + Item config.RuntimeHookItemConfig +} + +type repoHookConfigFile struct { + Hooks struct { + Items []config.RuntimeHookItemConfig `yaml:"items"` + } `yaml:"hooks"` +} + +type userHookConfigFile struct { + Runtime struct { + Hooks struct { + Items []config.RuntimeHookItemConfig `yaml:"items"` + } `yaml:"hooks"` + } `yaml:"runtime"` +} + +type hookLintDocument struct { + Items []hookLintItem +} + +type hookLintItem struct { + Line int + Item config.RuntimeHookItemConfig +} + +type hookTraceAggregate struct { + HookID string + Count int + DurationMS int64 + MaxDuration int64 +} + +// newHookCommand 创建 hooks CLI 子命令组。 +func newHookCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "hook", + Short: "Inspect and debug runtime hooks", + } + cmd.AddCommand( + newHookLintCommand(), + newHookDryRunCommand(), + newHookTraceCommand(), + ) + return cmd +} + +// newHookLintCommand 创建 hook lint 子命令,并负责输出稳定诊断与退出码。 +func newHookLintCommand() *cobra.Command { + return &cobra.Command{ + Use: "lint [path]", + Short: "Lint hook configuration files", + Long: "Examples:\n neocode hook lint\n neocode hook lint .neocode/hooks.yaml\n neocode hook lint ~/.neocode/config.yaml", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + workdir, _ := cmd.Flags().GetString("workdir") + targets, err := resolveHookLintTargets(strings.TrimSpace(workdir), args) + if err != nil { + return newCommandExitError(hookExitSystemError, "%v", err) + } + diagnostics, err := lintHookTargets(targets) + if err != nil { + return newCommandExitError(hookExitSystemError, "%v", err) + } + if len(diagnostics) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "hook lint passed") + return nil + } + for _, diagnostic := range diagnostics { + _, _ = fmt.Fprintf( + cmd.OutOrStdout(), + "%s:%d: %s: %s", + diagnostic.Path, + diagnostic.Line, + diagnostic.Severity, + diagnostic.Message, + ) + if strings.TrimSpace(diagnostic.Hint) != "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " (hint: %s)", diagnostic.Hint) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + } + return newCommandExitError(hookExitLintFindings, "hook lint found %d issue(s)", len(diagnostics)) + }, + } +} + +// newHookDryRunCommand 创建 hook dry-run 子命令,并执行单条 hook 的本地回放。 +func newHookDryRunCommand() *cobra.Command { + var hookID string + var fixturePath string + var repoScope bool + command := &cobra.Command{ + Use: "dry-run [path]", + Short: "Execute one hook against a fixture payload", + Long: "Examples:\n neocode hook dry-run --hook warn-bash --fixture fixture.yaml\n" + + " neocode hook dry-run --hook repo-guard --fixture fixture.json --repo\n" + + " neocode hook dry-run .neocode/hooks.yaml --hook repo-guard --fixture fixture.json", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(hookID) == "" { + return newCommandExitError(hookExitSystemError, "--hook is required") + } + if strings.TrimSpace(fixturePath) == "" { + return newCommandExitError(hookExitSystemError, "--fixture is required") + } + workdir, _ := cmd.Flags().GetString("workdir") + explicitTarget := "" + if len(args) > 0 { + explicitTarget = strings.TrimSpace(args[0]) + } + candidate, err := resolveHookCandidate(strings.TrimSpace(workdir), hookID, repoScope, explicitTarget) + if err != nil { + return newCommandExitError(hookExitSystemError, "%v", err) + } + parsedFixture, err := hookfixture.ParseFile(fixturePath) + if err != nil { + return newCommandExitError(hookExitSystemError, "%v", err) + } + spec, err := buildHookSpecForCandidate(candidate, strings.TrimSpace(workdir)) + if err != nil { + return newCommandExitError(hookExitSystemError, "%v", err) + } + if parsedFixture.Point != spec.Point { + return newCommandExitError( + hookExitSystemError, + "fixture point %q does not match hook %q point %q", + parsedFixture.Point, + spec.ID, + spec.Point, + ) + } + registry := runtimehooks.NewRegistry() + if err := registry.Register(spec); err != nil { + return newCommandExitError(hookExitSystemError, "%v", err) + } + executor := runtimehooks.NewExecutor(registry, nil, spec.Timeout) + startedAt := time.Now() + output := executor.Run(context.Background(), parsedFixture.Point, parsedFixture.Context) + duration := time.Since(startedAt).Milliseconds() + status := "pass" + if output.Blocked { + status = "block" + } else if hasHookFailed(output) { + status = "failed" + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "status: %s\n", status) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "block: %t\n", output.Blocked) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "duration_ms: %d\n", duration) + for _, result := range output.Results { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "hook: %s status=%s\n", result.HookID, result.Status) + if strings.TrimSpace(result.Message) != "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "message: %s\n", result.Message) + } + if len(result.Metadata.Annotations) > 0 { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "annotations: %s\n", strings.Join(result.Metadata.Annotations, " | ")) + } + } + switch status { + case "pass": + return nil + case "block": + return newCommandExitError(hookExitHookBlocked, "hook blocked") + default: + return newCommandExitError(hookExitHookFailed, "hook failed") + } + }, + } + command.Flags().StringVar(&hookID, "hook", "", "hook id to execute") + command.Flags().StringVar(&fixturePath, "fixture", "", "fixture yaml/json path") + command.Flags().BoolVar(&repoScope, "repo", false, "resolve the hook from repo hooks instead of user hooks") + return command +} + +// newHookTraceCommand 创建 hook trace 子命令,并回放落盘的 runtime hook 事件。 +func newHookTraceCommand() *cobra.Command { + var runID string + command := &cobra.Command{ + Use: "trace", + Short: "Read persisted hook trace events for one run", + Long: "Examples:\n neocode hook trace --run-id run_123\n neocode --workdir /repo hook trace --run-id run_456", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(runID) == "" { + return newCommandExitError(hookExitSystemError, "--run-id is required") + } + workdir, _ := cmd.Flags().GetString("workdir") + tracePath, err := resolveHookTracePath(strings.TrimSpace(workdir), runID) + if err != nil { + return newCommandExitError(hookExitSystemError, "%v", err) + } + records, err := readHookTraceRecords(tracePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return newCommandExitError(hookExitLintFindings, "trace not found for run %s", runID) + } + return newCommandExitError(hookExitSystemError, "%v", err) + } + for _, record := range records { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), formatHookTraceRecord(record)) + } + aggregates := aggregateHookTraceRecords(records) + if len(aggregates) > 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "summary:") + } + for _, aggregate := range aggregates { + _, _ = fmt.Fprintf( + cmd.OutOrStdout(), + "%s count=%d duration_ms=%d max=%d %s\n", + aggregate.HookID, + aggregate.Count, + aggregate.DurationMS, + aggregate.MaxDuration, + renderHookTraceHistogram(aggregate.DurationMS), + ) + } + return nil + }, + } + command.Flags().StringVar(&runID, "run-id", "", "runtime run id to replay") + return command +} + +// resolveHookLintTargets 解析 lint 的显式路径或默认扫描路径。 +func resolveHookLintTargets(workdir string, args []string) ([]string, error) { + if len(args) > 0 { + target, err := filepath.Abs(strings.TrimSpace(args[0])) + if err != nil { + return nil, err + } + return []string{target}, nil + } + defaults := make([]string, 0, 2) + loader := config.NewLoader("", config.StaticDefaults()) + defaults = append(defaults, loader.ConfigPath()) + resolvedWorkdir, err := resolveHookWorkspace(workdir) + if err != nil { + return nil, err + } + defaults = append(defaults, filepath.Join(resolvedWorkdir, ".neocode", "hooks.yaml")) + return defaults, nil +} + +// lintHookTargets 逐个校验目标文件中的 hook 配置,并收敛稳定输出顺序。 +func lintHookTargets(targets []string) ([]hookLintDiagnostic, error) { + var diagnostics []hookLintDiagnostic + for _, target := range targets { + info, err := os.Stat(target) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + if info.IsDir() { + return nil, fmt.Errorf("hook lint target is a directory: %s", target) + } + document, scope, err := parseHookLintDocument(target) + if err != nil { + return nil, err + } + for _, item := range document.Items { + if err := validateLintItem(item.Item, scope); err != nil { + diagnostics = append(diagnostics, hookLintDiagnostic{ + Path: target, + Line: item.Line, + Severity: "error", + Message: err.Error(), + Hint: hookLintHint(err.Error()), + }) + } + } + } + sort.Slice(diagnostics, func(i, j int) bool { + if diagnostics[i].Path != diagnostics[j].Path { + return diagnostics[i].Path < diagnostics[j].Path + } + return diagnostics[i].Line < diagnostics[j].Line + }) + return diagnostics, nil +} + +// parseHookLintDocument 读取目标 hook 配置,并补齐 item 的起始行号信息。 +func parseHookLintDocument(path string) (hookLintDocument, string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return hookLintDocument{}, "", err + } + var root yaml.Node + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(false) + if err := decoder.Decode(&root); err != nil { + return hookLintDocument{}, "", fmt.Errorf("parse hook yaml %s: %w", path, err) + } + scope := "repo" + var items []config.RuntimeHookItemConfig + if strings.EqualFold(filepath.Base(path), "config.yaml") { + scope = "user" + items, err = loadUserHookItems(path) + if err != nil { + return hookLintDocument{}, "", err + } + } else { + items, err = loadRepoHookItemsForCLI(path) + if err != nil { + return hookLintDocument{}, "", err + } + } + lines := collectHookItemLines(&root, scope) + document := hookLintDocument{ + Items: make([]hookLintItem, 0, len(items)), + } + for index, item := range items { + line := 1 + if index < len(lines) && lines[index] > 0 { + line = lines[index] + } + document.Items = append(document.Items, hookLintItem{ + Line: line, + Item: item.Clone(), + }) + } + return document, scope, nil +} + +// collectHookItemLines 从 YAML AST 中提取 hooks.items 每一项的首行位置。 +func collectHookItemLines(root *yaml.Node, scope string) []int { + if root == nil || len(root.Content) == 0 { + return nil + } + node := root.Content[0] + switch scope { + case "user": + node = findMappingValue(findMappingValue(node, "runtime"), "hooks") + default: + node = findMappingValue(node, "hooks") + } + itemsNode := findMappingValue(node, "items") + if itemsNode == nil || itemsNode.Kind != yaml.SequenceNode { + return nil + } + lines := make([]int, 0, len(itemsNode.Content)) + for _, itemNode := range itemsNode.Content { + lines = append(lines, itemNode.Line) + } + return lines +} + +// findMappingValue 在 YAML mapping 节点中查找指定 key 对应的 value 节点。 +func findMappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for index := 0; index+1 < len(node.Content); index += 2 { + if strings.EqualFold(strings.TrimSpace(node.Content[index].Value), strings.TrimSpace(key)) { + return node.Content[index+1] + } + } + return nil +} + +// validateLintItem 复用现有配置真源,对单条 user/repo hook 执行语义校验。 +func validateLintItem(item config.RuntimeHookItemConfig, scope string) error { + defaults := config.StaticDefaults().Runtime.Hooks + clone := item.Clone() + clone.ApplyDefaults(defaults) + if scope == "repo" { + return agentruntime.ValidateRepoHookItem(clone) + } + return clone.Validate(defaults.DefaultFailurePolicy) +} + +// hookLintHint 将底层校验错误映射为面向 CLI 的修复提示。 +func hookLintHint(message string) string { + lower := strings.ToLower(strings.TrimSpace(message)) + switch { + case strings.Contains(lower, "requires match"): + return "add a supported match section for this hook point" + case strings.Contains(lower, "unknown field"): + return "remove unsupported matcher fields and keep only tool_name/tool_name_regex/arguments_contains" + case strings.Contains(lower, "invalid regex"): + return "fix the regular expression syntax in match.tool_name_regex" + case strings.Contains(lower, "loopback only"): + return "use localhost or a loopback IP for http observe" + case strings.Contains(lower, "failure_policy"): + return "change failure_policy to warn_only or fail_open for this hook kind" + case strings.Contains(lower, "params.command"): + return "use an argv array or string command with shell=true" + case strings.Contains(lower, "point"): + return "pick a supported hook point allowed for this hook scope" + default: + return "fix the hook item so it matches current runtime hook schema" + } +} + +// resolveHookCandidate 按默认扫描顺序定位待执行 hook,并处理 user/repo 同名优先级。 +func resolveHookCandidate(workdir string, hookID string, repoOnly bool, explicitTarget string) (hookCandidate, error) { + candidates, err := loadHookCandidates(workdir, explicitTarget) + if err != nil { + return hookCandidate{}, err + } + var userMatch *hookCandidate + var repoMatch *hookCandidate + for index := range candidates { + candidate := candidates[index] + if !strings.EqualFold(strings.TrimSpace(candidate.Item.ID), strings.TrimSpace(hookID)) { + continue + } + if candidate.Scope == "user" && userMatch == nil { + userMatch = &candidate + } + if candidate.Scope == "repo" && repoMatch == nil { + repoMatch = &candidate + } + } + if repoOnly { + if repoMatch == nil { + return hookCandidate{}, fmt.Errorf("repo hook %q not found", hookID) + } + return *repoMatch, nil + } + if userMatch != nil { + return *userMatch, nil + } + if repoMatch != nil { + return *repoMatch, nil + } + return hookCandidate{}, fmt.Errorf("hook %q not found", hookID) +} + +// loadHookCandidates 读取默认扫描范围内的 user/repo hook 候选集合。 +func loadHookCandidates(workdir string, explicitTarget string) ([]hookCandidate, error) { + var ( + targets []string + err error + ) + if strings.TrimSpace(explicitTarget) != "" { + targets, err = resolveHookLintTargets(workdir, []string{explicitTarget}) + } else { + targets, err = resolveHookLintTargets(workdir, nil) + } + if err != nil { + return nil, err + } + var candidates []hookCandidate + for _, target := range targets { + if strings.EqualFold(filepath.Base(target), "config.yaml") { + items, err := loadUserHookItems(target) + if err != nil { + return nil, err + } + for _, item := range items { + candidates = append(candidates, hookCandidate{ + Path: target, + Scope: "user", + Source: string(runtimehooks.HookSourceUser), + Item: item.Clone(), + }) + } + continue + } + items, err := loadRepoHookItemsForCLI(target) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + for _, item := range items { + candidates = append(candidates, hookCandidate{ + Path: target, + Scope: "repo", + Source: string(runtimehooks.HookSourceRepo), + Item: item.Clone(), + }) + } + } + return candidates, nil +} + +// loadUserHookItems 从 user config 文件中读取 runtime.hooks.items 列表。 +func loadUserHookItems(path string) ([]config.RuntimeHookItemConfig, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var parsed userHookConfigFile + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(false) + if err := decoder.Decode(&parsed); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return append([]config.RuntimeHookItemConfig(nil), parsed.Runtime.Hooks.Items...), nil +} + +// loadRepoHookItemsForCLI 从 repo hooks 文件中读取 hooks.items 列表。 +func loadRepoHookItemsForCLI(path string) ([]config.RuntimeHookItemConfig, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var parsed repoHookConfigFile + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(true) + if err := decoder.Decode(&parsed); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return append([]config.RuntimeHookItemConfig(nil), parsed.Hooks.Items...), nil +} + +// buildHookSpecForCandidate 复用 runtime 的公共构建入口,把配置项编译成可执行 HookSpec。 +func buildHookSpecForCandidate(candidate hookCandidate, workdir string) (runtimehooks.HookSpec, error) { + defaultWorkdir := workdir + if strings.TrimSpace(defaultWorkdir) == "" { + defaultWorkdir = filepath.Dir(candidate.Path) + } + item := candidate.Item.Clone() + item.ApplyDefaults(config.StaticDefaults().Runtime.Hooks) + if candidate.Scope == "repo" { + return agentruntime.BuildRepoHookSpec(item, defaultWorkdir) + } + return agentruntime.BuildUserHookSpec(item, defaultWorkdir) +} + +// hasHookFailed 判断本次 dry-run 是否出现 failed 终态结果。 +func hasHookFailed(output runtimehooks.RunOutput) bool { + for _, result := range output.Results { + if result.Status == runtimehooks.HookResultFailed { + return true + } + } + return false +} + +// resolveHookTracePath 根据当前 workspace 与 run_id 解析 trace 文件绝对路径。 +func resolveHookTracePath(workdir string, runID string) (string, error) { + resolvedWorkdir, err := resolveHookWorkspace(workdir) + if err != nil { + return "", err + } + loader := config.NewLoader("", config.StaticDefaults()) + return agentruntime.HookTracePath(loader.BaseDir(), resolvedWorkdir, runID) +} + +// resolveHookWorkspace 统一解析 hooks CLI 所使用的 workspace 根目录,优先取显式 --workdir,缺失时回退当前目录。 +func resolveHookWorkspace(workdir string) (string, error) { + candidate := strings.TrimSpace(workdir) + if candidate == "" { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + candidate = cwd + } + return agentsession.ResolveExistingDir(candidate) +} + +// readHookTraceRecords 读取并排序 JSONL trace 记录,坏行会返回定位后的解码错误。 +func readHookTraceRecords(path string) ([]agentruntime.HookTraceRecord, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + scanner := bufio.NewScanner(file) + records := make([]agentruntime.HookTraceRecord, 0, 16) + lineNo := 0 + for scanner.Scan() { + if strings.TrimSpace(scanner.Text()) == "" { + continue + } + lineNo++ + var record agentruntime.HookTraceRecord + if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { + return nil, fmt.Errorf("decode trace line %d: %w", lineNo, err) + } + records = append(records, record) + } + if err := scanner.Err(); err != nil { + return nil, err + } + sort.SliceStable(records, func(i, j int) bool { + return records[i].Timestamp.Before(records[j].Timestamp) + }) + return records, nil +} + +// aggregateHookTraceRecords 汇总每个 hook 的终态次数与耗时,供 trace summary 输出。 +func aggregateHookTraceRecords(records []agentruntime.HookTraceRecord) []hookTraceAggregate { + byHook := make(map[string]*hookTraceAggregate) + for _, record := range records { + if !isHookTraceTerminalRecord(record) { + continue + } + hookID := strings.TrimSpace(record.HookID) + if hookID == "" { + continue + } + aggregate, ok := byHook[hookID] + if !ok { + aggregate = &hookTraceAggregate{HookID: hookID} + byHook[hookID] = aggregate + } + aggregate.Count++ + if record.DurationMS > 0 { + aggregate.DurationMS += record.DurationMS + if record.DurationMS > aggregate.MaxDuration { + aggregate.MaxDuration = record.DurationMS + } + } + } + out := make([]hookTraceAggregate, 0, len(byHook)) + for _, aggregate := range byHook { + out = append(out, *aggregate) + } + sort.Slice(out, func(i, j int) bool { + return out[i].HookID < out[j].HookID + }) + return out +} + +// isHookTraceTerminalRecord 判断 trace 记录是否属于一次 hook 执行的终态事件。 +func isHookTraceTerminalRecord(record agentruntime.HookTraceRecord) bool { + switch strings.TrimSpace(record.EventType) { + case string(agentruntime.EventHookFinished), string(agentruntime.EventHookFailed), string(agentruntime.EventHookBlocked): + return true + default: + return false + } +} + +// formatHookTraceRecord 将单条 trace 记录渲染为稳定且易读的一行文本。 +func formatHookTraceRecord(record agentruntime.HookTraceRecord) string { + line := fmt.Sprintf( + "%s %-14s hook=%s point=%s status=%s", + record.Timestamp.Format(time.RFC3339Nano), + record.EventType, + strings.TrimSpace(record.HookID), + strings.TrimSpace(record.Point), + strings.TrimSpace(record.Status), + ) + if message := strings.TrimSpace(record.Message); message != "" { + line += " message=" + message + } + if errText := strings.TrimSpace(record.Error); errText != "" { + line += " error=" + errText + } + if record.DurationMS > 0 { + line += fmt.Sprintf(" duration_ms=%d", record.DurationMS) + } + return line +} + +// renderHookTraceHistogram 根据累计耗时生成简单文本直方图。 +func renderHookTraceHistogram(durationMS int64) string { + if durationMS <= 0 { + return "" + } + width := int(durationMS / 10) + if width < 1 { + width = 1 + } + if width > 24 { + width = 24 + } + return strings.Repeat("#", width) +} diff --git a/internal/cli/hook_command_test.go b/internal/cli/hook_command_test.go new file mode 100644 index 000000000..a346ed772 --- /dev/null +++ b/internal/cli/hook_command_test.go @@ -0,0 +1,864 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" +) + +func TestRootCommandIncludesHookCommand(t *testing.T) { + command := NewRootCommand() + found := false + for _, child := range command.Commands() { + if child.Name() == "hook" { + found = true + break + } + } + if !found { + t.Fatal("expected root command to include hook subcommand") + } +} + +func TestHookCommandsHelp(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + cases := [][]string{ + {"hook", "lint", "--help"}, + {"hook", "dry-run", "--help"}, + {"hook", "trace", "--help"}, + } + for _, args := range cases { + command := NewRootCommand() + command.SetArgs(args) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("%v help failed: %v", args, err) + } + } +} + +func TestReadHookTraceRecordsAndAggregate(t *testing.T) { + tracePath := filepath.Join(t.TempDir(), "run-1.jsonl") + records := []agentruntime.HookTraceRecord{ + { + EventType: "hook_started", + Timestamp: time.Unix(10, 0).UTC(), + RunID: "run-1", + HookID: "warn-bash", + Point: "before_tool_call", + DurationMS: 0, + }, + { + EventType: "hook_finished", + Timestamp: time.Unix(11, 0).UTC(), + RunID: "run-1", + HookID: "warn-bash", + Point: "before_tool_call", + Status: "pass", + DurationMS: 12, + }, + { + EventType: "hook_blocked", + Timestamp: time.Unix(12, 0).UTC(), + RunID: "run-1", + HookID: "repo-guard", + Point: "accept_gate", + Status: "block", + DurationMS: 33, + }, + } + file, err := os.Create(tracePath) + if err != nil { + t.Fatalf("create trace file: %v", err) + } + defer file.Close() + encoder := json.NewEncoder(file) + for _, record := range records { + if err := encoder.Encode(record); err != nil { + t.Fatalf("encode trace record: %v", err) + } + } + + loaded, err := readHookTraceRecords(tracePath) + if err != nil { + t.Fatalf("readHookTraceRecords() error = %v", err) + } + if len(loaded) != len(records) { + t.Fatalf("records len = %d, want %d", len(loaded), len(records)) + } + aggregates := aggregateHookTraceRecords(loaded) + if len(aggregates) != 2 { + t.Fatalf("aggregates len = %d, want 2", len(aggregates)) + } + if aggregates[0].Count != 1 || aggregates[1].Count != 1 { + t.Fatalf("aggregate counts = %#v, want terminal events only", aggregates) + } + if renderHookTraceHistogram(0) != "" { + t.Fatal("expected empty histogram for zero duration") + } + if got := renderHookTraceHistogram(120); !strings.Contains(got, "#") { + t.Fatalf("expected histogram bars, got %q", got) + } +} + +func TestHookTraceCommandReturnsExitCodeWhenTraceMissing(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + workdir := t.TempDir() + command := NewRootCommand() + command.SetArgs([]string{"--workdir", workdir, "hook", "trace", "--run-id", "missing-run"}) + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected missing trace error") + } + exitErr, ok := err.(ExitCoder) + if !ok { + t.Fatalf("error type = %T, want ExitCoder", err) + } + if exitErr.ExitCode() != hookExitLintFindings { + t.Fatalf("exit code = %d, want %d", exitErr.ExitCode(), hookExitLintFindings) + } +} + +func TestHookTraceCommandReadsCorruptedTraceAsSystemError(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + homeDir := t.TempDir() + workdir := t.TempDir() + setHookTestHome(t, homeDir) + + tracePath, err := agentruntime.HookTracePath(filepath.Join(homeDir, ".neocode"), workdir, "run-bad") + if err != nil { + t.Fatalf("HookTracePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(tracePath), 0o755); err != nil { + t.Fatalf("MkdirAll(trace dir) error = %v", err) + } + if err := os.WriteFile(tracePath, []byte("{bad json}\n"), 0o644); err != nil { + t.Fatalf("WriteFile(trace) error = %v", err) + } + + command := NewRootCommand() + command.SetArgs([]string{"--workdir", workdir, "hook", "trace", "--run-id", "run-bad"}) + err = command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected corrupted trace error") + } + exitErr, ok := err.(ExitCoder) + if !ok { + t.Fatalf("error type = %T, want ExitCoder", err) + } + if exitErr.ExitCode() != hookExitSystemError { + t.Fatalf("exit code = %d, want %d", exitErr.ExitCode(), hookExitSystemError) + } +} + +func TestHookTraceCommandPrintsReplayAndSummary(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + homeDir := t.TempDir() + workdir := t.TempDir() + setHookTestHome(t, homeDir) + + tracePath, err := agentruntime.HookTracePath(filepath.Join(homeDir, ".neocode"), workdir, "run-1") + if err != nil { + t.Fatalf("HookTracePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(tracePath), 0o755); err != nil { + t.Fatalf("MkdirAll(trace dir) error = %v", err) + } + records := []agentruntime.HookTraceRecord{ + { + EventType: "hook_started", + Timestamp: time.Unix(10, 0).UTC(), + RunID: "run-1", + HookID: "warn-bash", + Point: "before_tool_call", + }, + { + EventType: "hook_finished", + Timestamp: time.Unix(11, 0).UTC(), + RunID: "run-1", + HookID: "warn-bash", + Point: "before_tool_call", + Status: "pass", + Message: "ok", + DurationMS: 18, + }, + { + EventType: "hook_blocked", + Timestamp: time.Unix(12, 0).UTC(), + RunID: "run-1", + HookID: "repo-guard", + Point: "accept_gate", + Status: "block", + Message: "manual review required", + DurationMS: 33, + }, + } + file, err := os.Create(tracePath) + if err != nil { + t.Fatalf("create trace file: %v", err) + } + for _, record := range records { + if err := json.NewEncoder(file).Encode(record); err != nil { + file.Close() + t.Fatalf("encode trace record: %v", err) + } + } + if err := file.Close(); err != nil { + t.Fatalf("Close(trace file) error = %v", err) + } + + command := NewRootCommand() + buffer := &bytes.Buffer{} + command.SetOut(buffer) + command.SetErr(buffer) + command.SetArgs([]string{"--workdir", workdir, "hook", "trace", "--run-id", "run-1"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext(trace) error = %v", err) + } + output := buffer.String() + if !strings.Contains(output, "hook_finished") || !strings.Contains(output, "hook_blocked") { + t.Fatalf("expected replay output, got %q", output) + } + if !strings.Contains(output, "summary:") || !strings.Contains(output, "warn-bash count=1") { + t.Fatalf("expected summary output, got %q", output) + } +} + +func TestHookLintCommandDetectsExpectedScenarios(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + cases := []struct { + name string + path string + content string + wantSubstr string + wantHint string + }{ + { + name: "unsupported point", + path: "hooks.yaml", + content: ` +hooks: + items: + - id: bad-point + point: impossible_point + scope: repo + kind: builtin + mode: sync + handler: add_context_note + params: + note: bad +`, + wantSubstr: `error: point "impossible_point" is not supported`, + wantHint: "supported hook point", + }, + { + name: "user disallowed point", + path: "config.yaml", + content: ` +runtime: + hooks: + items: + - id: no-user-permission + point: before_permission_decision + scope: user + kind: builtin + mode: sync + handler: add_context_note + params: + note: bad +`, + wantSubstr: `error: point "before_permission_decision" does not allow user hooks`, + wantHint: "supported hook point", + }, + { + name: "warn_on_tool_call missing match", + path: "config.yaml", + content: ` +runtime: + hooks: + items: + - id: missing-match + point: before_tool_call + scope: user + kind: builtin + mode: sync + handler: warn_on_tool_call + params: + message: warn +`, + wantSubstr: `error: handler "warn_on_tool_call" requires match`, + wantHint: "match section", + }, + { + name: "matcher unknown field", + path: "config.yaml", + content: ` +runtime: + hooks: + items: + - id: matcher-unknown + point: before_tool_call + scope: user + kind: builtin + mode: sync + handler: warn_on_tool_call + match: + tool_name: bash + unknown_field: bash +`, + wantSubstr: `error: match: match contains unknown field`, + wantHint: "tool_name_regex", + }, + { + name: "matcher invalid regex", + path: "config.yaml", + content: ` +runtime: + hooks: + items: + - id: matcher-regex + point: before_tool_call + scope: user + kind: builtin + mode: sync + handler: warn_on_tool_call + match: + tool_name_regex: "[" +`, + wantSubstr: `error: match: matcher field "tool_name_regex" has invalid regex`, + wantHint: "regular expression", + }, + { + name: "http non loopback", + path: "config.yaml", + content: ` +runtime: + hooks: + items: + - id: http-remote + point: before_tool_call + scope: user + kind: http + mode: observe + params: + url: https://example.com/hook +`, + wantSubstr: `error: kind http params.url host "example.com" is not allowed`, + wantHint: "loopback", + }, + { + name: "http fail_closed", + path: "config.yaml", + content: ` +runtime: + hooks: + items: + - id: http-fail-closed + point: before_tool_call + scope: user + kind: http + mode: observe + failure_policy: fail_closed + params: + url: http://127.0.0.1:8080/hook +`, + wantSubstr: `error: failure_policy "fail_closed" is not supported for kind http observe`, + wantHint: "warn_only or fail_open", + }, + { + name: "command params invalid", + path: "hooks.yaml", + content: ` +hooks: + items: + - id: command-invalid + point: before_tool_call + scope: repo + kind: command + mode: sync + params: + command: echo hello +`, + wantSubstr: `error: string params.command requires params.shell=true`, + wantHint: "argv array or string command", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, tc.path) + if err := os.WriteFile(target, []byte(strings.TrimSpace(tc.content)+"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(%s) error = %v", target, err) + } + + command := NewRootCommand() + buffer := &bytes.Buffer{} + command.SetOut(buffer) + command.SetErr(buffer) + command.SetArgs([]string{"hook", "lint", target}) + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected lint findings") + } + exitErr, ok := err.(ExitCoder) + if !ok { + t.Fatalf("error type = %T, want ExitCoder", err) + } + if exitErr.ExitCode() != hookExitLintFindings { + t.Fatalf("exit code = %d, want %d", exitErr.ExitCode(), hookExitLintFindings) + } + output := buffer.String() + if !strings.Contains(output, tc.wantSubstr) { + t.Fatalf("expected output to contain %q, got %q", tc.wantSubstr, output) + } + if !strings.Contains(output, "(hint: ") || !strings.Contains(output, tc.wantHint) { + t.Fatalf("expected output hint to contain %q, got %q", tc.wantHint, output) + } + if !strings.Contains(output, target+":") { + t.Fatalf("expected output to include file path, got %q", output) + } + }) + } +} + +func TestHookLintCommandSkipsMissingDefaultFiles(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + homeDir := t.TempDir() + workdir := t.TempDir() + setHookTestHome(t, homeDir) + + command := NewRootCommand() + buffer := &bytes.Buffer{} + command.SetOut(buffer) + command.SetErr(buffer) + command.SetArgs([]string{"--workdir", workdir, "hook", "lint"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext(lint default) error = %v", err) + } + if !strings.Contains(buffer.String(), "hook lint passed") { + t.Fatalf("expected success message, got %q", buffer.String()) + } +} + +func TestHookDryRunCommandPassesBuiltInHook(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + homeDir := t.TempDir() + setHookTestHome(t, homeDir) + workdir := t.TempDir() + + writeUserHookConfig(t, homeDir, []config.RuntimeHookItemConfig{ + { + ID: "warn-bash", + Enabled: boolPtrForHookTest(true), + Point: "before_tool_call", + Scope: "user", + Kind: "builtin", + Mode: "sync", + Handler: "warn_on_tool_call", + Match: map[string]any{ + "tool_name": "bash", + }, + Params: map[string]any{ + "message": "bash warned", + }, + }, + }) + + fixturePath := writeHookFixture(t, "fixture.yaml", ` +payload_version: "1" +point: before_tool_call +run_id: run-1 +session_id: session-1 +metadata: + tool_name: bash + tool_call_id: call-1 + tool_arguments_preview: echo hello + workdir: `+workdir+` +`) + + command := NewRootCommand() + buffer := &bytes.Buffer{} + command.SetOut(buffer) + command.SetErr(buffer) + command.SetArgs([]string{"--workdir", workdir, "hook", "dry-run", "--hook", "warn-bash", "--fixture", fixturePath}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext(dry-run) error = %v", err) + } + output := buffer.String() + if !strings.Contains(output, "status: pass") { + t.Fatalf("expected pass output, got %q", output) + } + if !strings.Contains(output, "message: bash warned") { + t.Fatalf("expected hook message in output, got %q", output) + } +} + +func TestHookDryRunCommandSupportsBuiltinHandlersAndExitCodes(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + homeDir := t.TempDir() + setHookTestHome(t, homeDir) + workdir := t.TempDir() + requiredFile := filepath.Join(workdir, "README.md") + if err := os.WriteFile(requiredFile, []byte("hello"), 0o644); err != nil { + t.Fatalf("WriteFile(required file) error = %v", err) + } + + writeUserHookConfig(t, homeDir, []config.RuntimeHookItemConfig{ + { + ID: "require-readme", + Enabled: boolPtrForHookTest(true), + Point: "before_tool_call", + Scope: "user", + Kind: "builtin", + Mode: "sync", + Handler: "require_file_exists", + Params: map[string]any{ + "path": "README.md", + }, + }, + { + ID: "note-context", + Enabled: boolPtrForHookTest(true), + Point: "session_start", + Scope: "user", + Kind: "builtin", + Mode: "sync", + Handler: "add_context_note", + Params: map[string]any{ + "note": "remember this context", + }, + }, + { + ID: "block-tool", + Enabled: boolPtrForHookTest(true), + Point: "before_tool_call", + Scope: "user", + Kind: "command", + Mode: "sync", + Params: map[string]any{ + "command": []string{"cmd", "/c", "exit", "1"}, + }, + }, + { + ID: "fail-tool", + Enabled: boolPtrForHookTest(true), + Point: "before_tool_call", + Scope: "user", + Kind: "command", + Mode: "sync", + Params: map[string]any{ + "command": []string{"cmd", "/c", "exit", "9"}, + }, + }, + }) + + beforeToolFixture := writeHookFixture(t, "before-tool.yaml", ` +payload_version: "1" +point: before_tool_call +run_id: run-1 +session_id: session-1 +metadata: + tool_name: bash + tool_call_id: call-1 + tool_arguments_preview: echo hello + workdir: `+workdir+` +`) + sessionStartFixture := writeHookFixture(t, "session-start.json", `{"payload_version":"1","point":"session_start","run_id":"run-2","session_id":"session-2","metadata":{"run_id":"run-2","session_id":"session-2","workdir":"`+filepath.ToSlash(workdir)+`"}}`) + + t.Run("require_file_exists passes", func(t *testing.T) { + output, err := runHookCommand(t, workdir, []string{"hook", "dry-run", "--hook", "require-readme", "--fixture", beforeToolFixture}) + if err != nil { + t.Fatalf("runHookCommand(require-readme) error = %v", err) + } + if !strings.Contains(output, "status: pass") { + t.Fatalf("expected pass output, got %q", output) + } + }) + + t.Run("add_context_note passes with json fixture", func(t *testing.T) { + output, err := runHookCommand(t, workdir, []string{"hook", "dry-run", "--hook", "note-context", "--fixture", sessionStartFixture}) + if err != nil { + t.Fatalf("runHookCommand(note-context) error = %v", err) + } + if !strings.Contains(output, "message: remember this context") { + t.Fatalf("expected note output, got %q", output) + } + }) + + t.Run("command block exits with 3", func(t *testing.T) { + output, err := runHookCommand(t, workdir, []string{"hook", "dry-run", "--hook", "block-tool", "--fixture", beforeToolFixture}) + if err == nil { + t.Fatal("expected block exit error") + } + exitErr, ok := err.(ExitCoder) + if !ok { + t.Fatalf("error type = %T, want ExitCoder", err) + } + if exitErr.ExitCode() != hookExitHookBlocked { + t.Fatalf("exit code = %d, want %d", exitErr.ExitCode(), hookExitHookBlocked) + } + if !strings.Contains(output, "status: block") || !strings.Contains(output, "block: true") { + t.Fatalf("expected block output, got %q", output) + } + }) + + t.Run("command failure exits with 4", func(t *testing.T) { + output, err := runHookCommand(t, workdir, []string{"hook", "dry-run", "--hook", "fail-tool", "--fixture", beforeToolFixture}) + if err == nil { + t.Fatal("expected failure exit error") + } + exitErr, ok := err.(ExitCoder) + if !ok { + t.Fatalf("error type = %T, want ExitCoder", err) + } + if exitErr.ExitCode() != hookExitHookFailed { + t.Fatalf("exit code = %d, want %d", exitErr.ExitCode(), hookExitHookFailed) + } + if !strings.Contains(output, "status: failed") { + t.Fatalf("expected failed output, got %q", output) + } + }) +} + +func TestHookDryRunCommandRejectsInvalidFixtureAndPointMismatch(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + homeDir := t.TempDir() + setHookTestHome(t, homeDir) + workdir := t.TempDir() + + writeUserHookConfig(t, homeDir, []config.RuntimeHookItemConfig{ + { + ID: "warn-bash", + Enabled: boolPtrForHookTest(true), + Point: "before_tool_call", + Scope: "user", + Kind: "builtin", + Mode: "sync", + Handler: "warn_on_tool_call", + Match: map[string]any{ + "tool_name": "bash", + }, + }, + }) + + cases := []struct { + name string + content string + fileName string + wantSubstr string + }{ + { + name: "bad payload version", + fileName: "bad-version.yaml", + content: ` +payload_version: "9" +point: before_tool_call +metadata: + tool_name: bash + tool_call_id: call-1 +`, + wantSubstr: "payload_version", + }, + { + name: "unknown metadata field", + fileName: "bad-metadata.yaml", + content: ` +payload_version: "1" +point: before_tool_call +metadata: + unknown_field: value +`, + wantSubstr: "unknown field", + }, + { + name: "point mismatch", + fileName: "point-mismatch.yaml", + content: ` +payload_version: "1" +point: session_start +metadata: + run_id: run-1 + session_id: session-1 + workdir: ` + workdir + ` +`, + wantSubstr: "does not match hook", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fixturePath := writeHookFixture(t, tc.fileName, tc.content) + _, err := runHookCommand(t, workdir, []string{"hook", "dry-run", "--hook", "warn-bash", "--fixture", fixturePath}) + if err == nil { + t.Fatal("expected dry-run system error") + } + exitErr, ok := err.(ExitCoder) + if !ok { + t.Fatalf("error type = %T, want ExitCoder", err) + } + if exitErr.ExitCode() != hookExitSystemError { + t.Fatalf("exit code = %d, want %d", exitErr.ExitCode(), hookExitSystemError) + } + if !strings.Contains(err.Error(), tc.wantSubstr) { + t.Fatalf("expected error to contain %q, got %v", tc.wantSubstr, err) + } + }) + } +} + +func TestHookDryRunPrefersUserHookUnlessRepoRequested(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + homeDir := t.TempDir() + setHookTestHome(t, homeDir) + workdir := t.TempDir() + trustStorePath := filepath.Join(homeDir, ".neocode", "trusted-workspaces.json") + if err := os.MkdirAll(filepath.Dir(trustStorePath), 0o755); err != nil { + t.Fatalf("MkdirAll(trust store dir) error = %v", err) + } + trustStore := `{"version":1,"workspaces":["` + strings.ReplaceAll(filepath.Clean(workdir), `\`, `\\`) + `"]}` + if err := os.WriteFile(trustStorePath, []byte(trustStore), 0o644); err != nil { + t.Fatalf("WriteFile(trust store) error = %v", err) + } + + writeUserHookConfig(t, homeDir, []config.RuntimeHookItemConfig{ + { + ID: "same-id", + Enabled: boolPtrForHookTest(true), + Point: "before_tool_call", + Scope: "user", + Kind: "builtin", + Mode: "sync", + Handler: "warn_on_tool_call", + Match: map[string]any{ + "tool_name": "bash", + }, + Params: map[string]any{ + "message": "from user", + }, + }, + }) + repoHooksPath := filepath.Join(workdir, ".neocode", "hooks.yaml") + if err := os.MkdirAll(filepath.Dir(repoHooksPath), 0o755); err != nil { + t.Fatalf("MkdirAll(repo hooks dir) error = %v", err) + } + repoHooks := ` +hooks: + items: + - id: same-id + point: before_tool_call + scope: repo + kind: builtin + mode: sync + handler: warn_on_tool_call + match: + tool_name: bash + params: + message: from repo +` + if err := os.WriteFile(repoHooksPath, []byte(strings.TrimSpace(repoHooks)+"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(repo hooks) error = %v", err) + } + + fixturePath := writeHookFixture(t, "fixture.yaml", ` +payload_version: "1" +point: before_tool_call +metadata: + tool_name: bash + tool_call_id: call-1 + tool_arguments_preview: echo hello + workdir: `+workdir+` +`) + + output, err := runHookCommand(t, workdir, []string{"hook", "dry-run", "--hook", "same-id", "--fixture", fixturePath}) + if err != nil { + t.Fatalf("user-preferred dry-run error = %v", err) + } + if !strings.Contains(output, "message: from user") { + t.Fatalf("expected user hook output, got %q", output) + } + + output, err = runHookCommand(t, workdir, []string{"hook", "dry-run", "--repo", "--hook", "same-id", "--fixture", fixturePath}) + if err != nil { + t.Fatalf("repo dry-run error = %v", err) + } + if !strings.Contains(output, "message: from repo") { + t.Fatalf("expected repo hook output, got %q", output) + } + + output, err = runHookCommand(t, workdir, []string{"hook", "dry-run", repoHooksPath, "--hook", "same-id", "--fixture", fixturePath}) + if err != nil { + t.Fatalf("explicit-path dry-run error = %v", err) + } + if !strings.Contains(output, "message: from repo") { + t.Fatalf("expected explicit repo path output, got %q", output) + } +} + +func runHookCommand(t *testing.T, workdir string, args []string) (string, error) { + t.Helper() + command := NewRootCommand() + buffer := &bytes.Buffer{} + command.SetOut(buffer) + command.SetErr(buffer) + command.SetArgs(append([]string{"--workdir", workdir}, args...)) + err := command.ExecuteContext(context.Background()) + return buffer.String(), err +} + +func writeHookFixture(t *testing.T, name string, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(strings.TrimSpace(content)+"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(%s) error = %v", path, err) + } + return path +} + +func writeUserHookConfig(t *testing.T, homeDir string, items []config.RuntimeHookItemConfig) { + t.Helper() + cfgLoader := config.NewLoader(filepath.Join(homeDir, ".neocode"), config.StaticDefaults()) + cfg := cfgLoader.DefaultConfig() + cfg.Runtime.Hooks.Items = items + if err := cfgLoader.Save(context.Background(), &cfg); err != nil { + t.Fatalf("Save(config) error = %v", err) + } +} + +func setHookTestHome(t *testing.T, homeDir string) { + t.Helper() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(homeDir, ".config")) +} + +func boolPtrForHookTest(value bool) *bool { + return &value +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 8d672ef93..c2ce41b83 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -104,6 +104,7 @@ func NewRootCommand() *cobra.Command { _ = settings.BindPFlag("session", cmd.PersistentFlags().Lookup("session")) _ = settings.BindPFlag("wake-input-b64", cmd.PersistentFlags().Lookup("wake-input-b64")) cmd.AddCommand( + newHookCommand(), newGatewayCommand(), newAdapterCommand(), newLegacyFeishuAdapterCommand(), diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 2f5ebb98c..accc5b864 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -389,7 +389,7 @@ func TestDefaultGatewayCommandRunnerReturnsBuildRuntimePortError(t *testing.T) { prepareGatewayCommandRunnerTestEnv(t) newAuthManager = stubGatewayAuthManagerBuilder() - buildGatewayRuntimePort = func(context.Context, string) (gateway.RuntimePort, func() error, error) { + buildGatewayRuntimePort = func(context.Context, string, bool) (gateway.RuntimePort, func() error, error) { return nil, nil, errors.New("build runtime port failed") } @@ -1181,8 +1181,8 @@ func prepareGatewayCommandRunnerTestEnv(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", homeDir) } -func stubGatewayRuntimePortBuilder() func(context.Context, string) (gateway.RuntimePort, func() error, error) { - return func(context.Context, string) (gateway.RuntimePort, func() error, error) { +func stubGatewayRuntimePortBuilder() func(context.Context, string, bool) (gateway.RuntimePort, func() error, error) { + return func(context.Context, string, bool) (gateway.RuntimePort, func() error, error) { return stubRuntimePort{}, func() error { return nil }, nil } } diff --git a/internal/runtime/event_emitter.go b/internal/runtime/event_emitter.go index f5d38923e..d7ce3d338 100644 --- a/internal/runtime/event_emitter.go +++ b/internal/runtime/event_emitter.go @@ -52,6 +52,9 @@ func (s *Service) emitWithEnvelope(ctx context.Context, evt RuntimeEvent) error evt.Timestamp = time.Now() } s.captureAskRuntimeEvent(evt) + if s != nil && s.eventRecorder != nil { + s.eventRecorder.RecordRuntimeEvent(ctx, evt) + } if err := s.deliverEvent(ctx, evt); err != nil { return err } diff --git a/internal/runtime/hook_trace.go b/internal/runtime/hook_trace.go new file mode 100644 index 000000000..27a74043c --- /dev/null +++ b/internal/runtime/hook_trace.go @@ -0,0 +1,205 @@ +package runtime + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + agentsession "neo-code/internal/session" +) + +var hookTraceEventTypes = map[EventType]struct{}{ + EventHookStarted: {}, + EventHookFinished: {}, + EventHookFailed: {}, + EventHookBlocked: {}, +} + +// HookTraceRecord 描述 hook trace JSONL 的单条固定结构记录。 +type HookTraceRecord struct { + EventType string `json:"event_type"` + Timestamp time.Time `json:"timestamp"` + RunID string `json:"run_id"` + SessionID string `json:"session_id,omitempty"` + Turn int `json:"turn"` + Phase string `json:"phase,omitempty"` + HookID string `json:"hook_id,omitempty"` + Point string `json:"point,omitempty"` + Source string `json:"source,omitempty"` + Kind string `json:"kind,omitempty"` + Mode string `json:"mode,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` +} + +// HookTraceRecorder 将 hook 相关 runtime 事件旁路持久化为 JSONL。 +type HookTraceRecorder struct { + baseDir string + workspaceRoot string + + mu sync.Mutex + writers map[string]*hookTraceWriter +} + +type hookTraceWriter struct { + file *os.File + writer *bufio.Writer +} + +// NewHookTraceRecorder 创建 workspace 级 hook trace 记录器。 +func NewHookTraceRecorder(baseDir string, workspaceRoot string) *HookTraceRecorder { + return &HookTraceRecorder{ + baseDir: strings.TrimSpace(baseDir), + workspaceRoot: strings.TrimSpace(workspaceRoot), + writers: make(map[string]*hookTraceWriter), + } +} + +// RecordRuntimeEvent 将 hook 生命周期事件写入当前工作区的 trace 文件。 +func (r *HookTraceRecorder) RecordRuntimeEvent(_ context.Context, event RuntimeEvent) { + if r == nil { + return + } + if _, ok := hookTraceEventTypes[event.Type]; !ok { + return + } + record, ok := buildHookTraceRecord(event) + if !ok { + return + } + r.mu.Lock() + defer r.mu.Unlock() + + writer, err := r.ensureWriter(record.RunID) + if err != nil { + return + } + encoder := json.NewEncoder(writer.writer) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(record); err != nil { + return + } + _ = writer.writer.Flush() +} + +// Close 关闭 recorder 持有的全部打开文件。 +func (r *HookTraceRecorder) Close() error { + if r == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + + var firstErr error + for runID, writer := range r.writers { + if writer == nil { + delete(r.writers, runID) + continue + } + if writer.writer != nil { + if err := writer.writer.Flush(); err != nil && firstErr == nil { + firstErr = err + } + } + if writer.file != nil { + if err := writer.file.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + delete(r.writers, runID) + } + return firstErr +} + +// HookTracePath 返回当前 workspace/run 对应的 trace 文件绝对路径。 +func HookTracePath(baseDir string, workspaceRoot string, runID string) (string, error) { + trimmedBaseDir := strings.TrimSpace(baseDir) + trimmedWorkspace := strings.TrimSpace(workspaceRoot) + trimmedRunID := strings.TrimSpace(runID) + if trimmedBaseDir == "" { + return "", fmt.Errorf("hook trace baseDir is empty") + } + if trimmedWorkspace == "" { + return "", fmt.Errorf("hook trace workspace is empty") + } + if trimmedRunID == "" { + return "", fmt.Errorf("hook trace run_id is empty") + } + return filepath.Join( + trimmedBaseDir, + "projects", + agentsession.HashWorkspaceRoot(trimmedWorkspace), + "hook-traces", + trimmedRunID+".jsonl", + ), nil +} + +// ensureWriter 按 run_id 懒创建并缓存 JSONL writer,避免每条事件重复打开文件。 +func (r *HookTraceRecorder) ensureWriter(runID string) (*hookTraceWriter, error) { + if existing, ok := r.writers[runID]; ok && existing != nil { + return existing, nil + } + path, err := HookTracePath(r.baseDir, r.workspaceRoot, runID) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, err + } + writer := &hookTraceWriter{ + file: file, + writer: bufio.NewWriter(file), + } + r.writers[runID] = writer + return writer, nil +} + +// buildHookTraceRecord 将 runtime 事件映射为固定结构的 hook trace 记录。 +func buildHookTraceRecord(event RuntimeEvent) (HookTraceRecord, bool) { + record := HookTraceRecord{ + EventType: string(event.Type), + Timestamp: event.Timestamp.UTC(), + RunID: strings.TrimSpace(event.RunID), + SessionID: strings.TrimSpace(event.SessionID), + Turn: event.Turn, + Phase: strings.TrimSpace(event.Phase), + } + if record.RunID == "" { + return HookTraceRecord{}, false + } + switch payload := event.Payload.(type) { + case HookEventPayload: + record.HookID = strings.TrimSpace(payload.HookID) + record.Point = strings.TrimSpace(payload.Point) + record.Source = strings.TrimSpace(payload.Source) + record.Kind = strings.TrimSpace(payload.Kind) + record.Mode = strings.TrimSpace(payload.Mode) + record.Status = strings.TrimSpace(payload.Status) + record.Message = strings.TrimSpace(payload.Message) + record.Error = strings.TrimSpace(payload.Error) + record.StartedAt = payload.StartedAt.UTC() + record.DurationMS = payload.DurationMS + case HookBlockedPayload: + record.HookID = strings.TrimSpace(payload.HookID) + record.Point = strings.TrimSpace(payload.Point) + record.Source = strings.TrimSpace(payload.Source) + record.Status = "block" + record.Message = strings.TrimSpace(payload.Reason) + default: + return HookTraceRecord{}, false + } + return record, true +} diff --git a/internal/runtime/hook_trace_test.go b/internal/runtime/hook_trace_test.go new file mode 100644 index 000000000..0c1bf5796 --- /dev/null +++ b/internal/runtime/hook_trace_test.go @@ -0,0 +1,66 @@ +package runtime + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestHookTracePath(t *testing.T) { + path, err := HookTracePath(t.TempDir(), t.TempDir(), "run-1") + if err != nil { + t.Fatalf("HookTracePath() error = %v", err) + } + if !strings.HasSuffix(path, filepath.Join("hook-traces", "run-1.jsonl")) { + t.Fatalf("unexpected trace path: %s", path) + } +} + +func TestHookTraceRecorderWritesHookEvents(t *testing.T) { + baseDir := t.TempDir() + workspace := t.TempDir() + recorder := NewHookTraceRecorder(baseDir, workspace) + t.Cleanup(func() { + if err := recorder.Close(); err != nil { + t.Fatalf("recorder.Close() error = %v", err) + } + }) + + recorder.RecordRuntimeEvent(context.Background(), RuntimeEvent{ + Type: EventHookFinished, + RunID: "run-1", + SessionID: "session-1", + Turn: 2, + Phase: "execute", + Timestamp: time.Unix(123, 0).UTC(), + Payload: HookEventPayload{ + HookID: "warn-bash", + Point: "before_tool_call", + Source: "user", + Kind: "function", + Mode: "sync", + Status: "pass", + Message: "ok", + StartedAt: time.Unix(122, 0).UTC(), + DurationMS: 12, + }, + }) + + tracePath, err := HookTracePath(baseDir, workspace, "run-1") + if err != nil { + t.Fatalf("HookTracePath() error = %v", err) + } + data, err := os.ReadFile(tracePath) + if err != nil { + t.Fatalf("ReadFile(trace) error = %v", err) + } + if !strings.Contains(string(data), `"event_type":"hook_finished"`) { + t.Fatalf("trace file missing hook_finished record: %s", string(data)) + } + if !strings.Contains(string(data), `"hook_id":"warn-bash"`) { + t.Fatalf("trace file missing hook id: %s", string(data)) + } +} diff --git a/internal/runtime/hooks/fixture/fixture.go b/internal/runtime/hooks/fixture/fixture.go new file mode 100644 index 000000000..4445810f3 --- /dev/null +++ b/internal/runtime/hooks/fixture/fixture.go @@ -0,0 +1,111 @@ +package fixture + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + runtimehooks "neo-code/internal/runtime/hooks" +) + +// Parsed 描述 dry-run fixture 归一化后的结果。 +type Parsed struct { + Point runtimehooks.HookPoint + Context runtimehooks.HookContext + Payload map[string]any +} + +type rawFixture struct { + PayloadVersion string `json:"payload_version" yaml:"payload_version"` + Point string `json:"point" yaml:"point"` + RunID string `json:"run_id" yaml:"run_id"` + SessionID string `json:"session_id" yaml:"session_id"` + Metadata map[string]any `json:"metadata" yaml:"metadata"` +} + +// ParseFile 读取并校验 YAML/JSON fixture 文件。 +func ParseFile(path string) (Parsed, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Parsed{}, fmt.Errorf("read fixture: %w", err) + } + return ParseBytes(raw, path) +} + +// ParseBytes 解析并校验 fixture 内容,扩展名仅用于错误提示和格式推断。 +func ParseBytes(raw []byte, sourcePath string) (Parsed, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return Parsed{}, fmt.Errorf("fixture is empty") + } + var payload map[string]any + var decoded rawFixture + ext := strings.ToLower(strings.TrimSpace(filepath.Ext(sourcePath))) + switch ext { + case ".json": + if err := json.Unmarshal(raw, &payload); err != nil { + return Parsed{}, fmt.Errorf("parse fixture json: %w", err) + } + if err := json.Unmarshal(raw, &decoded); err != nil { + return Parsed{}, fmt.Errorf("parse fixture json: %w", err) + } + default: + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(false) + if err := decoder.Decode(&payload); err != nil { + return Parsed{}, fmt.Errorf("parse fixture yaml: %w", err) + } + decoder = yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(false) + if err := decoder.Decode(&decoded); err != nil { + return Parsed{}, fmt.Errorf("parse fixture yaml: %w", err) + } + } + point := runtimehooks.HookPoint(strings.TrimSpace(decoded.Point)) + schema := runtimehooks.PayloadSchema(point) + if schema.Point == "" { + return Parsed{}, fmt.Errorf("fixture point %q is not supported", decoded.Point) + } + if strings.TrimSpace(decoded.PayloadVersion) != runtimehooks.PayloadVersion { + return Parsed{}, fmt.Errorf( + "fixture payload_version %q does not match %q", + decoded.PayloadVersion, + runtimehooks.PayloadVersion, + ) + } + allowedTopLevel := make(map[string]struct{}, len(schema.TopLevel)+1) + for _, field := range schema.TopLevel { + allowedTopLevel[strings.ToLower(strings.TrimSpace(field.Name))] = struct{}{} + } + allowedTopLevel["metadata"] = struct{}{} + for key := range payload { + if _, ok := allowedTopLevel[strings.ToLower(strings.TrimSpace(key))]; !ok { + return Parsed{}, fmt.Errorf("fixture contains unknown top-level field %q", key) + } + } + if decoded.Metadata == nil { + decoded.Metadata = map[string]any{} + } + allowedMetadata := make(map[string]struct{}, len(schema.Metadata)) + for _, field := range schema.Metadata { + allowedMetadata[strings.ToLower(strings.TrimSpace(field.Name))] = struct{}{} + } + for key := range decoded.Metadata { + if _, ok := allowedMetadata[strings.ToLower(strings.TrimSpace(key))]; !ok { + return Parsed{}, fmt.Errorf("fixture metadata contains unknown field %q for point %q", key, point) + } + } + return Parsed{ + Point: point, + Context: runtimehooks.HookContext{ + RunID: strings.TrimSpace(decoded.RunID), + SessionID: strings.TrimSpace(decoded.SessionID), + Metadata: decoded.Metadata, + }, + Payload: payload, + }, nil +} diff --git a/internal/runtime/hooks/fixture/fixture_test.go b/internal/runtime/hooks/fixture/fixture_test.go new file mode 100644 index 000000000..95f71420e --- /dev/null +++ b/internal/runtime/hooks/fixture/fixture_test.go @@ -0,0 +1,63 @@ +package fixture + +import ( + "strings" + "testing" +) + +func TestParseBytesYAMLAndJSON(t *testing.T) { + yamlFixture := []byte(` +payload_version: "1" +point: before_tool_call +run_id: run-1 +session_id: session-1 +metadata: + tool_name: bash + tool_call_id: call-1 +`) + parsed, err := ParseBytes(yamlFixture, "fixture.yaml") + if err != nil { + t.Fatalf("ParseBytes(yaml) error = %v", err) + } + if string(parsed.Point) != "before_tool_call" { + t.Fatalf("point = %q, want before_tool_call", parsed.Point) + } + + jsonFixture := []byte(`{"payload_version":"1","point":"before_tool_call","metadata":{"tool_name":"bash","tool_call_id":"call-1"}}`) + if _, err := ParseBytes(jsonFixture, "fixture.json"); err != nil { + t.Fatalf("ParseBytes(json) error = %v", err) + } +} + +func TestParseBytesRejectsUnknownFieldsAndSchemaMismatch(t *testing.T) { + unknownField := []byte(` +payload_version: "1" +point: before_tool_call +metadata: + tool_name: bash +extra_field: value +`) + if _, err := ParseBytes(unknownField, "fixture.yaml"); err == nil || !strings.Contains(err.Error(), "unknown top-level field") { + t.Fatalf("expected unknown field error, got %v", err) + } + + badVersion := []byte(` +payload_version: "9" +point: before_tool_call +metadata: + tool_name: bash +`) + if _, err := ParseBytes(badVersion, "fixture.yaml"); err == nil || !strings.Contains(err.Error(), "payload_version") { + t.Fatalf("expected payload_version error, got %v", err) + } + + badMetadata := []byte(` +payload_version: "1" +point: before_tool_call +metadata: + phase: plan +`) + if _, err := ParseBytes(badMetadata, "fixture.yaml"); err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("expected metadata schema error, got %v", err) + } +} diff --git a/internal/runtime/repo_hooks.go b/internal/runtime/repo_hooks.go index 6983d46d0..12500d561 100644 --- a/internal/runtime/repo_hooks.go +++ b/internal/runtime/repo_hooks.go @@ -85,7 +85,7 @@ func buildUserHookExecutor( if !item.IsEnabled() { continue } - spec, err := buildUserHookSpec(item, cfg.Workdir) + spec, err := BuildUserHookSpec(item, cfg.Workdir) if err != nil { return nil, fmt.Errorf("runtime.hooks.items[%d]: %w", index, err) } @@ -206,7 +206,7 @@ func buildRepoHookExecutorForWorkspace( registry := runtimehooks.NewRegistry() for index, item := range items { - spec, err := buildRepoHookSpec(item, workspace) + spec, err := BuildRepoHookSpec(item, workspace) if err != nil { return nil, fmt.Errorf("%s: hooks.items[%d]: %w", hooksPath, index, err) } @@ -315,8 +315,8 @@ func applyRepoHookItemDefaults(item *config.RuntimeHookItemConfig, defaults conf } } -// validateRepoHookItem 校验 repo hook item 是否满足 P3 限定能力范围。 -func validateRepoHookItem(item config.RuntimeHookItemConfig) error { +// ValidateRepoHookItem 校验 repo hook item 是否满足当前阶段允许的能力范围。 +func ValidateRepoHookItem(item config.RuntimeHookItemConfig) error { if strings.TrimSpace(item.ID) == "" { return fmt.Errorf("id is required") } @@ -380,6 +380,11 @@ func validateRepoHookItem(item config.RuntimeHookItemConfig) error { return nil } +// validateRepoHookItem 兼容包内既有调用,内部统一转向导出实现。 +func validateRepoHookItem(item config.RuntimeHookItemConfig) error { + return ValidateRepoHookItem(item) +} + // evaluateWorkspaceTrust 根据 trust store 判断 workspace 是否可信并附带容错诊断。 func evaluateWorkspaceTrust(workspace string) trustDecision { storePath := resolveTrustedWorkspacesPath() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index af69c3e4f..4388b96b5 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -215,6 +215,7 @@ type Service struct { thinkingEnabled bool runnerToolDispatcher RunnerToolDispatcher + eventRecorder RuntimeEventRecorder } // RunnerToolDispatcher 可选:将工具执行分发到远程 runner。 @@ -228,6 +229,11 @@ func (s *Service) SetRunnerToolDispatcher(d RunnerToolDispatcher) { s.runnerToolDispatcher = d } +// RuntimeEventRecorder 定义 runtime 事件旁路记录器,用于追加可观测持久化而不影响主链事件消费。 +type RuntimeEventRecorder interface { + RecordRuntimeEvent(ctx context.Context, event RuntimeEvent) +} + // sessionLockEntry 维护单个会话读写锁及其当前引用计数,用于在无引用时回收 map 项。 type sessionLockEntry struct { mu sync.RWMutex @@ -661,6 +667,11 @@ func (s *Service) SetHookExecutor(executor HookExecutor) { s.hookExecutor = executor } +// SetRuntimeEventRecorder 设置可选的 runtime 事件记录器,供 CLI trace 等旁路观测能力复用。 +func (s *Service) SetRuntimeEventRecorder(recorder RuntimeEventRecorder) { + s.eventRecorder = recorder +} + // SetCheckpointDependencies 注入 checkpoint 存储与版本化文件历史快照后端,用于 pre-write checkpoint gate。 func (s *Service) SetCheckpointDependencies(store checkpoint.CheckpointStore, perEdit *checkpoint.PerEditSnapshotStore) { s.checkpointStore = store diff --git a/internal/runtime/user_hooks.go b/internal/runtime/user_hooks.go index 2eb45eeab..ff7858324 100644 --- a/internal/runtime/user_hooks.go +++ b/internal/runtime/user_hooks.go @@ -166,9 +166,9 @@ func runHookExecutorSafely( return executor.Run(ctx, point, input) } -// buildUserHookSpec 将 user hook 配置转换为 runtime 可执行 HookSpec。 -func buildUserHookSpec(item config.RuntimeHookItemConfig, defaultWorkdir string) (runtimehooks.HookSpec, error) { - return buildConfiguredHookSpec( +// BuildUserHookSpec 将 user hook 配置转换为 runtime 可执行 HookSpec。 +func BuildUserHookSpec(item config.RuntimeHookItemConfig, defaultWorkdir string) (runtimehooks.HookSpec, error) { + return BuildConfiguredHookSpec( item, defaultWorkdir, runtimehooks.HookScopeUser, @@ -176,9 +176,14 @@ func buildUserHookSpec(item config.RuntimeHookItemConfig, defaultWorkdir string) ) } -// buildRepoHookSpec 将 repo hook 配置转换为 runtime 可执行 HookSpec。 -func buildRepoHookSpec(item config.RuntimeHookItemConfig, defaultWorkdir string) (runtimehooks.HookSpec, error) { - return buildConfiguredHookSpec( +// buildUserHookSpec 兼容同包历史测试与旧调用,内部统一转到导出实现。 +func buildUserHookSpec(item config.RuntimeHookItemConfig, defaultWorkdir string) (runtimehooks.HookSpec, error) { + return BuildUserHookSpec(item, defaultWorkdir) +} + +// BuildRepoHookSpec 将 repo hook 配置转换为 runtime 可执行 HookSpec。 +func BuildRepoHookSpec(item config.RuntimeHookItemConfig, defaultWorkdir string) (runtimehooks.HookSpec, error) { + return BuildConfiguredHookSpec( item, defaultWorkdir, runtimehooks.HookScopeRepo, @@ -186,14 +191,19 @@ func buildRepoHookSpec(item config.RuntimeHookItemConfig, defaultWorkdir string) ) } -// buildConfiguredHookSpec 按给定 scope/source 构建配置化 hook 执行定义。 -func buildConfiguredHookSpec( +// buildRepoHookSpec 兼容同包历史测试与旧调用,内部统一转到导出实现。 +func buildRepoHookSpec(item config.RuntimeHookItemConfig, defaultWorkdir string) (runtimehooks.HookSpec, error) { + return BuildRepoHookSpec(item, defaultWorkdir) +} + +// BuildConfiguredHookSpec 按给定 scope/source 构建配置化 hook 执行定义。 +func BuildConfiguredHookSpec( item config.RuntimeHookItemConfig, defaultWorkdir string, scope runtimehooks.HookScope, source runtimehooks.HookSource, ) (runtimehooks.HookSpec, error) { - if err := validateConfiguredHookItemForP6Lite(item, scope); err != nil { + if err := ValidateConfiguredHookItemForP6Lite(item, scope); err != nil { return runtimehooks.HookSpec{}, err } point := runtimehooks.HookPoint(strings.TrimSpace(item.Point)) @@ -247,8 +257,8 @@ func buildConfiguredHookSpec( }, nil } -// validateConfiguredHookItemForP6Lite 在 runtime 装配阶段执行兜底校验,防止绕过配置层校验后出现半生效。 -func validateConfiguredHookItemForP6Lite(item config.RuntimeHookItemConfig, scope runtimehooks.HookScope) error { +// ValidateConfiguredHookItemForP6Lite 在 runtime 装配阶段执行兜底校验,防止绕过配置层校验后出现半生效。 +func ValidateConfiguredHookItemForP6Lite(item config.RuntimeHookItemConfig, scope runtimehooks.HookScope) error { expectedScope := strings.TrimSpace(string(scope)) actualScope := strings.ToLower(strings.TrimSpace(item.Scope)) if actualScope != expectedScope { @@ -308,6 +318,11 @@ func validateConfiguredHookItemForP6Lite(item config.RuntimeHookItemConfig, scop return nil } +// validateConfiguredHookItemForP6Lite 兼容同包历史测试与旧调用,内部统一转到导出实现。 +func validateConfiguredHookItemForP6Lite(item config.RuntimeHookItemConfig, scope runtimehooks.HookScope) error { + return ValidateConfiguredHookItemForP6Lite(item, scope) +} + // buildConfiguredHookMatcher 编译 hook matcher。 func buildConfiguredHookMatcher(item config.RuntimeHookItemConfig, point runtimehooks.HookPoint) (*runtimehooks.HookMatcher, error) { if !runtimehooks.HasHookMatcherConfig(item.Match) { From 75b0c851271a1d4842fa2d30e864e1b148c3e2c1 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Sun, 14 Jun 2026 22:06:55 +0800 Subject: [PATCH 2/4] fix: address hooks cli review feedback --- cmd/neocode/main.go | 21 +++++-- cmd/neocode/main_test.go | 94 +++++++++++++++++++++++++++++ internal/cli/hook_command.go | 19 +++++- internal/cli/hook_command_test.go | 89 +++++++++++++++++++++++++-- internal/runtime/hook_trace.go | 41 ++++++++++++- internal/runtime/hook_trace_test.go | 16 +++++ internal/runtime/repo_hooks.go | 9 ++- 7 files changed, 272 insertions(+), 17 deletions(-) create mode 100644 cmd/neocode/main_test.go diff --git a/cmd/neocode/main.go b/cmd/neocode/main.go index b1d662401..4596ff775 100644 --- a/cmd/neocode/main.go +++ b/cmd/neocode/main.go @@ -4,22 +4,33 @@ import ( "context" "errors" "fmt" + "io" "os" "neo-code/internal/cli" ) +var executeCLI = cli.Execute +var consumeCLIUpdateNotice = cli.ConsumeUpdateNotice +var exitProcess = os.Exit + func main() { - if err := cli.Execute(context.Background()); err != nil { - fmt.Fprintf(os.Stderr, "neocode: %v\n", err) + exitProcess(runMain(context.Background(), os.Stdout, os.Stderr)) +} + +// runMain 执行 CLI 主流程并返回最终进程退出码,便于主入口与测试共享同一套分支逻辑。 +func runMain(ctx context.Context, stdout io.Writer, stderr io.Writer) int { + if err := executeCLI(ctx); err != nil { + _, _ = fmt.Fprintf(stderr, "neocode: %v\n", err) exitCode := 1 var exitCoder interface{ ExitCode() int } if errors.As(err, &exitCoder) { exitCode = exitCoder.ExitCode() } - os.Exit(exitCode) + return exitCode } - if notice := cli.ConsumeUpdateNotice(); notice != "" { - fmt.Fprintln(os.Stdout, notice) + if notice := consumeCLIUpdateNotice(); notice != "" { + _, _ = fmt.Fprintln(stdout, notice) } + return 0 } diff --git a/cmd/neocode/main_test.go b/cmd/neocode/main_test.go new file mode 100644 index 000000000..c699ef079 --- /dev/null +++ b/cmd/neocode/main_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" +) + +type testExitError struct { + message string + code int +} + +func (e testExitError) Error() string { + return e.message +} + +func (e testExitError) ExitCode() int { + return e.code +} + +func TestRunMainReturnsExitCodeFromCLI(t *testing.T) { + originalExecute := executeCLI + originalConsume := consumeCLIUpdateNotice + t.Cleanup(func() { + executeCLI = originalExecute + consumeCLIUpdateNotice = originalConsume + }) + + executeCLI = func(context.Context) error { + return testExitError{message: "boom", code: 7} + } + consumeCLIUpdateNotice = func() string { return "" } + + var stdout strings.Builder + var stderr strings.Builder + exitCode := runMain(context.Background(), &stdout, &stderr) + if exitCode != 7 { + t.Fatalf("exitCode = %d, want 7", exitCode) + } + if stdout.String() != "" { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "neocode: boom") { + t.Fatalf("stderr = %q, want error output", stderr.String()) + } +} + +func TestRunMainPrintsUpdateNoticeOnSuccess(t *testing.T) { + originalExecute := executeCLI + originalConsume := consumeCLIUpdateNotice + t.Cleanup(func() { + executeCLI = originalExecute + consumeCLIUpdateNotice = originalConsume + }) + + executeCLI = func(context.Context) error { return nil } + consumeCLIUpdateNotice = func() string { return "update available" } + + var stdout strings.Builder + var stderr strings.Builder + exitCode := runMain(context.Background(), &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("exitCode = %d, want 0", exitCode) + } + if !strings.Contains(stdout.String(), "update available") { + t.Fatalf("stdout = %q, want update notice", stdout.String()) + } + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRunMainFallsBackToExitCodeOne(t *testing.T) { + originalExecute := executeCLI + originalConsume := consumeCLIUpdateNotice + t.Cleanup(func() { + executeCLI = originalExecute + consumeCLIUpdateNotice = originalConsume + }) + + executeCLI = func(context.Context) error { return errors.New("plain failure") } + consumeCLIUpdateNotice = func() string { return "" } + + var stderr strings.Builder + exitCode := runMain(context.Background(), &strings.Builder{}, &stderr) + if exitCode != 1 { + t.Fatalf("exitCode = %d, want 1", exitCode) + } + if !strings.Contains(stderr.String(), "plain failure") { + t.Fatalf("stderr = %q, want plain failure", stderr.String()) + } +} diff --git a/internal/cli/hook_command.go b/internal/cli/hook_command.go index def38efee..a03907d17 100644 --- a/internal/cli/hook_command.go +++ b/internal/cli/hook_command.go @@ -407,10 +407,11 @@ func findMappingValue(node *yaml.Node, key string) *yaml.Node { func validateLintItem(item config.RuntimeHookItemConfig, scope string) error { defaults := config.StaticDefaults().Runtime.Hooks clone := item.Clone() - clone.ApplyDefaults(defaults) if scope == "repo" { + agentruntime.ApplyRepoHookItemDefaults(&clone, defaults) return agentruntime.ValidateRepoHookItem(clone) } + clone.ApplyDefaults(defaults) return clone.Validate(defaults.DefaultFailurePolicy) } @@ -491,6 +492,9 @@ func loadHookCandidates(workdir string, explicitTarget string) ([]hookCandidate, if strings.EqualFold(filepath.Base(target), "config.yaml") { items, err := loadUserHookItems(target) if err != nil { + if strings.TrimSpace(explicitTarget) == "" && os.IsNotExist(err) { + continue + } return nil, err } for _, item := range items { @@ -549,7 +553,14 @@ func loadRepoHookItemsForCLI(path string) ([]config.RuntimeHookItemConfig, error if err := decoder.Decode(&parsed); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } - return append([]config.RuntimeHookItemConfig(nil), parsed.Hooks.Items...), nil + defaults := config.StaticDefaults().Runtime.Hooks + items := make([]config.RuntimeHookItemConfig, 0, len(parsed.Hooks.Items)) + for _, item := range parsed.Hooks.Items { + clone := item.Clone() + agentruntime.ApplyRepoHookItemDefaults(&clone, defaults) + items = append(items, clone) + } + return items, nil } // buildHookSpecForCandidate 复用 runtime 的公共构建入口,把配置项编译成可执行 HookSpec。 @@ -559,10 +570,12 @@ func buildHookSpecForCandidate(candidate hookCandidate, workdir string) (runtime defaultWorkdir = filepath.Dir(candidate.Path) } item := candidate.Item.Clone() - item.ApplyDefaults(config.StaticDefaults().Runtime.Hooks) + defaults := config.StaticDefaults().Runtime.Hooks if candidate.Scope == "repo" { + agentruntime.ApplyRepoHookItemDefaults(&item, defaults) return agentruntime.BuildRepoHookSpec(item, defaultWorkdir) } + item.ApplyDefaults(defaults) return agentruntime.BuildUserHookSpec(item, defaultWorkdir) } diff --git a/internal/cli/hook_command_test.go b/internal/cli/hook_command_test.go index a346ed772..c94759ca9 100644 --- a/internal/cli/hook_command_test.go +++ b/internal/cli/hook_command_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -559,9 +560,7 @@ func TestHookDryRunCommandSupportsBuiltinHandlersAndExitCodes(t *testing.T) { Scope: "user", Kind: "command", Mode: "sync", - Params: map[string]any{ - "command": []string{"cmd", "/c", "exit", "1"}, - }, + Params: commandHookExitParamsForTest(1), }, { ID: "fail-tool", @@ -570,9 +569,7 @@ func TestHookDryRunCommandSupportsBuiltinHandlersAndExitCodes(t *testing.T) { Scope: "user", Kind: "command", Mode: "sync", - Params: map[string]any{ - "command": []string{"cmd", "/c", "exit", "9"}, - }, + Params: commandHookExitParamsForTest(9), }, }) @@ -644,6 +641,79 @@ metadata: }) } +func TestHookDryRunCommandSkipsMissingDefaultUserConfigWhenRepoHookExists(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + homeDir := t.TempDir() + setHookTestHome(t, homeDir) + workdir := t.TempDir() + + repoHooksPath := filepath.Join(workdir, ".neocode", "hooks.yaml") + if err := os.MkdirAll(filepath.Dir(repoHooksPath), 0o755); err != nil { + t.Fatalf("MkdirAll(repo hooks dir) error = %v", err) + } + repoHooks := ` +hooks: + items: + - id: repo-only + point: before_tool_call + handler: warn_on_tool_call + match: + tool_name: bash + params: + message: from repo only +` + if err := os.WriteFile(repoHooksPath, []byte(strings.TrimSpace(repoHooks)+"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(repo hooks) error = %v", err) + } + + fixturePath := writeHookFixture(t, "fixture.yaml", ` +payload_version: "1" +point: before_tool_call +metadata: + tool_name: bash + tool_call_id: call-1 + tool_arguments_preview: echo hello + workdir: `+workdir+` +`) + + output, err := runHookCommand(t, workdir, []string{"hook", "dry-run", "--repo", "--hook", "repo-only", "--fixture", fixturePath}) + if err != nil { + t.Fatalf("repo-only dry-run error = %v", err) + } + if !strings.Contains(output, "message: from repo only") { + t.Fatalf("expected repo-only output, got %q", output) + } +} + +func TestHookLintCommandUsesRepoDefaults(t *testing.T) { + runGlobalPreload = func(context.Context) error { return nil } + t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) + + repoHooksPath := filepath.Join(t.TempDir(), "hooks.yaml") + repoHooks := ` +hooks: + items: + - id: repo-defaults + point: before_tool_call + handler: warn_on_tool_call + match: + tool_name: bash +` + if err := os.WriteFile(repoHooksPath, []byte(strings.TrimSpace(repoHooks)+"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(repo hooks) error = %v", err) + } + + output, err := runHookCommand(t, t.TempDir(), []string{"hook", "lint", repoHooksPath}) + if err != nil { + t.Fatalf("repo lint error = %v, output = %q", err, output) + } + if !strings.Contains(output, "hook lint passed") { + t.Fatalf("expected lint pass output, got %q", output) + } +} + func TestHookDryRunCommandRejectsInvalidFixtureAndPointMismatch(t *testing.T) { runGlobalPreload = func(context.Context) error { return nil } t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) @@ -862,3 +932,10 @@ func setHookTestHome(t *testing.T, homeDir string) { func boolPtrForHookTest(value bool) *bool { return &value } + +func commandHookExitParamsForTest(code int) map[string]any { + return map[string]any{ + "command": "exit " + strconv.Itoa(code), + "shell": true, + } +} diff --git a/internal/runtime/hook_trace.go b/internal/runtime/hook_trace.go index 27a74043c..301d65561 100644 --- a/internal/runtime/hook_trace.go +++ b/internal/runtime/hook_trace.go @@ -14,6 +14,8 @@ import ( agentsession "neo-code/internal/session" ) +const hookTraceSafeRunIDAlphabet = "0123456789abcdef" + var hookTraceEventTypes = map[EventType]struct{}{ EventHookStarted: {}, EventHookFinished: {}, @@ -139,7 +141,7 @@ func HookTracePath(baseDir string, workspaceRoot string, runID string) (string, "projects", agentsession.HashWorkspaceRoot(trimmedWorkspace), "hook-traces", - trimmedRunID+".jsonl", + escapeHookTraceRunID(trimmedRunID)+".jsonl", ), nil } @@ -203,3 +205,40 @@ func buildHookTraceRecord(event RuntimeEvent) (HookTraceRecord, bool) { } return record, true } + +// escapeHookTraceRunID 将任意 run_id 编码为仅包含安全文件名字节的稳定 token。 +func escapeHookTraceRunID(runID string) string { + trimmed := strings.TrimSpace(runID) + if trimmed == "" { + return "" + } + var builder strings.Builder + builder.Grow(len(trimmed)) + for index := 0; index < len(trimmed); index++ { + value := trimmed[index] + if isHookTraceSafeRunIDByte(value) { + builder.WriteByte(value) + continue + } + builder.WriteByte('~') + builder.WriteByte(hookTraceSafeRunIDAlphabet[value>>4]) + builder.WriteByte(hookTraceSafeRunIDAlphabet[value&0x0f]) + } + return builder.String() +} + +// isHookTraceSafeRunIDByte 判断单个字节能否直接作为 trace 文件名的一部分。 +func isHookTraceSafeRunIDByte(value byte) bool { + switch { + case value >= 'a' && value <= 'z': + return true + case value >= 'A' && value <= 'Z': + return true + case value >= '0' && value <= '9': + return true + case value == '-', value == '_': + return true + default: + return false + } +} diff --git a/internal/runtime/hook_trace_test.go b/internal/runtime/hook_trace_test.go index 0c1bf5796..acf84c5d7 100644 --- a/internal/runtime/hook_trace_test.go +++ b/internal/runtime/hook_trace_test.go @@ -19,6 +19,22 @@ func TestHookTracePath(t *testing.T) { } } +func TestHookTracePathEscapesRunID(t *testing.T) { + path, err := HookTracePath(t.TempDir(), t.TempDir(), "../../../../.ssh/foo") + if err != nil { + t.Fatalf("HookTracePath() error = %v", err) + } + if strings.Contains(path, "..") { + t.Fatalf("expected escaped trace path, got %s", path) + } + if strings.Contains(path, string(filepath.Separator)+".ssh"+string(filepath.Separator)) { + t.Fatalf("expected run_id to stay inside hook-traces dir, got %s", path) + } + if filepath.Base(path) != "~2e~2e~2f~2e~2e~2f~2e~2e~2f~2e~2e~2f~2essh~2ffoo.jsonl" { + t.Fatalf("unexpected escaped file name: %s", filepath.Base(path)) + } +} + func TestHookTraceRecorderWritesHookEvents(t *testing.T) { baseDir := t.TempDir() workspace := t.TempDir() diff --git a/internal/runtime/repo_hooks.go b/internal/runtime/repo_hooks.go index 12500d561..39af6067f 100644 --- a/internal/runtime/repo_hooks.go +++ b/internal/runtime/repo_hooks.go @@ -290,8 +290,8 @@ func loadRepoHookItems(path string, defaults config.RuntimeHooksConfig) ([]confi return items, nil } -// applyRepoHookItemDefaults 为 repo hook item 注入默认值并锁定 scope/kind/mode 约束。 -func applyRepoHookItemDefaults(item *config.RuntimeHookItemConfig, defaults config.RuntimeHooksConfig) { +// ApplyRepoHookItemDefaults 为 repo hook item 注入默认值,并保持与 runtime 加载路径一致。 +func ApplyRepoHookItemDefaults(item *config.RuntimeHookItemConfig, defaults config.RuntimeHooksConfig) { if item == nil { return } @@ -315,6 +315,11 @@ func applyRepoHookItemDefaults(item *config.RuntimeHookItemConfig, defaults conf } } +// applyRepoHookItemDefaults 兼容包内既有调用,内部统一转向导出实现。 +func applyRepoHookItemDefaults(item *config.RuntimeHookItemConfig, defaults config.RuntimeHooksConfig) { + ApplyRepoHookItemDefaults(item, defaults) +} + // ValidateRepoHookItem 校验 repo hook item 是否满足当前阶段允许的能力范围。 func ValidateRepoHookItem(item config.RuntimeHookItemConfig) error { if strings.TrimSpace(item.ID) == "" { From a156dd585db75e55cd7d3ccaef320436f1295266 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:37:55 +0800 Subject: [PATCH 3/4] test: cover before_tool_call trace e2e --- internal/app/bootstrap_test.go | 155 +++++++++++++----- internal/runtime/hooks_integration.go | 7 +- internal/runtime/hooks_integration_test.go | 28 ++++ internal/runtime/runtime.go | 5 + internal/runtime/runtime_gap_coverage_test.go | 11 ++ 5 files changed, 167 insertions(+), 39 deletions(-) diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 5e6f40b5b..d3b228bd7 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -921,12 +921,27 @@ func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) { func TestBuildRuntimeTraceHooksPersistsHookEvents(t *testing.T) { disableBuiltinProviderAPIKeys(t) + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) workdir := t.TempDir() + configDir := filepath.Join(home, ".neocode") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + configPath := filepath.Join(configDir, "config.yaml") + configRaw := strings.Join([]string{ + "selected_provider: openai", + "current_model: " + config.OpenAIDefaultModel, + "shell: powershell", + }, "\n") + "\n" + if err := os.WriteFile(configPath, []byte(configRaw), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + bundle, err := BuildGatewayServerDeps(context.Background(), BootstrapOptions{ Workdir: workdir, TraceHooks: true, @@ -946,19 +961,48 @@ func TestBuildRuntimeTraceHooksPersistsHookEvents(t *testing.T) { if !ok { t.Fatalf("bundle.Runtime type = %T, want *runtime.Service", bundle.Runtime) } - service.SetHookExecutor(traceBlockingHookExecutor{}) - session, err := service.CreateSession(context.Background(), "trace-session") - if err != nil { - t.Fatalf("CreateSession() error = %v", err) + service.SetProviderFactory(&traceScriptedProviderFactory{ + provider: &traceScriptedProvider{ + streams: [][]providertypes.StreamEvent{ + { + providertypes.NewToolCallStartStreamEvent(0, "call-trace-1", "filesystem_read_file"), + providertypes.NewToolCallDeltaStreamEvent(0, "call-trace-1", `{"path":"README.md"}`), + providertypes.NewMessageDoneStreamEvent("tool_calls", nil), + }, + { + providertypes.NewTextDeltaStreamEvent("trace complete"), + providertypes.NewMessageDoneStreamEvent("stop", nil), + }, + }, + }, + }) + registry := runtimehooks.NewRegistry() + if err := registry.Register(runtimehooks.HookSpec{ + ID: "trace-before-tool-call", + Point: runtimehooks.HookPointBeforeToolCall, + Scope: runtimehooks.HookScopeUser, + Source: runtimehooks.HookSourceUser, + Handler: func(context.Context, runtimehooks.HookContext) runtimehooks.HookResult { + return runtimehooks.HookResult{ + Status: runtimehooks.HookResultBlock, + Message: "trace guard blocked before_tool_call", + } + }, + }); err != nil { + t.Fatalf("register trace hook: %v", err) } + service.SetHookExecutor(runtimehooks.NewExecutor(registry, agentruntime.NewHookRuntimeEventEmitterForTests(service), time.Second)) runID := "trace-run-1" - _, err = service.Compact(context.Background(), agentruntime.CompactInput{ - SessionID: session.ID, - RunID: runID, + err = service.Run(context.Background(), agentruntime.UserInput{ + RunID: runID, + Workdir: workdir, + Parts: []providertypes.ContentPart{ + providertypes.NewTextPart("trigger before_tool_call trace"), + }, }) - if err == nil || !strings.Contains(err.Error(), "trace guard blocked compact") { - t.Fatalf("Compact() error = %v, want hook block error", err) + if err != nil { + t.Fatalf("Run() error = %v", err) } tracePath, err := agentruntime.HookTracePath(bundle.ConfigManager.BaseDir(), bundle.Config.Workdir, runID) @@ -973,37 +1017,23 @@ func TestBuildRuntimeTraceHooksPersistsHookEvents(t *testing.T) { if !strings.Contains(text, `"event_type":"hook_blocked"`) { t.Fatalf("trace file missing hook_blocked: %s", text) } - if !strings.Contains(text, `"hook_id":"trace-pre-compact"`) { + if !strings.Contains(text, `"hook_id":"trace-before-tool-call"`) { t.Fatalf("trace file missing hook id: %s", text) } -} - -type traceBlockingHookExecutor struct{} - -func (traceBlockingHookExecutor) Run( - ctx context.Context, - point runtimehooks.HookPoint, - input runtimehooks.HookContext, -) runtimehooks.RunOutput { - _ = ctx - _ = input - if point != runtimehooks.HookPointPreCompact { - return runtimehooks.RunOutput{} - } - return runtimehooks.RunOutput{ - Blocked: true, - BlockedBy: "trace-pre-compact", - BlockedSource: runtimehooks.HookSourceUser, - Results: []runtimehooks.HookResult{ - { - HookID: "trace-pre-compact", - Point: runtimehooks.HookPointPreCompact, - Scope: runtimehooks.HookScopeUser, - Source: runtimehooks.HookSourceUser, - Status: runtimehooks.HookResultBlock, - Message: "trace guard blocked compact", - }, - }, + if !strings.Contains(text, `"point":"before_tool_call"`) { + t.Fatalf("trace file missing before_tool_call point: %s", text) + } + if !strings.Contains(text, `"event_type":"hook_started"`) || !strings.Contains(text, `"event_type":"hook_finished"`) { + t.Fatalf("trace file missing hook lifecycle events: %s", text) + } + startedIndex := strings.Index(text, `"event_type":"hook_started"`) + finishedIndex := strings.Index(text, `"event_type":"hook_finished"`) + blockedIndex := strings.Index(text, `"event_type":"hook_blocked"`) + if startedIndex < 0 || finishedIndex < 0 || blockedIndex < 0 { + t.Fatalf("trace file missing ordered lifecycle events: %s", text) + } + if !(startedIndex < finishedIndex && finishedIndex < blockedIndex) { + t.Fatalf("unexpected hook trace order: %s", text) } } @@ -2364,6 +2394,55 @@ func (s *stubMemoProviderFactory) Build(ctx context.Context, cfg provider.Runtim return &stubMemoProvider{}, nil } +type traceScriptedProvider struct { + streams [][]providertypes.StreamEvent + callCount int +} + +func (p *traceScriptedProvider) EstimateInputTokens( + ctx context.Context, + req providertypes.GenerateRequest, +) (providertypes.BudgetEstimate, error) { + _ = ctx + return providertypes.BudgetEstimate{ + EstimatedInputTokens: provider.EstimateTextTokens(req.SystemPrompt), + EstimateSource: provider.EstimateSourceLocal, + GatePolicy: provider.EstimateGateGateable, + }, nil +} + +func (p *traceScriptedProvider) Generate( + ctx context.Context, + req providertypes.GenerateRequest, + events chan<- providertypes.StreamEvent, +) error { + _ = req + index := p.callCount + p.callCount++ + if index >= len(p.streams) { + events <- providertypes.NewMessageDoneStreamEvent("stop", nil) + return nil + } + for _, event := range p.streams[index] { + select { + case events <- event: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +type traceScriptedProviderFactory struct { + provider provider.Provider +} + +func (f *traceScriptedProviderFactory) Build(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { + _ = ctx + _ = cfg + return f.provider, nil +} + type stubMemoProvider struct { generate func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error } diff --git a/internal/runtime/hooks_integration.go b/internal/runtime/hooks_integration.go index 1497566e2..f2a236b90 100644 --- a/internal/runtime/hooks_integration.go +++ b/internal/runtime/hooks_integration.go @@ -5,8 +5,8 @@ import ( "encoding/json" "strings" - runtimehooks "neo-code/internal/runtime/hooks" providertypes "neo-code/internal/provider/types" + runtimehooks "neo-code/internal/runtime/hooks" ) const ( @@ -38,6 +38,11 @@ func newHookRuntimeEventEmitter(service *Service) *hookRuntimeEventEmitter { return &hookRuntimeEventEmitter{service: service} } +// NewHookRuntimeEventEmitterForTests 暴露 runtime hook 事件桥接器,供跨包集成测试复用真实 hook 生命周期发射链路。 +func NewHookRuntimeEventEmitterForTests(service *Service) runtimehooks.EventEmitter { + return newHookRuntimeEventEmitter(service) +} + // EmitHookEvent 将 hooks 包内事件桥接为 runtime 事件,供 TUI 与日志统一消费。 func (e *hookRuntimeEventEmitter) EmitHookEvent(ctx context.Context, event runtimehooks.HookEvent) error { if e == nil || e.service == nil { diff --git a/internal/runtime/hooks_integration_test.go b/internal/runtime/hooks_integration_test.go index 73bc279aa..e46f16811 100644 --- a/internal/runtime/hooks_integration_test.go +++ b/internal/runtime/hooks_integration_test.go @@ -559,6 +559,34 @@ func TestHookRuntimeEventEmitterBranches(t *testing.T) { } } +func TestNewHookRuntimeEventEmitterForTestsDelegates(t *testing.T) { + t.Parallel() + + service := &Service{events: make(chan RuntimeEvent, 8)} + emitter := NewHookRuntimeEventEmitterForTests(service) + ctx := withRuntimeHookEnvelope(context.Background(), hookRuntimeEnvelope{ + RunID: "run-exported-emitter", + SessionID: "session-exported-emitter", + Turn: 3, + Phase: "execute", + }) + if err := emitter.EmitHookEvent(ctx, runtimehooks.HookEvent{ + Type: runtimehooks.HookEventStarted, + HookID: "hook-exported", + Point: runtimehooks.HookPointBeforeToolCall, + }); err != nil { + t.Fatalf("EmitHookEvent() error = %v", err) + } + + events := collectRuntimeEvents(service.Events()) + if len(events) != 1 { + t.Fatalf("events len = %d, want 1", len(events)) + } + if events[0].Type != EventHookStarted || events[0].RunID != "run-exported-emitter" { + t.Fatalf("unexpected runtime event: %+v", events[0]) + } +} + func TestHookRuntimeEventEmitterNotificationPayloadBranch(t *testing.T) { t.Parallel() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 4388b96b5..abf468bfc 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -658,6 +658,11 @@ func (s *Service) SetBudgetResolver(resolver BudgetResolver) { s.budgetResolver = resolver } +// SetProviderFactory 设置运行时 provider 构建器,便于装配层或测试替换模型调用后端。 +func (s *Service) SetProviderFactory(factory ProviderFactory) { + s.providerFactory = factory +} + // SetHookExecutor 设置 runtime 生命周期 hook 执行器;传入 nil 可禁用 hook 执行。 func (s *Service) SetHookExecutor(executor HookExecutor) { if base, ok := executor.(*runtimehooks.Executor); ok { diff --git a/internal/runtime/runtime_gap_coverage_test.go b/internal/runtime/runtime_gap_coverage_test.go index f0e3783fc..b0cdc2b4c 100644 --- a/internal/runtime/runtime_gap_coverage_test.go +++ b/internal/runtime/runtime_gap_coverage_test.go @@ -160,6 +160,17 @@ func TestCompactProviderSelectionErrorBranches(t *testing.T) { } } +func TestSetProviderFactorySetter(t *testing.T) { + t.Parallel() + + service := &Service{} + factory := &scriptedProviderFactory{provider: &scriptedProvider{}} + service.SetProviderFactory(factory) + if service.providerFactory != factory { + t.Fatalf("providerFactory = %#v, want %#v", service.providerFactory, factory) + } +} + func TestCompactSummaryGeneratorErrorBranches(t *testing.T) { t.Parallel() From 72a1aa50f8e69410b671c401f99adce90d693a84 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:08:54 +0000 Subject: [PATCH 4/4] test: raise hooks cli coverage Generated with [FennoAI](https://github.com/apps/fennoai) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- cmd/neocode-gateway/main.go | 16 +- cmd/neocode-gateway/main_test.go | 64 +++++++ cmd/neocode/main_test.go | 25 +++ internal/cli/exit_code_test.go | 42 ++++ internal/cli/gateway_runtime_bridge_test.go | 36 ++++ internal/cli/hook_command_test.go | 174 +++++++++++++++++ internal/runtime/hook_trace_test.go | 181 ++++++++++++++++++ .../runtime/hooks/fixture/fixture_test.go | 99 ++++++++++ .../runtime/runtime_branch_coverage_test.go | 30 +++ 9 files changed, 664 insertions(+), 3 deletions(-) create mode 100644 internal/cli/exit_code_test.go diff --git a/cmd/neocode-gateway/main.go b/cmd/neocode-gateway/main.go index e48369b10..a22b3fd5a 100644 --- a/cmd/neocode-gateway/main.go +++ b/cmd/neocode-gateway/main.go @@ -4,19 +4,29 @@ import ( "context" "errors" "fmt" + "io" "os" "neo-code/internal/cli" ) +var executeGatewayServer = cli.ExecuteGatewayServer +var exitGatewayProcess = os.Exit + func main() { - if err := cli.ExecuteGatewayServer(context.Background(), os.Args[1:]); err != nil { - fmt.Fprintf(os.Stderr, "neocode-gateway: %v\n", err) + exitGatewayProcess(runMain(context.Background(), os.Args[1:], os.Stderr)) +} + +// runMain 执行 gateway-only 主流程并返回进程退出码,方便测试覆盖错误分支。 +func runMain(ctx context.Context, args []string, stderr io.Writer) int { + if err := executeGatewayServer(ctx, args); err != nil { + _, _ = fmt.Fprintf(stderr, "neocode-gateway: %v\n", err) exitCode := 1 var exitCoder interface{ ExitCode() int } if errors.As(err, &exitCoder) { exitCode = exitCoder.ExitCode() } - os.Exit(exitCode) + return exitCode } + return 0 } diff --git a/cmd/neocode-gateway/main_test.go b/cmd/neocode-gateway/main_test.go index f61c07994..beaf5cd53 100644 --- a/cmd/neocode-gateway/main_test.go +++ b/cmd/neocode-gateway/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "errors" "os" "os/exec" @@ -11,12 +12,21 @@ import ( func TestMainHelpPathDoesNotExit(t *testing.T) { originalArgs := os.Args + originalExit := exitGatewayProcess defer func() { os.Args = originalArgs + exitGatewayProcess = originalExit }() + var gotExitCode int + exitGatewayProcess = func(code int) { + gotExitCode = code + } os.Args = []string{"neocode-gateway", "--help"} main() + if gotExitCode != 0 { + t.Fatalf("exit code = %d, want 0", gotExitCode) + } } func TestMainReturnsExitCodeOneOnCommandError(t *testing.T) { @@ -46,3 +56,57 @@ func TestMainReturnsExitCodeOneOnCommandError(t *testing.T) { t.Fatalf("stderr = %q, want contains %q", stderr.String(), "neocode-gateway:") } } + +func TestRunMainReturnsExitCoderValue(t *testing.T) { + originalExecute := executeGatewayServer + t.Cleanup(func() { executeGatewayServer = originalExecute }) + + executeGatewayServer = func(context.Context, []string) error { + return testGatewayExitError{message: "denied", code: 5} + } + + var stderr strings.Builder + exitCode := runMain(context.Background(), []string{"gateway"}, &stderr) + if exitCode != 5 { + t.Fatalf("exit code = %d, want 5", exitCode) + } + if !strings.Contains(stderr.String(), "neocode-gateway: denied") { + t.Fatalf("stderr = %q, want gateway error", stderr.String()) + } +} + +func TestRunMainReturnsZeroOnSuccess(t *testing.T) { + originalExecute := executeGatewayServer + t.Cleanup(func() { executeGatewayServer = originalExecute }) + + var capturedArgs []string + executeGatewayServer = func(_ context.Context, args []string) error { + capturedArgs = append([]string(nil), args...) + return nil + } + + var stderr strings.Builder + exitCode := runMain(context.Background(), []string{"--help"}, &stderr) + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0", exitCode) + } + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + if len(capturedArgs) != 1 || capturedArgs[0] != "--help" { + t.Fatalf("captured args = %#v, want --help", capturedArgs) + } +} + +type testGatewayExitError struct { + message string + code int +} + +func (e testGatewayExitError) Error() string { + return e.message +} + +func (e testGatewayExitError) ExitCode() int { + return e.code +} diff --git a/cmd/neocode/main_test.go b/cmd/neocode/main_test.go index c699ef079..53e170a3e 100644 --- a/cmd/neocode/main_test.go +++ b/cmd/neocode/main_test.go @@ -47,6 +47,31 @@ func TestRunMainReturnsExitCodeFromCLI(t *testing.T) { } } +func TestMainInvokesExitProcessWithRunMainResult(t *testing.T) { + originalExecute := executeCLI + originalConsume := consumeCLIUpdateNotice + originalExit := exitProcess + t.Cleanup(func() { + executeCLI = originalExecute + consumeCLIUpdateNotice = originalConsume + exitProcess = originalExit + }) + + executeCLI = func(context.Context) error { + return testExitError{message: "boom", code: 6} + } + consumeCLIUpdateNotice = func() string { return "" } + var gotExitCode int + exitProcess = func(code int) { + gotExitCode = code + } + + main() + if gotExitCode != 6 { + t.Fatalf("exit code = %d, want 6", gotExitCode) + } +} + func TestRunMainPrintsUpdateNoticeOnSuccess(t *testing.T) { originalExecute := executeCLI originalConsume := consumeCLIUpdateNotice diff --git a/internal/cli/exit_code_test.go b/internal/cli/exit_code_test.go new file mode 100644 index 000000000..26f0028e3 --- /dev/null +++ b/internal/cli/exit_code_test.go @@ -0,0 +1,42 @@ +package cli + +import ( + "errors" + "strings" + "testing" +) + +func TestCommandExitErrorNilAndDefaultBranches(t *testing.T) { + var nilErr *commandExitError + if nilErr.Error() != "" { + t.Fatalf("nil Error() = %q, want empty", nilErr.Error()) + } + if nilErr.Unwrap() != nil { + t.Fatalf("nil Unwrap() = %#v, want nil", nilErr.Unwrap()) + } + if nilErr.ExitCode() != 1 { + t.Fatalf("nil ExitCode() = %d, want 1", nilErr.ExitCode()) + } + + empty := &commandExitError{} + if empty.Error() != "" { + t.Fatalf("empty Error() = %q, want empty", empty.Error()) + } + if empty.ExitCode() != 1 { + t.Fatalf("empty ExitCode() = %d, want 1", empty.ExitCode()) + } +} + +func TestCommandExitErrorWrapsCause(t *testing.T) { + cause := errors.New("cause") + err := &commandExitError{code: 9, err: cause} + if !errors.Is(err, cause) { + t.Fatalf("errors.Is() did not match wrapped cause") + } + if !strings.Contains(err.Error(), "cause") { + t.Fatalf("Error() = %q, want cause", err.Error()) + } + if err.ExitCode() != 9 { + t.Fatalf("ExitCode() = %d, want 9", err.ExitCode()) + } +} diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index 9000f8ac5..477d5bef3 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -3922,6 +3922,42 @@ func TestDefaultBuildGatewayRuntimePortListSessionsWithoutExplicitWorkdir(t *tes } } +func TestDefaultBuildGatewayRuntimePortAcceptsTraceHooks(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + workdir := t.TempDir() + secondaryWorkdir := t.TempDir() + index := agentsession.NewWorkspaceIndex(filepath.Join(home, ".neocode")) + secondaryRecord, err := index.Register(secondaryWorkdir, "") + if err != nil { + t.Fatalf("Register(secondary workspace) error = %v", err) + } + if err := index.Save(); err != nil { + t.Fatalf("Save(workspace index) error = %v", err) + } + + port, cleanup, err := defaultBuildGatewayRuntimePort(context.Background(), workdir, true) + if err != nil { + t.Fatalf("defaultBuildGatewayRuntimePort(traceHooks) error = %v", err) + } + if cleanup != nil { + defer func() { _ = cleanup() }() + } + + if _, err := port.ListSessions(context.Background()); err != nil { + t.Fatalf("ListSessions() with trace hooks should succeed, got %v", err) + } + + state := gateway.NewConnectionWorkspaceState() + state.SetWorkspaceHash(secondaryRecord.Hash) + if _, err := port.ListSessions(gateway.WithConnectionWorkspaceState(context.Background(), state)); err != nil { + t.Fatalf("ListSessions() for secondary trace workspace should succeed, got %v", err) + } +} + func TestGatewayRuntimePortBridgeAskDelegatesToRuntime(t *testing.T) { stub := &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)} bridge, err := newGatewayRuntimePortBridge(context.Background(), stub, testSessionStore, nil, nil) diff --git a/internal/cli/hook_command_test.go b/internal/cli/hook_command_test.go index c94759ca9..5645db5f0 100644 --- a/internal/cli/hook_command_test.go +++ b/internal/cli/hook_command_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "gopkg.in/yaml.v3" "neo-code/internal/config" agentruntime "neo-code/internal/runtime" ) @@ -111,6 +112,71 @@ func TestReadHookTraceRecordsAndAggregate(t *testing.T) { } } +func TestReadHookTraceRecordsSkipsBlanksSortsAndReportsScannerErrors(t *testing.T) { + tracePath := filepath.Join(t.TempDir(), "run.jsonl") + content := strings.Join([]string{ + "", + `{"event_type":"hook_finished","timestamp":"2026-01-01T00:00:02Z","run_id":"run-1","hook_id":"b","duration_ms":5}`, + " ", + `{"event_type":"hook_finished","timestamp":"2026-01-01T00:00:01Z","run_id":"run-1","hook_id":"a","duration_ms":7}`, + "", + }, "\n") + if err := os.WriteFile(tracePath, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(trace) error = %v", err) + } + records, err := readHookTraceRecords(tracePath) + if err != nil { + t.Fatalf("readHookTraceRecords() error = %v", err) + } + if len(records) != 2 || records[0].HookID != "a" || records[1].HookID != "b" { + t.Fatalf("records = %#v, want sorted non-blank records", records) + } + + hugeLinePath := filepath.Join(t.TempDir(), "huge.jsonl") + if err := os.WriteFile(hugeLinePath, []byte(strings.Repeat("x", 70*1024)), 0o644); err != nil { + t.Fatalf("WriteFile(huge trace) error = %v", err) + } + if _, err := readHookTraceRecords(hugeLinePath); err == nil || !strings.Contains(err.Error(), "token too long") { + t.Fatalf("expected scanner token-too-long error, got %v", err) + } +} + +func TestAggregateAndFormatHookTraceEdgeCases(t *testing.T) { + records := []agentruntime.HookTraceRecord{ + {EventType: "hook_started", HookID: "ignored", DurationMS: 100}, + {EventType: "hook_finished", HookID: " ", DurationMS: 100}, + {EventType: "hook_failed", HookID: "b", DurationMS: 5}, + {EventType: "hook_finished", HookID: "a", DurationMS: 12}, + {EventType: "hook_blocked", HookID: "a", DurationMS: 30}, + } + aggregates := aggregateHookTraceRecords(records) + if len(aggregates) != 2 { + t.Fatalf("aggregates len = %d, want 2: %#v", len(aggregates), aggregates) + } + if aggregates[0].HookID != "a" || aggregates[0].Count != 2 || aggregates[0].DurationMS != 42 || aggregates[0].MaxDuration != 30 { + t.Fatalf("aggregate[0] = %#v, want sorted aggregate for a", aggregates[0]) + } + + line := formatHookTraceRecord(agentruntime.HookTraceRecord{ + EventType: "hook_failed", + Timestamp: time.Unix(1, 2).UTC(), + HookID: " warn ", + Point: " before_tool_call ", + Status: " failed ", + Error: " boom ", + DurationMS: 3, + }) + if !strings.Contains(line, "error=boom") || !strings.Contains(line, "duration_ms=3") || strings.Contains(line, " warn ") { + t.Fatalf("unexpected formatted line: %q", line) + } + if got := renderHookTraceHistogram(1000); len(got) != 24 { + t.Fatalf("histogram len = %d, want capped width 24", len(got)) + } + if !isHookTraceTerminalRecord(agentruntime.HookTraceRecord{EventType: " hook_finished "}) { + t.Fatal("expected trimmed hook_finished to be terminal") + } +} + func TestHookTraceCommandReturnsExitCodeWhenTraceMissing(t *testing.T) { runGlobalPreload = func(context.Context) error { return nil } t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) @@ -461,6 +527,74 @@ func TestHookLintCommandSkipsMissingDefaultFiles(t *testing.T) { } } +func TestHookLintTargetsRejectsDirectoriesAndMalformedYAML(t *testing.T) { + dir := t.TempDir() + if _, err := lintHookTargets([]string{dir}); err == nil || !strings.Contains(err.Error(), "target is a directory") { + t.Fatalf("expected directory target error, got %v", err) + } + + path := filepath.Join(t.TempDir(), "hooks.yaml") + if err := os.WriteFile(path, []byte("hooks: ["), 0o644); err != nil { + t.Fatalf("WriteFile(bad yaml) error = %v", err) + } + if _, err := lintHookTargets([]string{path}); err == nil || !strings.Contains(err.Error(), "parse hook yaml") { + t.Fatalf("expected parse hook yaml error, got %v", err) + } +} + +func TestHookLintHelpersCoverMissingMappingsAndDefaultHint(t *testing.T) { + var emptyRoot yaml.Node + if lines := collectHookItemLines(&emptyRoot, "repo"); lines != nil { + t.Fatalf("empty root lines = %#v, want nil", lines) + } + scalar := &yaml.Node{Kind: yaml.ScalarNode, Value: "not-a-map"} + if got := findMappingValue(scalar, "hooks"); got != nil { + t.Fatalf("findMappingValue(non-map) = %#v, want nil", got) + } + if got := hookLintHint("something else"); got != "fix the hook item so it matches current runtime hook schema" { + t.Fatalf("hookLintHint(default) = %q", got) + } +} + +func TestResolveHookLintTargetsExplicitAndWorkspaceErrors(t *testing.T) { + target := filepath.Join(t.TempDir(), "hooks.yaml") + targets, err := resolveHookLintTargets("ignored", []string{target}) + if err != nil { + t.Fatalf("resolveHookLintTargets(explicit) error = %v", err) + } + if len(targets) != 1 || targets[0] != target { + t.Fatalf("targets = %#v, want explicit absolute path", targets) + } + + missing := filepath.Join(t.TempDir(), "missing") + if _, err := resolveHookLintTargets(missing, nil); err == nil { + t.Fatal("expected missing workspace error") + } +} + +func TestResolveHookWorkspaceUsesCurrentDirectory(t *testing.T) { + originalCwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + tempDir := t.TempDir() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Chdir(temp) error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(originalCwd); err != nil { + t.Fatalf("restore cwd error = %v", err) + } + }) + resolved, err := resolveHookWorkspace("") + if err != nil { + t.Fatalf("resolveHookWorkspace(empty) error = %v", err) + } + if resolved != tempDir { + t.Fatalf("resolved = %q, want %q", resolved, tempDir) + } +} + func TestHookDryRunCommandPassesBuiltInHook(t *testing.T) { runGlobalPreload = func(context.Context) error { return nil } t.Cleanup(func() { runGlobalPreload = defaultGlobalPreload }) @@ -892,6 +1026,46 @@ metadata: } } +func TestResolveHookCandidateReportsMissingHooks(t *testing.T) { + homeDir := t.TempDir() + setHookTestHome(t, homeDir) + workdir := t.TempDir() + + if _, err := resolveHookCandidate(workdir, "missing", false, ""); err == nil || !strings.Contains(err.Error(), `hook "missing" not found`) { + t.Fatalf("expected missing hook error, got %v", err) + } + if _, err := resolveHookCandidate(workdir, "missing", true, ""); err == nil || !strings.Contains(err.Error(), `repo hook "missing" not found`) { + t.Fatalf("expected missing repo hook error, got %v", err) + } +} + +func TestBuildHookSpecForCandidateDefaultsWorkdirToConfigDir(t *testing.T) { + dir := t.TempDir() + candidate := hookCandidate{ + Path: filepath.Join(dir, "config.yaml"), + Scope: "user", + Item: config.RuntimeHookItemConfig{ + ID: "note", + Enabled: boolPtrForHookTest(true), + Point: "session_start", + Scope: "user", + Kind: "builtin", + Mode: "sync", + Handler: "add_context_note", + Params: map[string]any{ + "note": "hello", + }, + }, + } + spec, err := buildHookSpecForCandidate(candidate, "") + if err != nil { + t.Fatalf("buildHookSpecForCandidate() error = %v", err) + } + if string(spec.Point) != "session_start" || spec.ID != "note" { + t.Fatalf("spec = %#v, want compiled note hook", spec) + } +} + func runHookCommand(t *testing.T, workdir string, args []string) (string, error) { t.Helper() command := NewRootCommand() diff --git a/internal/runtime/hook_trace_test.go b/internal/runtime/hook_trace_test.go index acf84c5d7..e005c3d6d 100644 --- a/internal/runtime/hook_trace_test.go +++ b/internal/runtime/hook_trace_test.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "encoding/json" "os" "path/filepath" "strings" @@ -19,6 +20,28 @@ func TestHookTracePath(t *testing.T) { } } +func TestHookTracePathRejectsEmptyInputs(t *testing.T) { + cases := []struct { + name string + baseDir string + workspace string + runID string + want string + }{ + {name: "baseDir", workspace: t.TempDir(), runID: "run-1", want: "baseDir is empty"}, + {name: "workspace", baseDir: t.TempDir(), runID: "run-1", want: "workspace is empty"}, + {name: "runID", baseDir: t.TempDir(), workspace: t.TempDir(), want: "run_id is empty"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := HookTracePath(tc.baseDir, tc.workspace, tc.runID) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("HookTracePath() error = %v, want contains %q", err, tc.want) + } + }) + } +} + func TestHookTracePathEscapesRunID(t *testing.T) { path, err := HookTracePath(t.TempDir(), t.TempDir(), "../../../../.ssh/foo") if err != nil { @@ -35,6 +58,23 @@ func TestHookTracePathEscapesRunID(t *testing.T) { } } +func TestHookTraceRunIDEscapingCoversUnsafeBytes(t *testing.T) { + if got := escapeHookTraceRunID(" run:你好/A_B-9 "); !strings.Contains(got, "~3a") || !strings.Contains(got, "~2f") { + t.Fatalf("escapeHookTraceRunID() = %q, want escaped colon and slash", got) + } + if got := escapeHookTraceRunID(" "); got != "" { + t.Fatalf("escapeHookTraceRunID(blank) = %q, want empty", got) + } + for _, value := range []byte{'a', 'Z', '7', '-', '_'} { + if !isHookTraceSafeRunIDByte(value) { + t.Fatalf("byte %q should be safe", value) + } + } + if isHookTraceSafeRunIDByte('.') { + t.Fatal("dot should not be a safe run_id byte") + } +} + func TestHookTraceRecorderWritesHookEvents(t *testing.T) { baseDir := t.TempDir() workspace := t.TempDir() @@ -80,3 +120,144 @@ func TestHookTraceRecorderWritesHookEvents(t *testing.T) { t.Fatalf("trace file missing hook id: %s", string(data)) } } + +func TestHookTraceRecorderWritesBlockedPayloadAndReusesWriter(t *testing.T) { + baseDir := t.TempDir() + workspace := t.TempDir() + recorder := NewHookTraceRecorder(" "+baseDir+" ", " "+workspace+" ") + t.Cleanup(func() { + if err := recorder.Close(); err != nil { + t.Fatalf("recorder.Close() error = %v", err) + } + }) + + event := RuntimeEvent{ + Type: EventHookBlocked, + RunID: "run-block", + SessionID: "session-1", + Timestamp: time.Unix(200, 0).UTC(), + Payload: HookBlockedPayload{ + HookID: "guard", + Point: "accept_gate", + Source: "repo", + Reason: "manual review", + }, + } + recorder.RecordRuntimeEvent(context.Background(), event) + recorder.RecordRuntimeEvent(context.Background(), event) + if len(recorder.writers) != 1 { + t.Fatalf("writer count = %d, want 1", len(recorder.writers)) + } + + tracePath, err := HookTracePath(baseDir, workspace, "run-block") + if err != nil { + t.Fatalf("HookTracePath() error = %v", err) + } + data, err := os.ReadFile(tracePath) + if err != nil { + t.Fatalf("ReadFile(trace) error = %v", err) + } + lines := strings.Count(strings.TrimSpace(string(data)), "\n") + 1 + if lines != 2 { + t.Fatalf("trace lines = %d, want 2: %s", lines, string(data)) + } + if !strings.Contains(string(data), `"status":"block"`) || !strings.Contains(string(data), `"message":"manual review"`) { + t.Fatalf("trace file missing blocked payload fields: %s", string(data)) + } +} + +func TestHookTraceRecorderIgnoresUnsupportedEventsAndInvalidRecords(t *testing.T) { + baseDir := t.TempDir() + workspace := t.TempDir() + recorder := NewHookTraceRecorder(baseDir, workspace) + + recorder.RecordRuntimeEvent(context.Background(), RuntimeEvent{Type: EventAgentChunk, RunID: "run-1"}) + recorder.RecordRuntimeEvent(context.Background(), RuntimeEvent{ + Type: EventHookFinished, + Timestamp: time.Unix(1, 0), + Payload: HookEventPayload{HookID: "missing-run"}, + }) + recorder.RecordRuntimeEvent(context.Background(), RuntimeEvent{ + Type: EventHookFinished, + RunID: "run-1", + Payload: map[string]any{"hook_id": "unsupported"}, + }) + if err := recorder.Close(); err != nil { + t.Fatalf("recorder.Close() error = %v", err) + } + + projectRoot := filepath.Join(baseDir, "projects") + entries, err := os.ReadDir(projectRoot) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("ReadDir(project root) error = %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected no trace directories, got %d", len(entries)) + } +} + +func TestHookTraceRecorderCloseHandlesNilAndInjectedWriters(t *testing.T) { + if err := (*HookTraceRecorder)(nil).Close(); err != nil { + t.Fatalf("nil Close() error = %v", err) + } + + recorder := NewHookTraceRecorder(t.TempDir(), t.TempDir()) + recorder.writers["nil"] = nil + if err := recorder.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + if len(recorder.writers) != 0 { + t.Fatalf("writers len = %d, want 0", len(recorder.writers)) + } +} + +func TestHookTraceRecorderSkipsWriterErrors(t *testing.T) { + recorder := NewHookTraceRecorder("", t.TempDir()) + recorder.RecordRuntimeEvent(context.Background(), RuntimeEvent{ + Type: EventHookFinished, + RunID: "run-1", + Timestamp: time.Unix(1, 0).UTC(), + Payload: HookEventPayload{HookID: "warn-bash"}, + }) + if len(recorder.writers) != 0 { + t.Fatalf("writer count = %d, want 0 after invalid baseDir", len(recorder.writers)) + } +} + +func TestBuildHookTraceRecordPayloadVariants(t *testing.T) { + startedAt := time.Unix(50, 0).UTC() + record, ok := buildHookTraceRecord(RuntimeEvent{ + Type: EventHookFailed, + RunID: " run-1 ", + SessionID: " session-1 ", + Turn: 3, + Phase: " hooks ", + Timestamp: time.Unix(60, 0), + Payload: HookEventPayload{ + HookID: " hook ", + Point: " before_tool_call ", + Source: " user ", + Kind: " builtin ", + Mode: " sync ", + Status: " failed ", + Message: " nope ", + Error: " boom ", + StartedAt: startedAt, + DurationMS: 22, + }, + }) + if !ok { + t.Fatal("buildHookTraceRecord() ok = false") + } + if record.RunID != "run-1" || record.HookID != "hook" || record.Error != "boom" || record.StartedAt != startedAt { + encoded, _ := json.Marshal(record) + t.Fatalf("unexpected record: %s", encoded) + } + + if _, ok := buildHookTraceRecord(RuntimeEvent{Type: EventHookFinished, RunID: "run-1"}); ok { + t.Fatal("expected unsupported payload to be rejected") + } + if _, ok := buildHookTraceRecord(RuntimeEvent{Type: EventHookFinished, Payload: HookEventPayload{HookID: "x"}}); ok { + t.Fatal("expected missing run_id to be rejected") + } +} diff --git a/internal/runtime/hooks/fixture/fixture_test.go b/internal/runtime/hooks/fixture/fixture_test.go index 95f71420e..312d3cf63 100644 --- a/internal/runtime/hooks/fixture/fixture_test.go +++ b/internal/runtime/hooks/fixture/fixture_test.go @@ -1,6 +1,8 @@ package fixture import ( + "os" + "path/filepath" "strings" "testing" ) @@ -29,6 +31,37 @@ metadata: } } +func TestParseFileReadsFixtureAndWrapsReadError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fixture.yaml") + if err := os.WriteFile(path, []byte(` +payload_version: "1" +point: before_tool_call +run_id: run-file +session_id: session-file +metadata: + tool_name: bash + tool_call_id: call-1 +`), 0o644); err != nil { + t.Fatalf("WriteFile(fixture) error = %v", err) + } + + parsed, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile() error = %v", err) + } + if parsed.Context.RunID != "run-file" || parsed.Context.SessionID != "session-file" { + t.Fatalf("context = %#v, want run/session from fixture", parsed.Context) + } + if _, ok := parsed.Payload["metadata"]; !ok { + t.Fatalf("payload = %#v, want metadata preserved", parsed.Payload) + } + + if _, err := ParseFile(filepath.Join(dir, "missing.yaml")); err == nil || !strings.Contains(err.Error(), "read fixture") { + t.Fatalf("expected wrapped read error, got %v", err) + } +} + func TestParseBytesRejectsUnknownFieldsAndSchemaMismatch(t *testing.T) { unknownField := []byte(` payload_version: "1" @@ -61,3 +94,69 @@ metadata: t.Fatalf("expected metadata schema error, got %v", err) } } + +func TestParseBytesRejectsMalformedAndUnsupportedFixtures(t *testing.T) { + cases := []struct { + name string + sourcePath string + content string + want string + }{ + { + name: "empty", + sourcePath: "fixture.yaml", + content: " \n\t ", + want: "fixture is empty", + }, + { + name: "bad json", + sourcePath: "fixture.json", + content: `{"payload_version":`, + want: "parse fixture json", + }, + { + name: "bad yaml", + sourcePath: "fixture.yaml", + content: "payload_version: [", + want: "parse fixture yaml", + }, + { + name: "unsupported point", + sourcePath: "fixture.yaml", + content: ` +payload_version: "1" +point: unsupported_point +`, + want: "not supported", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseBytes([]byte(tc.content), tc.sourcePath) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("ParseBytes() error = %v, want contains %q", err, tc.want) + } + }) + } +} + +func TestParseBytesDefaultsToYAMLAndInitializesMetadata(t *testing.T) { + parsed, err := ParseBytes([]byte(` +payload_version: "1" +point: session_start +run_id: " run-1 " +session_id: " session-1 " +`), "fixture") + if err != nil { + t.Fatalf("ParseBytes() error = %v", err) + } + if string(parsed.Point) != "session_start" { + t.Fatalf("point = %q, want session_start", parsed.Point) + } + if parsed.Context.RunID != "run-1" || parsed.Context.SessionID != "session-1" { + t.Fatalf("context = %#v, want trimmed run/session", parsed.Context) + } + if parsed.Context.Metadata == nil { + t.Fatal("metadata map is nil") + } +} diff --git a/internal/runtime/runtime_branch_coverage_test.go b/internal/runtime/runtime_branch_coverage_test.go index 3114a9a56..ab0da7966 100644 --- a/internal/runtime/runtime_branch_coverage_test.go +++ b/internal/runtime/runtime_branch_coverage_test.go @@ -11,6 +11,14 @@ import ( agentsession "neo-code/internal/session" ) +type recordingRuntimeEventRecorder struct { + events []RuntimeEvent +} + +func (r *recordingRuntimeEventRecorder) RecordRuntimeEvent(_ context.Context, event RuntimeEvent) { + r.events = append(r.events, event) +} + func TestExecuteAssistantToolCallsReturnsNilForEmptyCalls(t *testing.T) { t.Parallel() @@ -149,6 +157,28 @@ func TestEmitWithEnvelopeBackfillsVersionAndTimestamp(t *testing.T) { } } +func TestSetRuntimeEventRecorderReceivesEmittedEvents(t *testing.T) { + t.Parallel() + + recorder := &recordingRuntimeEventRecorder{} + service := &Service{events: make(chan RuntimeEvent, 4)} + service.SetRuntimeEventRecorder(recorder) + + if err := service.emitWithEnvelope(context.Background(), RuntimeEvent{ + Type: EventAgentChunk, + RunID: "run-recorder", + Payload: "chunk", + }); err != nil { + t.Fatalf("emitWithEnvelope() error = %v", err) + } + if len(recorder.events) != 1 { + t.Fatalf("recorded events = %d, want 1", len(recorder.events)) + } + if recorder.events[0].RunID != "run-recorder" { + t.Fatalf("recorded event = %+v, want run_id", recorder.events[0]) + } +} + func TestFinishRunPromotesLatestRemainingToken(t *testing.T) { t.Parallel()