diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 733028dca9..531db293c8 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -179,6 +179,10 @@ When the listing changes: this project; add `--global` to apply everywhere. - `reasonix doctor` reports skill health warnings, such as a missing description or a required capability that is not available. +- The terminal UI and Studio show discovery diagnostics as renderer-managed + notices, including new diagnostics discovered while completing a slash command. + Identical diagnostics are shown once per skill store; changed diagnostics remain + visible. Noninteractive commands keep their stderr diagnostics. ```toml [skills] diff --git a/internal/assembly/boot/composition_live_skills.go b/internal/assembly/boot/composition_live_skills.go index d3806d9459..55b568a94b 100644 --- a/internal/assembly/boot/composition_live_skills.go +++ b/internal/assembly/boot/composition_live_skills.go @@ -16,7 +16,7 @@ func (b *builder) liveSkillSource() func() *skill.Store { ProjectRoot: b.root, ReasonixHomeDir: b.roots.Home(), CustomPaths: cfg.SkillCustomPaths(), PluginPaths: cfg.PluginPackageSkillOwners(), PluginAgentPaths: cfg.PluginPackageAgentOwners(), ExcludedPaths: cfg.SkillExcludedPaths(), DisabledNames: func() []string { return disabledSkillNames(cfg, b.root) }, - MaxDepth: cfg.SkillMaxDepth(), Stderr: io.Discard, SuppressWarnings: true, + MaxDepth: cfg.SkillMaxDepth(), Stderr: io.Discard, SuppressWarnings: cfg.Skills.SuppressWarnings, }) } } diff --git a/internal/assembly/boot/options.go b/internal/assembly/boot/options.go index 7fafbdfb9a..4cb5aad478 100644 --- a/internal/assembly/boot/options.go +++ b/internal/assembly/boot/options.go @@ -66,6 +66,9 @@ type Options struct { // Interactive terminals must pass a private writer (or io.Discard) so // background output cannot corrupt the TUI's raw mode. Stderr io.Writer + // DiagnosticSink receives skill discovery warnings as renderer-managed notices. + // Nil preserves the noninteractive stderr destination. + DiagnosticSink event.Sink // OnPhase receives completed assembly timings synchronously, including on a later failure. OnPhase func(Phase) // Project root for config, skills, memory, commands, hooks, and tool diff --git a/internal/assembly/boot/skill_diagnostics.go b/internal/assembly/boot/skill_diagnostics.go new file mode 100644 index 0000000000..01bef253cf --- /dev/null +++ b/internal/assembly/boot/skill_diagnostics.go @@ -0,0 +1,14 @@ +package boot + +import ( + "strings" + + "reasonix/internal/contract/event" +) + +type skillDiagnosticWriter struct{ sink event.Sink } + +func (w skillDiagnosticWriter) Write(p []byte) (int, error) { + report(w.sink, event.Event{Level: event.LevelWarn, Text: strings.TrimRight(string(p), "\r\n")}) + return len(p), nil +} diff --git a/internal/assembly/boot/skill_diagnostics_test.go b/internal/assembly/boot/skill_diagnostics_test.go new file mode 100644 index 0000000000..64cabfee4b --- /dev/null +++ b/internal/assembly/boot/skill_diagnostics_test.go @@ -0,0 +1,111 @@ +package boot + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "reasonix/internal/contract/config" + "reasonix/internal/contract/event" + "reasonix/internal/ext/skill" +) + +func TestSkillDiagnosticsReachRendererAcrossRediscovery(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + t.Setenv("REASONIX_HOME", home) + var raw bytes.Buffer + var notices []event.Event + opts := Options{Stderr: &raw, DiagnosticSink: event.FuncSink(func(e event.Event) { notices = append(notices, e) })} + assembly := buildSkillAssembly(opts, &config.Config{}, root, true, "") + notices = nil + raw.Reset() + dir := filepath.Join(root, ".reasonix", "skills", "new-warning") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "SKILL.md") + write := func(name string) { + t.Helper() + if err := os.WriteFile(path, []byte("---\nname: "+name+"\n---\nA runtime skill.\n"), 0600); err != nil { + t.Fatal(err) + } + } + write("new-warning") + assembly.store.SlashList() + if raw.Len() != 0 { + t.Fatalf("runtime diagnostic overwrote terminal stderr: %s", raw.String()) + } + if len(notices) != 1 || notices[0].Kind != event.Notice || notices[0].Level != event.LevelWarn || !strings.Contains(notices[0].Text, "new-warning") { + t.Fatalf("renderer notices = %+v", notices) + } + assembly.store.SlashList() + if len(notices) != 1 { + t.Fatalf("identical diagnostic repeated: %+v", notices) + } + write("changed-warning") + time.Sleep(2100 * time.Millisecond) + assembly.store.SlashList() + if len(notices) != 2 || !strings.Contains(notices[1].Text, "changed-warning") { + t.Fatalf("changed diagnostic lost: %+v", notices) + } + assembly.store.ConfigureDiscoverySource(func() *skill.Store { + return skill.New(skill.Options{ProjectRoot: root, ReasonixHomeDir: home, Stderr: io.Discard}) + }) + assembly.store.SlashList() + if len(notices) != 2 { + t.Fatalf("live source repeated diagnostic: %+v", notices) + } + write("live-warning") + assembly.store.SlashList() + if len(notices) != 3 || !strings.Contains(notices[2].Text, "live-warning") { + t.Fatalf("live diagnostic lost: %+v", notices) + } +} + +func TestSkillDiagnosticDestinationsAtStartup(t *testing.T) { + for _, interactive := range []bool{false, true} { + for _, suppressed := range []bool{false, true} { + t.Run(fmt.Sprintf("renderer=%t/suppressed=%t", interactive, suppressed), func(t *testing.T) { + root := t.TempDir() + t.Setenv("REASONIX_HOME", t.TempDir()) + dir := filepath.Join(root, ".reasonix", "skills", "startup-warning") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: startup-warning\n---\nBody.\n"), 0600); err != nil { + t.Fatal(err) + } + var raw bytes.Buffer + var notices []event.Event + opts := Options{Stderr: &raw} + if interactive { + opts.DiagnosticSink = event.FuncSink(func(e event.Event) { notices = append(notices, e) }) + } + cfg := &config.Config{} + cfg.Skills.SuppressWarnings = suppressed + assembly := buildSkillAssembly(opts, cfg, root, true, "") + assembly.store.SlashList() + if interactive && raw.Len() != 0 { + t.Fatalf("raw stderr: %s", raw.String()) + } + if suppressed { + if len(notices) != 0 || raw.Len() != 0 { + t.Fatalf("suppressed diagnostic emitted: %+v %q", notices, raw.String()) + } + } else if interactive { + if len(notices) != 1 { + t.Fatalf("startup notices = %+v", notices) + } + } else if !strings.Contains(raw.String(), "startup-warning") { + t.Fatalf("missing noninteractive stderr: %q", raw.String()) + } + }) + } + } +} diff --git a/internal/assembly/boot/skills.go b/internal/assembly/boot/skills.go index c060cb8ffd..df87b8f459 100644 --- a/internal/assembly/boot/skills.go +++ b/internal/assembly/boot/skills.go @@ -27,8 +27,8 @@ func buildSkillAssembly(opts Options, cfg *config.Config, root string, implicit reuse := opts.ReuseAssembly != nil && shouldReuseDiscovery(opts.PreviousPlan) && opts.ReuseAssembly.ImplicitSkillInvocation == implicit stderr := opts.Stderr - if reuse { - stderr = io.Discard + if opts.DiagnosticSink != nil { + stderr = skillDiagnosticWriter{sink: opts.DiagnosticSink} } a.store = skill.New(skill.Options{ ProjectRoot: root, ReasonixHomeDir: home, CustomPaths: cfg.SkillCustomPaths(), PluginPaths: cfg.PluginPackageSkillOwners(), diff --git a/internal/ext/skill/discover.go b/internal/ext/skill/discover.go index d35cee8a76..ca9f8efdf7 100644 --- a/internal/ext/skill/discover.go +++ b/internal/ext/skill/discover.go @@ -18,6 +18,7 @@ func (s *Store) discover(scan func(discoveryRoot) []Skill) []Skill { return nil } current.admits = s.admits + current.stderr, current.said = s.stderr, s.said return current.discover(current.discoverRoot) } var out []Skill diff --git a/internal/frontend/cli/build_options.go b/internal/frontend/cli/build_options.go index b438e2657b..4602685dc9 100644 --- a/internal/frontend/cli/build_options.go +++ b/internal/frontend/cli/build_options.go @@ -32,6 +32,7 @@ type cliBuildOverrides struct { GoalTurnsUnreachable bool HoldOwedChecks bool Stderr io.Writer + DiagnosticSink event.Sink OnSessionRecovered func(control.SessionRecoveryInfo) error Ablation ablation.Set // NewConversation marks an interactive start that is not resuming anything, so the user's default mode applies. @@ -82,6 +83,7 @@ func cliProfileBuildOptions(modelName string, maxStepsOverride int, requireKey b StatsSource: surface.CLI, FeedbackSurface: feedback.SurfaceTUI, Stderr: overrides.Stderr, + DiagnosticSink: overrides.DiagnosticSink, OnSessionRecovered: overrides.OnSessionRecovered, Ablation: overrides.Ablation, NewConversation: overrides.NewConversation, diff --git a/internal/frontend/cli/tui_command.go b/internal/frontend/cli/tui_command.go index e43a75b2bb..398241d183 100644 --- a/internal/frontend/cli/tui_command.go +++ b/internal/frontend/cli/tui_command.go @@ -91,6 +91,7 @@ func runTUI(args []string, version string) int { Version: version, WorkspaceRoot: workspaceRoot, OnSessionRecovered: cliSessionRecoveredHandler(leases), Effort: f.effortOverride(), PermissionAllow: allowed, AdditionalDirs: f.addDirs, NewConversation: resumePath == "", + DiagnosticSink: bc, }) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) diff --git a/internal/frontend/serve/hub.go b/internal/frontend/serve/hub.go index 21541279da..3ee4b18c37 100644 --- a/internal/frontend/serve/hub.go +++ b/internal/frontend/serve/hub.go @@ -354,6 +354,7 @@ func (h *Hub) openBound(ctx context.Context, req OpenRequest, inherited *agentco Sink: paneSink, NativeSurfaces: h.opts.NativeSurfaces, Stderr: os.Stderr, + DiagnosticSink: paneSink, StatsSource: h.surface(), FeedbackSurface: feedbackSurface(h.surface()), AccountsURL: accountsBaseURL(), diff --git a/internal/frontend/serve/workspace.go b/internal/frontend/serve/workspace.go index f683545eb7..3c14574fb7 100644 --- a/internal/frontend/serve/workspace.go +++ b/internal/frontend/serve/workspace.go @@ -237,6 +237,7 @@ func (s *Server) statsSurface() surface.Surface { return s.surface.Or(surface.Se // switch would quietly serve another project's conversations. func (s *Server) rebuildOptions(cur control.SessionAPI, ref string) boot.Options { opts := boot.Options{Model: ref, Sink: s.rebuildSink(), NativeSurfaces: true, Stderr: os.Stderr, StatsSource: s.statsSurface(), FeedbackSurface: feedbackSurface(s.statsSurface()), AccountsURL: accountsBaseURL(), ProviderResolver: s.resolver, CleanupPendingReconciler: BackgroundCleanupReconciler} + opts.DiagnosticSink = s.rebuildSink() if s.lastBuild != nil { opts.ComputerDriver = s.lastBuild.ComputerDriver } diff --git a/release-notes/studio/unreleased/13655-renderer-safe-skill-warnings.md b/release-notes/studio/unreleased/13655-renderer-safe-skill-warnings.md new file mode 100644 index 0000000000..ca078d6b88 --- /dev/null +++ b/release-notes/studio/unreleased/13655-renderer-safe-skill-warnings.md @@ -0,0 +1,4 @@ +## 修复 + +- TUI and Studio skill diagnostics use renderer-managed notices, including first/new warnings during slash completion (#13655). +- Identical-warning deduplication from #13656 and noninteractive stderr output remain intact.