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:
SivanColaandLinearl committed 2026-08-18 18:33:20 +08:00
1 parent 69d597e219
commit bd8a8822b5
95 files changed
+1658 -444

No files matched your search

+51 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
-2
View File
@@ -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{},
+1 -1
View File
@@ -9,7 +9,7 @@ import (
)
type backgroundRuntimeController struct {
control.SessionAPI
stubSessionAPI
status control.RuntimeStatus
jobs []jobs.View
lease workspacelease.State
+1 -1
View File
@@ -11,7 +11,7 @@ import (
)
type billingRuntimeController struct {
control.SessionAPI
stubSessionAPI
status control.RuntimeStatus
started chan struct{}
release chan struct{}
+35
View File
@@ -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
}
+49
View File
@@ -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")
}
}
+2 -2
View File
@@ -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())
+5 -2
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
}
+2 -2
View File
@@ -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);
}
+22 -1
View File
@@ -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}
+49 -2
View File
@@ -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 ? (
+3 -1
View File
@@ -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) {},
+11 -2
View File
@@ -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) {},
};
}
+10 -7
View File
@@ -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 {
+14 -14
View File
@@ -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,
+5
View File
@@ -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.",
+5
View File
@@ -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": "暫停目標",
+5
View File
@@ -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": "部分验证未完成或受限,请查看改动详情。",
+53
View File
@@ -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;
+26 -24
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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")
}
}
+2 -4
View File
@@ -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)
+167
View File
@@ -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
}
-1
View File
@@ -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()
}
}()
+1 -1
View File
@@ -662,7 +662,7 @@ func TestAuthoritativeBotStylePersistIndexesSessionImmediately(t *testing.T) {
}
type retargetRuntimeController struct {
control.SessionAPI
stubSessionAPI
status control.RuntimeStatus
path string
}
+1
View File
@@ -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())
+15
View File
@@ -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
View File
@@ -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)
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
)
type tabScopedActionController struct {
control.SessionAPI
stubSessionAPI
history []provider.Message
newSessionCalls int
clearSessionCalls int
+80 -79
View File
@@ -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) {
+1 -1
View File
@@ -17,7 +17,7 @@ import (
)
type runtimeStatusSessionController struct {
control.SessionAPI
stubSessionAPI
status control.RuntimeStatus
}
+1 -1
View File
@@ -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
View File
@@ -27,7 +27,7 @@ reasonix acp --model deepseek-pro
```
客户端未覆盖模型时,`--model` 用于选择启动模型。普通请求一律进入 executor,
没有自动简单 / 轻量 / 完整任务模式;验证义务由宿主根据真实工具动作建立。
没有自动任务模式;唯一的会话角色是质量底线(standard/delivery),验证义务由宿主根据真实工具动作建立。
标准输出专用于 ACP 消息,Reasonix 会把诊断写入标准错误,因此 host 不应合并这两个
流。尚未配置 provider 时先运行 `reasonix setup`;initialize 响应也会声明一个启动
+1 -1
View File
@@ -402,7 +402,7 @@ SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键
切换模型或 effort 会重建运行时,同时保留当前对话、会话级权限覆盖、附加目录
访问权限和 session ownership。`/reload` 使用同一套失败原子重建语义。
普通请求一律进入 executor,没有自动简单 / 轻量 / 完整任务模式。
普通请求一律进入 executor,没有自动任务模式。唯一的会话角色是质量底线:standard(默认)或 delivery;事实仍可能高于它。
独立 Planner 只响应显式 Plan、批准边界和 Goal 启动。
用量统计使用独立的可丢弃 rollup 投影:
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)。
+1 -1
View File
@@ -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
+1 -1
View File
@@ -4,7 +4,7 @@ Reasonix 桌面端输入框下方的“询问 / 自动 / Yolo”控制的是工
工具权限和“协作方式”是两条独立轴:
- **协作方式**(普通 / 计划 / 目标)决定 Reasonix 怎么推进任务。没有自动简单 / 轻量 / 完整任务模式;验证义务由宿主根据真实工具动作建立。
- **协作方式**(普通 / 计划 / 目标)决定 Reasonix 怎么推进任务。没有自动任务模式;唯一的会话角色是质量底线(standard/delivery),验证义务由宿主根据真实工具动作建立。
- **工具权限**决定 Reasonix 调用受控工具时,是否需要你审批。
## 快速对比
+40
View File
@@ -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
}
+6 -7
View File
@@ -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
View File
@@ -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 {
+14 -4
View File
@@ -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" {
+3
View File
@@ -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
}
+7 -4
View File
@@ -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"`
+5
View File
@@ -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"})
}
+6 -1
View File
@@ -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) {
+3
View File
@@ -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...)
}
+18
View File
@@ -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
}
+3 -1
View File
@@ -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())
+11
View File
@@ -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)
+1 -1
View File
@@ -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,
+34 -42
View File
@@ -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)
}
+39
View File
@@ -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)
}
}
+24 -15
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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})
+109
View File
@@ -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
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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,
+3 -6
View File
@@ -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 {
+4 -2
View File
@@ -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
View File
@@ -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)
}
+3 -3
View File
@@ -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)
+27 -32
View File
@@ -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)
}
}
+20 -15
View File
@@ -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
+6 -2
View File
@@ -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 {
+1 -1
View File
@@ -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()
+1
View File
@@ -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
+7 -1
View File
@@ -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
+89
View File
@@ -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
}
+83
View File
@@ -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)
}
+1 -1
View File
@@ -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
+4
View File
@@ -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
+2
View File
@@ -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
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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: "仅对话",
+3 -2
View File
@@ -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 -1
View File
@@ -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.
+38
View File
@@ -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)
}
+127
View File
@@ -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)
}
}
}
+1
View File
@@ -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"
)
+7
View File
@@ -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
}