fix(control): resolve the projection seer on the current executor

The seer controller.New bound was the executor's method value, and that value
reads the session in place: a SetSession from /new, resume, a branch switch, an
extension system prompt or a fold is already visible to it. Those sites answer
from the current session. A model switch is different — it builds a new
controller, and ApplyRuntimeMigration adopts the old conversation into it. The
carried turns still hold the standing blocks the old model was sent, so the
derived rule reported them delivered and the new model's first request carried
no listing.

The seer now resolves through the controller and answers "nothing is visible"
for a generation that has delivered nothing yet. ApplyRuntimeMigration marks
that state when the replacement's model ref differs from the outgoing one; the
hand-off to the runner clears it, so exactly one request restates the standing
blocks and the visible history answers after that. A rebuild that keeps the
model keeps the derived answer.
This commit is contained in:
Will committed 2026-10-22 12:41:12 +08:00
1 parent 87965d1863
commit 2a8bdbce0b
5 files changed
+168 -5

No files matched your search

@@ -0,0 +1,115 @@
package boot
// A model switch must restate the standing blocks to the new model once: the
// conversation it adopts was composed for the model being replaced.
import (
"context"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
func TestProjectionModelSwitchReOwesTheStandingBlocksOnce(t *testing.T) {
t.Parallel()
home := statedBootHome(t)
dir := testenv.TempDir(t)
rec := &effectRecordingProvider{}
provider.Register("boot-projection-switch", func(provider.Config) (provider.Provider, error) { return rec, nil })
writeFile(t, dir, ".reasonix/skills/ledger-audit/SKILL.md", `---
name: ledger-audit
description: Audits the evidence ledger for receipts nothing reads.
---
Read the ledger and report.
`)
writeFile(t, dir, "reasonix.toml", `
default_model = "model-a"
[agent]
system_prompt = "BASE"
[[providers]]
name = "model-a"
kind = "boot-projection-switch"
model = "a"
[[providers]]
name = "model-b"
kind = "boot-projection-switch"
model = "b"
`)
approveWorkspaceAt(t, home, dir)
from, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard})
if err != nil {
t.Fatalf("Build model-a: %v", err)
}
defer from.Close()
from.EnsureSessionPath()
if err := from.Run(context.Background(), "turn-first"); err != nil {
t.Fatalf("Run(turn-first): %v", err)
}
first := rec.requests()[0]
if !strings.Contains(projectionOf(t, first, "turn-first"), "<available-skills>") {
t.Fatalf("the first model's request carried no listing, so this test measures nothing:\n%s", projectionOf(t, first, "turn-first"))
}
if err := from.Snapshot(); err != nil {
t.Fatalf("Snapshot: %v", err)
}
to, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Model: "model-b", Sink: event.Discard})
if err != nil {
t.Fatalf("Build model-b: %v", err)
}
defer to.Close()
if err := ApplyRuntimeMigration(to, from, CaptureRuntimeMigration(from)); err != nil {
t.Fatalf("ApplyRuntimeMigration: %v", err)
}
// The adopted conversation already holds the listing the old model was sent;
// that copy is not a delivery to this model.
before := len(rec.requests())
if err := to.Run(context.Background(), "turn-switched"); err != nil {
t.Fatalf("Run(turn-switched): %v", err)
}
switched := rec.requests()[before:]
if len(switched) != 1 {
t.Fatalf("switch request count = %d, want 1", len(switched))
}
if got := projectionOf(t, switched[0], "turn-switched"); !strings.Contains(got, "<available-skills>") {
t.Fatalf("the switched model's first request carries no listing — it inherits another model's delivery:\n%s", got)
}
// One restatement, not a repeat on every turn: this runtime has now put the
// listing in front of its model, and the visible history says so.
before = len(rec.requests())
if err := to.Run(context.Background(), "turn-second"); err != nil {
t.Fatalf("Run(turn-second): %v", err)
}
second := rec.requests()[before:]
if got := projectionOf(t, second[len(second)-1], "turn-second"); strings.Contains(got, "<available-skills>") {
t.Fatalf("the restatement repeated on the next turn:\n%s", got)
}
// A rebuild that keeps the model carries the conversation verbatim: the copy
// the history already holds is delivered, and no arm may re-send it.
same, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Model: "model-b", Sink: event.Discard})
if err != nil {
t.Fatalf("Build model-b again: %v", err)
}
defer same.Close()
if err := ApplyRuntimeMigration(same, to, CaptureRuntimeMigration(to)); err != nil {
t.Fatalf("ApplyRuntimeMigration (same model): %v", err)
}
before = len(rec.requests())
if err := same.Run(context.Background(), "turn-rebuilt"); err != nil {
t.Fatalf("Run(turn-rebuilt): %v", err)
}
rebuilt := rec.requests()[before:]
if got := projectionOf(t, rebuilt[len(rebuilt)-1], "turn-rebuilt"); strings.Contains(got, "<available-skills>") {
t.Fatalf("a rebuild on the same model re-sent a listing the conversation already carries:\n%s", got)
}
}
+7
View File
@@ -146,6 +146,7 @@ func rebuildWithPrevious(ctx context.Context, old *control.Controller, previous
// conversation, and the session axes a switch must not silently reset.
type RuntimeMigration struct {
prevPath string
prevModelRef string
carried []provider.Message
authorizations control.SessionAuthorizations
toolApprovalMode string
@@ -162,6 +163,7 @@ type RuntimeMigration struct {
func CaptureRuntimeMigration(old control.SessionAPI) RuntimeMigration {
m := RuntimeMigration{
prevPath: old.SessionPath(),
prevModelRef: old.ModelRef(),
carried: old.History(),
toolApprovalMode: old.ToolApprovalMode(),
postureDefaulted: old.Posture().Defaulted,
@@ -185,6 +187,11 @@ func ApplyRuntimeMigration(ctrl, old *control.Controller, m RuntimeMigration) er
carried := spliceFreshSystemPrompt(m.carried, ctrl.History())
path := sessionstore.ContinueSessionPath(m.prevPath, ctrl.SessionDir(), ctrl.Label())
ctrl.AdoptHistory(carried, path)
// Same conversation, different model: the carried blocks were composed
// for the model being replaced, so this one is owed them once.
if old != nil && m.prevModelRef != ctrl.ModelRef() {
ctrl.RestateStandingProjection()
}
// Re-apply session axes a rebuild must not reset.
if m.postureDefaulted {
+4 -1
View File
@@ -92,6 +92,9 @@ type Controller struct {
guardianPath string // persisted guardian session file ("" when disabled)
systemPrompt string
commands atomic.Pointer[[]command.Command]
// restateProjection answers "not visible" until a turn is handed to the
// runner; a model switch sets it. See projection_debt.go.
restateProjection atomic.Bool
// hookContexts carries one-shot lifecycle hook context into the next real
// user turn without changing the cache-stable system prompt.
hookContexts []string
@@ -482,7 +485,7 @@ func New(opts Options) *Controller {
runtimeGeneration: opts.RuntimeGeneration,
}
if c.executor != nil {
c.bindProjectionSeer(c.executor.NewestStandingBlock, c.executor.SeesProjection)
c.bindProjectionSeer(c.deliveredNewest, c.deliveredSees)
}
c.adoptSessionResources(opts)
c.publishPerProjectContext(opts)
@@ -42,6 +42,42 @@ func (c *Controller) bindProjectionSeer(newest func(string) (string, bool), note
c.deliveryNotes(notes)
}
// deliveredNewest is the seer a controller uses for its own model: it resolves
// the executor held at call time, so an adopted or rotated session is answered
// from the session this runtime has now, and it answers "nothing is visible"
// until a turn is handed to the runner. A switched model inherits another
// model's delivery, so the replacement restates the blocks once (see Restate).
func (c *Controller) deliveredNewest(tag string) (string, bool) {
if c == nil || c.restateProjection.Load() || c.executor == nil {
return "", false
}
return c.executor.NewestStandingBlock(tag)
}
// deliveredSees is deliveredNewest's question for the one-time notes.
func (c *Controller) deliveredSees(text string) bool {
if c == nil || c.restateProjection.Load() || c.executor == nil {
return false
}
return c.executor.SeesProjection(text)
}
// RestateStandingProjection marks this generation as having delivered nothing to
// the model it is about to address: a rebuild that switched models adopted a
// conversation composed for the model being replaced, not for this one.
func (c *Controller) RestateStandingProjection() {
if c == nil {
return
}
c.restateProjection.Store(true)
}
// settleProjection records that a composed turn is being handed to the runner.
// From here on the visible history is the record of what this runtime delivered.
func (c *Controller) settleProjection() {
c.restateProjection.Store(false)
}
// deliveryNotes binds the one-time facts a turn also carries. They are not
// standing blocks: a note is delivered when the model can read it anywhere.
func (c *Controller) deliveryNotes(notes func(string) bool) {
+6 -4
View File
@@ -162,13 +162,15 @@ func (c *Controller) recallTail(source string, hasNotes bool) string {
// a hardware exception on Windows, recovered or not.
var ErrNoRunner = errors.New("controller has no runner")
// runWithRunner hands the composed turn to the runner. Nothing about delivery is
// recorded here: whether the model holds a projected block is asked of the
// model-visible history, so a refusal, a cancel or a failed send needs no
// compensating bookkeeping.
// runWithRunner hands the composed turn to the runner. Whether the model holds a
// projected block is asked of the model-visible history, so a refusal, a cancel
// or a failed send needs no compensating bookkeeping. The exception is the
// opening restatement of a rebuilt runtime (see projection_debt.go): this
// hand-off ends that one-turn state.
func (c *Controller) runWithRunner(ctx context.Context, input string) error {
if c.runner == nil {
return ErrNoRunner
}
c.settleProjection()
return c.runner.Run(ctx, input)
}