mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
feat(session): restore standard/delivery role as quality floor / 恢复标准与交付角色为质量底线
Problem PR #8866 removed the light/balanced/delivery execution modes in favor of one adaptive standard execution, and #8930 finished the fact-driven cleanup. Delivery-grade sessions lost their sticky completion gates, and ordinary sessions inherited the delivery-only readiness pause: nothing lets a session pin delivery-level verification, and nothing lets an ordinary session opt out of the recovery card. Root cause The role setting existed only as deprecated no-op compatibility shims (SetAgentPreset, tokenMode, /preset), while the fact contract rebuilt each turn had no session-scoped floor input. The final-readiness pause was armed by delivery scope and strict obligations instead of by an explicit, user-selectable floor. Fix - taskcontract: PolicyFloor enum + ReasonPolicyFloor. The floor in force at write time is stamped on the evidence receipt, so the per-turn pure replay re-derives identical floor obligations even after the session floor changes: a downgrade never retracts established obligations, and an upgrade never retroactively tightens old writes. - control: SetQualityFloor/QualityFloor single write path on a grouped sessionSettings sub-state; the floor rides runtimepolicy.Constraints through withPlannerTurnMetadata into each turn's engine. - agent: readinessPauseActive makes the floor the only thing that arms the final-readiness pause. The standard floor restores the pre-#8866 balanced feel across all three execution modes -- a readiness gap is still audited but never stops the turn. Goal keeps driving because its FSM reads Agent.ReadinessResult directly rather than consuming FinalReadinessError, so suppressing the pause costs it no signal. - Delivery deepens verification; it does not take over persistence. The readiness continuation stays bounded at its existing 1-2 turns, so the floor owns verification depth and goal mode owns "keep going". - entries: light and every alias fold silently to standard; unknown values error. CLI /preset and --preset/--profile, ACP session/set_config_option (work_mode/agent_preset/quality_floor) all become real switches; boot.Options seeds the floor at build. - desktop: WorkspaceTab.qualityFloor replaces the redundant tokenMode field; the eight pinned compat sites now derive from the floor; isolated worktrees infer delivery (floorInferred); the composer gains a standard/delivery segmented control beside tool approval, orthogonal to the execution-mode menu; BranchMeta persists quality_floor with legacy delivery migration. - AcceptDeliveryToTab clears awaiting_delivery with no model turn, idempotently, touching no other status. It is bound to the persisted tab state rather than only the end-of-turn card: the project-tree topic status already carries that state, so a reopened session has an exit and no second source of truth is added. Adapted from #9037 (Phase 1) for issue #9036. Verification - go test ./internal/... (agent, control, taskcontract, boot, cli, acp, evidence, agentpreset) - go test -race ./internal/control/ ./internal/taskcontract/ ./internal/agent/ - cd desktop && go test . (TestTabEventSinkForwardsImmediateWorkspace- Mutation fails identically on pristine origin/main-v2 - pre-existing) - desktop/frontend: pnpm typecheck, pnpm lint:hooks, pnpm build (all bundle budgets PASS) - gofmt, go vet ./..., make lint (golangci-lint + repolint clean, ratchet baseline unchanged) Cache-impact: none - the floor is host-only session state; the golden baseline (system prompt, tool schemas, provider request, prefix shape) stays green and TestEffectQualityFloorDoesNotTouchProviderPrefix pins byte-identical prefixes across floors. Co-authored-by: Linearl <[email protected]>
This commit is contained in:
1 parent
69d597e219
commit
bd8a8822b5
95 files changed
+1658
-444
No files matched your search
@@ -6,32 +6,53 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
func TestSaveTabSessionMetaReplacesStaleAgentPreset(t *testing.T) {
|
||||
func TestSaveTabSessionMetaPersistsDeliveryFloor(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
meta, err := agent.EnsureBranchMeta(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.AgentPreset = boot.AgentPresetBalanced
|
||||
meta.AgentPreset = boot.AgentPresetStandard
|
||||
meta.TokenMode = boot.TokenModeFull
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: path, tokenMode: boot.TokenModeDelivery}); err != nil {
|
||||
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: path, qualityFloor: control.QualityFloorDelivery}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok, err := agent.LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta = %+v, %v, %v", got, ok, err)
|
||||
}
|
||||
if got.AgentPreset != boot.AgentPresetBalanced || got.TokenMode != boot.TokenModeFull {
|
||||
t.Fatalf("persisted role = preset:%q tokenMode:%q, want pinned balanced/full", got.AgentPreset, got.TokenMode)
|
||||
if got.QualityFloor != control.QualityFloorDelivery {
|
||||
t.Fatalf("persisted floor = %q, want delivery", got.QualityFloor)
|
||||
}
|
||||
if restored := tabSessionProfileFromMeta(path, got).tokenMode; restored != boot.TokenModeFull {
|
||||
t.Fatalf("restored tokenMode = %q, want pinned full", restored)
|
||||
if got.AgentPreset != boot.AgentPresetDelivery {
|
||||
t.Fatalf("dual-write preset = %q, want delivery", got.AgentPreset)
|
||||
}
|
||||
if got.TokenMode != boot.TokenModeDelivery {
|
||||
t.Fatalf("dual-write tokenMode = %q, want delivery", got.TokenMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveTabSessionMetaStandardWritesNoFloor(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "standard.jsonl")
|
||||
if _, err := agent.EnsureBranchMeta(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: path, qualityFloor: control.QualityFloorStandard}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok, err := agent.LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta = %+v, %v, %v", got, ok, err)
|
||||
}
|
||||
if got.QualityFloor != "" {
|
||||
t.Fatalf("standard floor must not persist a value, got %q", got.QualityFloor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +64,30 @@ func TestTabSessionProfileFromMetaDecodesLegacyDelivery(t *testing.T) {
|
||||
}
|
||||
meta.AgentPreset = boot.AgentPresetDelivery
|
||||
meta.TokenMode = boot.TokenModeDelivery
|
||||
if restored := tabSessionProfileFromMeta(path, meta).tokenMode; restored != boot.TokenModeDelivery {
|
||||
t.Fatalf("legacy decode tokenMode = %q, want delivery", restored)
|
||||
profile := tabSessionProfileFromMeta(path, meta)
|
||||
if profile.qualityFloor != control.QualityFloorDelivery {
|
||||
t.Fatalf("legacy decode floor = %q, want delivery", profile.qualityFloor)
|
||||
}
|
||||
tab := &WorkspaceTab{}
|
||||
applyTabSessionProfile(tab, tabSessionProfileFromMeta(path, meta))
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("legacy delivery must not change runtime tokenMode: got %q", got)
|
||||
applyTabSessionProfile(tab, profile)
|
||||
if got := tab.qualityFloor; got != control.QualityFloorDelivery {
|
||||
t.Fatalf("legacy delivery must reach the tab floor: got %q", got)
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeDelivery {
|
||||
t.Fatalf("derived tokenMode = %q, want delivery", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabSessionProfileFromMetaFoldsLegacyLight(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "light.jsonl")
|
||||
meta, err := agent.EnsureBranchMeta(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.AgentPreset = "light"
|
||||
meta.TokenMode = "economy"
|
||||
profile := tabSessionProfileFromMeta(path, meta)
|
||||
if profile.qualityFloor != control.QualityFloorStandard {
|
||||
t.Fatalf("legacy light must fold to standard, got %q", profile.qualityFloor)
|
||||
}
|
||||
}
|
||||
+14
-10
@@ -722,10 +722,13 @@ func (a *App) restoreOrBuildTabs() {
|
||||
}
|
||||
tab.model = entry.Model
|
||||
tab.effort = cloneStringPtr(entry.Effort)
|
||||
// Execution modes are gone: old agentPreset/tokenMode entries are
|
||||
// parsed for compatibility but never applied. The tab keeps the
|
||||
// pinned dual-write compat value.
|
||||
tab.tokenMode = boot.TokenModeFull
|
||||
// The role entry seeds the quality floor: delivery (and legacy
|
||||
// delivery labels) raise it; light folds to standard.
|
||||
if entry.QualityFloor == control.QualityFloorDelivery {
|
||||
tab.qualityFloor = control.QualityFloorDelivery
|
||||
} else {
|
||||
tab.qualityFloor = ""
|
||||
}
|
||||
tab.mode = persistedTabMode(entry.Mode)
|
||||
// Validate the persisted goal against the session's goal-state
|
||||
// sidecar: a typed /new or /clear rotates the session through the
|
||||
@@ -827,7 +830,7 @@ func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *Wo
|
||||
TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),
|
||||
topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
|
||||
model: model,
|
||||
tokenMode: boot.TokenModeFull,
|
||||
qualityFloor: "",
|
||||
mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
|
||||
toolApprovalMode: toolApprovalMode,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
@@ -3342,7 +3345,7 @@ func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
|
||||
topicTitleSource: topicTitleSourceAuto,
|
||||
SessionPath: sessionPath,
|
||||
model: model,
|
||||
tokenMode: boot.TokenModeFull,
|
||||
qualityFloor: "",
|
||||
mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
|
||||
toolApprovalMode: toolApprovalMode,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
@@ -10047,12 +10050,14 @@ func (a *App) SetAgentPreset(preset string) error {
|
||||
// the legacy argument, does not require an idle tab, saves no mode, rebuilds
|
||||
// no agent, and always succeeds with the deprecation notice.
|
||||
func (a *App) SetAgentPresetForTab(tabID, preset string) error {
|
||||
_ = boot.NormalizeAgentPreset(preset)
|
||||
normalized, err := boot.NormalizeAgentPresetErr(preset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tab := a.tabByID(tabID); tab == nil && strings.TrimSpace(tabID) != "" {
|
||||
return fmt.Errorf("tab %q not found", tabID)
|
||||
}
|
||||
a.noticeForTab(strings.TrimSpace(tabID), SetAgentPresetDeprecatedNotice)
|
||||
return nil
|
||||
return a.SetQualityFloorForTab(tabID, normalized)
|
||||
}
|
||||
|
||||
// persistTabTokenMode persists the deprecated dual-write compatibility values
|
||||
@@ -10064,7 +10069,6 @@ func (a *App) persistTabTokenMode(tab *WorkspaceTab) {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
tab.tokenMode = boot.TokenModeFull
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
_ = a.saveTabSessionMetaForCurrentSession(tab)
|
||||
|
||||
+23
-19
@@ -47,7 +47,7 @@ import (
|
||||
)
|
||||
|
||||
type todoMetaController struct {
|
||||
control.SessionAPI
|
||||
stubSessionAPI
|
||||
todos []evidence.TodoItem
|
||||
}
|
||||
|
||||
@@ -5387,29 +5387,26 @@ func assertDeprecatedExecutionModeNoop(t *testing.T, app *App, tab *WorkspaceTab
|
||||
if tab.Ctrl == nil || tab.Ctrl != old {
|
||||
t.Fatalf("controller identity changed: got %p want %p", tab.Ctrl, old)
|
||||
}
|
||||
if got := old.AgentPreset(); got != boot.AgentPresetBalanced {
|
||||
t.Fatalf("controller AgentPreset = %q, want balanced", got)
|
||||
if got := old.AgentPreset(); got != boot.AgentPresetStandard {
|
||||
t.Fatalf("controller AgentPreset = %q, want standard (light folds)", got)
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("token mode = %q, want pinned full", got)
|
||||
t.Fatalf("token mode = %q, want full", got)
|
||||
}
|
||||
meta := app.MetaForTab(tab.ID)
|
||||
if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetBalanced {
|
||||
t.Fatalf("meta token/preset = %q/%q, want full/balanced", meta.TokenMode, meta.AgentPreset)
|
||||
}
|
||||
if len(notices) == 0 || notices[len(notices)-1] != SetAgentPresetDeprecatedNotice {
|
||||
t.Fatalf("deprecation notice = %q, want %q", notices, SetAgentPresetDeprecatedNotice)
|
||||
if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetStandard {
|
||||
t.Fatalf("meta token/preset = %q/%q, want full/standard", meta.TokenMode, meta.AgentPreset)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSetTokenModeDidNotPersistLiveModes(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, entry := range loadTabsFile().Tabs {
|
||||
if entry.TokenMode == boot.TokenModeEconomy || entry.TokenMode == boot.TokenModeDelivery {
|
||||
t.Fatalf("SetTokenMode persisted live mode %q", entry.TokenMode)
|
||||
if entry.TokenMode == "economy" || entry.TokenMode == "light" {
|
||||
t.Fatalf("SetTokenMode persisted folded mode %q", entry.TokenMode)
|
||||
}
|
||||
if entry.AgentPreset == boot.AgentPresetLight || entry.AgentPreset == boot.AgentPresetDelivery {
|
||||
t.Fatalf("SetTokenMode persisted live preset %q", entry.AgentPreset)
|
||||
if entry.AgentPreset == "light" || entry.AgentPreset == "balanced" {
|
||||
t.Fatalf("SetTokenMode persisted non-floor preset %q", entry.AgentPreset)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5421,8 +5418,8 @@ func assertPinnedCompatPersisted(t *testing.T, app *App, tab *WorkspaceTab) {
|
||||
if len(saved.Tabs) != 1 {
|
||||
t.Fatalf("saved tabs = %+v, want 1", saved.Tabs)
|
||||
}
|
||||
if saved.Tabs[0].TokenMode != boot.TokenModeFull || saved.Tabs[0].AgentPreset != boot.AgentPresetBalanced {
|
||||
t.Fatalf("saved compat = token:%q preset:%q, want full/balanced", saved.Tabs[0].TokenMode, saved.Tabs[0].AgentPreset)
|
||||
if saved.Tabs[0].TokenMode != boot.TokenModeFull {
|
||||
t.Fatalf("saved compat token = %q, want full", saved.Tabs[0].TokenMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5452,7 +5449,7 @@ func TestSetTokenModeRebuildsController(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSetTokenModeDeliveryRebuildsAndPersistsProfile(t *testing.T) {
|
||||
// Name kept for history; SetTokenMode(delivery) is ignored and does not persist delivery.
|
||||
// SetTokenMode(delivery) now writes the session quality floor in place.
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
@@ -5471,8 +5468,15 @@ func TestSetTokenModeDeliveryRebuildsAndPersistsProfile(t *testing.T) {
|
||||
if err := app.SetTokenMode(boot.TokenModeDelivery); err != nil {
|
||||
t.Fatalf("SetTokenMode(delivery): %v", err)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
assertSetTokenModeDidNotPersistLiveModes(t)
|
||||
if tab.Ctrl == nil || tab.Ctrl != old {
|
||||
t.Fatalf("controller identity changed: got %p want %p", tab.Ctrl, old)
|
||||
}
|
||||
if got := old.QualityFloor(); got != control.QualityFloorDelivery {
|
||||
t.Fatalf("controller QualityFloor = %q, want delivery", got)
|
||||
}
|
||||
if got := tab.qualityFloor; got != control.QualityFloorDelivery {
|
||||
t.Fatalf("tab qualityFloor = %q, want delivery", got)
|
||||
}
|
||||
|
||||
if err := app.SetTokenMode(boot.TokenModeFull); err != nil {
|
||||
t.Fatalf("SetTokenMode(full): %v", err)
|
||||
@@ -5868,7 +5872,7 @@ func TestSetTokenModeUnknownTabErrors(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), `tab "missing-tab" not found`) {
|
||||
t.Fatalf("SetTokenModeForTab(unknown) = %v, want tab not found", err)
|
||||
}
|
||||
err = app.SetAgentPresetForTab("missing-tab", boot.AgentPresetLight)
|
||||
err = app.SetAgentPresetForTab("missing-tab", "light")
|
||||
if err == nil || !strings.Contains(err.Error(), `tab "missing-tab" not found`) {
|
||||
t.Fatalf("SetAgentPresetForTab(unknown) = %v, want tab not found", err)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
@@ -61,7 +60,6 @@ api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
Scope: "project",
|
||||
WorkspaceRoot: root,
|
||||
model: "test-model",
|
||||
tokenMode: boot.TokenModeFull,
|
||||
mode: "normal",
|
||||
toolApprovalMode: control.ToolApprovalAuto,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type backgroundRuntimeController struct {
|
||||
control.SessionAPI
|
||||
stubSessionAPI
|
||||
status control.RuntimeStatus
|
||||
jobs []jobs.View
|
||||
lease workspacelease.State
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
type billingRuntimeController struct {
|
||||
control.SessionAPI
|
||||
stubSessionAPI
|
||||
status control.RuntimeStatus
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
// Bound to the persisted tab state, not the end-of-turn notice card: that card
|
||||
// renders once per finished turn, so a session reopened while still awaiting
|
||||
// delivery would otherwise have no exit (#9036).
|
||||
|
||||
// AcceptDelivery accepts the active tab's pending delivery checks.
|
||||
func (a *App) AcceptDelivery() error {
|
||||
return a.AcceptDeliveryToTab("")
|
||||
}
|
||||
|
||||
// AcceptDeliveryToTab clears one tab's awaiting-delivery state. It touches no
|
||||
// other status, starts no turn, and is idempotent: accepting a tab that is not
|
||||
// awaiting delivery succeeds and changes nothing.
|
||||
func (a *App) AcceptDeliveryToTab(tabID string) error {
|
||||
tab := a.tabByID(tabID)
|
||||
if tab == nil {
|
||||
return a.workspaceNotReadyErr(nil)
|
||||
}
|
||||
a.mu.Lock()
|
||||
cleared := a.tabs[tab.ID] == tab && awaitingDelivery(tab)
|
||||
if cleared {
|
||||
tab.ActivityStatus = ""
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if cleared {
|
||||
a.emitProjectTreeChanged()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// awaitingDelivery reports the state the accept action is bound to.
|
||||
func awaitingDelivery(tab *WorkspaceTab) bool {
|
||||
return tab != nil && tab.ActivityStatus == topicStatusAwaitingDelivery
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAcceptDeliveryToTabClearsOnlyAwaitingDelivery(t *testing.T) {
|
||||
app := &App{tabs: map[string]*WorkspaceTab{}}
|
||||
tab := &WorkspaceTab{ID: "tab-a", ActivityStatus: topicStatusAwaitingDelivery}
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
if err := app.AcceptDeliveryToTab(tab.ID); err != nil {
|
||||
t.Fatalf("AcceptDeliveryToTab: %v", err)
|
||||
}
|
||||
if tab.ActivityStatus != "" {
|
||||
t.Fatalf("ActivityStatus = %q, want cleared", tab.ActivityStatus)
|
||||
}
|
||||
// Idempotent: a second acceptance is a no-op, not an error.
|
||||
if err := app.AcceptDeliveryToTab(tab.ID); err != nil {
|
||||
t.Fatalf("second AcceptDeliveryToTab: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptDeliveryToTabLeavesOtherStatesUntouched(t *testing.T) {
|
||||
app := &App{tabs: map[string]*WorkspaceTab{}}
|
||||
tab := &WorkspaceTab{ID: "tab-a", ActivityStatus: topicStatusPaused}
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
if err := app.AcceptDeliveryToTab(tab.ID); err != nil {
|
||||
t.Fatalf("AcceptDeliveryToTab: %v", err)
|
||||
}
|
||||
if tab.ActivityStatus != topicStatusPaused {
|
||||
t.Fatalf("ActivityStatus = %q, want %q", tab.ActivityStatus, topicStatusPaused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwaitingDeliveryReportsTheBoundState(t *testing.T) {
|
||||
if awaitingDelivery(nil) {
|
||||
t.Fatal("nil tab must not report awaiting delivery")
|
||||
}
|
||||
if awaitingDelivery(&WorkspaceTab{ActivityStatus: topicStatusPaused}) {
|
||||
t.Fatal("paused tab must not report awaiting delivery")
|
||||
}
|
||||
if !awaitingDelivery(&WorkspaceTab{ActivityStatus: topicStatusAwaitingDelivery}) {
|
||||
t.Fatal("awaiting-delivery tab must report the state")
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,8 @@ func (a *App) IsolatedWorktreeAvailability(workspaceRoot string) worktree.Availa
|
||||
|
||||
// CreateIsolatedWorktree creates a durable branch-backed worktree and opens it
|
||||
// as a project. It never switches or modifies the source checkout, and it does
|
||||
// not delete the new worktree if later UI registration fails. Creation does
|
||||
// not force any execution mode: the standard policy adapts per task.
|
||||
// not delete the new worktree if later UI registration fails. The opened tab
|
||||
// infers the delivery quality floor (switchable to standard at any time).
|
||||
func (a *App) CreateIsolatedWorktree(workspaceRoot string) (IsolatedWorktreeOpenResult, error) {
|
||||
workspaceRoot = strings.TrimSpace(workspaceRoot)
|
||||
created, err := createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
|
||||
|
||||
@@ -63,7 +63,10 @@ func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
|
||||
if result.Tab.WorkspaceRoot != isolatedRoot || !result.Tab.IsolatedWorktree || !result.Tab.Active {
|
||||
t.Fatalf("opened tab = %+v", result.Tab)
|
||||
}
|
||||
if result.Tab.TokenMode != "full" {
|
||||
t.Fatalf("isolated worktree tokenMode = %q, want pinned full (must not force delivery)", result.Tab.TokenMode)
|
||||
if result.Tab.TokenMode != "delivery" {
|
||||
t.Fatalf("isolated worktree tokenMode = %q, want delivery (inferred floor)", result.Tab.TokenMode)
|
||||
}
|
||||
if result.Tab.QualityFloor != "delivery" || !result.Tab.FloorInferred {
|
||||
t.Fatalf("isolated worktree floor = %q inferred=%v, want delivery/inferred", result.Tab.QualityFloor, result.Tab.FloorInferred)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// from a tab controller; everything else falls through the embedded nil
|
||||
// SessionAPI and must not be called.
|
||||
type extensionCatalogCtrl struct {
|
||||
control.SessionAPI
|
||||
stubSessionAPI
|
||||
catalog []provider.Descriptor
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// SubmitExtensionForm (asserted locally by the binding until the port grows
|
||||
// it).
|
||||
type extensionUIController struct {
|
||||
control.SessionAPI
|
||||
stubSessionAPI
|
||||
actions []control.ExtensionActionView
|
||||
invokeMessage string
|
||||
invokeErr error
|
||||
@@ -184,7 +184,7 @@ func TestSubmitExtensionFormWithoutSubmitterFailsCleanly(t *testing.T) {
|
||||
}
|
||||
|
||||
type extensionUIActionsOnlyController struct {
|
||||
control.SessionAPI
|
||||
stubSessionAPI
|
||||
}
|
||||
|
||||
func (c *extensionUIActionsOnlyController) ExtensionActions() []control.ExtensionActionView {
|
||||
|
||||
@@ -87,9 +87,11 @@ for (const path of localeChunks) {
|
||||
// compaction settings add 40 bytes gzip of policy guidance to simplified
|
||||
// Chinese, while scheduled billing adds compact rate-band labels/tooltips.
|
||||
// The three StepFun presets add localized names/descriptions (~0.1 KiB
|
||||
// gzip); the two pay-as-you-go presets add the same again. Retain all
|
||||
// with the smallest 0.1 KiB ratchet increments.
|
||||
const budget = name.startsWith("zh-TW-") ? 56.1 * 1024 : 55.4 * 1024;
|
||||
// gzip); the two pay-as-you-go presets add the same again. The delivery
|
||||
// floor segmented control adds two labels plus one explanatory tooltip,
|
||||
// measured at 23 B gzip for zh and 8 B for zh-TW. Retain all with the
|
||||
// smallest 0.1 KiB ratchet increments.
|
||||
const budget = name.startsWith("zh-TW-") ? 56.2 * 1024 : 55.5 * 1024;
|
||||
assertBudget(`${name} gzip`, gzipBytes(path), budget);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
type RemoteHostView,
|
||||
type SessionMeta,
|
||||
type SettingsView,
|
||||
type QualityFloor,
|
||||
type TabMeta,
|
||||
type ToolApprovalMode,
|
||||
type WorkspaceConflictView,
|
||||
@@ -267,7 +268,14 @@ function NoticePreviewPanel() {
|
||||
<div style={{ maxWidth: 920, margin: "0 auto" }}>
|
||||
{noticePreviewItems().map((item) => {
|
||||
if (item.kind !== "notice") return null;
|
||||
return <NoticeCard key={item.id} item={item} onAction={item.action ? () => undefined : undefined} />;
|
||||
return (
|
||||
<NoticeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onAction={item.action ? () => undefined : undefined}
|
||||
onAccept={item.action === "continue_delivery" ? () => undefined : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1067,6 +1075,7 @@ export default function App() {
|
||||
drainExtensionNotifications,
|
||||
setCollaborationMode: setControllerCollaborationMode,
|
||||
setToolApprovalMode: setControllerToolApprovalMode,
|
||||
setQualityFloor: setControllerQualityFloor,
|
||||
setComposerProfileForTab: setControllerComposerProfileForTab,
|
||||
setGoalForTab: setControllerGoalForTab,
|
||||
resumeGoalForTab: resumeControllerGoalForTab,
|
||||
@@ -1916,6 +1925,14 @@ export default function App() {
|
||||
},
|
||||
[activeTabId, patchActiveComposerProfile, setControllerToolApprovalMode, toolApprovalMode],
|
||||
);
|
||||
const applyQualityFloor = useCallback(
|
||||
(floor: QualityFloor) => {
|
||||
if (!activeTabId) return;
|
||||
patchActiveComposerProfile({ qualityFloor: floor }, ["qualityFloor"]);
|
||||
void setControllerQualityFloor(floor);
|
||||
},
|
||||
[activeTabId, patchActiveComposerProfile, setControllerQualityFloor],
|
||||
);
|
||||
const toggleYoloApprovalMode = useCallback(() => {
|
||||
if (!activeTabId) return;
|
||||
const next = toggleYoloToolApprovalMode(
|
||||
@@ -4834,6 +4851,7 @@ export default function App() {
|
||||
footerHeight={footerHeight}
|
||||
onPrompt={handleTranscriptPrompt}
|
||||
onDeliveryContinue={() => void handleDeliveryContinue()}
|
||||
onAcceptDelivery={() => void app.AcceptDeliveryToTab(activeTabIdRef.current ?? "")}
|
||||
onOpenChanges={() => openRightDockMode("changed")}
|
||||
onEditPrompt={handleEditPrompt}
|
||||
onRewind={handleMessageAction}
|
||||
@@ -5042,6 +5060,9 @@ export default function App() {
|
||||
running={state.running || rewindCommitting}
|
||||
collaborationMode={collaborationMode}
|
||||
toolApprovalMode={toolApprovalMode}
|
||||
qualityFloor={composerProfile.qualityFloor}
|
||||
floorInferred={(activeTab?.floorInferred ?? false) && !composerProfile.pending.qualityFloor}
|
||||
onSetQualityFloor={applyQualityFloor}
|
||||
turnPhase={state.turnPhase}
|
||||
goal={goal}
|
||||
goalStatus={state.meta?.goalStatus}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { ArrowRight, ArrowUp, AtSign, Check, ChevronsUpDown, CornerDownRight, Eye, FilePlus2, FileText, Folder, Gauge, Hash, List, MessageSquare, Plus, Search, Shield, ShieldAlert, ShieldCheck, Square, Target, Trash2, X } from "lucide-react";
|
||||
import { ArrowRight, ArrowUp, AtSign, Check, ChevronsUpDown, CornerDownRight, Equal, Eye, FilePlus2, FileText, Folder, Gauge, Hash, List, MessageSquare, PackageCheck, Plus, Search, Shield, ShieldAlert, ShieldCheck, Square, Target, Trash2, X } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { filterAtMatches } from "../lib/atMatches";
|
||||
import { DedupIndex, sha256 } from "../lib/attachDedup";
|
||||
@@ -34,7 +34,7 @@ import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/
|
||||
import { createRafResizeUpdater } from "../lib/resizeDrag";
|
||||
import { observeComposerMenuViewport } from "../lib/composerMenuViewport";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { type CollaborationMode, type CommandInfo, type ComposerInsertRequest, type ContextInfo, type DirEntry, type EffortInfo, type GoalRuntime, type HistoryMessage, type Mode, type PromptHistoryEntry, type SessionMeta, type SessionReference, type SlashArgItem, type SlashArgsResult, type ToolApprovalMode, type BalanceInfo } from "../lib/types";
|
||||
import { type CollaborationMode, type CommandInfo, type ComposerInsertRequest, type ContextInfo, type DirEntry, type EffortInfo, type GoalRuntime, type HistoryMessage, type Mode, type PromptHistoryEntry, type QualityFloor, type SessionMeta, type SessionReference, type SlashArgItem, type SlashArgsResult, type ToolApprovalMode, type BalanceInfo } from "../lib/types";
|
||||
import {
|
||||
formatWorkspaceReference,
|
||||
parseWorkspaceReference,
|
||||
@@ -534,6 +534,8 @@ export function Composer({
|
||||
running,
|
||||
collaborationMode,
|
||||
toolApprovalMode,
|
||||
qualityFloor,
|
||||
floorInferred,
|
||||
turnPhase,
|
||||
goal,
|
||||
goalStatus,
|
||||
@@ -550,6 +552,7 @@ export function Composer({
|
||||
onSetMode,
|
||||
onSetCollaborationMode,
|
||||
onSetToolApprovalMode,
|
||||
onSetQualityFloor,
|
||||
onToggleYoloApprovalMode,
|
||||
onClearGoal,
|
||||
onPauseGoal,
|
||||
@@ -600,6 +603,8 @@ export function Composer({
|
||||
running: boolean;
|
||||
collaborationMode: CollaborationMode;
|
||||
toolApprovalMode: ToolApprovalMode;
|
||||
qualityFloor?: QualityFloor;
|
||||
floorInferred?: boolean;
|
||||
/** Host turn phase: working | checking | verifying | reviewing */
|
||||
turnPhase?: string;
|
||||
goal?: string;
|
||||
@@ -620,6 +625,7 @@ export function Composer({
|
||||
onSetMode: (mode: Mode) => void;
|
||||
onSetCollaborationMode: (mode: CollaborationMode) => void;
|
||||
onSetToolApprovalMode: (mode: ToolApprovalMode) => void;
|
||||
onSetQualityFloor?: (floor: QualityFloor) => void;
|
||||
onToggleYoloApprovalMode: () => void;
|
||||
onClearGoal: () => void;
|
||||
onPauseGoal: () => void;
|
||||
@@ -3617,6 +3623,11 @@ export function Composer({
|
||||
requestActiveDraftFrame(focusComposerInput);
|
||||
});
|
||||
};
|
||||
const floorOn = qualityFloor === "delivery";
|
||||
const chooseQualityFloor = (floor: QualityFloor) => {
|
||||
if (floor === qualityFloor) return;
|
||||
onSetQualityFloor?.(floor);
|
||||
};
|
||||
const stopGoalMode = () => {
|
||||
closeIntentMenu(() => {
|
||||
onClearGoal();
|
||||
@@ -4672,6 +4683,42 @@ export function Composer({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!heroMode && (
|
||||
<div className="composer-meta__control composer-meta__control--floor">
|
||||
{/* Orthogonal to the intent menu: the floor only raises the
|
||||
completion gates, so goal mode and delivery combine. */}
|
||||
<div
|
||||
className="composer-modebar composer-modebar--floor"
|
||||
data-floor={floorOn ? "delivery" : "standard"}
|
||||
title={t("composer.qualityFloor")}
|
||||
>
|
||||
<span className="composer-modebar__thumb" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-modebar__item${floorOn ? "" : " composer-modebar__item--active"}`}
|
||||
onClick={() => chooseQualityFloor("standard")}
|
||||
disabled={approvalBarDisabled || !onSetQualityFloor}
|
||||
aria-pressed={!floorOn}
|
||||
title={t("composer.qualityFloor")}
|
||||
>
|
||||
<Equal size={14} />
|
||||
<span>{t("composer.qualityFloorStandard")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-modebar__item${floorOn ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseQualityFloor("delivery")}
|
||||
disabled={approvalBarDisabled || !onSetQualityFloor}
|
||||
aria-pressed={floorOn}
|
||||
title={t("composer.qualityFloorDeliveryTitle")}
|
||||
>
|
||||
<PackageCheck size={14} />
|
||||
<span>{t("composer.qualityFloorDelivery")}</span>
|
||||
{floorOn && floorInferred ? <span className="composer-modebar__inferred" /> : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!heroMode && <span className="composer-meta__divider" aria-hidden="true" />}
|
||||
<div className="composer-meta__control composer-meta__control--model">
|
||||
{/*
|
||||
|
||||
@@ -233,6 +233,7 @@ export function Transcript({
|
||||
footerHeight = 0,
|
||||
onPrompt,
|
||||
onDeliveryContinue,
|
||||
onAcceptDelivery,
|
||||
onOpenChanges,
|
||||
onEditPrompt,
|
||||
onRewind,
|
||||
@@ -261,6 +262,7 @@ export function Transcript({
|
||||
footerHeight?: number;
|
||||
onPrompt: (text: string) => void;
|
||||
onDeliveryContinue?: () => void;
|
||||
onAcceptDelivery?: () => void;
|
||||
onOpenChanges?: () => void;
|
||||
onEditPrompt?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
|
||||
onRewind?: (turn: number, scope: string) => void;
|
||||
@@ -758,6 +760,7 @@ export function Transcript({
|
||||
: row.item.action === "open_changes"
|
||||
? onOpenChanges
|
||||
: undefined}
|
||||
onAccept={row.item.action === "continue_delivery" ? onAcceptDelivery : undefined}
|
||||
/>
|
||||
);
|
||||
case "extension":
|
||||
@@ -794,6 +797,7 @@ export function Transcript({
|
||||
loadingOlderHistory,
|
||||
olderHistoryCount,
|
||||
onDeliveryContinue,
|
||||
onAcceptDelivery,
|
||||
onEditPrompt,
|
||||
onLoadOlderHistory,
|
||||
onOpenChanges,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// decision receipts, and compaction cards.
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, CirclePlay, FileSearch, Info, TriangleAlert } from "lucide-react";
|
||||
import { CheckCheck, ChevronRight, CirclePlay, FileSearch, Info, TriangleAlert } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { CompactionItem, NoticeItem } from "../lib/transcriptRows";
|
||||
import { STEER_NOTICE_PREFIX } from "../lib/useController";
|
||||
@@ -64,7 +64,7 @@ function DecisionReceiptLine({ receipt }: { receipt: NonNullable<NoticeItem["dec
|
||||
);
|
||||
}
|
||||
|
||||
export function NoticeCard({ item, onAction, actionDisabled = false }: { item: NoticeItem; onAction?: () => void; actionDisabled?: boolean }) {
|
||||
export function NoticeCard({ item, onAction, onAccept, actionDisabled = false }: { item: NoticeItem; onAction?: () => void; onAccept?: () => void; actionDisabled?: boolean }) {
|
||||
const t = useT();
|
||||
const StatusIcon = item.level === "warn" ? TriangleAlert : Info;
|
||||
const ActionIcon = item.action === "open_changes" ? FileSearch : CirclePlay;
|
||||
@@ -86,6 +86,12 @@ export function NoticeCard({ item, onAction, actionDisabled = false }: { item: N
|
||||
<ActionIcon size={13} aria-hidden="true" />
|
||||
<span>{item.action === "open_changes" ? t("notice.completionViewChanges") : t("notice.deliveryIncompleteContinue")}</span>
|
||||
</button>
|
||||
{onAccept ? (
|
||||
<button className="btn btn--small" type="button" onClick={onAccept}>
|
||||
<CheckCheck size={13} aria-hidden="true" />
|
||||
<span>{t("notice.deliveryIncompleteAccept")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{item.detail ? (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { makeMockSessionCatalogBindings } from "./sessionCatalogBridge";
|
||||
import { makeMockHistoryCatalogBindings, type HistoryCatalogBindings } from "./historyCatalogBridge";
|
||||
import { makeMockTaskCatalogBindings, type TaskCatalogBindings } from "./taskCatalogBridge";
|
||||
import { makeMockBlankProjectBindings, type BlankProjectBindings } from "./blankProjectBridge";
|
||||
import { makeMockQualityFloorBindings, type QualityFloorBindings } from "./deliveryFloorBridge";
|
||||
import { t } from "./i18n";
|
||||
import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels";
|
||||
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems";
|
||||
@@ -173,7 +174,7 @@ interface DesktopWindowState {
|
||||
|
||||
// AppBindings is the hand-written React-to-Go contract. _CheckGeneratedBindings
|
||||
// catches generated methods missing here; update this interface and typecheck.
|
||||
export interface AppBindings extends SessionCatalogBindings, ProjectTreeOrganizationBindings, HistoryCatalogBindings, TaskCatalogBindings, BlankProjectBindings, SessionTitleBindings {
|
||||
export interface AppBindings extends SessionCatalogBindings, ProjectTreeOrganizationBindings, HistoryCatalogBindings, TaskCatalogBindings, BlankProjectBindings, QualityFloorBindings, SessionTitleBindings {
|
||||
Platform(): Promise<string>;
|
||||
MinimiseMainWindow(): Promise<void>;
|
||||
ToggleMaximiseMainWindow(): Promise<void>;
|
||||
@@ -5037,6 +5038,7 @@ function makeMockApp(): AppBindings {
|
||||
async DeliveryWorktreeAvailability(workspaceRoot: string) {
|
||||
return this.IsolatedWorktreeAvailability(workspaceRoot);
|
||||
},
|
||||
...makeMockQualityFloorBindings(() => mockTabs, (next) => { mockTabs = next; }),
|
||||
async SetAgentPreset(_preset: string) {},
|
||||
async SetAgentPresetForTab(_tabID: string, _preset: string) {},
|
||||
async SetTokenMode(_mode: string) {},
|
||||
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
type Meta,
|
||||
type Mode,
|
||||
type TabMeta,
|
||||
type QualityFloor,
|
||||
type ToolApprovalMode,
|
||||
} from "./types";
|
||||
|
||||
export type ComposerProfileField = "collaborationMode" | "toolApprovalMode" | "goal";
|
||||
export type ComposerProfileField = "collaborationMode" | "toolApprovalMode" | "goal" | "qualityFloor";
|
||||
|
||||
export type ComposerProfilePending = Partial<Record<ComposerProfileField, true>>;
|
||||
|
||||
@@ -22,19 +23,21 @@ export interface ComposerProfile {
|
||||
goalDraftMode: boolean;
|
||||
toolApprovalMode: ToolApprovalMode;
|
||||
goal: string;
|
||||
qualityFloor: QualityFloor;
|
||||
pending: ComposerProfilePending;
|
||||
}
|
||||
|
||||
export type ComposerProfilesByTab = Record<string, ComposerProfile>;
|
||||
export type UserPlanModeIntents = Record<string, true>;
|
||||
|
||||
const profileFields: ComposerProfileField[] = ["collaborationMode", "toolApprovalMode", "goal"];
|
||||
const profileFields: ComposerProfileField[] = ["collaborationMode", "toolApprovalMode", "goal", "qualityFloor"];
|
||||
|
||||
export const defaultComposerProfile: ComposerProfile = Object.freeze({
|
||||
collaborationMode: "normal",
|
||||
goalDraftMode: false,
|
||||
toolApprovalMode: "ask",
|
||||
goal: "",
|
||||
qualityFloor: "standard",
|
||||
pending: {},
|
||||
});
|
||||
|
||||
@@ -68,6 +71,7 @@ export function composerProfileFromTab(tab?: TabMeta | null, fallback?: ToolAppr
|
||||
fallbackToolApprovalMode(tab.toolApprovalMode, fallback),
|
||||
),
|
||||
goal,
|
||||
qualityFloor: tab.qualityFloor ?? "standard",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,6 +90,7 @@ export function composerProfileFromMeta(meta?: Meta | null, legacyMode?: Mode, f
|
||||
goalDraftMode: false,
|
||||
toolApprovalMode,
|
||||
goal,
|
||||
qualityFloor: meta.qualityFloor ?? "standard",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,6 +109,9 @@ function assignField(profile: ComposerProfile, field: ComposerProfileField, valu
|
||||
case "goal":
|
||||
profile.goal = value;
|
||||
return;
|
||||
case "qualityFloor":
|
||||
profile.qualityFloor = value as QualityFloor;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +121,7 @@ function profilesEqual(a: ComposerProfile | undefined, b: ComposerProfile | unde
|
||||
&& a.goalDraftMode === b.goalDraftMode
|
||||
&& a.toolApprovalMode === b.toolApprovalMode
|
||||
&& a.goal === b.goal
|
||||
&& a.qualityFloor === b.qualityFloor
|
||||
&& profileFields.every((field) => Boolean(a.pending[field]) === Boolean(b.pending[field]));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { QualityFloor, TabMeta } from "./types";
|
||||
|
||||
export interface QualityFloorBindings {
|
||||
SetQualityFloor(floor: string): Promise<void>;
|
||||
SetQualityFloorForTab(tabID: string, floor: string): Promise<void>;
|
||||
// Model-free exit from the delivery pause; see desktop/delivery_accept.go.
|
||||
AcceptDelivery(): Promise<void>;
|
||||
AcceptDeliveryToTab(tabID: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function normalizeQualityFloor(floor: string): QualityFloor {
|
||||
return floor === "delivery" ? "delivery" : "standard";
|
||||
}
|
||||
|
||||
// The mock stores the floor on the tab list so the browser shell reflects the
|
||||
// toggle the way the Wails host does; the host derives it from the session.
|
||||
export function makeMockQualityFloorBindings(
|
||||
tabs: () => TabMeta[],
|
||||
setTabs: (next: TabMeta[]) => void,
|
||||
): QualityFloorBindings {
|
||||
const applyToTab = (tabID: string, floor: string) => {
|
||||
const next = normalizeQualityFloor(floor);
|
||||
setTabs(tabs().map((tab) => (tab.id === tabID ? { ...tab, qualityFloor: next } : tab)));
|
||||
};
|
||||
return {
|
||||
async SetQualityFloor(floor: string) {
|
||||
const active = tabs().find((tab) => tab.active);
|
||||
if (active) applyToTab(active.id, floor);
|
||||
},
|
||||
async SetQualityFloorForTab(tabID: string, floor: string) {
|
||||
applyToTab(tabID, floor);
|
||||
},
|
||||
async AcceptDelivery() {},
|
||||
async AcceptDeliveryToTab(_tabID: string) {},
|
||||
};
|
||||
}
|
||||
@@ -456,8 +456,9 @@ export interface TabMeta {
|
||||
collaborationMode?: CollaborationMode;
|
||||
toolApprovalMode?: ToolApprovalMode;
|
||||
tokenMode?: TokenMode;
|
||||
/** Canonical role setting (light|balanced|delivery). Prefer over tokenMode. */
|
||||
agentPreset?: AgentPreset;
|
||||
agentPreset?: AgentPreset; // canonical role; prefer qualityFloor
|
||||
qualityFloor?: QualityFloor; // absent means standard
|
||||
floorInferred?: boolean; // facts, not user choice, put the session at delivery
|
||||
goal?: string;
|
||||
goalStatus?: GoalStatus;
|
||||
recovered?: boolean;
|
||||
@@ -926,8 +927,9 @@ export interface Meta {
|
||||
collaborationMode?: CollaborationMode;
|
||||
toolApprovalMode?: ToolApprovalMode;
|
||||
tokenMode?: TokenMode;
|
||||
/** Canonical role setting (light|balanced|delivery). Prefer over tokenMode. */
|
||||
agentPreset?: AgentPreset;
|
||||
agentPreset?: AgentPreset; // canonical role; prefer qualityFloor
|
||||
qualityFloor?: QualityFloor; // absent means standard
|
||||
floorInferred?: boolean; // facts, not user choice, put the session at delivery
|
||||
goal?: string;
|
||||
goalStatus?: GoalStatus;
|
||||
goalRuntime?: GoalRuntime;
|
||||
@@ -936,11 +938,12 @@ export interface Meta {
|
||||
|
||||
export type CollaborationMode = "normal" | "plan" | "goal";
|
||||
export type ToolApprovalMode = "ask" | "auto" | "yolo";
|
||||
// TokenMode is the dual-write wire value for Agent role settings (角色设定).
|
||||
// Canonical product ids are light|balanced|delivery; economy/full remain one
|
||||
// compatibility version of persisted/API values.
|
||||
// TokenMode is the dual-write wire value for the session quality floor.
|
||||
// The floor itself is standard|delivery; light and its aliases fold to
|
||||
// standard, and full/economy remain one compatibility version of old values.
|
||||
export type TokenMode = "full" | "economy" | "delivery" | "light" | "balanced";
|
||||
export type AgentPreset = "light" | "balanced" | "delivery";
|
||||
export type QualityFloor = "standard" | "delivery";
|
||||
export type GoalStatus = "running" | "complete" | "blocked" | "stopped";
|
||||
// Optional Goal runtime summary; absent for old hosts or when no goal is active.
|
||||
export interface GoalRuntime {
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useStaleTurnWatchdog } from "./useStaleTurnWatchdog";
|
||||
import type { SearchSource } from "./searchSources";
|
||||
import { attachWebSearchOutput, historySearchAndAnswer } from "./searchTranscript";
|
||||
import { fileDiffFromWire, parseTodos, summarize, summarizeFileDiff, type ToolFileDiff } from "./tools";
|
||||
import { modeHasAutoApproveTools, normalizeMode, normalizeToolApprovalMode } from "./types";
|
||||
import { modeHasAutoApproveTools, normalizeMode, normalizeToolApprovalMode, type QualityFloor } from "./types";
|
||||
import type {
|
||||
BalanceInfo,
|
||||
CheckpointMeta,
|
||||
@@ -136,9 +136,8 @@ function tailPreview(text: string, limit: number): string {
|
||||
}
|
||||
// --- Sub-agent progress reducer helpers --------------------------------------
|
||||
// Applies one reserved ToolProgress event to the target card's in-memory
|
||||
// preview. The card must exist (its dispatch always precedes progress events)
|
||||
// and have been initialized by the dispatch. Never writes tool.output, never
|
||||
// touches the parent LiveStream, and never produces history data.
|
||||
// preview. The card must exist and be dispatch-initialized; never writes
|
||||
// tool.output, the parent LiveStream, or history data.
|
||||
function applySubagentProgress(s: State, t: WireTool): State {
|
||||
if (!t.id) return s;
|
||||
const idx = s.items.findIndex((it) => it.kind === "tool" && it.id === t.id);
|
||||
@@ -410,12 +409,9 @@ interface State {
|
||||
lastRequestTps?: number | null;
|
||||
pendingRequestModelMs?: number;
|
||||
promptWaitStartedAt?: number;
|
||||
// promptEventClock() reading taken when the CURRENT pending prompt first
|
||||
// arrived. Orders the prompt against reconciliation snapshots so a snapshot
|
||||
// fetched before the event cannot clear the prompt it never knew about
|
||||
// (#6429). Anchored to the prompt's first arrival and NOT advanced by a
|
||||
// same-id replay, so an authoritative idle snapshot taken after the user
|
||||
// answered is never mistaken for stale (#6432 reverse race).
|
||||
// promptEventClock() at the CURRENT prompt's first arrival; not advanced by
|
||||
// a same-id replay. Orders the prompt against reconciliation snapshots so a
|
||||
// snapshot cannot clear a prompt it never knew about (#6429, #6432).
|
||||
promptArrivedAt?: number;
|
||||
// Id of the prompt promptArrivedAt is anchored to. A replay re-emitting the
|
||||
// same id keeps the original arrival time; only a genuinely new prompt id
|
||||
@@ -3862,9 +3858,7 @@ export function useController() {
|
||||
const setToolApprovalModeForTab = useCallback(async (tabId: string, mode: ToolApprovalMode): Promise<void> => {
|
||||
if (!tabId) return;
|
||||
const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0;
|
||||
// Same contract as setControllerMode: the backend reports which pending
|
||||
// approvals the new posture auto-allowed; anything else is still pending
|
||||
// there (fresh prompts; ask-rule approvals under auto) and stays visible.
|
||||
// Backend reports auto-allowed pending approvals; the rest stay visible.
|
||||
const drained = await app.SetToolApprovalModeForTab(tabId, mode).catch(() => undefined);
|
||||
const ids = Array.isArray(drained) ? drained : [];
|
||||
if (ids.length) dispatchTo(tabId, { type: "approval_drained", ids, epoch });
|
||||
@@ -3876,6 +3870,12 @@ export function useController() {
|
||||
await setToolApprovalModeForTab(activeTabId, mode);
|
||||
}, [activeTabId, setToolApprovalModeForTab]);
|
||||
|
||||
const setQualityFloor = useCallback(async (floor: QualityFloor): Promise<void> => {
|
||||
if (!activeTabId) return;
|
||||
await app.SetQualityFloorForTab(activeTabId, floor).catch(() => undefined);
|
||||
await refreshMetaForTab(activeTabId);
|
||||
}, [activeTabId, refreshMetaForTab]);
|
||||
|
||||
const setComposerProfileForTab = useCallback(async (
|
||||
tabId: string,
|
||||
collaborationMode: CollaborationMode,
|
||||
@@ -4773,7 +4773,7 @@ export function useController() {
|
||||
activeTabId,
|
||||
send, sendToTab, recoverDeliveryToTab, runShell, runShellForTab, steer, steerForTab, notice, cancel, approve, resolvePlanDecision, resolveRecovery, answerQuestion, setControllerMode,
|
||||
dismissExtensionForm, drainExtensionNotifications,
|
||||
setCollaborationMode, setCollaborationModeForTab, setToolApprovalMode, setToolApprovalModeForTab, setComposerProfileForTab, setGoal, setGoalForTab, clearGoal, clearGoalForTab, resumeGoal, resumeGoalForTab, pauseGoal, pauseGoalForTab,
|
||||
setCollaborationMode, setCollaborationModeForTab, setToolApprovalMode, setToolApprovalModeForTab, setQualityFloor, setComposerProfileForTab, setGoal, setGoalForTab, clearGoal, clearGoalForTab, resumeGoal, resumeGoalForTab, pauseGoal, pauseGoalForTab,
|
||||
newSession, clearSession, listSessions, listTrashedSessions, retrySessionHistory, resumeSession, openChannelSession, previewSession, deleteSession, restoreSession, purgeTrashedSession, renameSession,
|
||||
loadOlderHistory,
|
||||
requestHistoryFullContent,
|
||||
|
||||
@@ -696,6 +696,10 @@ export const en = {
|
||||
"composer.taskModeGoal": "Goal · Keep progressing",
|
||||
"composer.taskModeGoalShort": "Goal",
|
||||
"composer.taskModeGoalDesc": "Enter a goal, then keep working until done or blocked.",
|
||||
"composer.qualityFloor": "Delivery floor",
|
||||
"composer.qualityFloorStandard": "Standard",
|
||||
"composer.qualityFloorDelivery": "Delivery",
|
||||
"composer.qualityFloorDeliveryTitle": "Delivery: writes require full verification and green project checks; combines with goal mode.",
|
||||
"composer.taskModeGoalTooltipSummary": "Keep working until done or blocked",
|
||||
"composer.taskModeStopGoal": "End goal",
|
||||
"composer.taskModePauseGoal": "Pause goal",
|
||||
@@ -2924,6 +2928,7 @@ export const en = {
|
||||
"notice.recoveryPausedTitle": "Automatic retries paused",
|
||||
"notice.recoveryPausedBody": "Reasonix stopped repeated attempts and kept completed work. Send “Continue” to start a fresh attempt, or add instructions to change direction.",
|
||||
"notice.deliveryIncompleteContinue": "Continue checks",
|
||||
"notice.deliveryIncompleteAccept": "Accept as done",
|
||||
"notice.deliveryIncompleteContinuePrompt": "Continue and complete the remaining task checks.",
|
||||
"notice.completionAttentionTitle": "This turn still needs attention",
|
||||
"notice.completionGapsBody": "Some verification is incomplete or limited. Review the changes for details.",
|
||||
|
||||
@@ -2040,6 +2040,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"notice.recoveryPausedBody": "Reasonix 已停止重複嘗試,並保留已完成的工作。傳送「繼續」即可開始新一輪,也可以補充要求來調整方向。",
|
||||
"notice.deliveryIncompleteBody": "上方是助手答覆,並非系統檢查結果。仍有必要的收尾步驟未完成,請展開「詳情」查看。",
|
||||
"notice.deliveryIncompleteContinue": "繼續檢查",
|
||||
"notice.deliveryIncompleteAccept": "接受驗收",
|
||||
"notice.deliveryIncompleteContinuePrompt": "繼續完成剩餘的任務收尾檢查。",
|
||||
"notice.completionAttentionTitle": "本輪仍需處理",
|
||||
"notice.completionGapsBody": "部分驗證未完成或受限,請查看變更詳情。",
|
||||
@@ -2470,6 +2471,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"composer.taskModeGoal": "目標 · 持續推進",
|
||||
"composer.taskModeGoalShort": "目標",
|
||||
"composer.taskModeGoalDesc": "輸入目標後持續工作,直到完成或阻塞。",
|
||||
"composer.qualityFloor": "交付底線",
|
||||
"composer.qualityFloorStandard": "標準",
|
||||
"composer.qualityFloorDelivery": "交付",
|
||||
"composer.qualityFloorDeliveryTitle": "交付:寫入後必須全量驗證並通過專案檢查;可與目標模式並用。",
|
||||
"composer.taskModeGoalTooltipSummary": "持續推進,直到完成或阻塞",
|
||||
"composer.taskModeStopGoal": "結束目標",
|
||||
"composer.taskModePauseGoal": "暫停目標",
|
||||
|
||||
@@ -697,6 +697,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.taskModeGoal": "目标 · 持续推进",
|
||||
"composer.taskModeGoalShort": "目标",
|
||||
"composer.taskModeGoalDesc": "输入目标后持续工作,直到完成或阻塞。",
|
||||
"composer.qualityFloor": "交付底线",
|
||||
"composer.qualityFloorStandard": "标准",
|
||||
"composer.qualityFloorDelivery": "交付",
|
||||
"composer.qualityFloorDeliveryTitle": "交付:写入后必须全量验证并通过项目检查;可与目标模式并用。",
|
||||
"composer.taskModeGoalTooltipSummary": "持续推进,直到完成或阻塞",
|
||||
"composer.taskModeStopGoal": "结束目标",
|
||||
"composer.taskModePauseGoal": "暂停目标",
|
||||
@@ -2927,6 +2931,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"notice.recoveryPausedBody": "Reasonix 已停止重复尝试,并保留已完成的工作。发送“继续”即可开始新一轮,也可以补充要求来调整方向。",
|
||||
"notice.deliveryIncompleteBody": "上方是助手答复,并非系统检查结果。仍有必需的收尾步骤未完成,请展开“详情”查看。",
|
||||
"notice.deliveryIncompleteContinue": "继续检查",
|
||||
"notice.deliveryIncompleteAccept": "接受验收",
|
||||
"notice.deliveryIncompleteContinuePrompt": "继续完成剩余的任务收尾检查。",
|
||||
"notice.completionAttentionTitle": "本轮仍需处理",
|
||||
"notice.completionGapsBody": "部分验证未完成或受限,请查看改动详情。",
|
||||
|
||||
@@ -7247,6 +7247,15 @@ body > .mermaid-diagram--fullscreen {
|
||||
max-width: min(168px, 28vw);
|
||||
}
|
||||
|
||||
.composer-meta__control--floor {
|
||||
flex: 0 1 auto;
|
||||
min-width: 132px;
|
||||
max-width: min(220px, 26vw);
|
||||
margin-inline-start: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.composer-meta__control--approval {
|
||||
flex: 0 1 auto;
|
||||
min-width: 216px;
|
||||
@@ -7300,6 +7309,27 @@ body > .mermaid-diagram--fullscreen {
|
||||
--composer-modebar-active-fg: var(--mode-auto-fg);
|
||||
}
|
||||
|
||||
/* Two segments instead of three, so the thumb spans half the track. Standard
|
||||
needs a solid neutral thumb, not the near-invisible default: otherwise
|
||||
leaving delivery reads as the pill vanishing rather than sliding. */
|
||||
.composer-modebar--floor {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
--composer-modebar-active-bg: color-mix(in srgb, var(--fg) 16%, var(--surface-2));
|
||||
--composer-modebar-active-border: color-mix(in srgb, var(--fg) 30%, transparent);
|
||||
--composer-modebar-active-fg: var(--fg);
|
||||
}
|
||||
|
||||
.composer-modebar--floor .composer-modebar__thumb {
|
||||
width: calc((100% - 4px) / 2);
|
||||
}
|
||||
|
||||
.composer-modebar--floor[data-floor="delivery"] {
|
||||
--composer-modebar-index: 1;
|
||||
--composer-modebar-active-bg: var(--accent);
|
||||
--composer-modebar-active-border: var(--accent-strong);
|
||||
--composer-modebar-active-fg: var(--accent-fg);
|
||||
}
|
||||
|
||||
.composer-modebar--approval[data-mode="auto"] {
|
||||
--composer-modebar-active-bg: color-mix(in srgb, var(--ok) 58%, #0b3d24);
|
||||
--composer-modebar-active-border: color-mix(in srgb, var(--ok) 76%, #0b3d24);
|
||||
@@ -7576,6 +7606,12 @@ body > .mermaid-diagram--fullscreen {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.composer-meta__control--floor {
|
||||
flex-basis: 120px;
|
||||
min-width: 110px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.composer-meta__control--intent {
|
||||
max-width: 128px;
|
||||
}
|
||||
@@ -7629,6 +7665,12 @@ body > .mermaid-diagram--fullscreen {
|
||||
max-width: 92px;
|
||||
}
|
||||
|
||||
.composer-meta__control--floor {
|
||||
flex-basis: 62px;
|
||||
min-width: 58px;
|
||||
max-width: 62px;
|
||||
}
|
||||
|
||||
.composer-meta__divider {
|
||||
display: none;
|
||||
}
|
||||
@@ -7829,6 +7871,17 @@ body > .mermaid-diagram--fullscreen {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.composer-modebar__inferred {
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.6;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.composer-intent-menu__stop {
|
||||
--wails-draggable: no-drag;
|
||||
align-self: flex-end;
|
||||
|
||||
@@ -80,7 +80,7 @@ func newGoalDeliveryYoloTestApp(t *testing.T, goalStatus string) (*App, *Workspa
|
||||
app.readyHook = func() {}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_goal_delivery_yolo", Scope: "global", Ready: true,
|
||||
SessionPath: path, model: "test/model", tokenMode: boot.TokenModeFull,
|
||||
SessionPath: path, model: "test/model",
|
||||
mode: "yolo", toolApprovalMode: control.ToolApprovalYolo,
|
||||
goal: "stale tab goal", Ctrl: oldCtrl,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
@@ -121,8 +121,8 @@ func TestGoalDeliveryYoloTokenSwitchPreservesBlockedGoalCheckpoint(t *testing.T)
|
||||
if ctrl.ToolApprovalMode() != control.ToolApprovalYolo {
|
||||
t.Fatalf("tool approval = %q, want yolo", ctrl.ToolApprovalMode())
|
||||
}
|
||||
if currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
t.Fatalf("token mode = %q, want pinned full", currentTabTokenMode(tab))
|
||||
if currentTabTokenMode(tab) != boot.TokenModeDelivery {
|
||||
t.Fatalf("token mode = %q, want delivery", currentTabTokenMode(tab))
|
||||
}
|
||||
app.mu.RLock()
|
||||
runtimeForPath := app.runtimeBySessionKey[sessionRuntimeKey(path)]
|
||||
@@ -188,8 +188,8 @@ func TestPlanYoloDeliveryRebuildUsesLiveControllerAxes(t *testing.T) {
|
||||
if ctrl.GoalStatus() != control.GoalStatusBlocked {
|
||||
t.Fatalf("blocked Goal status = %q, want preserved while Plan is active", ctrl.GoalStatus())
|
||||
}
|
||||
if currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
t.Fatalf("token mode = %q, want pinned full", currentTabTokenMode(tab))
|
||||
if currentTabTokenMode(tab) != boot.TokenModeDelivery {
|
||||
t.Fatalf("token mode = %q, want delivery", currentTabTokenMode(tab))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ func TestPlanWinsRunningGoalConflictDuringDeliveryRebuild(t *testing.T) {
|
||||
app.mu.Lock()
|
||||
tab.mode = "plan-yolo"
|
||||
tab.goal = "ship the combined mode"
|
||||
tab.tokenMode = boot.TokenModeDelivery
|
||||
tab.qualityFloor = control.QualityFloorDelivery
|
||||
app.mu.Unlock()
|
||||
|
||||
if err := app.SetModelForTab(tab.ID, "alt/alt-model"); err != nil {
|
||||
@@ -282,7 +282,7 @@ func TestGoalDeliveryYoloSurvivesEveryControllerRebuildPath(t *testing.T) {
|
||||
name: "settings",
|
||||
prepare: func(app *App, tab *WorkspaceTab) {
|
||||
app.mu.Lock()
|
||||
tab.tokenMode = boot.TokenModeDelivery
|
||||
tab.qualityFloor = control.QualityFloorDelivery
|
||||
app.mu.Unlock()
|
||||
},
|
||||
rebuild: func(app *App, _ *WorkspaceTab) error {
|
||||
@@ -293,7 +293,7 @@ func TestGoalDeliveryYoloSurvivesEveryControllerRebuildPath(t *testing.T) {
|
||||
name: "model",
|
||||
prepare: func(app *App, tab *WorkspaceTab) {
|
||||
app.mu.Lock()
|
||||
tab.tokenMode = boot.TokenModeDelivery
|
||||
tab.qualityFloor = control.QualityFloorDelivery
|
||||
app.mu.Unlock()
|
||||
},
|
||||
rebuild: func(app *App, tab *WorkspaceTab) error {
|
||||
@@ -304,7 +304,7 @@ func TestGoalDeliveryYoloSurvivesEveryControllerRebuildPath(t *testing.T) {
|
||||
name: "effort",
|
||||
prepare: func(app *App, tab *WorkspaceTab) {
|
||||
app.mu.Lock()
|
||||
tab.tokenMode = boot.TokenModeDelivery
|
||||
tab.qualityFloor = control.QualityFloorDelivery
|
||||
app.mu.Unlock()
|
||||
},
|
||||
rebuild: func(app *App, tab *WorkspaceTab) error {
|
||||
@@ -341,8 +341,8 @@ func TestGoalDeliveryYoloSurvivesEveryControllerRebuildPath(t *testing.T) {
|
||||
if ctrl.PlanMode() || ctrl.GoalStatus() != control.GoalStatusRunning || ctrl.Goal() != "ship the combined mode" {
|
||||
t.Fatalf("collaboration state plan=%v goal=%q status=%q, want running Goal", ctrl.PlanMode(), ctrl.Goal(), ctrl.GoalStatus())
|
||||
}
|
||||
if ctrl.ToolApprovalMode() != control.ToolApprovalYolo || currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
t.Fatalf("runtime axes approval=%q token=%q, want yolo/full", ctrl.ToolApprovalMode(), currentTabTokenMode(tab))
|
||||
if ctrl.ToolApprovalMode() != control.ToolApprovalYolo || currentTabTokenMode(tab) != boot.TokenModeDelivery {
|
||||
t.Fatalf("runtime axes approval=%q token=%q, want yolo/delivery", ctrl.ToolApprovalMode(), currentTabTokenMode(tab))
|
||||
}
|
||||
|
||||
var persisted struct {
|
||||
@@ -399,7 +399,7 @@ func TestPlanYoloDeliveryOrderConverges(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctrl := app.controllerForTab(tab)
|
||||
if !ctrl.PlanMode() || ctrl.ToolApprovalMode() != control.ToolApprovalYolo || currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
if !ctrl.PlanMode() || ctrl.ToolApprovalMode() != control.ToolApprovalYolo || currentTabTokenMode(tab) != boot.TokenModeDelivery {
|
||||
t.Fatalf("final axes plan=%v approval=%q token=%q", ctrl.PlanMode(), ctrl.ToolApprovalMode(), currentTabTokenMode(tab))
|
||||
}
|
||||
})
|
||||
@@ -445,11 +445,11 @@ func TestEveryCollaborationAndTokenModeCombinationConvergesInBothOrders(t *testi
|
||||
if ctrl == nil {
|
||||
t.Fatal("final controller is nil")
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("token mode = %q, want pinned full", got)
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeDelivery {
|
||||
t.Fatalf("token mode = %q, want delivery", got)
|
||||
}
|
||||
if got := ctrl.AgentPreset(); got != boot.AgentPresetBalanced {
|
||||
t.Fatalf("controller AgentPreset = %q, want balanced", got)
|
||||
if got := ctrl.AgentPreset(); got != boot.AgentPresetDelivery {
|
||||
t.Fatalf("controller AgentPreset = %q, want delivery", got)
|
||||
}
|
||||
switch collaborationMode {
|
||||
case "goal":
|
||||
@@ -504,26 +504,28 @@ func TestGoalAndCollaborationResyncBeforeSendPreserveRunningDeliveryScope(t *tes
|
||||
}
|
||||
|
||||
func TestTokenModeSwitchWaitsForForegroundTurnAdmission(t *testing.T) {
|
||||
// Name kept for history; the deprecated wrapper must not wait on turn admission.
|
||||
// The floor switch waits for turn admission, then applies in place.
|
||||
app, tab, oldCtrl, _ := newGoalDeliveryYoloTestApp(t, control.GoalStatusBlocked)
|
||||
tab.turnStartMu.Lock()
|
||||
defer tab.turnStartMu.Unlock()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- app.SetTokenModeForTab(tab.ID, boot.TokenModeDelivery)
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("SetTokenModeForTab: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("SetTokenModeForTab must block while turn admission is held")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("SetTokenModeForTab blocked on turn admission; deprecated wrapper must not wait")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
tab.turnStartMu.Unlock()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("SetTokenModeForTab after release: %v", err)
|
||||
}
|
||||
if app.controllerForTab(tab) != oldCtrl {
|
||||
t.Fatal("SetTokenModeForTab rebuilt the controller")
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("token mode = %q, want pinned full", got)
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeDelivery {
|
||||
t.Fatalf("token mode = %q, want delivery", got)
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func (s heartbeatStatusStub) RuntimeStatus() control.RuntimeStatus {
|
||||
}
|
||||
|
||||
type heartbeatExecuteTaskCtrlStub struct {
|
||||
control.SessionAPI
|
||||
stubSessionAPI
|
||||
status control.RuntimeStatus
|
||||
submitted []string
|
||||
approvalMode string
|
||||
|
||||
+9
-13
@@ -1333,7 +1333,6 @@ func TestRebindTabToLoadedSessionPersistsAndRestoresSessionProfile(t *testing.T)
|
||||
SessionPath: currentPath,
|
||||
Ctrl: ctrl,
|
||||
Ready: true,
|
||||
tokenMode: boot.TokenModeEconomy,
|
||||
mode: "plan",
|
||||
toolApprovalMode: control.ToolApprovalAuto,
|
||||
sink: &tabEventSink{tabID: "tab", app: app},
|
||||
@@ -1351,9 +1350,9 @@ func TestRebindTabToLoadedSessionPersistsAndRestoresSessionProfile(t *testing.T)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
|
||||
}
|
||||
if currentMeta.TokenMode != boot.TokenModeFull || currentMeta.AgentPreset != boot.AgentPresetBalanced ||
|
||||
if currentMeta.TokenMode != boot.TokenModeFull || currentMeta.AgentPreset != "" ||
|
||||
currentMeta.Mode != "plan" || currentMeta.ToolApprovalMode != control.ToolApprovalAuto {
|
||||
t.Fatalf("current session profile = token:%q preset:%q mode:%q approval:%q, want full/balanced/plan/auto",
|
||||
t.Fatalf("current session profile = token:%q preset:%q mode:%q approval:%q, want full/empty/plan/auto",
|
||||
currentMeta.TokenMode, currentMeta.AgentPreset, currentMeta.Mode, currentMeta.ToolApprovalMode)
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
@@ -1618,7 +1617,6 @@ func newAtomicRebindTestApp(t *testing.T) (*App, *WorkspaceTab, control.SessionA
|
||||
Ctrl: oldCtrl,
|
||||
Ready: true,
|
||||
model: "",
|
||||
tokenMode: boot.TokenModeEconomy,
|
||||
mode: "plan-yolo",
|
||||
toolApprovalMode: control.ToolApprovalYolo,
|
||||
sink: &tabEventSink{tabID: "atomic-rebind", app: app, ctx: app.ctx},
|
||||
@@ -1758,7 +1756,6 @@ func TestCloseTabPersistsSessionProfileBeforeRemovingVisibleTab(t *testing.T) {
|
||||
SessionPath: currentPath,
|
||||
Ctrl: ctrl,
|
||||
Ready: true,
|
||||
tokenMode: boot.TokenModeEconomy,
|
||||
mode: "plan",
|
||||
toolApprovalMode: control.ToolApprovalAuto,
|
||||
sink: &tabEventSink{tabID: "profile", app: app},
|
||||
@@ -1785,9 +1782,9 @@ func TestCloseTabPersistsSessionProfileBeforeRemovingVisibleTab(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetBalanced ||
|
||||
if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != "" ||
|
||||
meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "finish the review" {
|
||||
t.Fatalf("closed session profile = token:%q preset:%q mode:%q approval:%q goal:%q, want full/balanced/plan/auto/goal",
|
||||
t.Fatalf("closed session profile = token:%q preset:%q mode:%q approval:%q goal:%q, want full/empty/plan/auto/goal",
|
||||
meta.TokenMode, meta.AgentPreset, meta.Mode, meta.ToolApprovalMode, meta.Goal)
|
||||
}
|
||||
}
|
||||
@@ -1829,7 +1826,6 @@ func TestKeepOnlyVisibleTabPersistsRemovedSessionProfile(t *testing.T) {
|
||||
SessionPath: removedPath,
|
||||
Ctrl: removedCtrl,
|
||||
Ready: true,
|
||||
tokenMode: boot.TokenModeEconomy,
|
||||
mode: "plan",
|
||||
toolApprovalMode: control.ToolApprovalAuto,
|
||||
sink: &tabEventSink{tabID: "removed", app: app},
|
||||
@@ -1848,9 +1844,9 @@ func TestKeepOnlyVisibleTabPersistsRemovedSessionProfile(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta removed ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetBalanced ||
|
||||
if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != "" ||
|
||||
meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "keep this profile" {
|
||||
t.Fatalf("removed session profile = token:%q preset:%q mode:%q approval:%q goal:%q, want full/balanced/plan/auto/goal",
|
||||
t.Fatalf("removed session profile = token:%q preset:%q mode:%q approval:%q goal:%q, want full/empty/plan/auto/goal",
|
||||
meta.TokenMode, meta.AgentPreset, meta.Mode, meta.ToolApprovalMode, meta.Goal)
|
||||
}
|
||||
}
|
||||
@@ -1863,7 +1859,7 @@ func TestLoadTabSessionProfileIgnoresTerminalGoalState(t *testing.T) {
|
||||
}
|
||||
writeHistoryTestSession(t, sessionPath, "terminal prompt")
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
||||
TokenMode: boot.TokenModeEconomy, Mode: "plan", ToolApprovalMode: control.ToolApprovalAuto, Goal: "stale terminal goal",
|
||||
TokenMode: "economy", Mode: "plan", ToolApprovalMode: control.ToolApprovalAuto, Goal: "stale terminal goal",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1871,13 +1867,13 @@ func TestLoadTabSessionProfileIgnoresTerminalGoalState(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := loadTabSessionProfile(sessionPath)
|
||||
if profile.goal != "" || profile.tokenMode != boot.TokenModeEconomy || profile.mode != "plan" || profile.toolApprovalMode != control.ToolApprovalAuto {
|
||||
if profile.goal != "" || profile.tokenMode != "full" || profile.qualityFloor != control.QualityFloorStandard || profile.mode != "plan" || profile.toolApprovalMode != control.ToolApprovalAuto {
|
||||
t.Fatalf("profile=%+v", profile)
|
||||
}
|
||||
tab := &WorkspaceTab{}
|
||||
applyTabSessionProfile(tab, profile)
|
||||
if currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
t.Fatal("legacy economy must not change runtime tokenMode")
|
||||
t.Fatal("legacy economy must fold to standard")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
@@ -33,7 +32,6 @@ func TestEnsureBlankTabInheritsActiveTabLocalSettings(t *testing.T) {
|
||||
SessionPath: filepath.Join(workspace, "src.jsonl"), // non-empty so src isn't reused as the blank tab
|
||||
model: "inherit/model",
|
||||
effort: &effort,
|
||||
tokenMode: "economy",
|
||||
mode: "plan",
|
||||
toolApprovalMode: control.ToolApprovalYolo,
|
||||
disabledMCP: map[string]ServerView{"srv-x": {}},
|
||||
@@ -59,8 +57,8 @@ func TestEnsureBlankTabInheritsActiveTabLocalSettings(t *testing.T) {
|
||||
if created.effort == nil || *created.effort != "max" {
|
||||
t.Fatalf("effort = %v, want inherited \"max\"", created.effort)
|
||||
}
|
||||
if created.tokenMode != boot.TokenModeFull {
|
||||
t.Fatalf("tokenMode = %q, want pinned default %q (must not inherit economy)", created.tokenMode, boot.TokenModeFull)
|
||||
if created.qualityFloor != "" && created.qualityFloor != control.QualityFloorStandard {
|
||||
t.Fatalf("qualityFloor = %q, want standard default (must not inherit economy)", created.qualityFloor)
|
||||
}
|
||||
if created.toolApprovalMode != control.ToolApprovalAuto {
|
||||
t.Fatalf("toolApprovalMode = %q, want global default auto", created.toolApprovalMode)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
|
||||
// Session-scoped quality floor: SetQualityFloorForTab is the single write
|
||||
// path; derivedQualityFloor is the read model where facts may outrank the
|
||||
// recorded choice, mirroring the fact-driven contract.
|
||||
|
||||
// SetQualityFloor applies the floor to the active tab.
|
||||
func (a *App) SetQualityFloor(floor string) error {
|
||||
return a.SetQualityFloorForTab("", floor)
|
||||
}
|
||||
|
||||
// SetQualityFloorForTab updates the tab's floor and pushes it to the
|
||||
// controller between turns. Failures return error so the Wails Promise
|
||||
// rejects; an unknown value never reaches the controller.
|
||||
func (a *App) SetQualityFloorForTab(tabID, floor string) error {
|
||||
normalized, err := control.NormalizeQualityFloor(floor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tab := a.tabByID(tabID)
|
||||
if tab == nil {
|
||||
return a.workspaceNotReadyErr(nil)
|
||||
}
|
||||
tab.turnStartMu.Lock()
|
||||
defer tab.turnStartMu.Unlock()
|
||||
a.mu.Lock()
|
||||
if a.tabs[tab.ID] != tab {
|
||||
a.mu.Unlock()
|
||||
return a.workspaceNotReadyErr(nil)
|
||||
}
|
||||
tab.qualityFloor = normalized
|
||||
ctrl := tab.Ctrl
|
||||
tabIDForSave := tab.ID
|
||||
a.mu.Unlock()
|
||||
if ctrl != nil {
|
||||
if err := ctrl.SetQualityFloor(normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.tabs[tabIDForSave] == tab {
|
||||
a.saveTabsLocked()
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// derivedFloor is the effective floor plus whether facts — not the user's
|
||||
// choice — put the session at the delivery level. isolated carries the
|
||||
// worktree predicate so callers that need both do the path math once.
|
||||
type derivedFloor struct {
|
||||
floor string
|
||||
inferred bool
|
||||
isolated bool
|
||||
}
|
||||
|
||||
// ctrlQualityFloor reads a controller's floor. An empty answer means the
|
||||
// controller has no opinion yet and the recorded tab value wins.
|
||||
func ctrlQualityFloor(ctrl control.SessionAPI) (string, bool) {
|
||||
if ctrl == nil {
|
||||
return "", false
|
||||
}
|
||||
floor := ctrl.QualityFloor()
|
||||
return floor, floor != ""
|
||||
}
|
||||
|
||||
// derivedQualityFloor resolves the effective floor for display: an explicit
|
||||
// delivery choice wins; otherwise an isolated-worktree tab or an active
|
||||
// delivery-gated session infers delivery. Standard is the default.
|
||||
func derivedQualityFloor(tab *WorkspaceTab) derivedFloor {
|
||||
if tab == nil {
|
||||
return derivedFloor{floor: control.QualityFloorStandard}
|
||||
}
|
||||
isolated := worktree.IsManagedPath(tab.WorkspaceRoot, config.DeliveryWorktreeDir())
|
||||
if strings.TrimSpace(tab.qualityFloor) == control.QualityFloorDelivery {
|
||||
return derivedFloor{floor: control.QualityFloorDelivery, isolated: isolated}
|
||||
}
|
||||
if isolated {
|
||||
return derivedFloor{floor: control.QualityFloorDelivery, inferred: true, isolated: true}
|
||||
}
|
||||
if floor, ok := ctrlQualityFloor(tab.Ctrl); ok && floor == control.QualityFloorDelivery {
|
||||
return derivedFloor{floor: control.QualityFloorDelivery, inferred: true}
|
||||
}
|
||||
return derivedFloor{floor: control.QualityFloorStandard}
|
||||
}
|
||||
|
||||
// tabQualityFloor seeds a new tab's recorded floor. Isolated worktrees are
|
||||
// left empty — derivedQualityFloor infers delivery for them at read time —
|
||||
// so the UI keeps the "(inferred)" distinction until the user chooses.
|
||||
// Other tabs inherit the sibling tab's explicit delivery choice.
|
||||
func tabQualityFloor(workspaceRoot string, siblingExplicit string) string {
|
||||
if worktree.IsManagedPath(workspaceRoot, config.DeliveryWorktreeDir()) {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(siblingExplicit) == control.QualityFloorDelivery {
|
||||
return control.QualityFloorDelivery
|
||||
}
|
||||
return control.QualityFloorStandard
|
||||
}
|
||||
|
||||
// applyTabQualityFloorToController pushes the recorded floor (or the
|
||||
// worktree-inferred one) onto a controller before it takes over a session.
|
||||
func applyTabQualityFloorToController(ctrl control.SessionAPI, floor string) {
|
||||
if ctrl == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(floor) == "" {
|
||||
return
|
||||
}
|
||||
if _, err := control.NormalizeQualityFloor(floor); err != nil {
|
||||
return
|
||||
}
|
||||
_ = ctrl.SetQualityFloor(floor)
|
||||
}
|
||||
|
||||
// currentTabTokenMode returns the dual-write compat label derived from the
|
||||
// tab's quality floor: delivery writes "delivery", standard writes "full".
|
||||
func currentTabTokenMode(tab *WorkspaceTab) string {
|
||||
return tokenModeForFloor(derivedQualityFloor(tab).floor)
|
||||
}
|
||||
|
||||
// currentTabAgentPreset returns the role label derived from the quality floor.
|
||||
func currentTabAgentPreset(tab *WorkspaceTab) string {
|
||||
return agentPresetForFloor(derivedQualityFloor(tab).floor)
|
||||
}
|
||||
|
||||
// tokenModeForFloor and agentPresetForFloor map an already-derived floor onto
|
||||
// the compat labels, so callers holding a derivedFloor skip the path math.
|
||||
func tokenModeForFloor(floor string) string {
|
||||
if floor == control.QualityFloorDelivery {
|
||||
return boot.TokenModeDelivery
|
||||
}
|
||||
return boot.TokenModeFull
|
||||
}
|
||||
|
||||
func agentPresetForFloor(floor string) string {
|
||||
if floor == control.QualityFloorDelivery {
|
||||
return boot.AgentPresetDelivery
|
||||
}
|
||||
return boot.AgentPresetStandard
|
||||
}
|
||||
|
||||
// qualityFloorSafe reads the recorded floor; nil-safe for lookups.
|
||||
func (t *WorkspaceTab) qualityFloorSafe() string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.qualityFloor
|
||||
}
|
||||
|
||||
// firstCtrlFloor prefers the controller's live floor, falling back to the
|
||||
// recorded value when the controller cannot answer.
|
||||
func firstCtrlFloor(ctrl control.SessionAPI, fallback string) string {
|
||||
if floor, ok := ctrlQualityFloor(ctrl); ok {
|
||||
return floor
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -579,7 +579,6 @@ func TestMetaForTabConcurrentWithBuildSwap(t *testing.T) {
|
||||
tab.StartupErr = ""
|
||||
tab.model = fmt.Sprintf("provider/m%d", i)
|
||||
tab.goal = fmt.Sprintf("goal-%d", i)
|
||||
tab.tokenMode = "full"
|
||||
app.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -662,7 +662,7 @@ func TestAuthoritativeBotStylePersistIndexesSessionImmediately(t *testing.T) {
|
||||
}
|
||||
|
||||
type retargetRuntimeController struct {
|
||||
control.SessionAPI
|
||||
stubSessionAPI
|
||||
status control.RuntimeStatus
|
||||
path string
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ func configureControllerRuntime(ctrl, oldCtrl control.SessionAPI, runtime normal
|
||||
ctrl.EnableInteractiveApproval()
|
||||
applyTabModeToController(ctrl, runtime.tabMode())
|
||||
applyTabToolApprovalModeToController(ctrl, runtime.toolApprovalMode)
|
||||
applyTabQualityFloorToController(ctrl, runtime.qualityFloor)
|
||||
if next, ok := ctrl.(*control.Controller); ok {
|
||||
if prev, ok := oldCtrl.(*control.Controller); ok {
|
||||
next.RestoreSessionAuthorizations(prev.SessionAuthorizations())
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import "reasonix/internal/control"
|
||||
|
||||
// stubSessionAPI supplies port defaults for the test controllers that only
|
||||
// override the few methods their scenario exercises. Embedding the bare
|
||||
// control.SessionAPI interface leaves it nil, so every un-overridden call
|
||||
// panics; production readers call the port directly and must not carry a
|
||||
// recover to absorb that.
|
||||
type stubSessionAPI struct {
|
||||
control.SessionAPI
|
||||
}
|
||||
|
||||
func (stubSessionAPI) QualityFloor() string { return control.QualityFloorStandard }
|
||||
func (stubSessionAPI) SetQualityFloor(string) error { return nil }
|
||||
+14
-10
@@ -207,13 +207,13 @@ func TestSaveTabsPersistsNonBalancedTokenModes(t *testing.T) {
|
||||
|
||||
app := NewApp()
|
||||
tab := testTab("a", t.TempDir())
|
||||
tab.tokenMode = "economy"
|
||||
tab.qualityFloor = control.QualityFloorStandard
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
for _, inMemory := range []string{"economy", "delivery", "full"} {
|
||||
tab.tokenMode = inMemory
|
||||
for _, inMemory := range []string{"", "delivery", ""} {
|
||||
tab.qualityFloor = inMemory
|
||||
app.mu.Lock()
|
||||
app.saveTabsLocked()
|
||||
app.mu.Unlock()
|
||||
@@ -222,9 +222,13 @@ func TestSaveTabsPersistsNonBalancedTokenModes(t *testing.T) {
|
||||
if len(got.Tabs) != 1 {
|
||||
t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
|
||||
}
|
||||
if got.Tabs[0].TokenMode != boot.TokenModeFull || got.Tabs[0].AgentPreset != boot.AgentPresetBalanced {
|
||||
t.Fatalf("saved compat after in-memory %q = token:%q preset:%q, want full/balanced",
|
||||
inMemory, got.Tabs[0].TokenMode, got.Tabs[0].AgentPreset)
|
||||
wantToken, wantPreset := boot.TokenModeFull, boot.AgentPresetStandard
|
||||
if inMemory == "delivery" {
|
||||
wantToken, wantPreset = boot.TokenModeDelivery, boot.AgentPresetDelivery
|
||||
}
|
||||
if got.Tabs[0].TokenMode != wantToken || got.Tabs[0].AgentPreset != wantPreset {
|
||||
t.Fatalf("saved compat after in-memory %q = token:%q preset:%q, want %q/%q",
|
||||
inMemory, got.Tabs[0].TokenMode, got.Tabs[0].AgentPreset, wantToken, wantPreset)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +244,7 @@ func TestLoadTabsFileDecodesLegacyTokenModes(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := loadTabsFile()
|
||||
if len(got.Tabs) != 1 || got.Tabs[0].TokenMode != boot.TokenModeEconomy || got.Tabs[0].AgentPreset != boot.AgentPresetLight {
|
||||
if len(got.Tabs) != 1 || got.Tabs[0].TokenMode != "economy" || got.Tabs[0].AgentPreset != "light" {
|
||||
t.Fatalf("legacy tabs decode = %+v", got.Tabs)
|
||||
}
|
||||
}
|
||||
@@ -485,10 +489,10 @@ func TestMetaReportsGoalStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
app.SetCollaborationModeForTab(tab.ID, "plan")
|
||||
tab.tokenMode = boot.TokenModeEconomy
|
||||
tab.qualityFloor = ""
|
||||
meta = app.MetaForTab(tab.ID)
|
||||
if meta.CollaborationMode != "plan" || meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetBalanced {
|
||||
t.Fatalf("profile meta = %+v, want plan + pinned full/balanced", meta)
|
||||
if meta.CollaborationMode != "plan" || meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetStandard {
|
||||
t.Fatalf("profile meta = %+v, want plan + full/standard", meta)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
type tabScopedActionController struct {
|
||||
control.SessionAPI
|
||||
stubSessionAPI
|
||||
history []provider.Message
|
||||
newSessionCalls int
|
||||
clearSessionCalls int
|
||||
|
||||
+80
-79
@@ -25,7 +25,6 @@ import (
|
||||
"reasonix/internal/notify"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
"reasonix/internal/worktree"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -234,7 +233,7 @@ type WorkspaceTab struct {
|
||||
|
||||
model string // active model ref (for meta)
|
||||
effort *string
|
||||
tokenMode string
|
||||
qualityFloor string // standard|delivery; see desktop/quality_floor.go
|
||||
mode string // "normal" | "plan" | "yolo" | "plan-yolo"; yolo/full access is runtime-only
|
||||
goal string
|
||||
toolApprovalMode string
|
||||
@@ -690,7 +689,7 @@ func cloneDetachedRuntimeTab(tab *WorkspaceTab, key, path string) *WorkspaceTab
|
||||
displayState: tab.displayBufferState(),
|
||||
model: tab.model,
|
||||
effort: cloneStringPtr(tab.effort),
|
||||
tokenMode: tab.tokenMode,
|
||||
qualityFloor: tab.qualityFloor,
|
||||
mode: tab.mode,
|
||||
goal: tab.goal,
|
||||
toolApprovalMode: tab.toolApprovalMode,
|
||||
@@ -794,7 +793,7 @@ func applyRuntimeTab(target, source *WorkspaceTab, path string, wailsCtx context
|
||||
target.ActivityStatus = source.ActivityStatus
|
||||
target.model = source.model
|
||||
target.effort = cloneStringPtr(source.effort)
|
||||
target.tokenMode = source.tokenMode
|
||||
target.qualityFloor = source.qualityFloor
|
||||
target.mode = source.mode
|
||||
target.goal = source.goal
|
||||
target.toolApprovalMode = source.toolApprovalMode
|
||||
@@ -2172,6 +2171,8 @@ type TabMeta struct {
|
||||
ToolApprovalMode string `json:"toolApprovalMode"`
|
||||
TokenMode string `json:"tokenMode"`
|
||||
AgentPreset string `json:"agentPreset,omitempty"`
|
||||
QualityFloor string `json:"qualityFloor,omitempty"`
|
||||
FloorInferred bool `json:"floorInferred,omitempty"`
|
||||
Goal string `json:"goal,omitempty"`
|
||||
GoalStatus string `json:"goalStatus,omitempty"`
|
||||
Recovered bool `json:"recovered,omitempty"`
|
||||
@@ -2208,6 +2209,7 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
|
||||
sessionRevision = meta.Revision
|
||||
sessionDigest = meta.ContentDigest
|
||||
}
|
||||
floor := derivedQualityFloor(tab)
|
||||
m := TabMeta{
|
||||
ID: tab.ID,
|
||||
Scope: tab.Scope,
|
||||
@@ -2228,14 +2230,16 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
|
||||
Mode: currentTabMode(tab),
|
||||
CollaborationMode: currentTabCollaborationMode(tab),
|
||||
ToolApprovalMode: currentTabToolApprovalMode(tab),
|
||||
AgentPreset: boot.AgentPresetBalanced, // deprecated compat label
|
||||
TokenMode: boot.TokenModeFull, // deprecated compat label
|
||||
QualityFloor: floor.floor,
|
||||
FloorInferred: floor.inferred,
|
||||
AgentPreset: agentPresetForFloor(floor.floor),
|
||||
TokenMode: tokenModeForFloor(floor.floor),
|
||||
Goal: currentTabGoal(tab),
|
||||
GoalStatus: currentTabGoalStatus(tab),
|
||||
StartupErr: tab.StartupErr,
|
||||
Active: active,
|
||||
Cwd: tab.WorkspaceRoot,
|
||||
IsolatedWorktree: worktree.IsManagedPath(tab.WorkspaceRoot, config.DeliveryWorktreeDir()),
|
||||
IsolatedWorktree: floor.isolated,
|
||||
}
|
||||
switch tab.Scope {
|
||||
case "global":
|
||||
@@ -2641,13 +2645,12 @@ func (a *App) ensureBlankTab(scope, workspaceRoot string) (TabMeta, error) {
|
||||
return enrichTabMeta(meta), nil
|
||||
}
|
||||
|
||||
// New blank sessions start from global session defaults for model and
|
||||
// Ask/Auto/YOLO approval posture. Keep the remaining execution-local settings
|
||||
// from the active tab so a new blank session preserves effort/token/MCP
|
||||
// continuity without letting the active tab override global defaults (#4019).
|
||||
// New blank sessions start from global defaults for model and approval
|
||||
// posture, keeping execution-local settings (effort/floor/MCP) from the
|
||||
// active tab without letting it override global defaults (#4019).
|
||||
inheritedModel := defaultModel
|
||||
var inheritedEffort *string
|
||||
inheritedTokenMode := boot.TokenModeFull // deprecated dual-write compat value
|
||||
inheritedFloor := tabQualityFloor(workspaceRoot, a.activeTabLocked().qualityFloorSafe())
|
||||
inheritedMode := tabModeFromAxes(false, defaultToolApprovalMode == control.ToolApprovalYolo)
|
||||
inheritedToolApprovalMode := defaultToolApprovalMode
|
||||
inheritedDisabledMCP := map[string]ServerView{}
|
||||
@@ -2680,7 +2683,7 @@ func (a *App) ensureBlankTab(scope, workspaceRoot string) (TabMeta, error) {
|
||||
topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
|
||||
model: inheritedModel,
|
||||
effort: inheritedEffort,
|
||||
tokenMode: inheritedTokenMode,
|
||||
qualityFloor: inheritedFloor,
|
||||
mode: inheritedMode,
|
||||
toolApprovalMode: inheritedToolApprovalMode,
|
||||
disabledMCP: inheritedDisabledMCP,
|
||||
@@ -2736,7 +2739,7 @@ func (a *App) ensureBlankTab(scope, workspaceRoot string) (TabMeta, error) {
|
||||
topicTitleSource: topicTitleSourceAuto,
|
||||
model: inheritedModel,
|
||||
effort: inheritedEffort,
|
||||
tokenMode: inheritedTokenMode,
|
||||
qualityFloor: inheritedFloor,
|
||||
mode: inheritedMode,
|
||||
toolApprovalMode: inheritedToolApprovalMode,
|
||||
disabledMCP: inheritedDisabledMCP,
|
||||
@@ -3843,7 +3846,7 @@ func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession
|
||||
tab.Label = model
|
||||
tab.SharedHostKey = rootKey
|
||||
buildEffort := cloneStringPtr(tab.effort)
|
||||
buildTokenMode := boot.NormalizeTokenMode(tab.tokenMode)
|
||||
buildTokenMode := currentTabTokenMode(tab)
|
||||
buildMode := tab.mode
|
||||
buildToolApprovalMode := tab.toolApprovalMode
|
||||
buildGoal := tab.goal
|
||||
@@ -4862,6 +4865,7 @@ type desktopTabEntry struct {
|
||||
Effort *string `json:"effort,omitempty"`
|
||||
TokenMode string `json:"tokenMode,omitempty"`
|
||||
AgentPreset string `json:"agentPreset,omitempty"`
|
||||
QualityFloor string `json:"qualityFloor,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Goal string `json:"goal,omitempty"`
|
||||
ToolApprovalMode string `json:"toolApprovalMode,omitempty"`
|
||||
@@ -4924,8 +4928,9 @@ func (a *App) saveTabsCollectLocked() (string, []desktopTabEntry, string, uint64
|
||||
ReadOnly: tab.ReadOnly,
|
||||
Model: tab.model,
|
||||
Effort: cloneStringPtr(tab.effort),
|
||||
TokenMode: persistedTabTokenMode(currentTabTokenMode(tab)),
|
||||
AgentPreset: boot.AgentPresetBalanced,
|
||||
AgentPreset: currentTabAgentPreset(tab),
|
||||
TokenMode: currentTabTokenMode(tab),
|
||||
QualityFloor: tab.qualityFloor,
|
||||
Mode: persistedTabMode(currentTabMode(tab)),
|
||||
Goal: persistedTabGoal(tab),
|
||||
ToolApprovalMode: persistedToolApprovalMode(currentTabToolApprovalMode(tab)),
|
||||
@@ -6132,6 +6137,7 @@ func (a *App) tabSessionRecoveryMeta(tab *WorkspaceTab) func(control.SessionReco
|
||||
topicTitle := tab.TopicTitle
|
||||
model := strings.TrimSpace(tab.model)
|
||||
tokenMode := boot.TokenModeFull // deprecated dual-write compat value
|
||||
qualityFloor := strings.TrimSpace(tab.qualityFloor)
|
||||
mode := normalizeTabMode(tab.mode)
|
||||
toolApprovalMode := normalizeToolApprovalMode(tab.toolApprovalMode)
|
||||
goal := strings.TrimSpace(tab.goal)
|
||||
@@ -6158,7 +6164,8 @@ func (a *App) tabSessionRecoveryMeta(tab *WorkspaceTab) func(control.SessionReco
|
||||
TopicID: topicID,
|
||||
TopicTitle: topicTitle,
|
||||
Model: model,
|
||||
AgentPreset: boot.AgentPresetBalanced, // deprecated compat label
|
||||
AgentPreset: currentTabAgentPreset(&WorkspaceTab{qualityFloor: qualityFloor}),
|
||||
QualityFloor: qualityFloor,
|
||||
TokenMode: tokenMode,
|
||||
Mode: persistedTabMode(mode),
|
||||
ToolApprovalMode: persistedToolApprovalMode(toolApprovalMode),
|
||||
@@ -7451,14 +7458,6 @@ func currentTabToolApprovalMode(tab *WorkspaceTab) string {
|
||||
return normalizeToolApprovalMode(tab.toolApprovalMode)
|
||||
}
|
||||
|
||||
// currentTabTokenMode returns the deprecated dual-write compat label. Execution
|
||||
// modes no longer exist at runtime; the value is pinned so one-version-old
|
||||
// clients keep parsing tab state.
|
||||
func currentTabTokenMode(tab *WorkspaceTab) string {
|
||||
_ = tab
|
||||
return boot.TokenModeFull
|
||||
}
|
||||
|
||||
// tabRuntimeSnapshot is a consistent under-a.mu copy of the per-tab fields
|
||||
// that bound methods and rebuild paths need after releasing the lock. The
|
||||
// build/rebuild goroutines write these fields under a.mu, so lock-free reads
|
||||
@@ -7466,34 +7465,30 @@ func currentTabTokenMode(tab *WorkspaceTab) string {
|
||||
// fixed for #5955). Controller methods are invoked on the snapshot's ctrl
|
||||
// AFTER unlocking, never while holding a.mu.
|
||||
type tabRuntimeSnapshot struct {
|
||||
ctrl control.SessionAPI
|
||||
sink *tabEventSink
|
||||
label string
|
||||
ready bool
|
||||
readOnly bool
|
||||
startupErr string
|
||||
scope string
|
||||
workspaceRoot string
|
||||
sessionPath string
|
||||
topicID string
|
||||
topicTitle string
|
||||
sharedHostKey string
|
||||
model string
|
||||
effort *string
|
||||
tokenMode string
|
||||
mode string
|
||||
goal string
|
||||
toolApprovalMode string
|
||||
ctrl control.SessionAPI
|
||||
sink *tabEventSink
|
||||
label string
|
||||
ready bool
|
||||
readOnly bool
|
||||
startupErr string
|
||||
scope string
|
||||
workspaceRoot string
|
||||
sessionPath string
|
||||
topicID string
|
||||
topicTitle string
|
||||
sharedHostKey string
|
||||
model string
|
||||
effort *string
|
||||
tokenMode, qualityFloor, mode string
|
||||
goal, toolApprovalMode string
|
||||
}
|
||||
|
||||
// normalizedTabRuntime is the internal, orthogonal runtime profile restored
|
||||
// across controller rebuilds. Goal sidecars remain authoritative; legacyGoal is
|
||||
// only a fallback for a running legacy Goal with no sidecar.
|
||||
type normalizedTabRuntime struct {
|
||||
collaborationMode string
|
||||
toolApprovalMode string
|
||||
tokenMode string
|
||||
legacyGoal string
|
||||
collaborationMode, toolApprovalMode, tokenMode string
|
||||
qualityFloor, legacyGoal string
|
||||
}
|
||||
|
||||
// snapshotTabRuntimeLocked copies the racy per-tab fields. Callers must hold
|
||||
@@ -7517,7 +7512,8 @@ func snapshotTabRuntimeLocked(tab *WorkspaceTab) tabRuntimeSnapshot {
|
||||
sharedHostKey: tab.SharedHostKey,
|
||||
model: tab.model,
|
||||
effort: cloneStringPtr(tab.effort),
|
||||
tokenMode: tab.tokenMode,
|
||||
tokenMode: currentTabTokenMode(tab),
|
||||
qualityFloor: tab.qualityFloor,
|
||||
mode: tab.mode,
|
||||
goal: tab.goal,
|
||||
toolApprovalMode: tab.toolApprovalMode,
|
||||
@@ -7595,10 +7591,15 @@ func (s tabRuntimeSnapshot) normalizedRuntime() normalizedTabRuntime {
|
||||
goalStatus = s.ctrl.GoalStatus()
|
||||
}
|
||||
|
||||
qualityFloor := s.qualityFloor
|
||||
if qualityFloor == "" {
|
||||
qualityFloor = tabQualityFloor(s.workspaceRoot, "")
|
||||
}
|
||||
runtime := normalizedTabRuntime{
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: approvalMode,
|
||||
tokenMode: boot.NormalizeTokenMode(s.tokenMode),
|
||||
qualityFloor: qualityFloor,
|
||||
}
|
||||
switch {
|
||||
case plan:
|
||||
@@ -7620,7 +7621,7 @@ func applyNormalizedRuntimeToTabLocked(tab *WorkspaceTab, runtime normalizedTabR
|
||||
}
|
||||
tab.mode = runtime.tabMode()
|
||||
tab.toolApprovalMode = normalizeToolApprovalMode(runtime.toolApprovalMode)
|
||||
tab.tokenMode = boot.NormalizeTokenMode(runtime.tokenMode)
|
||||
tab.qualityFloor = runtime.qualityFloor
|
||||
if runtime.collaborationMode == "goal" {
|
||||
tab.goal = strings.TrimSpace(runtime.legacyGoal)
|
||||
} else {
|
||||
@@ -7628,13 +7629,6 @@ func applyNormalizedRuntimeToTabLocked(tab *WorkspaceTab, runtime normalizedTabR
|
||||
}
|
||||
}
|
||||
|
||||
// persistedTabTokenMode pins the deprecated tokenMode compat value written
|
||||
// into tab state and session metas. It is always the safe default.
|
||||
func persistedTabTokenMode(mode string) string {
|
||||
_ = mode
|
||||
return boot.TokenModeFull
|
||||
}
|
||||
|
||||
func normalizeToolApprovalMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case control.ToolApprovalAuto:
|
||||
@@ -7827,7 +7821,7 @@ func (a *App) saveTabSessionMeta(tab *WorkspaceTab, path string) error {
|
||||
workspaceRoot: tab.WorkspaceRoot,
|
||||
topicID: tab.TopicID,
|
||||
topicTitle: tab.TopicTitle,
|
||||
tokenMode: boot.NormalizeTokenMode(tab.tokenMode),
|
||||
tokenMode: currentTabTokenMode(tab),
|
||||
mode: normalizeTabMode(tab.mode),
|
||||
toolApprovalMode: normalizeToolApprovalMode(tab.toolApprovalMode),
|
||||
goal: strings.TrimSpace(tab.goal),
|
||||
@@ -7846,15 +7840,11 @@ func (a *App) saveTabSessionMeta(tab *WorkspaceTab, path string) error {
|
||||
}
|
||||
|
||||
type tabSessionMetaSnapshot struct {
|
||||
path string
|
||||
scope string
|
||||
workspaceRoot string
|
||||
topicID string
|
||||
topicTitle string
|
||||
tokenMode string
|
||||
mode string
|
||||
toolApprovalMode string
|
||||
goal string
|
||||
path, scope, workspaceRoot string
|
||||
topicID, topicTitle string
|
||||
tokenMode, qualityFloor string
|
||||
mode, toolApprovalMode string
|
||||
goal string
|
||||
}
|
||||
|
||||
func (a *App) saveTabSessionMetaForCurrentSession(tab *WorkspaceTab) error {
|
||||
@@ -7881,7 +7871,8 @@ func (a *App) tabSessionMetaSnapshotForCurrentSession(tab *WorkspaceTab) (tabSes
|
||||
workspaceRoot := tab.WorkspaceRoot
|
||||
topicID := tab.TopicID
|
||||
topicTitle := tab.TopicTitle
|
||||
tokenMode := boot.NormalizeTokenMode(tab.tokenMode)
|
||||
tokenMode := currentTabTokenMode(tab)
|
||||
qualityFloor := strings.TrimSpace(tab.qualityFloor)
|
||||
mode := normalizeTabMode(tab.mode)
|
||||
toolApprovalMode := normalizeToolApprovalMode(tab.toolApprovalMode)
|
||||
goal := strings.TrimSpace(tab.goal)
|
||||
@@ -7902,6 +7893,7 @@ func (a *App) tabSessionMetaSnapshotForCurrentSession(tab *WorkspaceTab) (tabSes
|
||||
activeWork = status.Running || status.PendingPrompt || status.BackgroundJobs > 0
|
||||
mode = tabModeFromAxes(ctrl.PlanMode(), ctrl.AutoApproveTools())
|
||||
toolApprovalMode = normalizeToolApprovalMode(ctrl.ToolApprovalMode())
|
||||
qualityFloor = firstCtrlFloor(ctrl, qualityFloor)
|
||||
if ctrl.GoalStatus() == control.GoalStatusRunning {
|
||||
goal = strings.TrimSpace(ctrl.Goal())
|
||||
} else {
|
||||
@@ -7943,6 +7935,7 @@ func (a *App) tabSessionMetaSnapshotForCurrentSession(tab *WorkspaceTab) (tabSes
|
||||
topicID: topicID,
|
||||
topicTitle: topicTitle,
|
||||
tokenMode: tokenMode,
|
||||
qualityFloor: qualityFloor,
|
||||
mode: mode,
|
||||
toolApprovalMode: toolApprovalMode,
|
||||
goal: goal,
|
||||
@@ -7983,8 +7976,11 @@ func saveTabSessionMetaSnapshot(snap tabSessionMetaSnapshot) error {
|
||||
m.WorkspaceRoot = workspaceRoot
|
||||
m.TopicID = snap.topicID
|
||||
m.TopicTitle = snap.topicTitle
|
||||
m.TokenMode = persistedTabTokenMode(snap.tokenMode)
|
||||
m.AgentPreset = boot.AgentPresetBalanced
|
||||
delivery := strings.TrimSpace(snap.qualityFloor) == control.QualityFloorDelivery
|
||||
m.QualityFloor, m.TokenMode, m.AgentPreset = "", boot.TokenModeFull, ""
|
||||
if delivery {
|
||||
m.QualityFloor, m.TokenMode, m.AgentPreset = control.QualityFloorDelivery, boot.TokenModeDelivery, boot.AgentPresetDelivery
|
||||
}
|
||||
m.Mode = persistedTabMode(snap.mode)
|
||||
m.ToolApprovalMode = persistedToolApprovalMode(snap.toolApprovalMode)
|
||||
m.Goal = strings.TrimSpace(snap.goal)
|
||||
@@ -8013,10 +8009,8 @@ func tabSessionMetaPathForSession(runtimeDir, sessionDir, sessionPath string) st
|
||||
}
|
||||
|
||||
type tabSessionProfile struct {
|
||||
tokenMode string
|
||||
mode string
|
||||
toolApprovalMode string
|
||||
goal string
|
||||
tokenMode, qualityFloor, mode string
|
||||
toolApprovalMode, goal string
|
||||
}
|
||||
|
||||
func defaultTabSessionProfile() tabSessionProfile {
|
||||
@@ -8029,11 +8023,18 @@ func defaultTabSessionProfile() tabSessionProfile {
|
||||
|
||||
func tabSessionProfileFromMeta(sessionPath string, meta agent.BranchMeta) tabSessionProfile {
|
||||
profile := defaultTabSessionProfile()
|
||||
// Prefer agent_preset; fall back to legacy token_mode for one version.
|
||||
if strings.TrimSpace(meta.AgentPreset) != "" {
|
||||
// Prefer agent_preset/quality_floor; fall back to legacy token_mode.
|
||||
profile.tokenMode = boot.NormalizeTokenMode(meta.TokenMode)
|
||||
if meta.AgentPreset != "" {
|
||||
profile.tokenMode = boot.TokenModeFromAgentPreset(meta.AgentPreset)
|
||||
} else {
|
||||
profile.tokenMode = boot.NormalizeTokenMode(meta.TokenMode)
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(meta.QualityFloor) != "":
|
||||
profile.qualityFloor = meta.QualityFloor
|
||||
case boot.NormalizeTokenMode(meta.TokenMode) == boot.TokenModeDelivery || meta.AgentPreset == boot.AgentPresetDelivery:
|
||||
profile.qualityFloor = control.QualityFloorDelivery
|
||||
default:
|
||||
profile.qualityFloor = control.QualityFloorStandard
|
||||
}
|
||||
profile.mode = normalizeTabMode(meta.Mode)
|
||||
profile.toolApprovalMode = normalizeToolApprovalMode(meta.ToolApprovalMode)
|
||||
@@ -8056,7 +8057,7 @@ func applyTabSessionProfile(tab *WorkspaceTab, profile tabSessionProfile) {
|
||||
if tab == nil {
|
||||
return
|
||||
}
|
||||
tab.tokenMode = boot.NormalizeTokenMode(profile.tokenMode)
|
||||
tab.qualityFloor = profile.qualityFloor
|
||||
tab.mode = normalizeTabMode(profile.mode)
|
||||
tab.toolApprovalMode = normalizeToolApprovalMode(profile.toolApprovalMode)
|
||||
if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(tab.mode) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
)
|
||||
|
||||
type runtimeStatusSessionController struct {
|
||||
control.SessionAPI
|
||||
stubSessionAPI
|
||||
status control.RuntimeStatus
|
||||
}
|
||||
|
||||
|
||||
@@ -308,7 +308,7 @@ func TestStartTopicActivationSyncBuildAndReuseFastPath(t *testing.T) {
|
||||
// activationStubController is the minimal SessionAPI surface exercised by
|
||||
// prune/detach/attach flows around a tab with active runtime work.
|
||||
type activationStubController struct {
|
||||
control.SessionAPI
|
||||
stubSessionAPI
|
||||
sessionPath string
|
||||
closed atomic.Bool
|
||||
status *control.RuntimeStatus
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ reasonix acp --model deepseek-pro
|
||||
```
|
||||
|
||||
客户端未覆盖模型时,`--model` 用于选择启动模型。普通请求一律进入 executor,
|
||||
没有自动简单 / 轻量 / 完整任务模式;验证义务由宿主根据真实工具动作建立。
|
||||
没有自动任务模式;唯一的会话角色是质量底线(standard/delivery),验证义务由宿主根据真实工具动作建立。
|
||||
|
||||
标准输出专用于 ACP 消息,Reasonix 会把诊断写入标准错误,因此 host 不应合并这两个
|
||||
流。尚未配置 provider 时先运行 `reasonix setup`;initialize 响应也会声明一个启动
|
||||
|
||||
+1
-1
@@ -402,7 +402,7 @@ SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键
|
||||
|
||||
切换模型或 effort 会重建运行时,同时保留当前对话、会话级权限覆盖、附加目录
|
||||
访问权限和 session ownership。`/reload` 使用同一套失败原子重建语义。
|
||||
普通请求一律进入 executor,没有自动简单 / 轻量 / 完整任务模式。
|
||||
普通请求一律进入 executor,没有自动任务模式。唯一的会话角色是质量底线:standard(默认)或 delivery;事实仍可能高于它。
|
||||
独立 Planner 只响应显式 Plan、批准边界和 Goal 启动。
|
||||
|
||||
用量统计使用独立的可丢弃 rollup 投影:
|
||||
|
||||
@@ -5,7 +5,7 @@ The desktop composer menu has two independent collaboration axes:
|
||||
- **Plan mode**: draft a plan, then implement after approval.
|
||||
- **Goal mode**: pursue one objective until it is complete, blocked, or stopped.
|
||||
|
||||
There is no automatic simple / light / full task mode. Ordinary requests always
|
||||
There is no automatic task mode. The one session role is the quality floor: standard (default) or delivery; facts can still raise it. Ordinary requests always
|
||||
enter the executor. The dedicated planner runs only for an explicit Plan, an
|
||||
approval boundary, or Goal start. Todos and sub-agents are model-chosen. The
|
||||
host builds verification obligations from real tool actions.
|
||||
|
||||
@@ -5,7 +5,7 @@ Reasonix 桌面端输入框左下角的菜单包含两条互相独立的协作
|
||||
- **计划模式**:要求模型先产出计划,确认后再切换到实施。
|
||||
- **目标模式**:给 Reasonix 一个目标,让它持续推进直到完成、阻塞或停止。
|
||||
|
||||
计划模式和目标模式属于“协作方式”轴,通常二选一。没有自动简单/轻量/完整任务模式。
|
||||
计划模式和目标模式属于“协作方式”轴,通常二选一。没有自动任务模式。唯一的会话角色是质量底线:standard(默认)或 delivery;事实仍可能高于它。
|
||||
普通请求一律进入 executor;Planner 只响应显式 Plan、批准边界和 Goal 启动。
|
||||
验证义务由宿主根据真实工具动作建立。
|
||||
|
||||
@@ -73,7 +73,7 @@ Goal 模式会把这些部分当作任务边界;除非下一步涉及不可逆
|
||||
|
||||
## 事实驱动执行
|
||||
|
||||
Reasonix 没有自动简单/轻量/完整模式。普通请求进入 executor;显式 Plan、批准边界和
|
||||
没有自动任务模式。唯一的会话角色是质量底线:standard(默认)或 delivery;事实仍可能高于它。普通请求进入 executor;显式 Plan、批准边界和
|
||||
Goal 启动才会调用独立 Planner。todo 与 subagent 由模型按需使用。宿主依据真实动作
|
||||
建立验证义务:只读无义务,文档修改为 Advisory,单文件生产修改为 Recoverable,
|
||||
Schema/认证/破坏性操作为 Strict。Plan、Goal、permission、sandbox 互相独立。
|
||||
|
||||
+2
-2
@@ -1238,7 +1238,7 @@ Ordinary requests always stay with the executor. The dedicated planner runs
|
||||
only for an explicit `plan first` / `先规划` request, an explicit wait-for-
|
||||
approval boundary, an explicit `plan only` / `不要执行` request, or Goal
|
||||
start. Wording such as "complex refactor" or "fix login" does not start the
|
||||
planner. There is no automatic light / full planning depth. Explicit Plan Mode
|
||||
planner. There is no automatic planning depth. Explicit Plan Mode
|
||||
remains a separate host workflow on the executor and is never planned twice.
|
||||
`just do it` / `直接改` also stays with the executor. Execution boundaries are
|
||||
recognized across the request, not only at its beginning, while quoted
|
||||
@@ -1400,7 +1400,7 @@ non-destructive MCP, while a strict child requires an explicit reader hint and
|
||||
never exposes writers at all.
|
||||
|
||||
Reasonix uses **fact-driven execution**. Ordinary requests always enter the
|
||||
executor. There is no automatic simple / light / full task mode. Planner,
|
||||
executor. There is no automatic task mode. The one session role is the quality floor: standard (default) or delivery; facts can still raise it. Planner,
|
||||
Goal, permission, sandbox, and the task contract are independent states.
|
||||
|
||||
Every task shares the same provider-visible core tool surface: direct
|
||||
|
||||
+3
-3
@@ -1072,8 +1072,8 @@ enable、授权与完整连接身份,因此共享 Host 中另一个项目/tab
|
||||
server 无法在这里提升权限。严格只读边界比独立 Planner 更窄:Planner 接受已授权的 opaque
|
||||
非 destructive MCP,而严格只读子会话必须有明确 reader hint,且根本不暴露 writer。
|
||||
|
||||
Reasonix 使用**事实驱动执行**。普通请求一律进入 executor,没有自动的简单 /
|
||||
轻量 / 完整任务模式。Plan、Goal、permission、sandbox 与任务合同是互相独立的状态。
|
||||
Reasonix 使用**事实驱动执行**。普通请求一律进入 executor,没有自动任务模式;
|
||||
唯一的会话角色是质量底线(standard/delivery),事实仍可能高于它。Plan、Goal、permission、sandbox 与任务合同是互相独立的状态。
|
||||
|
||||
所有任务共享同一套 provider 可见核心工具面(直接读/bash/编辑/写入、后台 shell
|
||||
生命周期工具,以及稳定的 `use_capability` 代理)。可选工具(搜索、MCP、skills、
|
||||
@@ -1103,7 +1103,7 @@ reasoning-language 写项目级覆盖时,才给 shell 命令加 `--local`。
|
||||
|
||||
桌面端“协作方式”菜单里的计划模式与目标模式的使用方法与注意事项,
|
||||
见 [`COLLABORATION_MODES.zh-CN.md`](./COLLABORATION_MODES.zh-CN.md)。没有自动
|
||||
简单 / 轻量 / 完整任务模式;验证义务由宿主根据真实工具动作建立。
|
||||
任务模式;唯一的会话角色是质量底线(standard/delivery),验证义务由宿主根据真实工具动作建立。
|
||||
|
||||
桌面端“工具权限”里的询问、自动和 Yolo 模式的区别与使用场景,
|
||||
见 [`TOOL_APPROVAL_MODES.zh-CN.md`](./TOOL_APPROVAL_MODES.zh-CN.md)。
|
||||
|
||||
@@ -4,7 +4,7 @@ The Ask / Auto / Yolo control under the desktop composer sets how Reasonix handl
|
||||
|
||||
Tool permission is independent of collaboration mode:
|
||||
|
||||
- **Collaboration mode** (Normal / Plan / Goal) decides how Reasonix advances the task. There is no automatic simple / light / full task mode. Verification obligations come from real tool actions.
|
||||
- **Collaboration mode** (Normal / Plan / Goal) decides how Reasonix advances the task. There is no automatic task mode. The one session role is the quality floor: standard (default) or delivery; facts can still raise it. Verification obligations come from real tool actions.
|
||||
- **Tool permission** decides whether controlled tools wait for approval before running.
|
||||
|
||||
## Quick comparison
|
||||
|
||||
@@ -4,7 +4,7 @@ Reasonix 桌面端输入框下方的“询问 / 自动 / Yolo”控制的是工
|
||||
|
||||
工具权限和“协作方式”是两条独立轴:
|
||||
|
||||
- **协作方式**(普通 / 计划 / 目标)决定 Reasonix 怎么推进任务。没有自动简单 / 轻量 / 完整任务模式;验证义务由宿主根据真实工具动作建立。
|
||||
- **协作方式**(普通 / 计划 / 目标)决定 Reasonix 怎么推进任务。没有自动任务模式;唯一的会话角色是质量底线(standard/delivery),验证义务由宿主根据真实工具动作建立。
|
||||
- **工具权限**决定 Reasonix 调用受控工具时,是否需要你审批。
|
||||
|
||||
## 快速对比
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package acp
|
||||
|
||||
import "reasonix/internal/control"
|
||||
|
||||
// currentQualityFloor reads the floor from the live controller; sessions
|
||||
// without a controller report the standard default.
|
||||
func (s *acpSession) currentQualityFloor() string {
|
||||
ctrl := s.currentCtrl()
|
||||
if ctrl == nil {
|
||||
return control.QualityFloorStandard
|
||||
}
|
||||
return ctrl.QualityFloor()
|
||||
}
|
||||
|
||||
// withQualityFloorConfig publishes the session quality floor as a select
|
||||
// option. Light vocabulary is absent: standard is where it folds.
|
||||
func withQualityFloorConfig(state SessionConfigState, floor string) SessionConfigState {
|
||||
if floor != control.QualityFloorDelivery {
|
||||
floor = control.QualityFloorStandard
|
||||
}
|
||||
option := SessionConfigOption{
|
||||
ID: "quality_floor",
|
||||
Name: "Quality Floor",
|
||||
Category: "work_mode",
|
||||
Type: "select",
|
||||
CurrentValue: floor,
|
||||
Options: []SessionConfigSelectOption{
|
||||
{Value: control.QualityFloorStandard, Name: "Standard", Description: "Adaptive verification and review follow task risk"},
|
||||
{Value: control.QualityFloorDelivery, Name: "Delivery", Description: "Session-sticky delivery gates: full verification, review receipts, project checks"},
|
||||
},
|
||||
}
|
||||
for i := range state.ConfigOptions {
|
||||
if normalizeConfigID(state.ConfigOptions[i].ID) == option.ID {
|
||||
state.ConfigOptions[i] = option
|
||||
return state
|
||||
}
|
||||
}
|
||||
state.ConfigOptions = append(state.ConfigOptions, option)
|
||||
return state
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/agent/testutil"
|
||||
"reasonix/internal/agentpreset"
|
||||
"reasonix/internal/command"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
@@ -262,8 +261,8 @@ func requireDeprecatedConfigNoop(t *testing.T, client *rpcClient, factory *confi
|
||||
t.Fatalf("set_config_option %s=%s: %+v", configID, value, resp.Error)
|
||||
}
|
||||
var set SetSessionConfigOptionResult
|
||||
if err := json.Unmarshal(resp.Result, &set); err != nil || set.DeprecatedNotice != agentpreset.DeprecatedNotice {
|
||||
t.Fatalf("set_config_option %s result err=%v notice=%q", configID, err, set.DeprecatedNotice)
|
||||
if err := json.Unmarshal(resp.Result, &set); err != nil {
|
||||
t.Fatalf("set_config_option %s result err=%v", configID, err)
|
||||
}
|
||||
if got := factory.buildCount(); got != buildsBefore {
|
||||
t.Fatalf("set_config_option %s rebuilt controller: builds=%d, want %d", configID, got, buildsBefore)
|
||||
@@ -1039,8 +1038,8 @@ func TestServeSessionAxesStayIndependent(t *testing.T) {
|
||||
t.Fatalf("goal prompt: %+v", promptResp.Error)
|
||||
}
|
||||
goalObserved := <-seen
|
||||
if goalObserved.preset != "balanced" || goalObserved.approval != control.ToolApprovalAuto || goalObserved.plan || goalObserved.goal != "ship the ACP profile switch" {
|
||||
t.Fatalf("goal axes = %+v, want balanced + auto + goal", goalObserved)
|
||||
if goalObserved.preset != "standard" || goalObserved.approval != control.ToolApprovalAuto || goalObserved.plan || goalObserved.goal != "ship the ACP profile switch" {
|
||||
t.Fatalf("goal axes = %+v, want standard + auto + goal", goalObserved)
|
||||
}
|
||||
|
||||
setPlan := client.call(t, "session/set_mode", SessionSetModeParams{SessionID: nr.SessionID, ModeID: sessionModePlan})
|
||||
@@ -1056,8 +1055,8 @@ func TestServeSessionAxesStayIndependent(t *testing.T) {
|
||||
t.Fatalf("plan prompt: %+v", promptResp.Error)
|
||||
}
|
||||
planObserved := <-seen
|
||||
if planObserved.preset != "balanced" || planObserved.approval != control.ToolApprovalAuto || !planObserved.plan || planObserved.goal != "" {
|
||||
t.Fatalf("plan axes = %+v, want balanced + auto + plan", planObserved)
|
||||
if planObserved.preset != "standard" || planObserved.approval != control.ToolApprovalAuto || !planObserved.plan || planObserved.goal != "" {
|
||||
t.Fatalf("plan axes = %+v, want standard + auto + plan", planObserved)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-10
@@ -1499,20 +1499,26 @@ func (s *service) sessionSetConfigOption(ctx context.Context, raw json.RawMessag
|
||||
if sess == nil {
|
||||
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: unknown session " + p.SessionID}
|
||||
}
|
||||
// Deprecated execution-mode options are no longer published, but
|
||||
// one-version-old clients still send them. Accept, switch nothing, rebuild
|
||||
// nothing, and answer success with a deprecation notice.
|
||||
if id := normalizeConfigID(p.ConfigID); id == "work_mode" || id == "agent_preset" {
|
||||
// The execution-mode options are now the session quality floor: light
|
||||
// folds to standard silently, delivery sets the delivery floor.
|
||||
if id := normalizeConfigID(p.ConfigID); id == "work_mode" || id == "agent_preset" || id == "quality_floor" {
|
||||
if err := validateDeprecatedModeValue(p.Value); err != nil {
|
||||
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: " + err.Error()}
|
||||
}
|
||||
ctrl := sess.currentCtrl()
|
||||
if ctrl != nil {
|
||||
if p, err := agentpreset.Normalize(p.Value); err == nil {
|
||||
if err := ctrl.SetQualityFloor(string(p)); err != nil {
|
||||
return nil, &RPCError{Code: ErrInternal, Message: "session/set_config_option: " + err.Error()}
|
||||
}
|
||||
}
|
||||
}
|
||||
cfgState, err := s.configStateForSession(ctx, sess)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: ErrInternal, Message: "session/set_config_option: " + err.Error()}
|
||||
}
|
||||
return SetSessionConfigOptionResult{
|
||||
ConfigOptions: cfgState.ConfigOptions,
|
||||
DeprecatedNotice: agentpreset.DeprecatedNotice,
|
||||
ConfigOptions: cfgState.ConfigOptions,
|
||||
}, nil
|
||||
}
|
||||
cfgState, err := s.configStateForSession(ctx, sess)
|
||||
@@ -2264,7 +2270,8 @@ func (s *service) configStateForSession(ctx context.Context, sess *acpSession) (
|
||||
// Fold in the live controller's extension catalog so plugin/... models
|
||||
// are discoverable on every config-state read, not only when current.
|
||||
state = enrichStateWithExtensionModels(state, sess.currentCtrl().ProviderCatalog())
|
||||
return withToolApprovalConfig(state, sess.currentToolApprovalMode()), nil
|
||||
state = withToolApprovalConfig(state, sess.currentToolApprovalMode())
|
||||
return withQualityFloorConfig(state, sess.currentQualityFloor()), nil
|
||||
}
|
||||
|
||||
func (s *acpSession) configStateParams() SessionConfigStateParams {
|
||||
@@ -2345,11 +2352,11 @@ func findConfigOption(options []SessionConfigOption, id string) (SessionConfigOp
|
||||
// well-formed values succeed as no-ops.
|
||||
func validateDeprecatedModeValue(value string) error {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "light", "economy", "eco", "lite",
|
||||
"balanced", "full", "delivery", "deliver", "quality":
|
||||
case "", "light", "economy", "eco", "lite", "save", "saving", "low", "minimal",
|
||||
"standard", "normal", "balanced", "full", "delivery", "deliver", "quality":
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid value %q for deprecated execution setting (accepted: light, balanced, delivery; legacy: economy, full)", value)
|
||||
return fmt.Errorf("invalid value %q for quality floor (accepted: standard, delivery; legacy light folds to standard)", value)
|
||||
}
|
||||
|
||||
func normalizeConfigID(id string) string {
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/agentpreset"
|
||||
"reasonix/internal/billing"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
@@ -320,7 +320,7 @@ func TestRestoreStatusMarksInterruptedTurnPaused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusWorkModeSetConfigOptionIsDeprecatedNoop(t *testing.T) {
|
||||
func TestStatusWorkModeSetConfigOptionSwitchesQualityFloor(t *testing.T) {
|
||||
factory := &runtimeTrackingFactory{configurableFactory: &configurableFactory{}}
|
||||
client, stop := startServer(t, factory)
|
||||
defer stop()
|
||||
@@ -344,8 +344,18 @@ func TestStatusWorkModeSetConfigOptionIsDeprecatedNoop(t *testing.T) {
|
||||
if err := json.Unmarshal(resp.Result, &set); err != nil {
|
||||
t.Fatalf("set work mode %q result: %v", value, err)
|
||||
}
|
||||
if set.DeprecatedNotice != agentpreset.DeprecatedNotice {
|
||||
t.Fatalf("set work mode %q deprecatedNotice = %q", value, set.DeprecatedNotice)
|
||||
want := control.QualityFloorStandard
|
||||
if value == "delivery" {
|
||||
want = control.QualityFloorDelivery
|
||||
}
|
||||
var floorOpt *SessionConfigOption
|
||||
for i := range set.ConfigOptions {
|
||||
if set.ConfigOptions[i].ID == "quality_floor" {
|
||||
floorOpt = &set.ConfigOptions[i]
|
||||
}
|
||||
}
|
||||
if floorOpt == nil || floorOpt.CurrentValue != want {
|
||||
t.Fatalf("quality floor option after work_mode %q = %+v, want %q", value, floorOpt, want)
|
||||
}
|
||||
status := getStatus(t, client, sessionID)
|
||||
if status.WorkMode != "balanced" || status.PlannerMode != "on" {
|
||||
|
||||
@@ -1178,6 +1178,9 @@ func (a *Agent) closedLoopActive() bool {
|
||||
if a.planContractSnapshot() != nil {
|
||||
return true
|
||||
}
|
||||
if a.turn.constraints.PolicyFloor == taskcontract.PolicyFloorDelivery {
|
||||
return true
|
||||
}
|
||||
if a.turn.engine == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -37,10 +37,13 @@ type BranchMeta struct {
|
||||
TopicTitle string `json:"topic_title,omitempty"`
|
||||
CustomTitle string `json:"custom_title,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
// TokenMode is a deprecated dual-write field; new writes pin it to "full".
|
||||
TokenMode string `json:"token_mode,omitempty"`
|
||||
// AgentPreset is a deprecated dual-write field; new writes pin it to "balanced".
|
||||
AgentPreset string `json:"agent_preset,omitempty"`
|
||||
// TokenMode and AgentPreset are deprecated dual-write fields derived from
|
||||
// QualityFloor; delivery writes "delivery", standard writes "full"/"".
|
||||
TokenMode string `json:"token_mode,omitempty"`
|
||||
AgentPreset string `json:"agent_preset,omitempty"`
|
||||
// QualityFloor is the session delivery floor (standard|delivery). Loading
|
||||
// a meta without it maps legacy AgentPreset/TokenMode "delivery" here.
|
||||
QualityFloor string `json:"quality_floor,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
ToolApprovalMode string `json:"tool_approval_mode,omitempty"`
|
||||
Goal string `json:"goal,omitempty"`
|
||||
|
||||
@@ -7,13 +7,18 @@ import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/evidence"
|
||||
"reasonix/internal/runtimepolicy"
|
||||
"reasonix/internal/taskcontract"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// A closed-loop turn is a delivery-floor turn: the floor alone arms the
|
||||
// readiness pause, so the scope on its own no longer produces one.
|
||||
func withClosedLoopContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx = runtimepolicy.WithContext(ctx, runtimepolicy.Constraints{PolicyFloor: taskcontract.PolicyFloorDelivery})
|
||||
return WithDeliveryExecutionScope(ctx, DeliveryExecutionScope{ID: "test-closed-loop", TaskText: "closed-loop test"})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/evidence"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/runtimepolicy"
|
||||
"reasonix/internal/taskcontract"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -41,8 +43,11 @@ func TestDeliveryExecutionScopeDoesNotChangeProviderRequestBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery-scoped goal turns run under the delivery floor: the floor is what
|
||||
// arms the readiness pause these tests assert on.
|
||||
func deliveryGoalContext(id, task string) context.Context {
|
||||
return WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{ID: id, TaskText: task})
|
||||
ctx := runtimepolicy.WithContext(context.Background(), runtimepolicy.Constraints{PolicyFloor: taskcontract.PolicyFloorDelivery})
|
||||
return WithDeliveryExecutionScope(ctx, DeliveryExecutionScope{ID: id, TaskText: task})
|
||||
}
|
||||
|
||||
func TestDeliveryGoalFinalAnswerAlwaysGatesMutationExpectation(t *testing.T) {
|
||||
|
||||
@@ -27,6 +27,9 @@ func mergeInheritedConstraints(child, parent runtimepolicy.Constraints) runtimep
|
||||
if parent.RequireFullVerification {
|
||||
child.RequireFullVerification = true
|
||||
}
|
||||
if parent.PolicyFloor == taskcontract.PolicyFloorDelivery {
|
||||
child.PolicyFloor = taskcontract.PolicyFloorDelivery
|
||||
}
|
||||
if len(parent.AllowedChecks) > 0 && len(child.AllowedChecks) == 0 {
|
||||
child.AllowedChecks = append([]string(nil), parent.AllowedChecks...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package agent
|
||||
|
||||
import "reasonix/internal/taskcontract"
|
||||
|
||||
// readinessPauseActive reports whether an unmet final-readiness requirement may
|
||||
// pause the turn and hand the user a recovery card.
|
||||
//
|
||||
// The floor alone decides this. The standard floor restores the pre-#8866
|
||||
// balanced feel across every execution mode: the host still records the
|
||||
// readiness audit, but a gap never stops the turn. Goal keeps driving because
|
||||
// its state machine reads Agent.ReadinessResult directly rather than consuming
|
||||
// this error, so suppressing the pause costs it no signal.
|
||||
func (a *Agent) readinessPauseActive() bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
return a.turn.constraints.PolicyFloor == taskcontract.PolicyFloorDelivery
|
||||
}
|
||||
@@ -551,7 +551,9 @@ func (a *Agent) handleFinalResponse(ctx context.Context, state *turnRuntime, tex
|
||||
// controller when automatic continuation is armed (or for the existing
|
||||
// strict/Goal path). Unfinished todos are hard failures only in closed-loop
|
||||
// turns; in ordinary turns they remain visible cross-turn work state.
|
||||
if a.turn.automaticReadinessContinuation || a.closedLoopActive() || readiness.missingSignoff > 0 || readiness.missingActionEvidence > 0 {
|
||||
// readinessPauseActive keeps the standard floor out of this entirely.
|
||||
if a.readinessPauseActive() &&
|
||||
(a.turn.automaticReadinessContinuation || a.closedLoopActive() || readiness.missingSignoff > 0 || readiness.missingActionEvidence > 0) {
|
||||
event.RecordReadinessAudit(a.svc.sink, readiness.audit(evidence.ReadinessErrored, false))
|
||||
a.pending.finalReadinessRecovery = true
|
||||
a.persistFinalReadinessRecovery(readiness.missingIDs())
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/evidence"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/taskcontract"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -38,9 +39,17 @@ func (a *Agent) recordToolReceipts(plan *toolCallPlan, result string, execution
|
||||
}
|
||||
call := plan.call
|
||||
args := json.RawMessage(call.Arguments)
|
||||
// The session floor in force at write time is a fact of the write: it
|
||||
// rides the receipt so the per-turn contract replay re-derives the same
|
||||
// floor obligations even after the floor changes.
|
||||
floorStamp := a.turn.constraints.PolicyFloor.String()
|
||||
if floorStamp == taskcontract.PolicyFloorNone.String() {
|
||||
floorStamp = ""
|
||||
}
|
||||
switch {
|
||||
case call.Name == "complete_step":
|
||||
rec := evidence.ReceiptFromToolCall(call.Name, args, err == nil, plan.readOnly)
|
||||
rec.PolicyFloor = floorStamp
|
||||
a.task.ledger.Record(rec)
|
||||
a.commitToolReceipt(rec)
|
||||
if err == nil {
|
||||
@@ -51,12 +60,14 @@ func (a *Agent) recordToolReceipts(plan *toolCallPlan, result string, execution
|
||||
a.task.ledger.Record(proxy)
|
||||
rec := evidence.ReceiptFromToolCall(plan.evidenceName, plan.evidenceArgs, err == nil, plan.readOnly)
|
||||
rec.Mutation = plan.effects.ContentMutation
|
||||
rec.PolicyFloor = floorStamp
|
||||
decorateExecutionReceipt(&rec, result, execution)
|
||||
a.task.ledger.Record(rec)
|
||||
a.commitToolReceipt(rec)
|
||||
default:
|
||||
rec := evidence.ReceiptFromToolCall(call.Name, args, err == nil, plan.tool.ReadOnly())
|
||||
rec.Mutation = plan.effects.ContentMutation
|
||||
rec.PolicyFloor = floorStamp
|
||||
decorateExecutionReceipt(&rec, result, execution)
|
||||
a.task.ledger.Record(rec)
|
||||
a.commitToolReceipt(rec)
|
||||
|
||||
@@ -95,7 +95,7 @@ func (a *Agent) emitCompletionSummary(c *taskcontract.Contract, report completio
|
||||
// Preset is a deprecated wire-compat field: it is pinned to the
|
||||
// historical default so one-version-old clients keep parsing. New
|
||||
// surfaces read the verdict/check/review/gap fields instead.
|
||||
Preset: string(agentpreset.Balanced),
|
||||
Preset: string(agentpreset.Standard),
|
||||
Verdict: summaryVerdict,
|
||||
Mutations: mutations,
|
||||
ChecksPassed: passed,
|
||||
|
||||
@@ -1,75 +1,67 @@
|
||||
// Package agentpreset holds the deprecated execution-mode vocabulary kept for
|
||||
// one compatibility version. Reasonix now runs a single adaptive standard
|
||||
// host execution (see internal/runtimepolicy); these helpers exist only so old
|
||||
// CLI flags, ACP options, persisted sessions, and Desktop tabs can be parsed,
|
||||
// answered with a deprecation notice, and dual-written with safe values for
|
||||
// older clients. Nothing here may influence runtime behavior.
|
||||
// Package agentpreset holds the session role vocabulary. The role is a
|
||||
// session quality floor (standard|delivery): it records intent and writes
|
||||
// through the controller's floor setting; enforcement lives in the fact
|
||||
// contract. Light and its historical aliases fold to standard silently.
|
||||
package agentpreset
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentPreset is a deprecated session-scoped execution-mode label.
|
||||
// AgentPreset is the session role label.
|
||||
type AgentPreset string
|
||||
|
||||
const (
|
||||
// Light is the deprecated 轻量 mode label.
|
||||
Light AgentPreset = "light"
|
||||
// Balanced is the deprecated 均衡 mode label. It is also the fixed safe
|
||||
// value written into new persisted compat fields.
|
||||
Balanced AgentPreset = "balanced"
|
||||
// Delivery is the deprecated 交付 mode label.
|
||||
// Standard is the 默认 (standard) floor: the adaptive policy unchanged.
|
||||
Standard AgentPreset = "standard"
|
||||
// Delivery is the 交付 floor: session-sticky delivery completion gates.
|
||||
Delivery AgentPreset = "delivery"
|
||||
)
|
||||
|
||||
// Normalize maps free-form and legacy values onto a canonical label. Empty and
|
||||
// unknown values fall back to Balanced. Old token/work-mode names are accepted
|
||||
// for one compatibility version.
|
||||
func Normalize(raw string) AgentPreset {
|
||||
// Normalize maps free-form and legacy values onto the canonical label. Light
|
||||
// and its aliases fold to Standard; unknown values are an error so no new
|
||||
// vocabulary can appear.
|
||||
func Normalize(raw string) (AgentPreset, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case string(Light), "economy", "eco", "save", "saving", "low", "lite", "minimal":
|
||||
return Light
|
||||
case string(Delivery), "deliver", "quality", "performance":
|
||||
return Delivery
|
||||
case string(Balanced), "full", "":
|
||||
return Balanced
|
||||
case "", string(Standard), "balanced", "full", "normal",
|
||||
"light", "economy", "eco", "save", "saving", "low", "lite", "minimal":
|
||||
return Standard, nil
|
||||
case string(Delivery), "deliver", "quality":
|
||||
return Delivery, nil
|
||||
default:
|
||||
return Balanced
|
||||
return "", fmt.Errorf("unknown role setting %q (accepted: standard, delivery; legacy light folds to standard)", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// IsValid reports whether raw is an exact canonical preset label.
|
||||
func IsValid(raw string) bool {
|
||||
switch AgentPreset(strings.ToLower(strings.TrimSpace(raw))) {
|
||||
case Light, Balanced, Delivery:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
p, err := Normalize(raw)
|
||||
return err == nil && p != ""
|
||||
}
|
||||
|
||||
// LegacyTokenMode returns the deprecated dual-write tokenMode value older
|
||||
// clients expect next to a persisted preset. It is a wire-compat mapping only.
|
||||
func LegacyTokenMode(p AgentPreset) string {
|
||||
switch Normalize(string(p)) {
|
||||
case Light:
|
||||
return "economy"
|
||||
case Delivery:
|
||||
if p == Delivery {
|
||||
return "delivery"
|
||||
default:
|
||||
return "full"
|
||||
}
|
||||
return "full"
|
||||
}
|
||||
|
||||
// FromLegacyTokenMode maps a persisted or CLI tokenMode onto a preset label.
|
||||
func FromLegacyTokenMode(mode string) AgentPreset {
|
||||
return Normalize(mode)
|
||||
p, err := Normalize(mode)
|
||||
if err != nil {
|
||||
return Standard
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// DeprecatedNotice is the one-time-per-process notice old entry points print
|
||||
// after accepting a mode argument.
|
||||
const DeprecatedNotice = "Reasonix now uses one adaptive standard execution: planning, verification, and review strength follow task risk. Execution modes are no longer switchable; this option is accepted for compatibility and ignored."
|
||||
// FloorNotice is printed once when a legacy mode value is folded.
|
||||
const FloorNotice = "The execution-mode setting is now a session quality floor: standard (default) or delivery. Light has been folded into standard; the adaptive policy already runs light work lightly."
|
||||
|
||||
// String returns the canonical identifier.
|
||||
func (p AgentPreset) String() string {
|
||||
return string(Normalize(string(p)))
|
||||
return string(p)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package agentpreset
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeTwoValueVocabulary(t *testing.T) {
|
||||
folds := []string{"", "standard", "balanced", "full", "normal", "light", "economy", "eco", "lite", "save", "saving", "low", "minimal", " STANDARD "}
|
||||
for _, raw := range folds {
|
||||
got, err := Normalize(raw)
|
||||
if err != nil || got != Standard {
|
||||
t.Errorf("Normalize(%q) = %q, %v; want standard, nil", raw, got, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"delivery", "deliver", "quality", " DELIVERY "} {
|
||||
got, err := Normalize(raw)
|
||||
if err != nil || got != Delivery {
|
||||
t.Errorf("Normalize(%q) = %q, %v; want delivery, nil", raw, got, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"turbo", "fuller"} {
|
||||
if _, err := Normalize(raw); err == nil {
|
||||
t.Errorf("Normalize(%q) must reject unknown values", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyTokenModeRoundTrip(t *testing.T) {
|
||||
if got := LegacyTokenMode(Delivery); got != "delivery" {
|
||||
t.Fatalf("LegacyTokenMode(delivery) = %q", got)
|
||||
}
|
||||
if got := LegacyTokenMode(Standard); got != "full" {
|
||||
t.Fatalf("LegacyTokenMode(standard) = %q", got)
|
||||
}
|
||||
if got := FromLegacyTokenMode("economy"); got != Standard {
|
||||
t.Fatalf("FromLegacyTokenMode(economy) = %q, want standard (light folds)", got)
|
||||
}
|
||||
if got := FromLegacyTokenMode("delivery"); got != Delivery {
|
||||
t.Fatalf("FromLegacyTokenMode(delivery) = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -4,33 +4,42 @@ import (
|
||||
"reasonix/internal/agentpreset"
|
||||
)
|
||||
|
||||
// Deprecated execution-mode vocabulary re-exported for old frontends.
|
||||
// Runtime constraints live in internal/runtimepolicy; these names only parse compat inputs.
|
||||
// Role vocabulary re-exported for old frontends. Runtime constraints live in
|
||||
// internal/runtimepolicy; these names only parse compat inputs.
|
||||
|
||||
// Deprecated AgentPreset label constants — one compatibility version.
|
||||
// AgentPreset label constants; light and its aliases fold to standard.
|
||||
const (
|
||||
AgentPresetLight = string(agentpreset.Light)
|
||||
AgentPresetBalanced = string(agentpreset.Balanced)
|
||||
AgentPresetStandard = string(agentpreset.Standard)
|
||||
AgentPresetDelivery = string(agentpreset.Delivery)
|
||||
)
|
||||
|
||||
// Deprecated TokenMode constants — one compatibility version.
|
||||
// Deprecated compat aliases for one-version-old callers.
|
||||
const (
|
||||
TokenModeFull = "full"
|
||||
TokenModeEconomy = "economy"
|
||||
TokenModeDelivery = "delivery"
|
||||
AgentPresetBalanced = AgentPresetStandard
|
||||
TokenModeFull = "full"
|
||||
TokenModeDelivery = "delivery"
|
||||
)
|
||||
|
||||
// NormalizeAgentPreset maps free-form and legacy values to a canonical
|
||||
// agent role setting. Empty/unknown → balanced.
|
||||
// NormalizeAgentPreset maps free-form and legacy values to the canonical
|
||||
// role label. Light folds to standard; unknown values return the input
|
||||
// unchanged so callers can surface an error.
|
||||
func NormalizeAgentPreset(raw string) string {
|
||||
return string(agentpreset.Normalize(raw))
|
||||
if p, err := agentpreset.Normalize(raw); err == nil {
|
||||
return string(p)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// NormalizeAgentPresetErr is NormalizeAgentPreset reporting parse errors.
|
||||
func NormalizeAgentPresetErr(raw string) (string, error) {
|
||||
p, err := agentpreset.Normalize(raw)
|
||||
return string(p), err
|
||||
}
|
||||
|
||||
// NormalizeTokenMode is the deprecated alias that returns legacy tokenMode
|
||||
// names (economy/full/delivery) for dual-write and older clients.
|
||||
// names (full/delivery) for dual-write and older clients.
|
||||
func NormalizeTokenMode(mode string) string {
|
||||
return agentpreset.LegacyTokenMode(agentpreset.Normalize(mode))
|
||||
return agentpreset.LegacyTokenMode(agentpreset.FromLegacyTokenMode(mode))
|
||||
}
|
||||
|
||||
// AgentPresetFromTokenMode maps a legacy tokenMode onto a role setting.
|
||||
@@ -40,7 +49,7 @@ func AgentPresetFromTokenMode(mode string) string {
|
||||
|
||||
// TokenModeFromAgentPreset maps a role setting onto the dual-write tokenMode.
|
||||
func TokenModeFromAgentPreset(preset string) string {
|
||||
return agentpreset.LegacyTokenMode(agentpreset.Normalize(preset))
|
||||
return agentpreset.LegacyTokenMode(agentpreset.FromLegacyTokenMode(preset))
|
||||
}
|
||||
|
||||
// CoreProviderToolNames is the stable top-level tool surface shared by every
|
||||
|
||||
+11
-5
@@ -25,6 +25,7 @@ import (
|
||||
|
||||
"reasonix/internal/ablation"
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/agentpreset"
|
||||
"reasonix/internal/billing"
|
||||
"reasonix/internal/capability"
|
||||
"reasonix/internal/command"
|
||||
@@ -133,9 +134,9 @@ type Options struct {
|
||||
// (for example ACP session/new). They are connected eagerly for this
|
||||
// controller but are not persisted to reasonix.toml.
|
||||
ExtraPlugins []plugin.Spec
|
||||
// AgentPreset and TokenMode are deprecated no-op compatibility inputs.
|
||||
// Host obligations are fact-driven from real tool actions; these fields are
|
||||
// accepted so old frontends keep compiling, and ignored.
|
||||
// AgentPreset and TokenMode seed the session quality floor. Delivery (or
|
||||
// its aliases) raises it to delivery; light and its aliases fold to
|
||||
// standard; unknown values keep the standard default.
|
||||
AgentPreset string
|
||||
TokenMode string
|
||||
// SessionDir overrides where persisted chat transcripts are written. When
|
||||
@@ -416,8 +417,8 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
}
|
||||
}
|
||||
config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, modelName)
|
||||
// Execution modes are gone: opts.AgentPreset/opts.TokenMode are deprecated
|
||||
// no-op inputs kept for one compatibility version of old frontends.
|
||||
// opts.AgentPreset/opts.TokenMode now seed the session quality floor (see
|
||||
// the SetQualityFloor call after control.New); light folds to standard.
|
||||
keepPolicy := agentKeepPolicy(cfg.Agent.Keep)
|
||||
// Entry resolution: the caller-owned broker is authoritative for every
|
||||
// ref; the extension-merged resolver only owns plugin refs — a config ref
|
||||
@@ -1881,6 +1882,11 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
}
|
||||
}
|
||||
ctrl := control.New(ctrlOpts)
|
||||
// The role inputs set the session quality floor: delivery/deliver/quality
|
||||
// raise it, light and its aliases fold to standard, unknown stays default.
|
||||
if p, err := agentpreset.Normalize(firstNonEmpty(opts.AgentPreset, opts.TokenMode)); err == nil && p == agentpreset.Delivery {
|
||||
_ = ctrl.SetQualityFloor(string(p))
|
||||
}
|
||||
// Publish the controller to the extension UI hub's indirection: from here
|
||||
// on, host/ui/* publishes ride ctrl.EmitExtensionEvent and blocking prompts
|
||||
// ride ctrl.Ask, exactly as if the hub had been built after control.New.
|
||||
|
||||
@@ -82,7 +82,7 @@ func TestBuildNormalModeKeepsSourceConnectorAndSkillTools(t *testing.T) {
|
||||
t.Chdir(dir)
|
||||
t.Setenv("REASONIX_SAFE_MODE", "")
|
||||
|
||||
for _, tokenMode := range []string{TokenModeFull, TokenModeEconomy} {
|
||||
for _, tokenMode := range []string{TokenModeFull, TokenModeDelivery} {
|
||||
ctrl, err := Build(context.Background(), Options{
|
||||
SessionDir: filepath.Join(t.TempDir(), "sessions"),
|
||||
TokenMode: tokenMode,
|
||||
|
||||
+18
-14
@@ -1990,14 +1990,17 @@ model = "x"
|
||||
}
|
||||
|
||||
func TestNormalizeTokenModeSupportsRuntimeProfilesAndLegacyAliases(t *testing.T) {
|
||||
// NormalizeTokenMode remains the dual-write legacy mapping.
|
||||
// NormalizeTokenMode remains the dual-write legacy mapping; light folds
|
||||
// to full because standard already runs light work lightly.
|
||||
for input, want := range map[string]string{
|
||||
"": TokenModeFull,
|
||||
"full": TokenModeFull,
|
||||
"standard": TokenModeFull,
|
||||
"balanced": TokenModeFull,
|
||||
"economy": TokenModeEconomy,
|
||||
"eco": TokenModeEconomy,
|
||||
"light": TokenModeEconomy,
|
||||
"economy": TokenModeFull,
|
||||
"eco": TokenModeFull,
|
||||
"light": TokenModeFull,
|
||||
"lite": TokenModeFull,
|
||||
"delivery": TokenModeDelivery,
|
||||
"quality": TokenModeDelivery,
|
||||
"unexpected": TokenModeFull,
|
||||
@@ -2007,11 +2010,12 @@ func TestNormalizeTokenModeSupportsRuntimeProfilesAndLegacyAliases(t *testing.T)
|
||||
}
|
||||
}
|
||||
for input, want := range map[string]string{
|
||||
"": AgentPresetBalanced,
|
||||
"full": AgentPresetBalanced,
|
||||
"balanced": AgentPresetBalanced,
|
||||
"economy": AgentPresetLight,
|
||||
"light": AgentPresetLight,
|
||||
"": AgentPresetStandard,
|
||||
"full": AgentPresetStandard,
|
||||
"standard": AgentPresetStandard,
|
||||
"balanced": AgentPresetStandard,
|
||||
"economy": AgentPresetStandard,
|
||||
"light": AgentPresetStandard,
|
||||
"delivery": AgentPresetDelivery,
|
||||
} {
|
||||
if got := NormalizeAgentPreset(input); got != want {
|
||||
@@ -2129,7 +2133,7 @@ model = "executor-model"%s
|
||||
}
|
||||
|
||||
func TestBuildInjectsEnvironmentBlockByDefaultAndEconomy(t *testing.T) {
|
||||
for _, tokenMode := range []string{"", TokenModeEconomy} {
|
||||
for _, tokenMode := range []string{"", "economy"} {
|
||||
t.Run(firstNonEmpty(tokenMode, "default"), func(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
@@ -2242,7 +2246,7 @@ model = "x"
|
||||
`)
|
||||
|
||||
fullReq, _ := captureTokenProfileSurface(t, TokenModeFull)
|
||||
economyReq, _ := captureTokenProfileSurface(t, TokenModeEconomy)
|
||||
economyReq, _ := captureTokenProfileSurface(t, "economy")
|
||||
doc, err := os.ReadFile(filepath.Join(pkgDir, "..", "..", "docs", "TOOL_CONTRACT.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read tool contract doc: %v", err)
|
||||
@@ -2319,7 +2323,7 @@ command = "reasonix-missing-mockmcp"
|
||||
`)
|
||||
writeFile(t, dir, ".reasonix/skills/projskill.md", "---\ndescription: a project skill\n---\nplaybook")
|
||||
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: "economy"})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -2412,7 +2416,7 @@ model = "x"
|
||||
testutil.Turn{Text: "done"},
|
||||
)
|
||||
setBootTokenProfileTestProvider(t, prov)
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: "economy"})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -2461,7 +2465,7 @@ model = "x"
|
||||
`)
|
||||
registerBootTokenProfileTestProvider()
|
||||
var base []string
|
||||
for _, mode := range []string{TokenModeEconomy, TokenModeFull, TokenModeDelivery, "light", "balanced"} {
|
||||
for _, mode := range []string{"economy", TokenModeFull, TokenModeDelivery, "light", "balanced"} {
|
||||
prov := testutil.NewMock("stable-"+mode, testutil.Turn{Text: "done"})
|
||||
setBootTokenProfileTestProvider(t, prov)
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: mode, AgentPreset: mode})
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package boot
|
||||
|
||||
// Effect tests for the session quality floor: the floor must reach the
|
||||
// controller without touching the provider-visible prefix.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/ablation"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// TestEffectQualityFloorDoesNotTouchProviderPrefix pins the cache contract:
|
||||
// standard and delivery send identical system prompts and tool schemas. The
|
||||
// workspace line embeds the run's temp dir, so both sides normalize it.
|
||||
func TestEffectQualityFloorDoesNotTouchProviderPrefix(t *testing.T) {
|
||||
standard := effectRun(t, "boot-effect-floor-standard", "", ablation.Set{})
|
||||
delivery := effectRun(t, "boot-effect-floor-delivery", "delivery", ablation.Set{})
|
||||
|
||||
stdReq, delReq := standard[0], delivery[0]
|
||||
stdPrompt := stripWorkspaceLine(systemMessage(stdReq.Messages))
|
||||
delPrompt := stripWorkspaceLine(systemMessage(delReq.Messages))
|
||||
if stdPrompt != delPrompt {
|
||||
t.Fatalf("delivery floor changed the provider-visible system prompt\n%s", firstPromptDiff(stdPrompt, delPrompt))
|
||||
}
|
||||
if !reflect.DeepEqual(toolSchemaNames(stdReq.Tools), toolSchemaNames(delReq.Tools)) {
|
||||
t.Fatalf("delivery floor changed tool schemas\nstandard=%v\ndelivery=%v",
|
||||
toolSchemaNames(stdReq.Tools), toolSchemaNames(delReq.Tools))
|
||||
}
|
||||
}
|
||||
|
||||
// firstPromptDiff names the first differing line so a failure is diagnosable
|
||||
// instead of reporting only that the two prefixes are unequal.
|
||||
func firstPromptDiff(want, got string) string {
|
||||
a, b := strings.Split(want, "\n"), strings.Split(got, "\n")
|
||||
for i := 0; i < len(a) || i < len(b); i++ {
|
||||
var x, y string
|
||||
if i < len(a) {
|
||||
x = a[i]
|
||||
}
|
||||
if i < len(b) {
|
||||
y = b[i]
|
||||
}
|
||||
if x != y {
|
||||
return fmt.Sprintf("line %d:\n standard: %q\n delivery: %q", i+1, x, y)
|
||||
}
|
||||
}
|
||||
return "no line differs (length mismatch only)"
|
||||
}
|
||||
|
||||
func stripWorkspaceLine(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "Current workspace:") {
|
||||
lines[i] = "Current workspace: <ROOT>"
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// TestEffectDeliveryFloorSetsSessionFloor asserts the role input reaches the
|
||||
// controller's floor, and light folds to standard.
|
||||
func TestEffectDeliveryFloorSetsSessionFloor(t *testing.T) {
|
||||
if got := effectFloorController(t, "delivery").QualityFloor(); got != "delivery" {
|
||||
t.Fatalf("QualityFloor = %q, want delivery", got)
|
||||
}
|
||||
if got := effectFloorController(t, "light").QualityFloor(); got != "standard" {
|
||||
t.Fatalf("light must fold to standard, got %q", got)
|
||||
}
|
||||
if got := effectFloorController(t, "").QualityFloor(); got != "standard" {
|
||||
t.Fatalf("default floor = %q, want standard", got)
|
||||
}
|
||||
}
|
||||
|
||||
// effectFloorController builds the real stack with a role input and returns
|
||||
// the controller; no turn runs.
|
||||
func effectFloorController(t *testing.T, tokenMode string) *control.Controller {
|
||||
t.Helper()
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
kind := "boot-effect-floor-ctl"
|
||||
if tokenMode != "" {
|
||||
kind += "-" + tokenMode
|
||||
}
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) {
|
||||
return &effectRecordingProvider{}, nil
|
||||
})
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "floor-model"
|
||||
|
||||
[[providers]]
|
||||
name = "floor-model"
|
||||
kind = "`+kind+`"
|
||||
model = "x"
|
||||
`)
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: tokenMode})
|
||||
if err != nil {
|
||||
t.Fatalf("Build(%q): %v", tokenMode, err)
|
||||
}
|
||||
t.Cleanup(func() { ctrl.Close() })
|
||||
return ctrl
|
||||
}
|
||||
@@ -15,7 +15,7 @@ func TestBootToolContractMatchesProviderVisibleSurface(t *testing.T) {
|
||||
tokenMode string
|
||||
}{
|
||||
{name: "default", tokenMode: ""},
|
||||
{name: "economy", tokenMode: TokenModeEconomy},
|
||||
{name: "economy", tokenMode: "economy"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
|
||||
@@ -32,7 +32,7 @@ model = "x"
|
||||
registerBootTokenProfileTestProvider()
|
||||
|
||||
var surfaces [][]string
|
||||
for _, mode := range []string{"", TokenModeEconomy, TokenModeFull, TokenModeDelivery, "light", "balanced"} {
|
||||
for _, mode := range []string{"", "economy", TokenModeFull, TokenModeDelivery, "light", "balanced"} {
|
||||
prov := testutil.NewMock("ucap-"+mode, testutil.Turn{Text: "done"})
|
||||
setBootTokenProfileTestProvider(t, prov)
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: mode, AgentPreset: mode})
|
||||
|
||||
+13
-8
@@ -270,6 +270,7 @@ func setupProfile(ctx context.Context, modelName string, maxStepsOverride int, r
|
||||
}
|
||||
|
||||
type cliBuildOverrides struct {
|
||||
Preset string
|
||||
Effort *string
|
||||
PermissionAllow []string
|
||||
AdditionalDirs []string
|
||||
@@ -307,6 +308,7 @@ func cliProfileBuildOptions(modelName string, maxStepsOverride int, requireKey b
|
||||
RequireKey: requireKey,
|
||||
Sink: sink,
|
||||
SessionDir: resolveCLISessionDir(),
|
||||
AgentPreset: overrides.Preset,
|
||||
WorkspaceRoot: overrides.WorkspaceRoot,
|
||||
EffortOverride: overrides.Effort,
|
||||
PermissionAllow: overrides.PermissionAllow,
|
||||
@@ -391,19 +393,19 @@ func setupQuietProfile(ctx context.Context, modelName string, maxStepsOverride i
|
||||
return boot.Build(ctx, cliProfileBuildOptions(modelName, maxStepsOverride, requireKey, sink, overrides))
|
||||
}
|
||||
|
||||
// parseRuntimeProfile validates a deprecated execution-mode flag value. The
|
||||
// returned label is accepted for one compatibility version and ignored: the
|
||||
// adaptive standard execution policy derives from task risk, never a flag.
|
||||
// parseRuntimeProfile validates a role-flag value and maps it onto the
|
||||
// session quality floor: light folds to standard silently, delivery sets the
|
||||
// delivery floor.
|
||||
func parseRuntimeProfile(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "balanced", boot.TokenModeFull:
|
||||
return "balanced", nil
|
||||
case boot.TokenModeEconomy, "light", "lite", "eco":
|
||||
return "light", nil
|
||||
case "", "balanced", "standard", boot.TokenModeFull:
|
||||
return "standard", nil
|
||||
case "economy", "light", "lite", "eco":
|
||||
return "standard", nil
|
||||
case boot.TokenModeDelivery, "deliver", "quality":
|
||||
return "delivery", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown execution setting %q (accepted for compatibility: light, balanced, delivery; legacy: economy, full)", value)
|
||||
return "", fmt.Errorf("unknown execution setting %q (accepted: standard, delivery; legacy light folds to standard)", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,6 +685,7 @@ func runAgent(args []string, version string) int {
|
||||
// UI can answer; unattended writes require explicit --auto/-y,
|
||||
// --permission-mode auto, or yolo.
|
||||
overrides := cliBuildOverrides{
|
||||
Preset: deprecatedMode,
|
||||
Effort: effortOverride,
|
||||
PermissionAllow: allowedTools,
|
||||
AdditionalDirs: additionalDirs,
|
||||
@@ -929,6 +932,7 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
|
||||
// The loopback-only provider setup surface stores the missing credential and
|
||||
// rebuilds this controller in place before the normal web UI is exposed.
|
||||
ctrl, err := setupProfileWithOverrides(ctx, *model, *maxSteps, false, bc, cliBuildOverrides{
|
||||
Preset: deprecatedMode,
|
||||
OnSessionRecovered: cliSessionRecoveredHandler(leases),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1120,6 +1124,7 @@ func chatREPL(args []string, version string) int {
|
||||
effortOverride = effort
|
||||
}
|
||||
overrides := cliBuildOverrides{
|
||||
Preset: deprecatedMode,
|
||||
Effort: effortOverride,
|
||||
PermissionAllow: allowedTools,
|
||||
AdditionalDirs: additionalDirs,
|
||||
|
||||
@@ -2195,12 +2195,9 @@ func TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {
|
||||
|
||||
func TestParseRuntimeProfile(t *testing.T) {
|
||||
for input, want := range map[string]string{
|
||||
"": "balanced",
|
||||
"balanced": "balanced",
|
||||
"full": "balanced",
|
||||
"economy": "light",
|
||||
"light": "light",
|
||||
"delivery": "delivery",
|
||||
"": "standard", "balanced": "standard", "standard": "standard", "full": "standard",
|
||||
"economy": "standard", "light": "standard", "lite": "standard", "eco": "standard",
|
||||
"delivery": "delivery", "deliver": "delivery", "quality": "delivery",
|
||||
} {
|
||||
got, err := parseRuntimeProfile(input)
|
||||
if err != nil || got != want {
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
|
||||
var deprecatedModeNoticeOnce sync.Once
|
||||
|
||||
// acceptDeprecatedModeFlag validates and ignores a compatibility value.
|
||||
// acceptDeprecatedModeFlag validates a role value; the caller applies the
|
||||
// returned floor to the controller. Unknown values error; light folds to
|
||||
// standard silently.
|
||||
func acceptDeprecatedModeFlag(raw string) error {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
@@ -20,7 +22,7 @@ func acceptDeprecatedModeFlag(raw string) error {
|
||||
return err
|
||||
}
|
||||
deprecatedModeNoticeOnce.Do(func() {
|
||||
fmt.Fprintln(os.Stderr, agentpreset.DeprecatedNotice)
|
||||
fmt.Fprintln(os.Stderr, agentpreset.FloorNotice)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
+29
-23
@@ -1,7 +1,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -10,51 +9,58 @@ import (
|
||||
"reasonix/internal/i18n"
|
||||
)
|
||||
|
||||
// Deprecated /preset, /work-mode, and /profile stay parseable for one version.
|
||||
// They print a single deprecation notice and never change runtime policy.
|
||||
// /preset, /work-mode, and /profile set the session quality floor:
|
||||
// standard (default) or delivery. Light folds to standard silently.
|
||||
|
||||
var presetDeprecationOnce sync.Once
|
||||
|
||||
// noticePresetDeprecated prints the standard-execution notice once per process.
|
||||
func (m *chatTUI) noticePresetDeprecated() {
|
||||
// noticePresetFolded prints the one-time fold notice per process.
|
||||
func (m *chatTUI) noticePresetFolded() {
|
||||
presetDeprecationOnce.Do(func() {
|
||||
m.notice(i18n.M.WorkModeDeprecatedNotice)
|
||||
})
|
||||
}
|
||||
|
||||
// parseAgentPreset validates a deprecated mode label. The result is only used
|
||||
// to decide whether the old argument was well-formed; it never changes
|
||||
// behavior.
|
||||
// parseAgentPreset maps a role argument onto the quality floor. The result
|
||||
// is "standard" or "delivery"; legacy light aliases fold to standard.
|
||||
func parseAgentPreset(value string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "light", "economy", "eco", "lite":
|
||||
return agentpreset.Light.String(), true
|
||||
case "balanced", "full":
|
||||
return agentpreset.Balanced.String(), true
|
||||
case "delivery":
|
||||
return agentpreset.Delivery.String(), true
|
||||
default:
|
||||
return "", false
|
||||
if p, err := agentpreset.Normalize(value); err == nil {
|
||||
return string(p), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// runPresetCommand is the no-op compatibility handler for /preset, /work-mode,
|
||||
// and /profile. Every invocation explains the adaptive standard execution; it
|
||||
// never switches, rebuilds, or persists anything.
|
||||
// runPresetCommand switches the session quality floor for subsequent turns.
|
||||
// It never rebuilds the controller and never schedules a turn.
|
||||
func (m *chatTUI) runPresetCommand(input string) tea.Cmd {
|
||||
args := tokenizeArgs(input)
|
||||
m.noticePresetDeprecated()
|
||||
if len(args) == 2 {
|
||||
if _, ok := parseAgentPreset(args[1]); !ok {
|
||||
floor, ok := parseAgentPreset(args[1])
|
||||
if !ok {
|
||||
m.notice(i18n.M.WorkModeUsage)
|
||||
return nil
|
||||
}
|
||||
if err := m.ctrl.SetQualityFloor(floor); err != nil {
|
||||
m.notice(err.Error())
|
||||
return nil
|
||||
}
|
||||
m.notice(i18n.M.QualityFloorApplied)
|
||||
return nil
|
||||
}
|
||||
if len(args) == 1 {
|
||||
m.notice(i18n.M.WorkModeUsage)
|
||||
return nil
|
||||
}
|
||||
m.noticePresetFolded()
|
||||
return nil
|
||||
}
|
||||
|
||||
// runWorkModeCommand keeps the legacy dispatch name compiling; /work-mode and
|
||||
// /profile delegate to the same compatibility handler.
|
||||
// /profile delegate to the same handler.
|
||||
func (m *chatTUI) runWorkModeCommand(input string) tea.Cmd {
|
||||
if len(tokenizeArgs(input)) > 2 {
|
||||
m.noticePresetFolded()
|
||||
return nil
|
||||
}
|
||||
return m.runPresetCommand(input)
|
||||
}
|
||||
@@ -317,13 +317,13 @@ func TestWorkModeSwitchUpdatesInPlaceWithoutRebuildOrLeaseMove(t *testing.T) {
|
||||
|
||||
cmd := m.runWorkModeCommand("/preset delivery")
|
||||
if cmd != nil {
|
||||
t.Fatal("deprecated /preset must not queue a controller rebuild")
|
||||
t.Fatal("/preset must not queue a controller rebuild")
|
||||
}
|
||||
if m.ctrl != oldCtrl {
|
||||
t.Fatal("controller instance must stay the same")
|
||||
}
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetBalanced {
|
||||
t.Fatalf("controller preset = %q, want balanced", m.ctrl.AgentPreset())
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetDelivery {
|
||||
t.Fatalf("controller preset = %q, want delivery", m.ctrl.AgentPreset())
|
||||
}
|
||||
if builds != 0 {
|
||||
t.Fatalf("unexpected rebuilds: %d", builds)
|
||||
|
||||
@@ -28,13 +28,15 @@ func committedNotices(m chatTUI) string {
|
||||
|
||||
func TestParseAgentPresetAcceptsLegacyLabels(t *testing.T) {
|
||||
for input, want := range map[string]string{
|
||||
"economy": "light",
|
||||
"light": "light",
|
||||
"eco": "light",
|
||||
"lite": "light",
|
||||
"balanced": "balanced",
|
||||
"full": "balanced",
|
||||
"economy": "standard",
|
||||
"light": "standard",
|
||||
"eco": "standard",
|
||||
"lite": "standard",
|
||||
"balanced": "standard",
|
||||
"full": "standard",
|
||||
"standard": "standard",
|
||||
"delivery": "delivery",
|
||||
"deliver": "delivery",
|
||||
} {
|
||||
got, ok := parseAgentPreset(input)
|
||||
if !ok || got != want {
|
||||
@@ -119,7 +121,7 @@ func TestWorkModeHelpHidesDeprecatedCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetCommandIsNoOpCompatibility(t *testing.T) {
|
||||
func TestPresetCommandSwitchesFloorInPlace(t *testing.T) {
|
||||
resetPresetDeprecationForTest()
|
||||
oldCtrl := control.New(control.Options{Label: "old"})
|
||||
oldCtrl.SetToolApprovalMode(control.ToolApprovalAuto)
|
||||
@@ -134,30 +136,26 @@ func TestPresetCommandIsNoOpCompatibility(t *testing.T) {
|
||||
|
||||
cmd := m.runWorkModeCommand("/preset delivery")
|
||||
if cmd != nil {
|
||||
t.Fatal("deprecated /preset must not schedule a controller rebuild")
|
||||
t.Fatal("/preset must not schedule a controller rebuild")
|
||||
}
|
||||
if m.ctrl != oldCtrl {
|
||||
t.Fatal("controller must stay the same instance")
|
||||
}
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetBalanced {
|
||||
t.Fatalf("controller preset = %q, want balanced", m.ctrl.AgentPreset())
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetDelivery {
|
||||
t.Fatalf("controller preset = %q, want delivery", m.ctrl.AgentPreset())
|
||||
}
|
||||
if builds != 0 {
|
||||
t.Fatalf("unexpected rebuilds: %d", builds)
|
||||
}
|
||||
out := committedNotices(m)
|
||||
if got := strings.Count(out, i18n.M.WorkModeDeprecatedNotice); got != 1 {
|
||||
t.Fatalf("deprecation notices = %d, want 1:\n%s", got, out)
|
||||
if out := committedNotices(m); !strings.Contains(out, i18n.M.QualityFloorApplied) {
|
||||
t.Fatalf("applied /preset missing floor notice:\n%s", out)
|
||||
}
|
||||
|
||||
if cmd := m.runWorkModeCommand("/preset light"); cmd != nil {
|
||||
t.Fatal("second /preset must stay a no-op")
|
||||
t.Fatal("second /preset must not rebuild")
|
||||
}
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetBalanced {
|
||||
t.Fatalf("controller preset changed to %q", m.ctrl.AgentPreset())
|
||||
}
|
||||
if got := strings.Count(committedNotices(m), i18n.M.WorkModeDeprecatedNotice); got != 1 {
|
||||
t.Fatalf("deprecation notice should print once, got %d:\n%s", got, committedNotices(m))
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetStandard {
|
||||
t.Fatalf("light must fold to standard, got %q", m.ctrl.AgentPreset())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,21 +174,18 @@ func TestPresetCommandRejectsInvalidValue(t *testing.T) {
|
||||
t.Fatal("invalid /preset unexpectedly scheduled a rebuild")
|
||||
}
|
||||
out := committedNotices(m)
|
||||
if !strings.Contains(out, i18n.M.WorkModeDeprecatedNotice) {
|
||||
t.Fatalf("invalid /preset missing deprecation notice:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, i18n.M.WorkModeUsage) {
|
||||
t.Fatalf("invalid /preset missing usage:\n%s", out)
|
||||
}
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetBalanced {
|
||||
t.Fatalf("controller preset = %q, want balanced", m.ctrl.AgentPreset())
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetStandard {
|
||||
t.Fatalf("controller preset = %q, want standard", m.ctrl.AgentPreset())
|
||||
}
|
||||
if builds != 0 {
|
||||
t.Fatalf("rejected preset request triggered %d builds", builds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetCommandStaysNoOpWhenBusy(t *testing.T) {
|
||||
func TestPresetCommandSwitchesWhenBusy(t *testing.T) {
|
||||
resetPresetDeprecationForTest()
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = control.New(control.Options{Label: "model"})
|
||||
@@ -204,15 +199,15 @@ func TestPresetCommandStaysNoOpWhenBusy(t *testing.T) {
|
||||
if cmd := m.runWorkModeCommand("/preset light"); cmd != nil {
|
||||
t.Fatal("busy /preset must not rebuild")
|
||||
}
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetBalanced {
|
||||
t.Fatalf("busy /preset changed AgentPreset to %q", m.ctrl.AgentPreset())
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetStandard {
|
||||
t.Fatalf("busy /preset AgentPreset = %q, want standard (light folds)", m.ctrl.AgentPreset())
|
||||
}
|
||||
if builds != 0 {
|
||||
t.Fatalf("busy /preset triggered %d builds", builds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetCommandStaysNoOpDuringRunningTurn(t *testing.T) {
|
||||
func TestPresetCommandSwitchesDuringRunningTurn(t *testing.T) {
|
||||
resetPresetDeprecationForTest()
|
||||
runner := &blockingTurnRunner{started: make(chan struct{})}
|
||||
ctrl := control.New(control.Options{Runner: runner, Sink: event.Discard, SessionDir: t.TempDir(), Label: "model"})
|
||||
@@ -237,13 +232,13 @@ func TestPresetCommandStaysNoOpDuringRunningTurn(t *testing.T) {
|
||||
if cmd := m.runWorkModeCommand("/preset delivery"); cmd != nil {
|
||||
t.Fatal("running-turn /preset must not rebuild")
|
||||
}
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetBalanced {
|
||||
t.Fatalf("running-turn /preset changed AgentPreset to %q", m.ctrl.AgentPreset())
|
||||
if m.ctrl.AgentPreset() != boot.AgentPresetDelivery {
|
||||
t.Fatalf("running-turn /preset AgentPreset = %q, want delivery", m.ctrl.AgentPreset())
|
||||
}
|
||||
if builds != 0 {
|
||||
t.Fatalf("running-turn /preset triggered %d builds", builds)
|
||||
}
|
||||
if got := strings.Count(committedNotices(m), i18n.M.WorkModeDeprecatedNotice); got != 1 {
|
||||
t.Fatalf("running-turn /preset notices = %d, want 1:\n%s", got, committedNotices(m))
|
||||
if out := committedNotices(m); !strings.Contains(out, i18n.M.QualityFloorApplied) {
|
||||
t.Fatalf("running-turn /preset missing floor notice:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -289,10 +289,12 @@ type Controller struct {
|
||||
// and rotating are mutually exclusive gates — a turn refuses to start while
|
||||
// a rotation is in progress, and a rotation refuses to start while a turn
|
||||
// runs — so the run loop's session reference cannot change under it.
|
||||
rotating bool
|
||||
autosaveWG sync.WaitGroup
|
||||
planMode bool
|
||||
sessionPath string
|
||||
rotating bool
|
||||
autosaveWG sync.WaitGroup
|
||||
// sessionSettings groups the per-session posture knobs that share one
|
||||
// lifetime: swapped together on session rotation.
|
||||
sessionSettings sessionSettings
|
||||
sessionPath string
|
||||
// sessionTemp owns the logical-session private temporary directory shared
|
||||
// by Bash calls. Retained for this Controller's lifetime; rotated on
|
||||
// /new, /clear, resume of another session, and branch switches.
|
||||
@@ -2630,27 +2632,30 @@ func (c *Controller) SetPlanMode(v bool) {
|
||||
c.applyPlanMode(v)
|
||||
}
|
||||
|
||||
// SetAgentPreset is a deprecated compatibility shim. Reasonix runs one
|
||||
// adaptive standard execution; the preset no longer exists at runtime. The
|
||||
// call is accepted, validated for diagnostics, and ignored: it never rebuilds
|
||||
// the agent, never changes tool schemas, and never alters planning,
|
||||
// verification, or review behavior.
|
||||
// SetAgentPreset writes the role through to the session quality floor:
|
||||
// delivery/deliver/quality set the delivery floor, everything valid folds to
|
||||
// standard, unknown values are ignored for compat. It never rebuilds the
|
||||
// agent and never changes tool schemas.
|
||||
func (c *Controller) SetAgentPreset(preset string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
_ = agentpreset.Normalize(preset)
|
||||
if p, err := agentpreset.Normalize(preset); err == nil {
|
||||
_ = c.SetQualityFloor(string(p))
|
||||
}
|
||||
}
|
||||
|
||||
// AgentPreset returns the fixed compatibility label. One-version-old clients
|
||||
// read it from status snapshots; new code must not branch on it.
|
||||
// AgentPreset returns the session role label derived from the quality floor.
|
||||
func (c *Controller) AgentPreset() string {
|
||||
return string(agentpreset.Balanced)
|
||||
if c.QualityFloor() == QualityFloorDelivery {
|
||||
return string(agentpreset.Delivery)
|
||||
}
|
||||
return string(agentpreset.Standard)
|
||||
}
|
||||
|
||||
func (c *Controller) applyPlanMode(v bool) {
|
||||
c.mu.Lock()
|
||||
c.planMode = v
|
||||
c.sessionSettings.planMode = v
|
||||
c.mu.Unlock()
|
||||
if setter, ok := c.runner.(interface{ SetPlanMode(bool) }); ok {
|
||||
setter.SetPlanMode(v)
|
||||
@@ -2694,7 +2699,7 @@ func (c *Controller) SetReasoningLanguage(lang string) {
|
||||
func (c *Controller) PlanMode() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.planMode
|
||||
return c.sessionSettings.planMode
|
||||
}
|
||||
|
||||
// GoalStrict enables or disables strict goal mode. Since the structured
|
||||
|
||||
@@ -701,8 +701,9 @@ func TestPlainDeliveryReadinessFailureRetriesThenSurfacesRecoveryCard(t *testing
|
||||
textTurn("premature final"),
|
||||
textTurn("still incomplete"),
|
||||
}}
|
||||
// "implement main" is an unanchored mutation: the standard policy derives
|
||||
// closed-loop evidence without any selectable delivery profile.
|
||||
// "implement main" is an unanchored mutation. The delivery floor is what
|
||||
// turns its closed-loop evidence gap into a pause; the standard floor lets
|
||||
// the answer stand (TestStandardFloorNeverPausesOnReadinessGap).
|
||||
ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{}, event.Discard)
|
||||
done := make(chan event.Event, 1)
|
||||
c := New(Options{
|
||||
@@ -715,6 +716,9 @@ func TestPlainDeliveryReadinessFailureRetriesThenSurfacesRecoveryCard(t *testing
|
||||
}),
|
||||
})
|
||||
|
||||
if err := c.SetQualityFloor(QualityFloorDelivery); err != nil {
|
||||
t.Fatalf("SetQualityFloor: %v", err)
|
||||
}
|
||||
c.Submit("implement main")
|
||||
ev := <-done
|
||||
if ev.Readiness == nil || len(ev.Readiness.Missing) == 0 {
|
||||
|
||||
@@ -155,7 +155,7 @@ func (c *Controller) composeWithGoal(
|
||||
goal, goalStatus string,
|
||||
) string {
|
||||
c.mu.Lock()
|
||||
plan := c.planMode
|
||||
plan := c.sessionSettings.planMode
|
||||
responseLanguage := c.responseLanguage
|
||||
reasoningLanguage := c.reasoningLanguage
|
||||
c.mu.Unlock()
|
||||
|
||||
@@ -58,6 +58,7 @@ func plannerTurnMetadataFromContext(ctx context.Context) (plannerTurnMetadata, b
|
||||
func (c *Controller) withPlannerTurnMetadata(ctx context.Context, userText string, synthetic bool, priorMessages int) context.Context {
|
||||
text := strings.TrimSpace(agent.StripTransientUserBlocks(userText))
|
||||
constraints := runtimepolicy.ParseConstraints(runtimepolicy.StripQuotedConstraints(text))
|
||||
constraints.PolicyFloor = c.qualityFloorConstraint()
|
||||
if c.PlanMode() {
|
||||
constraints.PlanModeReadOnly = true
|
||||
constraints.ForbidMutation = true
|
||||
|
||||
@@ -115,11 +115,17 @@ type Goals interface {
|
||||
ResetPlannerSession()
|
||||
PlanMode() bool
|
||||
SetPlanMode(v bool)
|
||||
// AgentPreset is the session role setting (light|balanced|delivery).
|
||||
// AgentPreset is the session role setting (standard|delivery), derived
|
||||
// from the quality floor.
|
||||
AgentPreset() string
|
||||
// SetAgentPreset updates the role setting for subsequent turns without
|
||||
// rebuilding the controller.
|
||||
SetAgentPreset(preset string)
|
||||
// QualityFloor is the session delivery floor (standard|delivery).
|
||||
QualityFloor() string
|
||||
// SetQualityFloor updates the session delivery floor for subsequent
|
||||
// turns without rebuilding the controller.
|
||||
SetQualityFloor(floor string) error
|
||||
}
|
||||
|
||||
// SessionHistory covers checkpoint/rewind, branch/fork, and the log-restructuring
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/taskcontract"
|
||||
)
|
||||
|
||||
func errUnknownQualityFloor(raw string) error {
|
||||
return fmt.Errorf("unknown quality floor %q (accepted: standard, delivery; legacy light folds to standard)", raw)
|
||||
}
|
||||
|
||||
// Session quality floor. Records intent only: enforcement lives in the fact
|
||||
// contract, which reads the floor stamped on each write receipt. Standard is
|
||||
// the zero value; legacy light vocabulary folds to standard at parse time.
|
||||
|
||||
const (
|
||||
QualityFloorStandard = "standard"
|
||||
QualityFloorDelivery = "delivery"
|
||||
)
|
||||
|
||||
// NormalizeQualityFloor maps free-form and legacy mode labels onto the two
|
||||
// floor values. Light and its aliases fold to standard silently; unknown
|
||||
// values are an error so no new vocabulary can appear.
|
||||
func NormalizeQualityFloor(raw string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", QualityFloorStandard, "balanced", "full", "normal":
|
||||
return QualityFloorStandard, nil
|
||||
case QualityFloorDelivery, "deliver", "quality":
|
||||
return QualityFloorDelivery, nil
|
||||
case "light", "economy", "eco", "lite", "save", "saving", "low", "minimal":
|
||||
return QualityFloorStandard, nil
|
||||
default:
|
||||
return "", errUnknownQualityFloor(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func QualityFloorConstraint(floor string) taskcontract.PolicyFloor {
|
||||
if strings.TrimSpace(floor) == QualityFloorDelivery {
|
||||
return taskcontract.PolicyFloorDelivery
|
||||
}
|
||||
return taskcontract.PolicyFloorNone
|
||||
}
|
||||
|
||||
// QualityFloor returns the session floor's wire label.
|
||||
func (c *Controller) QualityFloor() string {
|
||||
if c == nil {
|
||||
return QualityFloorStandard
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.sessionSettings.qualityFloor == "" {
|
||||
return QualityFloorStandard
|
||||
}
|
||||
return c.sessionSettings.qualityFloor
|
||||
}
|
||||
|
||||
// SetQualityFloor updates the session quality floor for subsequent turns. It
|
||||
// never rebuilds the controller or touches the provider-visible prefix; prior
|
||||
// obligations keep their receipt stamps, so a downgrade drops only future
|
||||
// writes' floor treatment.
|
||||
func (c *Controller) SetQualityFloor(floor string) error {
|
||||
normalized, err := NormalizeQualityFloor(floor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.sessionSettings.qualityFloor = normalized
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) qualityFloorConstraint() taskcontract.PolicyFloor {
|
||||
c.mu.Lock()
|
||||
floor := c.sessionSettings.qualityFloor
|
||||
c.mu.Unlock()
|
||||
return QualityFloorConstraint(floor)
|
||||
}
|
||||
|
||||
// sessionSettings groups the per-session posture knobs (plan mode, quality
|
||||
// floor) that share one lifetime under c.mu.
|
||||
type sessionSettings struct {
|
||||
planMode bool
|
||||
qualityFloor string
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/taskcontract"
|
||||
)
|
||||
|
||||
func TestSetQualityFloorNormalizesVocabulary(t *testing.T) {
|
||||
c := New(Options{Label: "floor"})
|
||||
cases := map[string]string{
|
||||
"": QualityFloorStandard,
|
||||
"standard": QualityFloorStandard,
|
||||
"balanced": QualityFloorStandard,
|
||||
"full": QualityFloorStandard,
|
||||
"light": QualityFloorStandard,
|
||||
"economy": QualityFloorStandard,
|
||||
"eco": QualityFloorStandard,
|
||||
"lite": QualityFloorStandard,
|
||||
"minimal": QualityFloorStandard,
|
||||
"delivery": QualityFloorDelivery,
|
||||
"deliver": QualityFloorDelivery,
|
||||
"quality": QualityFloorDelivery,
|
||||
" DELIVERY ": QualityFloorDelivery,
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if err := c.SetQualityFloor(raw); err != nil {
|
||||
t.Fatalf("SetQualityFloor(%q): %v", raw, err)
|
||||
}
|
||||
if got := c.QualityFloor(); got != want {
|
||||
t.Fatalf("SetQualityFloor(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
if err := c.SetQualityFloor(QualityFloorDelivery); err != nil {
|
||||
t.Fatalf("SetQualityFloor(delivery): %v", err)
|
||||
}
|
||||
if err := c.SetQualityFloor("turbo"); err == nil {
|
||||
t.Fatal("unknown floor value must error")
|
||||
}
|
||||
if got := c.QualityFloor(); got != QualityFloorDelivery {
|
||||
t.Fatalf("rejected value must not change state, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityFloorConstraintReachesTurnConstraints(t *testing.T) {
|
||||
c := New(Options{Label: "floor"})
|
||||
if got := c.qualityFloorConstraint(); got != taskcontract.PolicyFloorNone {
|
||||
t.Fatalf("default floor constraint = %v, want none", got)
|
||||
}
|
||||
if err := c.SetQualityFloor(QualityFloorDelivery); err != nil {
|
||||
t.Fatalf("SetQualityFloor: %v", err)
|
||||
}
|
||||
if got := c.qualityFloorConstraint(); got != taskcontract.PolicyFloorDelivery {
|
||||
t.Fatalf("floor constraint = %v, want delivery", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetQualityFloorConcurrentWithReads(t *testing.T) {
|
||||
c := New(Options{Label: "floor"})
|
||||
var wg sync.WaitGroup
|
||||
for i := range 8 {
|
||||
wg.Add(2)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
floor := QualityFloorStandard
|
||||
if i%2 == 0 {
|
||||
floor = QualityFloorDelivery
|
||||
}
|
||||
_ = c.SetQualityFloor(floor)
|
||||
}(i)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
switch c.QualityFloor() {
|
||||
case QualityFloorStandard, QualityFloorDelivery:
|
||||
default:
|
||||
t.Errorf("QualityFloor returned invalid value %q", c.QualityFloor())
|
||||
}
|
||||
_ = c.qualityFloorConstraint()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -30,6 +30,11 @@ func readinessContinuationControllerWithOptions(t *testing.T, turns [][]provider
|
||||
prov := &scriptedTurns{turns: turns}
|
||||
executor := agent.New(prov, reg, agent.NewSession("stable-system-prefix"), opts, event.Discard)
|
||||
c := New(Options{Runner: executor, Executor: executor, Sink: sink})
|
||||
// Readiness continuation is a delivery-floor mechanism: the standard floor
|
||||
// never pauses on a readiness gap, so these turns run under delivery.
|
||||
if err := c.SetQualityFloor(QualityFloorDelivery); err != nil {
|
||||
t.Fatalf("SetQualityFloor: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
return c, prov
|
||||
}
|
||||
@@ -133,7 +138,62 @@ func TestReadinessProgressRequiresTwoDistinctEvidenceKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryGenericReadinessContinuationStopsAfterOneTurn(t *testing.T) {
|
||||
// The standard floor is the 1.23.0 balanced feel: a readiness gap is recorded
|
||||
// but never pauses the turn, so the user never meets a recovery card.
|
||||
func TestStandardFloorNeverPausesOnReadinessGap(t *testing.T) {
|
||||
c, prov := readinessContinuationController(t, [][]provider.Chunk{
|
||||
{toolCallChunk("write", "write_file", `{"path":"main.go","content":"package main"}`), {Type: provider.ChunkDone}},
|
||||
textTurn("implemented without checks"),
|
||||
}, event.Discard)
|
||||
if err := c.SetQualityFloor(QualityFloorStandard); err != nil {
|
||||
t.Fatalf("SetQualityFloor: %v", err)
|
||||
}
|
||||
|
||||
err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "update main.go", "update main.go", "")
|
||||
var readinessErr *agent.FinalReadinessError
|
||||
if errors.As(err, &readinessErr) {
|
||||
t.Fatalf("standard floor paused on a readiness gap: %v", err)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("standard floor turn returned error: %v", err)
|
||||
}
|
||||
if got := readinessSyntheticTurns(c); got != 0 {
|
||||
t.Fatalf("synthetic readiness turns = %d, want 0 under the standard floor", got)
|
||||
}
|
||||
if prov.call != 2 {
|
||||
t.Fatalf("provider calls = %d, want exactly the scripted work and answer", prov.call)
|
||||
}
|
||||
}
|
||||
|
||||
// Problem-one regression: the floor alone decides the pause. Goal mode used to
|
||||
// force it through deliveryScopeActive, so a standard-floor goal session still
|
||||
// met the recovery card. The goal FSM reads Agent.ReadinessResult directly, so
|
||||
// suppressing the pause costs it nothing.
|
||||
func TestStandardFloorSuppressesThePauseInsideGoalMode(t *testing.T) {
|
||||
c, _ := readinessContinuationController(t, [][]provider.Chunk{
|
||||
{toolCallChunk("write", "write_file", `{"path":"main.go","content":"package main"}`), {Type: provider.ChunkDone}},
|
||||
textTurn("implemented without checks"),
|
||||
}, event.Discard)
|
||||
if err := c.SetQualityFloor(QualityFloorStandard); err != nil {
|
||||
t.Fatalf("SetQualityFloor: %v", err)
|
||||
}
|
||||
c.SetGoal("ship main.go")
|
||||
t.Cleanup(c.ClearGoal)
|
||||
|
||||
err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "update main.go", "update main.go", "")
|
||||
var readinessErr *agent.FinalReadinessError
|
||||
if errors.As(err, &readinessErr) {
|
||||
t.Fatalf("standard floor paused inside goal mode: %v", err)
|
||||
}
|
||||
// The FSM still sees the gap through the executor, not through the error.
|
||||
if got := c.executor.ReadinessResult(); got.Ready {
|
||||
t.Fatal("executor reported ready despite the unmet readiness requirement")
|
||||
}
|
||||
}
|
||||
|
||||
// The delivery floor deepens verification; it does not take over persistence.
|
||||
// The generic continuation stays bounded exactly as it was before the floor.
|
||||
func TestDeliveryGenericReadinessContinuationStopsAfterOneTurn(t *testing.T) {
|
||||
c, prov := readinessContinuationController(t, [][]provider.Chunk{
|
||||
{toolCallChunk("write", "write_file", `{"path":"main.go","content":"package main"}`), {Type: provider.ChunkDone}},
|
||||
textTurn("implemented without checks"),
|
||||
@@ -147,9 +207,6 @@ func TestOrdinaryGenericReadinessContinuationStopsAfterOneTurn(t *testing.T) {
|
||||
if readinessErr.Attempts != 2 {
|
||||
t.Fatalf("readiness attempts = %d, want original + one automatic turn", readinessErr.Attempts)
|
||||
}
|
||||
if readinessErr.ContinuationClass != agent.ReadinessContinuationGeneric || readinessErr.ProgressKey == "" {
|
||||
t.Fatalf("readiness classification = (%q, %q), want generic with a progress key", readinessErr.ContinuationClass, readinessErr.ProgressKey)
|
||||
}
|
||||
if got := readinessSyntheticTurns(c); got != 1 {
|
||||
t.Fatalf("synthetic readiness turns = %d, want 1", got)
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
plan := c.planMode
|
||||
plan := c.sessionSettings.planMode
|
||||
c.mu.Unlock()
|
||||
if !plan {
|
||||
return nil
|
||||
|
||||
@@ -39,6 +39,10 @@ type Receipt struct {
|
||||
// Verification is the host's classification of a shell call: one of the
|
||||
// Verification* values. Empty means the host never classified this receipt.
|
||||
Verification string `json:"verification,omitempty"`
|
||||
// PolicyFloor is the session quality floor in force when this write was
|
||||
// committed ("delivery" or empty). Host-only replay fact: the contract
|
||||
// rebuild reads it back so a floor change never rewrites history.
|
||||
PolicyFloor string `json:"policy_floor,omitempty"`
|
||||
}
|
||||
|
||||
// ObserveOutput records the trimmed output size and a compact digest without
|
||||
|
||||
@@ -351,6 +351,8 @@ type Messages struct {
|
||||
// WorkModeDeprecatedNotice is shown once when a legacy /work-mode or
|
||||
// /profile command is used. Prefer /preset.
|
||||
WorkModeDeprecatedNotice string
|
||||
// QualityFloorApplied confirms a quality floor switch.
|
||||
QualityFloorApplied string
|
||||
RewindNone string
|
||||
RewindCodeConversation string
|
||||
RewindConversationOnly string
|
||||
|
||||
@@ -359,8 +359,9 @@ var English = Messages{
|
||||
RuntimeReloadQueued: "reload queued — will run when the current work finishes",
|
||||
RuntimeReloaded: "runtime reloaded",
|
||||
RuntimeReloadedGenerationFmt: "runtime reloaded (generation %d)",
|
||||
WorkModeUsage: "usage: /preset <light|balanced|delivery> (deprecated and ignored)",
|
||||
WorkModeDeprecatedNotice: "Reasonix now uses one adaptive standard execution: planning, verification, and review strength follow task risk automatically. Execution modes are no longer switchable; /preset is accepted for compatibility and ignored.",
|
||||
WorkModeUsage: "usage: /preset <standard|delivery>",
|
||||
WorkModeDeprecatedNotice: "Execution modes are now a session quality floor: /preset standard (default) or /preset delivery. Light has been folded into standard; the adaptive policy already runs light work lightly.",
|
||||
QualityFloorApplied: "Quality floor set: subsequent turns use the delivery completion gates.",
|
||||
RewindNone: "nothing to rewind yet",
|
||||
RewindCodeConversation: "Code + conversation",
|
||||
RewindConversationOnly: "Conversation only",
|
||||
|
||||
@@ -360,8 +360,9 @@ var Chinese = Messages{
|
||||
RuntimeReloadQueued: "已加入重载队列——当前工作完成后执行",
|
||||
RuntimeReloaded: "运行时已重载",
|
||||
RuntimeReloadedGenerationFmt: "运行时已重载(构建代号 %d)",
|
||||
WorkModeUsage: "用法:/preset <light|balanced|delivery>(已弃用并忽略)",
|
||||
WorkModeDeprecatedNotice: "Reasonix 已改用自适应标准执行:规划、验证与复查强度随任务风险自动调整。执行模式已不可切换;/preset 仅作兼容接受,不产生任何效果。",
|
||||
WorkModeUsage: "用法:/preset <standard|delivery>",
|
||||
WorkModeDeprecatedNotice: "执行模式已改为会话级质量底线:/preset standard(默认)或 /preset delivery。轻量已并入标准档;自适应策略本就会轻跑轻任务。",
|
||||
QualityFloorApplied: "质量底线已设置:后续轮次将使用交付级完成门槛。",
|
||||
RewindNone: "暂无可回滚的内容",
|
||||
RewindCodeConversation: "代码 + 对话",
|
||||
RewindConversationOnly: "仅对话",
|
||||
|
||||
@@ -333,8 +333,9 @@ var ChineseTraditional = Messages{
|
||||
RuntimeReloadQueued: "已加入重載佇列——目前工作完成後執行",
|
||||
RuntimeReloaded: "執行階段已重載",
|
||||
RuntimeReloadedGenerationFmt: "執行階段已重載(構建代號 %d)",
|
||||
WorkModeUsage: "用法:/preset <light|balanced|delivery>(已棄用並忽略)",
|
||||
WorkModeDeprecatedNotice: "Reasonix 已改用自適應標準執行:規劃、驗證與復查強度隨任務風險自動調整。執行模式已不可切換;/preset 僅作相容接受,不產生任何效果。",
|
||||
WorkModeUsage: "用法:/preset <standard|delivery>",
|
||||
WorkModeDeprecatedNotice: "執行模式已改為會話級質量底線:/preset standard(默認)或 /preset delivery。輕量已併入標準檔;自適應策略本就會輕跑輕任務。",
|
||||
QualityFloorApplied: "質量底線已設置:後續輪次將使用交付級完成門檻。",
|
||||
RewindNone: "暫無可回滾的內容",
|
||||
RewindCodeConversation: "程式碼 + 對話",
|
||||
RewindConversationOnly: "僅對話",
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/shellparse"
|
||||
"reasonix/internal/taskcontract"
|
||||
)
|
||||
|
||||
// Constraints are explicit user or host limits. They never encode task
|
||||
@@ -16,7 +17,10 @@ type Constraints struct {
|
||||
ForbidExternal bool
|
||||
RequireFullVerification bool
|
||||
PlanModeReadOnly bool
|
||||
Notes []string
|
||||
// PolicyFloor is the session quality floor, set from session state only —
|
||||
// never parsed from user text. It stamps receipts at write time.
|
||||
PolicyFloor taskcontract.PolicyFloor
|
||||
Notes []string
|
||||
}
|
||||
|
||||
// ParseConstraints accepts only explicit forbid/limit phrasing.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package taskcontract
|
||||
|
||||
import "reasonix/internal/evidence"
|
||||
|
||||
// PolicyFloor is the session-scoped quality floor a write was committed
|
||||
// under. The floor in force at write time is a fact of that write: it rides
|
||||
// the receipt so a pure contract replay re-derives the same obligations even
|
||||
// after the session floor changes.
|
||||
type PolicyFloor uint8
|
||||
|
||||
const (
|
||||
PolicyFloorNone PolicyFloor = iota
|
||||
PolicyFloorDelivery
|
||||
)
|
||||
|
||||
// ParsePolicyFloor maps the persisted wire label onto a floor. Unknown and
|
||||
// empty values are standard; legacy light vocabulary folds here too.
|
||||
func ParsePolicyFloor(raw string) PolicyFloor {
|
||||
switch raw {
|
||||
case "delivery", "deliver", "quality":
|
||||
return PolicyFloorDelivery
|
||||
default:
|
||||
return PolicyFloorNone
|
||||
}
|
||||
}
|
||||
|
||||
func (f PolicyFloor) String() string {
|
||||
if f == PolicyFloorDelivery {
|
||||
return "delivery"
|
||||
}
|
||||
return "standard"
|
||||
}
|
||||
|
||||
// ReceiptPolicyFloor reads the floor stamp from a receipt. Unstamped
|
||||
// historical receipts replay as standard.
|
||||
func ReceiptPolicyFloor(rec evidence.Receipt) PolicyFloor {
|
||||
return ParsePolicyFloor(rec.PolicyFloor)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package taskcontract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/evidence"
|
||||
)
|
||||
|
||||
func deliveryWriteReceipt(t *testing.T, path string, floor PolicyFloor) evidence.Receipt {
|
||||
t.Helper()
|
||||
return evidence.Receipt{
|
||||
ToolName: "edit_file", Success: true, Write: true, Mutation: true,
|
||||
Args: json.RawMessage(`{"path":"` + path + `"}`), Paths: []string{path},
|
||||
PolicyFloor: floor.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func fullVerifyReceipt(command string) evidence.Receipt {
|
||||
return evidence.Receipt{
|
||||
ToolName: "bash", Success: true, Command: command,
|
||||
Verification: evidence.VerificationPassed,
|
||||
}
|
||||
}
|
||||
|
||||
func hasObligationKind(c *Contract, kind ObligationKind, enf Enforcement, origin ReasonCode) bool {
|
||||
t := false
|
||||
_ = t
|
||||
for _, o := range c.Obligations {
|
||||
if o.Kind == kind && o.Enforcement == enf && (origin == "" || o.Origin == origin) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestDeliveryFloorWriteAddsStrictFullVerify(t *testing.T) {
|
||||
c := New("")
|
||||
c.AbsorbReceipt(1, deliveryWriteReceipt(t, "internal/agent/agent.go", PolicyFloorDelivery), "", false, false)
|
||||
if !hasObligationKind(c, ObligationFullVerify, EnforcementStrict, ReasonPolicyFloor) {
|
||||
t.Fatalf("delivery floor write missing strict full-verify obligation: %+v", c.Obligations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardWriteAddsNoFloorObligation(t *testing.T) {
|
||||
c := New("")
|
||||
c.AbsorbReceipt(1, deliveryWriteReceipt(t, "internal/agent/agent.go", PolicyFloorNone), "", false, false)
|
||||
if hasObligationKind(c, ObligationFullVerify, 0, ReasonPolicyFloor) {
|
||||
t.Fatalf("standard write must not create a floor obligation: %+v", c.Obligations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryFloorClearsOnFullVerification(t *testing.T) {
|
||||
c := New("")
|
||||
c.AbsorbReceipt(1, deliveryWriteReceipt(t, "internal/agent/agent.go", PolicyFloorDelivery), "", false, false)
|
||||
c.AbsorbReceipt(2, fullVerifyReceipt("go test ./..."), "", false, false)
|
||||
if hasUnsatisfiedFloorObligation(c) {
|
||||
t.Fatalf("full verification must clear the floor obligation: %+v", c.Obligations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryFloorTargetedVerificationDoesNotClear(t *testing.T) {
|
||||
c := New("")
|
||||
c.AbsorbReceipt(1, deliveryWriteReceipt(t, "internal/agent/agent.go", PolicyFloorDelivery), "", false, false)
|
||||
c.AbsorbReceipt(2, fullVerifyReceipt("go test ./internal/agent"), "", false, false)
|
||||
if !hasUnsatisfiedFloorObligation(c) {
|
||||
t.Fatalf("targeted verification must not clear the floor obligation: %+v", c.Obligations)
|
||||
}
|
||||
}
|
||||
|
||||
// The ratchet contract under pure replay: a delivery-floor write keeps its
|
||||
// strict obligation after the session floor drops, and a later standard write
|
||||
// gains none — the stamp, not the current floor, decides.
|
||||
func TestFloorReplaySurvivesSessionDowngrade(t *testing.T) {
|
||||
receipts := []evidence.Receipt{
|
||||
deliveryWriteReceipt(t, "internal/agent/agent.go", PolicyFloorDelivery),
|
||||
deliveryWriteReceipt(t, "internal/cli/cli.go", PolicyFloorNone),
|
||||
}
|
||||
rebuilt := Rebuild(RebuildFacts{Receipts: receipts})
|
||||
if !hasUnsatisfiedFloorObligation(rebuilt) {
|
||||
t.Fatalf("downgraded session must keep the delivery write's floor obligation: %+v", rebuilt.Obligations)
|
||||
}
|
||||
for _, o := range rebuilt.Obligations {
|
||||
if o.Origin == ReasonPolicyFloor && len(o.Targets) > 0 && string(o.Targets[0]) != "" && !containsTarget(o.Targets, "internal/agent/agent.go") {
|
||||
t.Fatalf("standard write must not gain a floor obligation: %+v", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestsForbiddenDowngradesFloorToAdvisory(t *testing.T) {
|
||||
c := New("")
|
||||
c.AbsorbReceipt(1, deliveryWriteReceipt(t, "internal/agent/agent.go", PolicyFloorDelivery), "", true, false)
|
||||
if !hasObligationKind(c, ObligationFullVerify, EnforcementAdvisory, ReasonPolicyFloor) {
|
||||
t.Fatalf("tests-forbidden floor obligation must be advisory: %+v", c.Obligations)
|
||||
}
|
||||
}
|
||||
|
||||
func hasUnsatisfiedFloorObligation(c *Contract) bool {
|
||||
for _, o := range c.Obligations {
|
||||
if o.Origin == ReasonPolicyFloor && !c.obligationSatisfied(o) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsTarget(targets []evidence.TargetKey, path string) bool {
|
||||
for _, t := range targets {
|
||||
if len(t) > 0 && t[len(t)-len(path):] == evidence.TargetKey(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestParsePolicyFloorFoldsLegacyVocabulary(t *testing.T) {
|
||||
for _, raw := range []string{"", "standard", "balanced", "light", "economy", "eco", "unknown"} {
|
||||
if got := ParsePolicyFloor(raw); got != PolicyFloorNone {
|
||||
t.Fatalf("ParsePolicyFloor(%q) = %v, want none", raw, got)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"delivery", "deliver", "quality"} {
|
||||
if got := ParsePolicyFloor(raw); got != PolicyFloorDelivery {
|
||||
t.Fatalf("ParsePolicyFloor(%q) = %v, want delivery", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ const (
|
||||
ReasonUserConstraint ReasonCode = "user_constraint"
|
||||
ReasonPlanBoundary ReasonCode = "plan_boundary"
|
||||
ReasonTestsForbidden ReasonCode = "tests_forbidden"
|
||||
ReasonPolicyFloor ReasonCode = "policy_floor"
|
||||
ReasonConflict ReasonCode = "constraint_conflict"
|
||||
)
|
||||
|
||||
|
||||
@@ -90,6 +90,13 @@ func (c *Contract) AbsorbReceipt(seq int, rec evidence.Receipt, workspaceRoot st
|
||||
}
|
||||
c.addObligation(newObligation(ObligationFullVerify, enforcement, ReasonUserConstraint, seq, profile.TargetKeys()))
|
||||
}
|
||||
if ReceiptPolicyFloor(rec) == PolicyFloorDelivery && (profile.WorkspaceWrite || profile.RepoMetadata) {
|
||||
enforcement := EnforcementStrict
|
||||
if testsForbidden {
|
||||
enforcement = EnforcementAdvisory
|
||||
}
|
||||
c.addObligation(newObligation(ObligationFullVerify, enforcement, ReasonPolicyFloor, seq, profile.TargetKeys()))
|
||||
}
|
||||
c.satisfyKindAfter(ObligationActionReceipt, seq, rec)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user