fix(boot): a rebuilt dispatcher keeps the missing-sidecar notice

stageDispatcher built the successor with empty options, so after a subgraph
rebuild an optional extension without a sidecar was skipped silently, right at
the moment an install or enable makes that most likely. The successor now takes
the reporting of the dispatcher it replaces.

Refs #12258
This commit is contained in:
yhh committed 2026-10-11 05:29:37 -07:00
1 parent 268b955eab
commit 4d3ea82f67
3 files changed
+59 -1

No files matched your search

@@ -8,6 +8,8 @@ import (
"reasonix/internal/contract/event"
"reasonix/internal/ext/extension"
"reasonix/internal/ext/extension/dispatch"
"reasonix/internal/ext/extension/protocol"
"reasonix/internal/ext/extension/sidecar"
)
// The dispatcher the stage's hook feeds reaches the frontend sink as one typed
@@ -41,3 +43,49 @@ func TestMissingSidecarReachesTheSinkAsATypedNotice(t *testing.T) {
t.Fatalf("payload = %+v (ok=%v), want %+v", got, ok, want)
}
}
// A dispatcher staged by a subgraph rebuild keeps the stage's reporting, so a
// sidecar missing right after an extension was installed still reaches the sink.
func TestRebuiltDispatcherKeepsTheMissingSidecarNotice(t *testing.T) {
sink := &noticeSink{}
stage, err := startExtensions(context.Background(), Options{}, config.RootsForHome(t.TempDir()), t.TempDir(), nil, sink)
if err != nil {
t.Fatal(err)
}
point := extension.PointToolBefore
builder := extension.NewBuilder().WithSystemPrompt("p").WithGeneration(2)
builder.AddContributor(extension.ContributorFunc{
ContributorName: "rebuilt",
Fn: func(context.Context) ([]extension.Contribution, error) {
return []extension.Contribution{{
Kind: extension.KindInterceptor, ID: string(point),
Source: extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: "aipush-ask-bridge", Origin: "plugin"},
}}, nil
},
})
snap, set, err := builder.Build(context.Background())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = set.Close() })
previous := dispatch.New(nil, nil, func(string) dispatch.Client { return nil }, nil, dispatch.Options{
Warn: stage.warn, SidecarDown: stage.sidecarDown,
})
mgr, _, err := sidecar.StartPackagesWithPlan(context.Background(), robustTempDir(t), protocol.SessionContext{SessionID: "s", WorkspaceRoot: robustTempDir(t), Generation: 2}, nil, nil, &extension.RuntimePlan{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = mgr.Close() })
res := &BuildResult{Snapshot: snap, Dispatcher: previous, Extensions: mgr}
if err := stageDispatcher(res); err != nil {
t.Fatal(err)
}
for range 2 {
if _, err := res.Dispatcher.Intercept(context.Background(), point, &dispatch.ToolBeforePayload{}); err != nil {
t.Fatal(err)
}
}
if len(sink.ev) != 1 || sink.ev[0].Code != event.NoticeCodeExtensionSkipped {
t.Fatalf("notices = %+v, want exactly one %q notice from the rebuilt dispatcher", sink.ev, event.NoticeCodeExtensionSkipped)
}
}
+5 -1
View File
@@ -243,7 +243,11 @@ func stageDispatcher(res *BuildResult) error {
}
clients := sidecarClientResolver(res.Extensions)
required := requiredRuntimeSet(res.Extensions)
res.Dispatcher = dispatch.New(res.Snapshot.InterceptorChain(), res.Snapshot.Replacements(), clients, required, dispatch.Options{})
var opts dispatch.Options
if res.Dispatcher != nil {
opts = res.Dispatcher.Options()
}
res.Dispatcher = dispatch.New(res.Snapshot.InterceptorChain(), res.Snapshot.Replacements(), clients, required, opts)
return nil
}
@@ -175,6 +175,12 @@ func New(chain map[extension.InterceptorPoint][]extension.Contribution, replacem
}
}
// Options returns the reporting this dispatcher was built with, so a successor
// built after a rebuild reports through the same channels.
func (d *Dispatcher) Options() Options {
return Options{Warn: d.warn, SidecarDown: d.sidecarDown}
}
// Intercept walks the chain for point in frozen order, calling each
// plugin-backed interceptor with the current (possibly already replaced)
// payload. payloadPtr must be a pointer to the point's registered DTO; on