Files
zero/internal/sandbox/normalize_test.go
KRATOSandGnanam 69810d5ab0 Mid-run model escalation: escalate_model tool + --allow-escalation (#146)
* modelregistry: add UpgradeTargetID and Registry.UpgradeTarget escalation lookup

* modelregistry: seed escalation chains in catalog decoration

* modelregistry: verification gate for escalation upgrade map

* agent: add ModelSwitcher option and ToolResult.RequestedModel field

* agent: lift escalate_to_model meta into ToolResult.RequestedModel

* agent: perform at-most-one mid-run model switch in the turn loop

* tools: add SideEffectNone for control-only tools

* tools: add escalate_model control tool (metadata + args)

* cli: parse --allow-escalation exec flag

* cli: document --allow-escalation in exec help

* cli: register escalate_model tool under --allow-escalation

* cli: wire model switcher for exec escalation

* cli: guard byte-identical exec run without escalation flag

* cli: attribute post-switch usage to the escalated model

* tools: accept an explicit empty escalate_model reason

reason is an optional, informational-only arg, but the previous arg
parsing rejected an explicit reason:"" (non-empty-string check), failing
the whole escalation. Switch to stringArgWithEmpty so an absent OR empty
reason is accepted while a non-string value is still rejected. Add a test
covering both the empty and absent forms.

* agent: test (nil,nil) escalation switcher keeps original provider

When a ModelSwitcher returns (nil, nil) — no error, no replacement — the
loop's `else if newProvider != nil` guard already keeps the active
provider, so the run continues on the original model. Add an agent-layer
test that proves this (it panics if the nil guard is dropped), closing the
gap the adversarial review flagged.

Also note the deferred compaction limitation near the switch: the
compactor's context-window budget is fixed at run start and not updated on
a mid-run switch (needs a ModelSwitcher contract change to fix).

* cli: gate usage model attribution on the escalation flag and harden tests

FIX A (back-compat): the OnUsage closure wrote a "model" key into every
persisted EventUsage payload unconditionally, diverging a non-escalation
run from the byte-identical origin/main baseline. The model can only
change mid-run under --allow-escalation, so include the "model" key only
then. A new flag-OFF test asserts the persisted usage payload carries no
"model" key (mutation-proven).

Test hardening for the CLI escalation boundary:
- Wire test: each newProvider build returns a DISTINCT provider with its
  own turn counter; assert the source provider served exactly the
  escalation turn and the escalated provider served exactly the
  post-switch answer. Fails if `provider = newProvider` is dropped.
- Mixed usage attribution: emit usage pre- and post-switch and assert the
  first attributes to the original model, the second to the escalated
  target (the loop's ordering guarantee).
- Switch-error path: a failed rebuild is non-fatal and post-error usage
  stays attributed to the original model (currentModel unchanged).
- Top-tier decline end-to-end: a flag-on run that escalates while already
  top-tier performs no switch (newProvider built exactly once) and
  succeeds.

* modelregistry: validate UpgradeTargetID resolves at registry construction

* cli: update currentModel only when the switched provider is non-nil

* sandbox: make 'none' a first-class side effect (control-only tools)

---------

Co-authored-by: Gnanam <[email protected]>
2026-06-08 20:50:32 +05:30

43 lines
1.6 KiB
Go

package sandbox
import "testing"
func TestAutonomyRankOrdering(t *testing.T) {
if !(autonomyRank[AutonomyLow] < autonomyRank[AutonomyMedium] && autonomyRank[AutonomyMedium] < autonomyRank[AutonomyHigh]) {
t.Fatalf("autonomyRank not strictly ordered: %v", autonomyRank)
}
}
func TestAutonomyAllowedNormalizesBothOperands(t *testing.T) {
cases := []struct {
name string
requested Autonomy
max Autonomy
want bool
}{
{"low under high", AutonomyLow, AutonomyHigh, true},
{"high under medium ceiling", AutonomyHigh, AutonomyMedium, false},
{"equal medium", AutonomyMedium, AutonomyMedium, true},
{"uppercase requested still ranked", Autonomy("HIGH"), AutonomyMedium, false},
{"unknown requested fails closed against high ceiling", Autonomy("bogus"), AutonomyHigh, false},
{"unknown ceiling fails closed against high request", AutonomyHigh, Autonomy("bogus"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := autonomyAllowed(tc.requested, tc.max); got != tc.want {
t.Fatalf("autonomyAllowed(%q, %q) = %v, want %v", tc.requested, tc.max, got, tc.want)
}
})
}
}
func TestNormalizeSideEffectPreservesNone(t *testing.T) {
if got := NormalizeSideEffect(SideEffectNone); got != SideEffectNone {
t.Fatalf("NormalizeSideEffect(none) = %q, want none (must not collapse to out_of_workspace)", got)
}
// An unrecognized value still fails closed to out_of_workspace.
if got := NormalizeSideEffect(SideEffect("bogus")); got != SideEffectOutOfWorkspace {
t.Fatalf("NormalizeSideEffect(bogus) = %q, want out_of_workspace", got)
}
}