fix(skills): render runtime discovery warnings as notices

This commit is contained in:
Lancy committed 2026-11-02 12:27:26 +00:00
1 parent be5c98c744
commit 82c27867b3
12 files changed
+145 -3

No files matched your search

+4
View File
@@ -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]
@@ -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,
})
}
}
+3
View File
@@ -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
@@ -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
}
@@ -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())
}
})
}
}
}
+2 -2
View File
@@ -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(),
+1
View File
@@ -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
+2
View File
@@ -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,
+1
View File
@@ -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)
+1
View File
@@ -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(),
+1
View File
@@ -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
}
@@ -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.