fix(boot): bind switched interceptors to adopted sidecars

This commit is contained in:
xiexian committed 2026-10-08 12:59:32 +08:00
1 parent cb04e619db
commit 5da56c79dd
3 files changed
+129 -8

No files matched your search

-1
View File
@@ -581,7 +581,6 @@ func (b *builder) freeze(ctrl *control.Controller) (*BuildResult, error) {
onWarning: ext.warn,
onSidecarDown: ext.sidecarDown,
skipPromptStrategy: shouldSkipPromptStrategy(b.opts.PreviousPlan),
previousDispatcher: b.opts.PreviousDispatcher,
}, ext.mgr)
// Assembly owns the sidecars on every path: closed inside, or in the runtime set.
b.pendingMgr = nil
@@ -0,0 +1,128 @@
package boot
import (
"encoding/json"
"slices"
"strings"
"testing"
"time"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
func TestEffectModelSwitchKeepsAdoptedInterceptors(t *testing.T) {
for _, required := range []bool{false, true} {
name := "optional"
if required {
name = "required"
}
t.Run(name, func(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
rec := &effectRecordingProvider{}
kind := "switch-interceptor-effect-" + name
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
writeFile(t, dir, "reasonix.toml", `
default_model = "model-a"
[agent]
system_prompt = "BASE"
[environment]
enabled = false
[[providers]]
name = "model-a"
kind = "`+kind+`"
model = "x"
[[providers]]
name = "model-b"
kind = "`+kind+`"
model = "y"
`)
approveWorkspace(t, dir)
installBootFakePlugin(t, config.ReasonixHomeDir(), "rewriter", map[string]any{
"required": required,
"intercepts": []string{"input.receive"},
"env": map[string]string{bootFakeEnvReplaceInput: "EXTENSION INPUT"},
})
sink := &noticeSink{}
serving, err := BuildRuntime(t.Context(), Options{Sink: sink})
if err != nil {
t.Fatal(err)
}
t.Cleanup(serving.Controller.Close)
client := serving.Extensions.Client("rewriter")
if client == nil {
t.Fatal("initial build has no interceptor sidecar")
}
t.Cleanup(func() {
serving.Controller.Close()
waitForCond(t, "adopted interceptor process exit", 10*time.Second, client.Exited)
})
hash := serving.Snapshot.CacheHash()
schemas, err := json.Marshal(serving.Snapshot.ToolSchemas())
if err != nil {
t.Fatal(err)
}
assertInput := func() {
t.Helper()
if err := serving.Controller.RunTurn(t.Context(), "original input"); err != nil {
t.Fatalf("turn with adopted interceptor: %v", err)
}
reqs := rec.requests()
if len(reqs) == 0 {
t.Fatal("turn did not reach the provider")
}
messages := reqs[len(reqs)-1].Messages
for _, message := range slices.Backward(messages) {
if message.Role == provider.RoleUser {
if !strings.Contains(message.Content, "EXTENSION INPUT") || strings.Contains(message.Content, "original input") {
t.Errorf("model %s received the original input instead of the extension's replacement", serving.Controller.ModelRef())
}
return
}
}
t.Fatal("provider request has no user message")
}
assertInput()
for _, model := range []string{"model-b/y", "model-a/x"} {
// These are the live inputs serve.rebuildOptions/reuseFromLastBuild carry.
opts := Options{Model: model, Sink: sink, WorkspaceRoot: dir, SessionDir: serving.Controller.SessionDir(), SessionTemp: serving.Controller.SessionTemp(), BrowserSession: serving.Controller.BrowserSession(), WorkspaceRepo: serving.Controller.WorkspaceRepo()}
opts.RuntimeReload = RuntimeReload{
ForceFullRebuild: true, Extensions: serving.Extensions, Owner: serving.Owner,
Graph: serving.Plan.Graph, Generation: serving.Snapshot.Generation(),
PreviousSnapshot: serving.Snapshot, PreviousDispatcher: serving.Dispatcher,
PreviousPlan: serving.Plan, ReuseAssembly: serving.Assembly,
}
next, err := BuildRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
t.Cleanup(next.Controller.Close)
if next.Extensions.Client("rewriter") != client || client.Exited() {
t.Fatal("model switch did not keep the same live sidecar")
}
serving.Controller.Close()
serving = next
assertInput()
nextSchemas, err := json.Marshal(next.Snapshot.ToolSchemas())
if err != nil {
t.Fatal(err)
}
if next.Snapshot.CacheHash() != hash || string(nextSchemas) != string(schemas) {
t.Error("interceptor adoption changed the provider-visible prefix")
}
}
if ev, found := sink.notice(event.NoticeCodeExtensionSkipped); found {
t.Errorf("live adopted sidecar was reported missing: %s", ev.Text)
}
serving.Controller.Close()
waitForCond(t, "adopted interceptor process exit", 10*time.Second, client.Exited)
})
}
}
+1 -7
View File
@@ -147,8 +147,6 @@ type extensionBoot struct {
// skipPromptStrategy skips system_prompt.build strategy when the RuntimePlan
// is a no-op (or does not affect cache), preserving the previous prompt.
skipPromptStrategy bool
// previousDispatcher reuses an interceptor chain when the plan is no-op.
previousDispatcher *dispatch.Dispatcher
}
func (p extensionBoot) warn(msg string) {
@@ -326,11 +324,7 @@ func assembleLegacySnapshot(ctx context.Context, in legacyAssembly, generation u
}
postFreeze = func(snap *extension.RuntimeSnapshot) {
if ext.previousDispatcher != nil && ext.skipPromptStrategy {
dispatcher = ext.previousDispatcher
} else {
dispatcher = dispatch.New(snap.InterceptorChain(), snap.Replacements(), clients, required, dispatchOpts)
}
dispatcher = dispatch.New(snap.InterceptorChain(), snap.Replacements(), clients, required, dispatchOpts)
dispatcher.Event(extension.PointSystemPromptBuild, dispatch.SystemPromptPayload{
Prompt: prompt, WorkspaceRoot: ext.session.WorkspaceRoot,
})