diff --git a/desktop/app.go b/desktop/app.go index 863dd16f4..73a5d58e8 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -392,6 +392,7 @@ func (a *App) restoreOrBuildTabs() { } tab.model = entry.Model tab.effort = cloneStringPtr(entry.Effort) + tab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode) tab.mode = persistedTabMode(entry.Mode) tab.goal = strings.TrimSpace(entry.Goal) tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode) @@ -445,6 +446,7 @@ func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *Wo WorkspaceRoot: workspaceRoot, TopicID: topicID, TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID), + tokenMode: boot.TokenModeFull, mode: "normal", toolApprovalMode: control.ToolApprovalAsk, disabledMCP: map[string]ServerView{}, @@ -1820,16 +1822,18 @@ func (a *App) jobsForCtrl(ctrl *control.Controller, out []JobView) []JobView { // Meta describes the session for the frontend's header and status line. type Meta struct { - Label string `json:"label"` - Ready bool `json:"ready"` - StartupErr string `json:"startupErr,omitempty"` - EventChannel string `json:"eventChannel"` - Cwd string `json:"cwd"` - AutoApproveTools bool `json:"autoApproveTools"` - Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval - ToolApprovalMode string `json:"toolApprovalMode"` - Goal string `json:"goal,omitempty"` - GoalStatus string `json:"goalStatus,omitempty"` + Label string `json:"label"` + Ready bool `json:"ready"` + StartupErr string `json:"startupErr,omitempty"` + EventChannel string `json:"eventChannel"` + Cwd string `json:"cwd"` + AutoApproveTools bool `json:"autoApproveTools"` + Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval + CollaborationMode string `json:"collaborationMode"` + ToolApprovalMode string `json:"toolApprovalMode"` + TokenMode string `json:"tokenMode"` + Goal string `json:"goal,omitempty"` + GoalStatus string `json:"goalStatus,omitempty"` } // Meta reports the model label, readiness, any startup error, the working @@ -1849,20 +1853,24 @@ func (a *App) MetaForTab(tabID string) Meta { cwd, _ = os.Getwd() } autoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools() + collaborationMode := currentTabCollaborationMode(tab) toolApprovalMode := currentTabToolApprovalMode(tab) + tokenMode := currentTabTokenMode(tab) goal := currentTabGoal(tab) goalStatus := currentTabGoalStatus(tab) return Meta{ - Label: tab.Label, - Ready: tab.Ready, - StartupErr: tab.StartupErr, - EventChannel: eventChannel, - Cwd: cwd, - AutoApproveTools: autoApproveTools, - Bypass: autoApproveTools, - ToolApprovalMode: toolApprovalMode, - Goal: goal, - GoalStatus: goalStatus, + Label: tab.Label, + Ready: tab.Ready, + StartupErr: tab.StartupErr, + EventChannel: eventChannel, + Cwd: cwd, + AutoApproveTools: autoApproveTools, + Bypass: autoApproveTools, + CollaborationMode: collaborationMode, + ToolApprovalMode: toolApprovalMode, + TokenMode: tokenMode, + Goal: goal, + GoalStatus: goalStatus, } } @@ -3378,6 +3386,7 @@ func (a *App) SetModelForTab(tabID, name string) error { WorkspaceRoot: tab.WorkspaceRoot, SessionDir: tabSessionDir(tab), EffortOverride: cloneStringPtr(effortOverride), + TokenMode: currentTabTokenMode(tab), }) if err != nil { return err @@ -3472,6 +3481,7 @@ func (a *App) SetEffortForTab(tabID, level string) error { WorkspaceRoot: tab.WorkspaceRoot, SessionDir: tabSessionDir(tab), EffortOverride: &effort, + TokenMode: currentTabTokenMode(tab), }) if err != nil { return err @@ -3499,6 +3509,73 @@ func (a *App) SetEffortForTab(tabID, level string) error { return nil } +func (a *App) SetTokenMode(mode string) error { + return a.SetTokenModeForTab("", mode) +} + +func (a *App) SetTokenModeForTab(tabID, mode string) error { + mode = boot.NormalizeTokenMode(mode) + tab := a.tabByID(tabID) + if tab == nil { + if strings.TrimSpace(tabID) == "" { + return nil + } + return fmt.Errorf("tab %q not found", tabID) + } + if mode == currentTabTokenMode(tab) { + return nil + } + ctrl := tab.Ctrl + if ctrl != nil && ctrl.Running() { + return fmt.Errorf("finish or cancel the current turn before changing token mode") + } + + var carried []provider.Message + prevPath := "" + oldCtrl := tab.Ctrl + if oldCtrl != nil { + prevPath = oldCtrl.SessionPath() + _ = oldCtrl.Snapshot() + carried = oldCtrl.History() + } + newCtrl, err := boot.Build(a.bootContext(), boot.Options{ + Model: tab.model, + RequireKey: false, + Sink: tab.sink, + WorkspaceRoot: tab.WorkspaceRoot, + SessionDir: tabSessionDir(tab), + EffortOverride: cloneStringPtr(tab.effort), + TokenMode: mode, + }) + if err != nil { + return err + } + a.bindControllerDisplayRecorder(newCtrl) + if oldCtrl != nil { + oldCtrl.Close() + } + a.mu.Lock() + tab.Ctrl = newCtrl + tab.tokenMode = mode + tab.Label = newCtrl.Label() + tab.StartupErr = "" + tab.Ready = true + a.saveTabsLocked() + a.mu.Unlock() + newCtrl.EnableInteractiveApproval() + applyTabModeToController(newCtrl, tab.mode) + applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode) + newCtrl.SetGoal(tab.goal) + path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label()) + if len(carried) > 0 { + newCtrl.Resume(&agent.Session{Messages: carried}, path) + } else if path != "" { + newCtrl.SetSessionPath(path) + } + a.persistTabSessionPath(tab, path) + return nil +} + func (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error { return a.applyConfigChange(func(cfg *config.Config) error { if _, ok := cfg.Provider(entry.Name); !ok { diff --git a/desktop/app_test.go b/desktop/app_test.go index fe5819849..6c3c357fb 100644 --- a/desktop/app_test.go +++ b/desktop/app_test.go @@ -975,6 +975,78 @@ func TestSetEffortRebuildsController(t *testing.T) { } } +func TestSetTokenModeRebuildsController(t *testing.T) { + isolateDesktopUserDirs(t) + + app := NewApp() + app.ctx = context.Background() + app.readyHook = func() {} + old := control.New(control.Options{Label: "old-controller"}) + app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash") + defer func() { + if c := app.activeCtrl(); c != nil { + c.Close() + } + }() + + if err := app.SetTokenMode("economy"); err != nil { + t.Fatalf("SetTokenMode(economy): %v", err) + } + if c := app.activeCtrl(); c == nil { + t.Fatal("SetTokenMode should leave a rebuilt controller") + } + if c := app.activeCtrl(); c == old { + t.Fatal("SetTokenMode should rebuild the active controller so the provider sees the new tool profile") + } + tab := app.activeTab() + if tab == nil { + t.Fatal("active tab missing") + } + if got := currentTabTokenMode(tab); got != "economy" { + t.Fatalf("token mode = %q, want economy", got) + } + if got := app.Meta().TokenMode; got != "economy" { + t.Fatalf("Meta token mode = %q, want economy", got) + } + saved := loadTabsFile() + if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != "economy" { + t.Fatalf("saved tabs = %+v, want economy token mode", saved.Tabs) + } +} + +func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) { + isolateDesktopUserDirs(t) + + app := NewApp() + app.ctx = context.Background() + app.readyHook = func() {} + old := control.New(control.Options{Label: "old-controller"}) + app.setTestCtrl(old, "missing-token-mode-model") + defer func() { + if c := app.activeCtrl(); c != nil { + c.Close() + } + }() + + err := app.SetTokenMode("economy") + if err == nil { + t.Fatal("SetTokenMode(economy) with an unknown model should fail") + } + if c := app.activeCtrl(); c != old { + t.Fatalf("SetTokenMode failure replaced controller: got %p want %p", c, old) + } + tab := app.activeTab() + if tab == nil { + t.Fatal("active tab missing") + } + if got := currentTabTokenMode(tab); got != "full" { + t.Fatalf("token mode after failed rebuild = %q, want full", got) + } + if got := app.Meta().TokenMode; got != "full" { + t.Fatalf("Meta token mode after failed rebuild = %q, want full", got) + } +} + func TestSetEffortRejectsRunningTurn(t *testing.T) { isolateDesktopUserDirs(t) @@ -993,6 +1065,24 @@ func TestSetEffortRejectsRunningTurn(t *testing.T) { waitNotRunning(t, app.activeCtrl()) } +func TestSetTokenModeRejectsRunningTurn(t *testing.T) { + isolateDesktopUserDirs(t) + + runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})} + app := NewApp() + app.setTestCtrl(control.New(control.Options{Runner: runner}), "") + app.activeCtrl().Submit("work") + <-runner.started + + err := app.SetTokenMode("economy") + if err == nil || !strings.Contains(err.Error(), "finish or cancel") { + t.Fatalf("SetTokenMode while running error = %v, want finish/cancel guard", err) + } + + close(runner.release) + waitNotRunning(t, app.activeCtrl()) +} + func TestSearchFileRefsFindsNestedBasename(t *testing.T) { orig, _ := os.Getwd() defer os.Chdir(orig) diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 769f915fb..f2f558efa 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -10,9 +10,9 @@ "check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css", "test:todo-visibility": "node scripts/test-todo-visibility.mjs", "typecheck": "tsc --noEmit", - "test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/goal-draft-mode.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts", + "test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts", "test:typecheck": "tsc --noEmit -p tsconfig.test.json", - "test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/goal-draft-mode.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts" + "test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts" }, "dependencies": { "@tanstack/react-virtual": "^3.14.2", diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index f24fb8154..b99159d0e 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -48,11 +48,6 @@ import { CopyButton } from "./components/CopyButton"; import { parseTodos } from "./lib/tools"; import { shouldShowTodoPanel } from "./lib/todoVisibility"; import { - modeHasAutoApproveTools, - modeHasPlan, - modeFromAxes, - normalizeMode, - normalizeToolApprovalMode, type BotConnectionView, type BotSettingsView, type CollaborationMode, @@ -63,15 +58,23 @@ import { type SettingsTab, type SettingsView, type TabMeta, + type TokenMode, type ToolApprovalMode, } from "./lib/types"; import { - controllerCollaborationMode, - displayedCollaborationMode, - keepGoalDraftMode, - metaSyncedCollaborationMode, - tabListCollaborationMode, -} from "./lib/goalDraftMode"; + composerProfileFromMeta, + composerProfileFromTab, + composerProfileMode, + composerProfileWithMode, + controllerComposerProfileCollaborationMode, + defaultComposerProfile, + displayedComposerProfileCollaborationMode, + hydrateComposerProfileFromMeta, + hydrateComposerProfilesFromTabs, + patchComposerProfile, + type ComposerProfile, + type ComposerProfileField, +} from "./lib/composerProfile"; import { restorableToolApprovalMode, toggleYoloToolApprovalMode, @@ -659,6 +662,7 @@ export default function App() { rewind, setModel, setEffort, + setTokenMode, switchTab, openProjectTab, openGlobalTab, @@ -669,12 +673,8 @@ export default function App() { } = useController(); const { locale, setPref: setLocalePref } = useI18n(); const t = useT(); - const [modesByTab, setModesByTab] = useState>({}); - const [collaborationModesByTab, setCollaborationModesByTab] = useState>({}); - const [toolApprovalModesByTab, setToolApprovalModesByTab] = useState>({}); + const [composerProfilesByTab, setComposerProfilesByTab] = useState>({}); const yoloRestoreToolApprovalModesRef = useRef>({}); - const [goalsByTab, setGoalsByTab] = useState>({}); - const [goalDraftModesByTab, setGoalDraftModesByTab] = useState>({}); const [tabMetas, setTabMetas] = useState([]); const [tabOrderIds, setTabOrderIds] = useState([]); const [tabRevealSignal, setTabRevealSignal] = useState(0); @@ -973,66 +973,47 @@ export default function App() { return currentTabTurns > 0 ? currentTabTurns : activeTopicTurns ?? 0; }, [activeTopicTurns, state.checkpoints.length, state.items]); const startupSplashHold = state.meta?.ready !== true && !state.meta?.startupErr; - const legacyMode = activeTabId ? modesByTab[activeTabId] ?? "normal" : "normal"; - const goal = activeTabId ? goalsByTab[activeTabId] ?? state.meta?.goal ?? activeTab?.goal ?? "" : ""; - const goalDraftMode = activeTabId ? Boolean(goalDraftModesByTab[activeTabId]) : false; - const collaborationMode = activeTabId - ? displayedCollaborationMode({ - goalDraftMode, - localMode: collaborationModesByTab[activeTabId], - metaGoal: state.meta?.goal, - tabMode: activeTab?.collaborationMode, - goal, - legacyMode, - }) - : "normal"; - const toolApprovalMode = activeTabId - ? toolApprovalModesByTab[activeTabId] ?? normalizeToolApprovalMode(state.meta?.toolApprovalMode ?? activeTab?.toolApprovalMode, legacyMode, state.meta?.autoApproveTools ?? state.meta?.bypass) - : "ask"; + const backendActiveComposerProfile = useMemo(() => { + if (state.meta) { + return composerProfileFromMeta(state.meta, activeTab ? composerProfileMode(composerProfileFromTab(activeTab)) : undefined); + } + return composerProfileFromTab(activeTab); + }, [activeTab, state.meta]); + const composerProfile = activeTabId + ? composerProfilesByTab[activeTabId] ?? backendActiveComposerProfile + : defaultComposerProfile; + const goal = composerProfile.goal; + const collaborationMode = displayedComposerProfileCollaborationMode(composerProfile); + const toolApprovalMode = composerProfile.toolApprovalMode; + const tokenMode: TokenMode = composerProfile.tokenMode; const controllerReady = state.meta?.ready === true; - const setMode = useCallback( - (next: Mode | ((prev: Mode) => Mode)) => { + const patchActiveComposerProfile = useCallback( + (patch: Partial>, pendingFields: ComposerProfileField[]) => { if (!activeTabId) return; - setModesByTab((current) => { - const prev = current[activeTabId] ?? "normal"; - const value = typeof next === "function" ? next(prev) : next; - if (value === prev) return current; - return { ...current, [activeTabId]: value }; - }); + setComposerProfilesByTab((current) => patchComposerProfile(current, activeTabId, composerProfile, patch, pendingFields)); }, - [activeTabId], + [activeTabId, composerProfile], ); - const setGoalDraftModeForTab = useCallback((tabId: string, enabled: boolean) => { - setGoalDraftModesByTab((current) => { - if (Boolean(current[tabId]) === enabled) return current; - if (enabled) return { ...current, [tabId]: true }; - const next = { ...current }; - delete next[tabId]; - return next; - }); - }, []); const topicbarEditing = Boolean(activeTab?.topicId && activeTab.topicId === renamingTopicId); const visibleTabId = activeTabId; const visibleTabs = useMemo(() => { const byId = new Map(tabMetas.map((tab) => [tab.id, tab])); const ordered = tabOrderIds.map((id) => byId.get(id)).filter((tab): tab is TabMeta => Boolean(tab)); const missing = tabMetas.filter((tab) => !tabOrderIds.includes(tab.id)); - return [...ordered, ...missing].map((tab) => ({ - ...tab, - running: tab.id === visibleTabId ? tab.running || state.running : tab.running, - mode: modesByTab[tab.id] ?? normalizeMode(tab.mode), - collaborationMode: tabListCollaborationMode({ - goalDraftMode: Boolean(goalDraftModesByTab[tab.id]), - localMode: collaborationModesByTab[tab.id], - tabMode: tab.collaborationMode, - tabGoal: goalsByTab[tab.id] ?? tab.goal, - legacyMode: normalizeMode(tab.mode), - }), - toolApprovalMode: toolApprovalModesByTab[tab.id] ?? normalizeToolApprovalMode(tab.toolApprovalMode, normalizeMode(tab.mode), tab.toolApprovalMode === "yolo"), - goal: goalsByTab[tab.id] ?? tab.goal ?? "", - active: tab.id === visibleTabId, - })); - }, [collaborationModesByTab, goalDraftModesByTab, goalsByTab, modesByTab, state.running, tabMetas, tabOrderIds, toolApprovalModesByTab, visibleTabId]); + return [...ordered, ...missing].map((tab) => { + const profile = composerProfilesByTab[tab.id] ?? composerProfileFromTab(tab); + return { + ...tab, + running: tab.id === visibleTabId ? tab.running || state.running : tab.running, + mode: composerProfileMode(profile), + collaborationMode: displayedComposerProfileCollaborationMode(profile), + toolApprovalMode: profile.toolApprovalMode, + tokenMode: profile.tokenMode, + goal: profile.goal, + active: tab.id === visibleTabId, + }; + }); + }, [composerProfilesByTab, state.running, tabMetas, tabOrderIds, visibleTabId]); useEffect(() => { const ids = tabMetas.map((tab) => tab.id); @@ -1050,79 +1031,8 @@ export default function App() { for (const id of Object.keys(yoloRestoreToolApprovalModesRef.current)) { if (!ids.has(id)) delete yoloRestoreToolApprovalModesRef.current[id]; } - setGoalDraftModesByTab((current) => { - let changed = false; - const next: Record = {}; - for (const tab of tabMetas) { - if (keepGoalDraftMode(Boolean(current[tab.id]), tab.goal)) { - next[tab.id] = true; - } else if (current[tab.id]) { - changed = true; - } - } - for (const id of Object.keys(current)) { - if (!ids.has(id)) changed = true; - } - return changed ? next : current; - }); - setModesByTab((current) => { - let changed = false; - const next: Record = {}; - for (const tab of tabMetas) { - const mode = normalizeMode(tab.mode); - next[tab.id] = mode; - if (current[tab.id] !== mode) changed = true; - } - for (const id of Object.keys(current)) { - if (!ids.has(id)) changed = true; - } - return changed ? next : current; - }); - setCollaborationModesByTab((current) => { - let changed = false; - const next: Record = {}; - for (const tab of tabMetas) { - const value = tabListCollaborationMode({ - goalDraftMode: keepGoalDraftMode(Boolean(goalDraftModesByTab[tab.id]), tab.goal), - tabMode: tab.collaborationMode, - tabGoal: tab.goal, - legacyMode: normalizeMode(tab.mode), - }); - next[tab.id] = value; - if (current[tab.id] !== value) changed = true; - } - for (const id of Object.keys(current)) { - if (!ids.has(id)) changed = true; - } - return changed ? next : current; - }); - setToolApprovalModesByTab((current) => { - let changed = false; - const next: Record = {}; - for (const tab of tabMetas) { - const value = normalizeToolApprovalMode(tab.toolApprovalMode, normalizeMode(tab.mode)); - next[tab.id] = value; - if (current[tab.id] !== value) changed = true; - } - for (const id of Object.keys(current)) { - if (!ids.has(id)) changed = true; - } - return changed ? next : current; - }); - setGoalsByTab((current) => { - let changed = false; - const next: Record = {}; - for (const tab of tabMetas) { - const value = tab.goal ?? ""; - next[tab.id] = value; - if (current[tab.id] !== value) changed = true; - } - for (const id of Object.keys(current)) { - if (!ids.has(id)) changed = true; - } - return changed ? next : current; - }); - }, [goalDraftModesByTab, tabMetas]); + setComposerProfilesByTab((current) => hydrateComposerProfilesFromTabs(current, tabMetas)); + }, [tabMetas]); useEffect(() => { if (!renamingTopicId || activeTab?.topicId === renamingTopicId) return; @@ -1134,14 +1044,8 @@ export default function App() { useEffect(() => { if (!activeTabId || !state.meta) return; - const nextGoal = state.meta.goalStatus === "running" ? state.meta.goal ?? "" : ""; - if (nextGoal) setGoalDraftModeForTab(activeTabId, false); - setGoalsByTab((current) => (current[activeTabId] === nextGoal ? current : { ...current, [activeTabId]: nextGoal })); - setCollaborationModesByTab((current) => { - const nextMode = metaSyncedCollaborationMode({ nextGoal, goalDraftMode, legacyMode }); - return current[activeTabId] === nextMode ? current : { ...current, [activeTabId]: nextMode }; - }); - }, [activeTabId, goalDraftMode, legacyMode, setGoalDraftModeForTab, state.meta]); + setComposerProfilesByTab((current) => hydrateComposerProfileFromMeta(current, activeTabId, state.meta!)); + }, [activeTabId, state.meta]); const syncModeToController = useCallback((m: Mode) => setControllerMode(m), [setControllerMode]); @@ -1155,37 +1059,22 @@ export default function App() { // normal clears both. const applyMode = useCallback( (m: Mode) => { - if (!activeTabId) return; - const nextCollaborationMode: CollaborationMode = modeHasPlan(m) ? "plan" : "normal"; - const nextToolApprovalMode: ToolApprovalMode = modeHasAutoApproveTools(m) ? "yolo" : "ask"; - setGoalDraftModeForTab(activeTabId, false); - setMode(m); - setCollaborationModesByTab((current) => (current[activeTabId] === nextCollaborationMode ? current : { ...current, [activeTabId]: nextCollaborationMode })); - setToolApprovalModesByTab((current) => (current[activeTabId] === nextToolApprovalMode ? current : { ...current, [activeTabId]: nextToolApprovalMode })); - setGoalsByTab((current) => (current[activeTabId] ? { ...current, [activeTabId]: "" } : current)); + patchActiveComposerProfile(composerProfileWithMode(m), ["collaborationMode", "toolApprovalMode", "goal"]); void syncModeToController(m); }, - [activeTabId, setGoalDraftModeForTab, setMode, syncModeToController], + [patchActiveComposerProfile, syncModeToController], ); const applyCollaborationMode = useCallback( (m: CollaborationMode) => { - if (!activeTabId) return; if (m === "goal") { - setGoalDraftModeForTab(activeTabId, true); - setCollaborationModesByTab((current) => (current[activeTabId] === "goal" ? current : { ...current, [activeTabId]: "goal" })); - setMode(modeFromAxes(false, toolApprovalMode === "yolo")); + patchActiveComposerProfile({ collaborationMode: "normal", goalDraftMode: true, goal: "" }, ["collaborationMode", "goal"]); void setControllerCollaborationMode("normal"); return; } - setGoalDraftModeForTab(activeTabId, false); - setCollaborationModesByTab((current) => (current[activeTabId] === m ? current : { ...current, [activeTabId]: m })); - if (m === "normal" || m === "plan") { - setGoalsByTab((current) => (current[activeTabId] ? { ...current, [activeTabId]: "" } : current)); - } - setMode(modeFromAxes(m === "plan", toolApprovalMode === "yolo")); + patchActiveComposerProfile({ collaborationMode: m, goalDraftMode: false, goal: "" }, ["collaborationMode", "goal"]); void setControllerCollaborationMode(m); }, - [activeTabId, setControllerCollaborationMode, setGoalDraftModeForTab, setMode, toolApprovalMode], + [patchActiveComposerProfile, setControllerCollaborationMode], ); const applyToolApprovalMode = useCallback( (m: ToolApprovalMode) => { @@ -1197,11 +1086,10 @@ export default function App() { } else { yoloRestoreToolApprovalModesRef.current[activeTabId] = restorableToolApprovalMode(m); } - setToolApprovalModesByTab((current) => (current[activeTabId] === m ? current : { ...current, [activeTabId]: m })); - setMode(modeFromAxes(collaborationMode === "plan", m === "yolo")); + patchActiveComposerProfile({ toolApprovalMode: m }, ["toolApprovalMode"]); void setControllerToolApprovalMode(m); }, - [activeTabId, collaborationMode, setControllerToolApprovalMode, setMode, toolApprovalMode], + [activeTabId, patchActiveComposerProfile, setControllerToolApprovalMode, toolApprovalMode], ); const toggleYoloApprovalMode = useCallback(() => { if (!activeTabId) return; @@ -1216,18 +1104,22 @@ export default function App() { }, [activeTabId, applyToolApprovalMode, toolApprovalMode]); const applyGoal = useCallback( (nextGoal: string) => { - if (!activeTabId) return; const trimmed = nextGoal.trim(); - setGoalDraftModeForTab(activeTabId, false); - setGoalsByTab((current) => (current[activeTabId] === trimmed ? current : { ...current, [activeTabId]: trimmed })); - setCollaborationModesByTab((current) => { - const nextMode = trimmed ? "goal" : "normal"; - return current[activeTabId] === nextMode ? current : { ...current, [activeTabId]: nextMode }; - }); - setMode(modeFromAxes(false, toolApprovalMode === "yolo")); + patchActiveComposerProfile({ + collaborationMode: trimmed ? "goal" : "normal", + goalDraftMode: false, + goal: trimmed, + }, ["collaborationMode", "goal"]); void (trimmed ? setControllerGoal(trimmed) : clearControllerGoal()); }, - [activeTabId, clearControllerGoal, setControllerGoal, setGoalDraftModeForTab, setMode, toolApprovalMode], + [clearControllerGoal, patchActiveComposerProfile, setControllerGoal], + ); + const applyTokenMode = useCallback( + (m: TokenMode) => { + patchActiveComposerProfile({ tokenMode: m }, ["tokenMode"]); + void setTokenMode(m); + }, + [patchActiveComposerProfile, setTokenMode], ); const startGoal = useCallback( (nextGoal: string) => { @@ -1250,11 +1142,11 @@ export default function App() { const switchModel = useCallback( async (name: string) => { await setModel(name); - await setControllerCollaborationMode(controllerCollaborationMode({ collaborationMode, goal })); + await setControllerCollaborationMode(controllerComposerProfileCollaborationMode(composerProfile)); await setControllerToolApprovalMode(toolApprovalMode); if (goal.trim()) await setControllerGoal(goal); }, - [collaborationMode, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, setModel, toolApprovalMode], + [composerProfile, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, setModel, toolApprovalMode], ); // Startup and workspace/model rebuilds create a fresh controller in normal @@ -1263,10 +1155,10 @@ export default function App() { // SetBypass binding was a harmless no-op. useEffect(() => { if (!controllerReady) return; - void setControllerCollaborationMode(controllerCollaborationMode({ collaborationMode, goal })); + void setControllerCollaborationMode(controllerComposerProfileCollaborationMode(composerProfile)); void setControllerToolApprovalMode(toolApprovalMode); if (goal.trim()) void setControllerGoal(goal); - }, [collaborationMode, controllerReady, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, toolApprovalMode]); + }, [composerProfile, controllerReady, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, toolApprovalMode]); // The live task list pinned above the composer comes from the most recent // successful top-level todo_write result; failed or still-running attempts do @@ -1446,12 +1338,12 @@ export default function App() { return; } if (runningRef.current) { steer(submitText.trim()); return; } - await setControllerCollaborationMode(collaborationMode); + await setControllerCollaborationMode(controllerComposerProfileCollaborationMode(composerProfile)); await setControllerToolApprovalMode(toolApprovalMode); if (goal.trim()) await setControllerGoal(goal); send(trimmed, submitText.trim()); }, - [applyGoal, closeTransientOverlays, collaborationMode, goal, send, runShell, notice, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, steer, switchModel, t, toolApprovalMode], + [applyGoal, closeTransientOverlays, collaborationMode, composerProfile, goal, send, runShell, notice, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, steer, switchModel, t, toolApprovalMode], ); const refreshTabMetas = useCallback(async (): Promise => { @@ -1828,7 +1720,7 @@ export default function App() { const handleTabClose = useCallback(async (id: string) => { closeTransientOverlays(); - setModesByTab((current) => { + setComposerProfilesByTab((current) => { if (!(id in current)) return current; const next = { ...current }; delete next[id]; @@ -2602,6 +2494,7 @@ export default function App() { running={state.running} collaborationMode={collaborationMode} toolApprovalMode={toolApprovalMode} + tokenMode={tokenMode} goal={goal} cwd={state.meta?.cwd} modelLabel={state.meta?.label ?? t("status.connecting")} @@ -2618,6 +2511,7 @@ export default function App() { onClearGoal={() => applyGoal("")} onSwitchModel={switchModel} onSetEffort={setEffort} + onSetTokenMode={applyTokenMode} insertRequest={composerInsertRequest} disabled={state.meta?.ready === false || state.messageAction != null || state.approval != null || state.ask != null || clearContextPending} decisionPending={state.messageAction != null || state.approval != null || state.ask != null || clearContextPending} diff --git a/desktop/frontend/src/__tests__/composer-profile.test.ts b/desktop/frontend/src/__tests__/composer-profile.test.ts new file mode 100644 index 000000000..d77087c6b --- /dev/null +++ b/desktop/frontend/src/__tests__/composer-profile.test.ts @@ -0,0 +1,142 @@ +// Run: tsx src/__tests__/composer-profile.test.ts + +import { + composerProfileMode, + controllerComposerProfileCollaborationMode, + displayedComposerProfileCollaborationMode, + hydrateComposerProfileFromMeta, + hydrateComposerProfilesFromTabs, + patchComposerProfile, + type ComposerProfilesByTab, +} from "../lib/composerProfile"; +import type { Meta, TabMeta } from "../lib/types"; + +let passed = 0; +let failed = 0; + +function eq(a: unknown, b: unknown, label: string) { + if (a === b) { + process.stdout.write(` PASS ${label}\n`); + passed += 1; + } else { + process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); + failed += 1; + } +} + +function tab(overrides: Partial = {}): TabMeta { + return { + id: "tab-1", + scope: "project", + workspaceRoot: "/repo", + workspaceName: "repo", + topicId: "topic-1", + topicTitle: "Topic", + label: "DeepSeek-R1", + ready: true, + running: false, + mode: "normal", + collaborationMode: "normal", + toolApprovalMode: "ask", + tokenMode: "full", + goal: "", + goalStatus: "stopped", + active: true, + cwd: "/repo", + ...overrides, + }; +} + +function meta(overrides: Partial = {}): Meta { + return { + label: "DeepSeek-R1", + ready: true, + eventChannel: "events", + cwd: "/repo", + autoApproveTools: false, + bypass: false, + collaborationMode: "normal", + toolApprovalMode: "ask", + tokenMode: "full", + goal: "", + goalStatus: "stopped", + ...overrides, + }; +} + +console.log("\ncomposer profile"); + +{ + let profiles: ComposerProfilesByTab = {}; + profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ tokenMode: "economy" })]); + profiles = patchComposerProfile( + profiles, + "tab-1", + profiles["tab-1"], + { collaborationMode: "normal", goalDraftMode: true, goal: "" }, + ["collaborationMode", "goal"], + ); + profiles = patchComposerProfile( + profiles, + "tab-1", + profiles["tab-1"], + { collaborationMode: "plan", goalDraftMode: false, goal: "" }, + ["collaborationMode", "goal"], + ); + + profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ tokenMode: "economy" })]); + + eq(displayedComposerProfileCollaborationMode(profiles["tab-1"]), "plan", "stale tab hydration keeps locally selected plan mode"); + eq(profiles["tab-1"].tokenMode, "economy", "token saver remains independent of collaboration mode changes"); + eq(composerProfileMode(profiles["tab-1"]), "plan", "compat mode keeps the plan axis enabled"); + eq(Boolean(profiles["tab-1"].pending.collaborationMode), true, "pending plan stays pending until backend acknowledges it"); + + profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ mode: "plan", collaborationMode: "plan", tokenMode: "economy" })]); + + eq(displayedComposerProfileCollaborationMode(profiles["tab-1"]), "plan", "acknowledged tab hydration keeps plan visible"); + eq(Boolean(profiles["tab-1"].pending.collaborationMode), false, "backend acknowledgement clears pending plan"); +} + +{ + let profiles: ComposerProfilesByTab = {}; + profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]); + profiles = patchComposerProfile(profiles, "tab-1", profiles["tab-1"], { tokenMode: "economy" }, ["tokenMode"]); + profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ tokenMode: "full" })); + + eq(profiles["tab-1"].tokenMode, "economy", "stale meta cannot erase a pending token saver selection"); + eq(Boolean(profiles["tab-1"].pending.tokenMode), true, "token saver stays pending while meta is stale"); + + profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ tokenMode: "economy" })); + + eq(profiles["tab-1"].tokenMode, "economy", "acknowledged token saver remains enabled"); + eq(Boolean(profiles["tab-1"].pending.tokenMode), false, "token saver pending clears after matching meta"); +} + +{ + let profiles: ComposerProfilesByTab = {}; + profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]); + profiles = patchComposerProfile( + profiles, + "tab-1", + profiles["tab-1"], + { collaborationMode: "normal", goalDraftMode: true, goal: "" }, + ["collaborationMode", "goal"], + ); + profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]); + + eq(displayedComposerProfileCollaborationMode(profiles["tab-1"]), "goal", "empty goal draft remains visible through stale tab hydration"); + eq(controllerComposerProfileCollaborationMode(profiles["tab-1"]), "normal", "empty goal draft syncs to controller as normal"); + eq(composerProfileMode(profiles["tab-1"]), "normal", "empty goal draft does not enable plan compatibility mode"); +} + +{ + let profiles: ComposerProfilesByTab = {}; + profiles = hydrateComposerProfilesFromTabs(profiles, [tab(), tab({ id: "tab-2" })]); + profiles = patchComposerProfile(profiles, "tab-2", profiles["tab-2"], { tokenMode: "economy" }, ["tokenMode"]); + profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]); + + eq(Boolean(profiles["tab-2"]), false, "tab hydration removes profiles for closed tabs"); +} + +console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); +if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/__tests__/goal-draft-mode.test.ts b/desktop/frontend/src/__tests__/goal-draft-mode.test.ts deleted file mode 100644 index 16266fdbe..000000000 --- a/desktop/frontend/src/__tests__/goal-draft-mode.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Run: tsx src/__tests__/goal-draft-mode.test.ts - -import { - controllerCollaborationMode, - displayedCollaborationMode, - keepGoalDraftMode, - metaSyncedCollaborationMode, - tabListCollaborationMode, -} from "../lib/goalDraftMode"; - -let passed = 0; -let failed = 0; - -function eq(a: unknown, b: unknown, label: string) { - if (a === b) { - process.stdout.write(` PASS ${label}\n`); - passed += 1; - } else { - process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); - failed += 1; - } -} - -console.log("\ngoal draft mode"); - -eq( - displayedCollaborationMode({ goalDraftMode: true, localMode: "normal", goal: "" }), - "goal", - "draft goal mode wins over stale local normal mode", -); - -eq( - tabListCollaborationMode({ goalDraftMode: true, tabMode: "normal", tabGoal: "" }), - "goal", - "tab list keeps draft goal mode visible before a goal is started", -); - -eq( - metaSyncedCollaborationMode({ nextGoal: "", goalDraftMode: true, legacyMode: "normal" }), - "goal", - "empty controller meta does not collapse a draft goal mode", -); - -eq( - controllerCollaborationMode({ collaborationMode: "goal", goal: "" }), - "normal", - "empty draft goal syncs to the controller as normal mode", -); - -eq( - controllerCollaborationMode({ collaborationMode: "goal", goal: "ship the fix" }), - "goal", - "started goal syncs to the controller as goal mode", -); - -eq( - keepGoalDraftMode(true, ""), - true, - "draft flag is retained while goal text is empty", -); - -eq( - keepGoalDraftMode(true, "ship the fix"), - false, - "draft flag clears after a real goal exists", -); - -eq( - metaSyncedCollaborationMode({ nextGoal: "", goalDraftMode: false, legacyMode: "plan" }), - "plan", - "non-draft empty goal falls back to legacy plan mode", -); - -console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); -if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/__tests__/use-controller-meta.test.ts b/desktop/frontend/src/__tests__/use-controller-meta.test.ts new file mode 100644 index 000000000..d373803ca --- /dev/null +++ b/desktop/frontend/src/__tests__/use-controller-meta.test.ts @@ -0,0 +1,44 @@ +// Run: tsx src/__tests__/use-controller-meta.test.ts + +import { sameMeta } from "../lib/useController"; +import type { Meta } from "../lib/types"; + +let passed = 0; +let failed = 0; + +function eq(a: unknown, b: unknown, label: string) { + if (a === b) { + process.stdout.write(` PASS ${label}\n`); + passed += 1; + } else { + process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); + failed += 1; + } +} + +function meta(overrides: Partial = {}): Meta { + return { + label: "DeepSeek-R1", + ready: true, + eventChannel: "events", + cwd: "/repo", + autoApproveTools: false, + bypass: false, + collaborationMode: "normal", + toolApprovalMode: "ask", + tokenMode: "full", + goal: "", + goalStatus: "stopped", + ...overrides, + }; +} + +console.log("\nuse controller meta"); + +{ + eq(sameMeta(meta(), meta()), true, "identical meta is unchanged"); + eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality"); +} + +console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); +if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index 26ead899a..047770dce 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -8,7 +8,7 @@ import { app, onFilesDropped } from "../lib/bridge"; import { SPINNER_WORDS, useI18n } from "../lib/i18n"; import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences"; import { useToast } from "../lib/toast"; -import { type CollaborationMode, type CommandInfo, type ComposerInsertRequest, type DirEntry, type EffortInfo, type HistoryMessage, type Mode, type SessionMeta, type SessionReference, type SlashArgItem, type SlashArgsResult, type ToolApprovalMode } from "../lib/types"; +import { type CollaborationMode, type CommandInfo, type ComposerInsertRequest, type DirEntry, type EffortInfo, type HistoryMessage, type Mode, type SessionMeta, type SessionReference, type SlashArgItem, type SlashArgsResult, type TokenMode, type ToolApprovalMode } from "../lib/types"; import { formatWorkspaceReference, parseWorkspaceReference, @@ -322,6 +322,7 @@ export function Composer({ running, collaborationMode, toolApprovalMode, + tokenMode, goal, cwd, modelLabel, @@ -338,6 +339,7 @@ export function Composer({ onClearGoal, onSwitchModel, onSetEffort, + onSetTokenMode, insertRequest, disabled, decisionPending = false, @@ -350,6 +352,7 @@ export function Composer({ running: boolean; collaborationMode: CollaborationMode; toolApprovalMode: ToolApprovalMode; + tokenMode: TokenMode; goal?: string; cwd?: string; modelLabel: string; @@ -368,6 +371,7 @@ export function Composer({ onClearGoal: () => void; onSwitchModel: (name: string) => void; onSetEffort: (level: string) => void; + onSetTokenMode: (mode: TokenMode) => void; insertRequest?: ComposerInsertRequest | null; disabled?: boolean; decisionPending?: boolean; @@ -813,6 +817,7 @@ export function Composer({ const planModeOn = collaborationMode === "plan"; const activeGoal = (goal ?? "").trim(); const goalModeOn = collaborationMode === "goal"; + const tokenModeOn = tokenMode === "economy"; const submit = async () => { if (disabled || submittingRef.current) return; @@ -1479,6 +1484,12 @@ export function Composer({ requestAnimationFrame(() => taRef.current?.focus()); }); }; + const chooseTokenMode = () => { + closeIntentMenu(() => { + onSetTokenMode(tokenModeOn ? "full" : "economy"); + requestAnimationFrame(() => taRef.current?.focus()); + }); + }; const effortLevels = asArray(effort?.levels); const currentEffort = effort?.current || "auto"; const hasEffort = Boolean(effort?.supported && effortLevels.length > 0); @@ -1502,7 +1513,7 @@ export function Composer({ const composerMetaClass = [ "composer-meta", hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort", - planModeOn || goalModeOn ? "composer-meta--has-intent-chip" : "composer-meta--no-intent-chip", + planModeOn || goalModeOn || tokenModeOn ? "composer-meta--has-intent-chip" : "composer-meta--no-intent-chip", ].join(" "); return ( @@ -1553,6 +1564,22 @@ export function Composer({ + )} + {tokenModeOn && ( + + + + )}
diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index ab264e81b..b3f7006cd 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -8,7 +8,7 @@ import type * as GeneratedApp from "../../wailsjs/go/main/App"; import { t } from "./i18n"; -import { modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeToolApprovalMode } from "./types"; +import { modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeTokenMode, normalizeToolApprovalMode } from "./types"; import type { BalanceInfo, @@ -191,6 +191,8 @@ export interface AppBindings { SetEffort(level: string): Promise; EffortForTab(tabID: string): Promise; SetEffortForTab(tabID: string, level: string): Promise; + SetTokenMode(mode: string): Promise; + SetTokenModeForTab(tabID: string, mode: string): Promise; Memory(): Promise; MemorySuggestions(): Promise; AcceptMemorySuggestion(suggestion: MemorySuggestion): Promise; @@ -1075,6 +1077,7 @@ function makeMockApp(): AppBindings { mode: "normal", collaborationMode: "normal", toolApprovalMode: "ask", + tokenMode: "full", active: true, cwd: globalWorkspaceRoot, }, @@ -1093,6 +1096,7 @@ function makeMockApp(): AppBindings { mode: "normal", collaborationMode: "normal", toolApprovalMode: "ask", + tokenMode: "full", active: true, cwd: "~/projects/joyquant-db", }, @@ -1110,6 +1114,7 @@ function makeMockApp(): AppBindings { mode: "normal", collaborationMode: "normal", toolApprovalMode: "ask", + tokenMode: "full", active: false, cwd: "~/projects/joyquant-sys", }, @@ -1126,6 +1131,7 @@ function makeMockApp(): AppBindings { mode: "normal", collaborationMode: "normal", toolApprovalMode: "ask", + tokenMode: "full", active: false, cwd: "~/projects/joyquant-db", }, @@ -1673,6 +1679,7 @@ function makeMockApp(): AppBindings { const active = mockTabs.find((tab) => tab.active) ?? mockTabs[0]; const toolApprovalMode = normalizeToolApprovalMode(active?.toolApprovalMode, active ? normalizeMode(active.mode) : "normal", settings.autoApproveTools); const autoApproveTools = toolApprovalMode === "yolo"; + const collaborationMode = normalizeCollaborationMode(active?.collaborationMode, active?.goal, active ? normalizeMode(active.mode) : "normal"); return { label: active?.label ?? "DeepSeek-R1", ready: active?.ready ?? true, @@ -1680,7 +1687,9 @@ function makeMockApp(): AppBindings { cwd: active?.cwd || cwd, autoApproveTools, bypass: autoApproveTools, + collaborationMode, toolApprovalMode, + tokenMode: normalizeTokenMode(active?.tokenMode), goal: active?.goal ?? "", goalStatus: active?.goalStatus ?? (active?.goal ? "running" : "stopped"), }; @@ -1689,6 +1698,7 @@ function makeMockApp(): AppBindings { const tab = mockTabs.find((item) => item.id === tabID) ?? mockTabs.find((item) => item.active) ?? mockTabs[0]; const toolApprovalMode = normalizeToolApprovalMode(tab?.toolApprovalMode, tab ? normalizeMode(tab.mode) : "normal", settings.autoApproveTools); const autoApproveTools = toolApprovalMode === "yolo"; + const collaborationMode = normalizeCollaborationMode(tab?.collaborationMode, tab?.goal, tab ? normalizeMode(tab.mode) : "normal"); return { label: tab?.label ?? "DeepSeek-R1", ready: tab?.ready ?? true, @@ -1696,7 +1706,9 @@ function makeMockApp(): AppBindings { cwd: tab?.cwd || cwd, autoApproveTools, bypass: autoApproveTools, + collaborationMode, toolApprovalMode, + tokenMode: normalizeTokenMode(tab?.tokenMode), goal: tab?.goal ?? "", goalStatus: tab?.goalStatus ?? (tab?.goal ? "running" : "stopped"), }; @@ -2027,6 +2039,14 @@ function makeMockApp(): AppBindings { async SetEffortForTab(_tabID, level) { await this.SetEffort(level); }, + async SetTokenMode(mode: string) { + const active = mockTabs.find((tab) => tab.active); + if (active) await this.SetTokenModeForTab(active.id, mode); + }, + async SetTokenModeForTab(tabID, mode) { + const tokenMode = normalizeTokenMode(mode); + mockTabs = mockTabs.map((tab) => (tab.id === tabID ? { ...tab, tokenMode } : tab)); + }, async Memory() { return { available: true, @@ -2413,6 +2433,7 @@ function makeMockApp(): AppBindings { mode: "normal", collaborationMode: "normal", toolApprovalMode: "ask", + tokenMode: "full", active: true, cwd: workspaceRoot, }; @@ -2438,6 +2459,7 @@ function makeMockApp(): AppBindings { mode: "normal", collaborationMode: "normal", toolApprovalMode: "ask", + tokenMode: "full", active: true, cwd: "", }; diff --git a/desktop/frontend/src/lib/composerProfile.ts b/desktop/frontend/src/lib/composerProfile.ts new file mode 100644 index 000000000..cb4163ca7 --- /dev/null +++ b/desktop/frontend/src/lib/composerProfile.ts @@ -0,0 +1,204 @@ +import { + modeFromAxes, + modeHasAutoApproveTools, + modeHasPlan, + normalizeCollaborationMode, + normalizeMode, + normalizeTokenMode, + normalizeToolApprovalMode, + type CollaborationMode, + type GoalStatus, + type Meta, + type Mode, + type TabMeta, + type TokenMode, + type ToolApprovalMode, +} from "./types"; + +export type ComposerProfileField = "collaborationMode" | "toolApprovalMode" | "tokenMode" | "goal"; + +export type ComposerProfilePending = Partial>; + +export interface ComposerProfile { + collaborationMode: CollaborationMode; + goalDraftMode: boolean; + toolApprovalMode: ToolApprovalMode; + tokenMode: TokenMode; + goal: string; + pending: ComposerProfilePending; +} + +export type ComposerProfilesByTab = Record; + +const profileFields: ComposerProfileField[] = ["collaborationMode", "toolApprovalMode", "tokenMode", "goal"]; + +export const defaultComposerProfile: ComposerProfile = Object.freeze({ + collaborationMode: "normal", + goalDraftMode: false, + toolApprovalMode: "ask", + tokenMode: "full", + goal: "", + pending: {}, +}); + +function activeGoal(goal?: string, status?: GoalStatus): string { + const trimmed = (goal ?? "").trim(); + if (!trimmed) return ""; + if (status && status !== "running") return ""; + return trimmed; +} + +function profileWithPending(profile: Omit, pending: ComposerProfilePending = {}): ComposerProfile { + return { ...profile, pending }; +} + +export function composerProfileFromTab(tab?: TabMeta | null): ComposerProfile { + if (!tab) return { ...defaultComposerProfile, pending: {} }; + const legacyMode = normalizeMode(tab.mode); + const goal = activeGoal(tab.goal, tab.goalStatus); + return profileWithPending({ + collaborationMode: normalizeCollaborationMode(tab.collaborationMode, goal, legacyMode), + goalDraftMode: false, + toolApprovalMode: normalizeToolApprovalMode(tab.toolApprovalMode, legacyMode, tab.toolApprovalMode === "yolo"), + tokenMode: normalizeTokenMode(tab.tokenMode), + goal, + }); +} + +export function composerProfileFromMeta(meta?: Meta | null, legacyMode?: Mode): ComposerProfile { + if (!meta) return { ...defaultComposerProfile, pending: {} }; + const fallbackMode = normalizeMode(legacyMode); + const goal = activeGoal(meta.goal, meta.goalStatus); + const toolApprovalMode = normalizeToolApprovalMode(meta.toolApprovalMode, fallbackMode, meta.autoApproveTools ?? meta.bypass); + return profileWithPending({ + collaborationMode: normalizeCollaborationMode(meta.collaborationMode, goal, fallbackMode), + goalDraftMode: false, + toolApprovalMode, + tokenMode: normalizeTokenMode(meta.tokenMode), + goal, + }); +} + +function fieldValue(profile: ComposerProfile, field: ComposerProfileField): string { + return profile[field]; +} + +function assignField(profile: ComposerProfile, field: ComposerProfileField, value: string) { + switch (field) { + case "collaborationMode": + profile.collaborationMode = value as CollaborationMode; + return; + case "toolApprovalMode": + profile.toolApprovalMode = value as ToolApprovalMode; + return; + case "tokenMode": + profile.tokenMode = value as TokenMode; + return; + case "goal": + profile.goal = value; + return; + } +} + +function profilesEqual(a: ComposerProfile | undefined, b: ComposerProfile | undefined): boolean { + if (!a || !b) return a === b; + return a.collaborationMode === b.collaborationMode + && a.goalDraftMode === b.goalDraftMode + && a.toolApprovalMode === b.toolApprovalMode + && a.tokenMode === b.tokenMode + && a.goal === b.goal + && profileFields.every((field) => Boolean(a.pending[field]) === Boolean(b.pending[field])); +} + +export function reconcileComposerProfile(current: ComposerProfile | undefined, backend: ComposerProfile): ComposerProfile { + if (!current) return { ...backend, pending: {} }; + + const pending: ComposerProfilePending = {}; + const next: ComposerProfile = { ...backend, pending }; + + for (const field of profileFields) { + if (!current.pending[field]) continue; + if (fieldValue(current, field) === fieldValue(backend, field)) continue; + pending[field] = true; + assignField(next, field, fieldValue(current, field)); + } + + if (current.goalDraftMode && !backend.goal && !next.goal) { + next.goalDraftMode = true; + } + if (next.goal) { + next.goalDraftMode = false; + } + + return next; +} + +export function hydrateComposerProfilesFromTabs(current: ComposerProfilesByTab, tabs: TabMeta[]): ComposerProfilesByTab { + const next: ComposerProfilesByTab = {}; + let changed = false; + + for (const tab of tabs) { + const profile = reconcileComposerProfile(current[tab.id], composerProfileFromTab(tab)); + next[tab.id] = profile; + if (!profilesEqual(current[tab.id], profile)) changed = true; + } + + for (const id of Object.keys(current)) { + if (!next[id]) changed = true; + } + + return changed ? next : current; +} + +export function hydrateComposerProfileFromMeta(current: ComposerProfilesByTab, tabId: string, meta: Meta): ComposerProfilesByTab { + const previous = current[tabId]; + const backend = composerProfileFromMeta(meta, previous ? composerProfileMode(previous) : undefined); + const profile = reconcileComposerProfile(previous, backend); + if (profilesEqual(previous, profile)) return current; + return { ...current, [tabId]: profile }; +} + +export function patchComposerProfile( + current: ComposerProfilesByTab, + tabId: string, + base: ComposerProfile | undefined, + patch: Partial>, + pendingFields: ComposerProfileField[], +): ComposerProfilesByTab { + const previous = current[tabId] ?? base ?? defaultComposerProfile; + const pending: ComposerProfilePending = { ...previous.pending }; + for (const field of pendingFields) pending[field] = true; + const profile: ComposerProfile = { + ...previous, + ...patch, + pending, + }; + if (profile.goal) { + profile.goalDraftMode = false; + } + if (profilesEqual(previous, profile)) return current; + return { ...current, [tabId]: profile }; +} + +export function composerProfileMode(profile: ComposerProfile): Mode { + return modeFromAxes(profile.collaborationMode === "plan", profile.toolApprovalMode === "yolo"); +} + +export function displayedComposerProfileCollaborationMode(profile: ComposerProfile): CollaborationMode { + if (profile.goalDraftMode) return "goal"; + return profile.collaborationMode; +} + +export function controllerComposerProfileCollaborationMode(profile: ComposerProfile): CollaborationMode { + const displayed = displayedComposerProfileCollaborationMode(profile); + return displayed === "goal" && !profile.goal ? "normal" : displayed; +} + +export function composerProfileWithMode(mode: Mode): Partial> { + return { + collaborationMode: modeHasPlan(mode) ? "plan" : "normal", + goalDraftMode: false, + toolApprovalMode: modeHasAutoApproveTools(mode) ? "yolo" : "ask", + goal: "", + }; +} diff --git a/desktop/frontend/src/lib/goalDraftMode.ts b/desktop/frontend/src/lib/goalDraftMode.ts deleted file mode 100644 index 851d1bc3c..000000000 --- a/desktop/frontend/src/lib/goalDraftMode.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - normalizeCollaborationMode, - type CollaborationMode, - type Mode, -} from "./types"; - -export function keepGoalDraftMode(current: boolean, goal?: string): boolean { - return current && !(goal ?? "").trim(); -} - -export function displayedCollaborationMode(params: { - goalDraftMode: boolean; - localMode?: CollaborationMode; - metaGoal?: string; - tabMode?: string; - goal?: string; - legacyMode?: Mode; -}): CollaborationMode { - if (params.goalDraftMode) return "goal"; - return params.localMode ?? normalizeCollaborationMode(params.metaGoal ? "goal" : params.tabMode, params.goal, params.legacyMode); -} - -export function tabListCollaborationMode(params: { - goalDraftMode: boolean; - localMode?: CollaborationMode; - tabMode?: string; - tabGoal?: string; - legacyMode?: Mode; -}): CollaborationMode { - if (params.goalDraftMode) return "goal"; - return params.localMode ?? normalizeCollaborationMode(params.tabMode, params.tabGoal, params.legacyMode); -} - -export function metaSyncedCollaborationMode(params: { - nextGoal?: string; - goalDraftMode: boolean; - legacyMode?: Mode; -}): CollaborationMode { - return params.nextGoal || params.goalDraftMode - ? "goal" - : normalizeCollaborationMode(undefined, "", params.legacyMode); -} - -export function controllerCollaborationMode(params: { - collaborationMode: CollaborationMode; - goal?: string; -}): CollaborationMode { - return params.collaborationMode === "goal" && !params.goal?.trim() - ? "normal" - : params.collaborationMode; -} diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index 786c0a121..c1855bc96 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -134,6 +134,7 @@ export interface TabMeta { mode: Mode; collaborationMode?: CollaborationMode; toolApprovalMode?: ToolApprovalMode; + tokenMode?: TokenMode; goal?: string; goalStatus?: GoalStatus; startupErr?: string; @@ -285,13 +286,16 @@ export interface Meta { cwd: string; autoApproveTools?: boolean; bypass?: boolean; // legacy JSON key for YOLO/full-access tool auto-approval + collaborationMode?: CollaborationMode; toolApprovalMode?: ToolApprovalMode; + tokenMode?: TokenMode; goal?: string; goalStatus?: GoalStatus; } export type CollaborationMode = "normal" | "plan" | "goal"; export type ToolApprovalMode = "ask" | "auto" | "yolo"; +export type TokenMode = "full" | "economy"; export type GoalStatus = "running" | "complete" | "blocked" | "stopped"; export function normalizeCollaborationMode(mode?: string, goal?: string, legacyMode?: Mode): CollaborationMode { @@ -307,6 +311,11 @@ export function normalizeToolApprovalMode(mode?: string, legacyMode?: Mode, lega return "ask"; } +export function normalizeTokenMode(mode?: string): TokenMode { + if (mode === "economy") return "economy"; + return "full"; +} + // Mode is the compatibility string for two independent composer axes: // plan (read-only/user-plan gate) and yolo/full access (tool auto-approval). export type Mode = "normal" | "plan" | "yolo" | "plan-yolo"; diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 4f55f18c7..f439c2158 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -23,6 +23,7 @@ import type { QuestionAnswer, SessionMeta, TabMeta, + TokenMode, ToolApprovalMode, WireApproval, WireAsk, @@ -117,7 +118,7 @@ function usageTotalTokens(usage?: WireUsage): number { return Math.max(0, promptTokens + usage.completionTokens); } -function sameMeta(a?: Meta, b?: Meta): boolean { +export function sameMeta(a?: Meta, b?: Meta): boolean { if (a === b) return true; if (!a || !b) return false; return ( @@ -128,7 +129,9 @@ function sameMeta(a?: Meta, b?: Meta): boolean { a.cwd === b.cwd && a.autoApproveTools === b.autoApproveTools && a.bypass === b.bypass && + a.collaborationMode === b.collaborationMode && a.toolApprovalMode === b.toolApprovalMode && + a.tokenMode === b.tokenMode && a.goal === b.goal && a.goalStatus === b.goalStatus ); @@ -928,6 +931,21 @@ export function useController() { } catch { /* ignore */ } }, [activeTabId, dispatchTo]); + const setTokenMode = useCallback(async (mode: TokenMode) => { + if (!activeTabId) return; + try { + await app.SetTokenModeForTab(activeTabId, mode); + } catch (err) { + dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: t("status.tokenModeSwitchFailed", { err: errorMessage(err) }) }); + return; + } + try { + dispatchTo(activeTabId, { type: "meta", meta: await app.MetaForTab(activeTabId) }); + dispatchTo(activeTabId, { type: "context", context: await app.ContextUsageForTab(activeTabId) }); + dispatchTo(activeTabId, { type: "effort", effort: await app.EffortForTab(activeTabId) }); + } catch { /* ignore */ } + }, [activeTabId, dispatchTo]); + const fetchMemory = useCallback((): Promise => app.Memory().catch(() => ({ docs: [], facts: [], archives: [], scopes: [], storeDir: "", available: false })), []); const remember = useCallback(async (scope: string, note: string) => { await app.Remember(scope, note).catch(() => {}); }, []); @@ -1024,7 +1042,7 @@ export function useController() { activeTabId, send, runShell, steer, notice, cancel, approve, answerQuestion, setControllerMode, setCollaborationMode, setToolApprovalMode, setGoal, clearGoal, newSession, clearSession, listSessions, listTrashedSessions, resumeSession, previewSession, deleteSession, restoreSession, purgeTrashedSession, renameSession, - refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, setModel, setEffort, + refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, setModel, setEffort, setTokenMode, fetchMemory, remember, forget, saveDoc, switchTab, openProjectTab, openGlobalTab, ensureBlankTab, closeTab, reorderTabs, syncActiveTab: syncActiveTabFromBackend, diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 390a75b9e..0221da314 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -295,6 +295,11 @@ export const en = { "composer.goalMode": "goal mode", "composer.goalModeDesc": "Type a goal, then start goal mode.", "composer.goalModeActiveDesc": "Keeps working until complete, blocked, or stopped.", + "composer.tokenEconomy": "token saver", + "composer.tokenEconomyShort": "eco", + "composer.tokenEconomyDesc": "Keeps the initial tools and skill index lean; enables extras on demand.", + "composer.tokenEconomyOnDesc": "Initial context is lean; skills, MCP, CodeGraph, and related sources are enabled on demand.", + "composer.tokenEconomyExitTitle": "Turn off token saver", "composer.goalInputPlaceholder": "Enter a goal…", "composer.goalInputRequired": "Enter a goal", "composer.planHint": "shift+tab", @@ -377,6 +382,7 @@ export const en = { "status.switchModel": "Switch model", "status.noModels": "no switchable models", "status.modelSwitchFailed": "Model switch failed: {err}", + "status.tokenModeSwitchFailed": "Token saver switch failed: {err}", "status.effort": "effort {level}", "status.effortTitle": "Reasoning effort", "status.effortAutoTitle": "Reasoning effort: auto (model default: {def})", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 9175b3c65..5f98078cb 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -296,6 +296,11 @@ export const zh: Record = { "composer.goalMode": "目标模式", "composer.goalModeDesc": "先输入目标,再点目标启动。", "composer.goalModeActiveDesc": "会自动推进,直到完成、阻塞或停止。", + "composer.tokenEconomy": "省 token", + "composer.tokenEconomyShort": "省", + "composer.tokenEconomyDesc": "精简初始工具和技能索引,需要时再启用。", + "composer.tokenEconomyOnDesc": "已精简初始上下文;需要技能、MCP、CodeGraph 等时会按需启用。", + "composer.tokenEconomyExitTitle": "关闭省 token 模式", "composer.goalInputPlaceholder": "请输入目标…", "composer.goalInputRequired": "请输入目标", "composer.planHint": "shift+tab", @@ -378,6 +383,7 @@ export const zh: Record = { "status.switchModel": "切换模型", "status.noModels": "没有可切换模型", "status.modelSwitchFailed": "模型切换失败:{err}", + "status.tokenModeSwitchFailed": "省 token 模式切换失败:{err}", "status.effort": "effort {level}", "status.effortTitle": "推理力度", "status.effortAutoTitle": "推理力度:auto(模型默认:{def})", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 6594bec67..b00444dce 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -4316,6 +4316,10 @@ a[href] { max-width: min(132px, 24vw); } +.composer-meta--has-intent-chip .composer-meta__control--intent { + max-width: none; +} + .composer-meta__control--approval { flex: 0 0 232px; min-width: 216px; @@ -4616,6 +4620,38 @@ a[href] { transform: none; } +.composer-mode-chip--plan, +.composer-mode-chip--goal { + flex: 0 0 72px; + min-width: 72px; + max-width: 72px; +} + +.composer-mode-chip--plan .composer-mode-chip__label, +.composer-mode-chip--goal .composer-mode-chip__label { + overflow: visible; + text-overflow: clip; +} + +.composer-mode-chip--token { + flex: 0 0 62px; + min-width: 62px; + max-width: 62px; + gap: 4px; + padding: 0 8px 0 7px; + border-color: color-mix(in srgb, #10b981 34%, var(--border)); + background: color-mix(in srgb, var(--surface-2) 82%, #10b981 10%); +} + +.composer-mode-chip--token .composer-mode-chip__icon--mode { + color: #10b981; +} + +.composer-mode-chip--token .composer-mode-chip__label { + overflow: visible; + text-overflow: clip; +} + .composer-meta .modelsw { width: 100%; max-width: 100%; @@ -4663,10 +4699,6 @@ a[href] { max-width: 148px; } - .composer-meta__control--intent { - max-width: 118px; - } - .composer-meta__control--approval { flex-basis: 178px; min-width: 164px; @@ -4708,10 +4740,6 @@ a[href] { max-width: 126px; } - .composer-meta__control--intent { - max-width: 106px; - } - .composer-meta__control--approval { flex-basis: 148px; min-width: 144px; @@ -17995,6 +18023,11 @@ a[href] { box-shadow: none; } +:root[data-theme-style] .composer-mode-chip--token { + border-color: color-mix(in srgb, #10b981 32%, var(--border)); + background: color-mix(in srgb, var(--bg-elev-2) 82%, #10b981 10%); +} + :root[data-theme-style] .composer-modebar__thumb, :root[data-theme-style] .composer-modebar__item { border-radius: 6px; diff --git a/desktop/new_session_inherit_test.go b/desktop/new_session_inherit_test.go index 7ec5ecc82..8a68d8a08 100644 --- a/desktop/new_session_inherit_test.go +++ b/desktop/new_session_inherit_test.go @@ -26,6 +26,7 @@ func TestEnsureBlankTabInheritsActiveTabSettings(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": {}}, @@ -51,6 +52,9 @@ func TestEnsureBlankTabInheritsActiveTabSettings(t *testing.T) { if created.effort == nil || *created.effort != "max" { t.Fatalf("effort = %v, want inherited \"max\"", created.effort) } + if created.tokenMode != "economy" { + t.Fatalf("tokenMode = %q, want inherited \"economy\"", created.tokenMode) + } if created.toolApprovalMode != control.ToolApprovalYolo { t.Fatalf("toolApprovalMode = %q, want inherited %q", created.toolApprovalMode, control.ToolApprovalYolo) } diff --git a/desktop/settings_app.go b/desktop/settings_app.go index add71c68f..64298f8e0 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -608,6 +608,7 @@ func (a *App) rebuild() error { WorkspaceRoot: tab.WorkspaceRoot, SessionDir: tabSessionDir(tab), EffortOverride: cloneStringPtr(tab.effort), + TokenMode: currentTabTokenMode(tab), }) if err != nil { a.mu.Lock() diff --git a/desktop/tab_profile_test.go b/desktop/tab_profile_test.go index 632274f02..168059dde 100644 --- a/desktop/tab_profile_test.go +++ b/desktop/tab_profile_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "reasonix/internal/boot" "reasonix/internal/control" ) @@ -165,6 +166,39 @@ func TestSaveTabsPersistsModelAndEffort(t *testing.T) { } } +func TestSaveTabsPersistsTokenModeOnlyWhenEconomy(t *testing.T) { + isolateDesktopUserDirs(t) + + app := NewApp() + tab := testTab("a", t.TempDir()) + tab.tokenMode = "economy" + app.tabs = map[string]*WorkspaceTab{tab.ID: tab} + app.tabOrder = []string{tab.ID} + app.activeTabID = tab.ID + + app.mu.Lock() + app.saveTabsLocked() + app.mu.Unlock() + + got := loadTabsFile() + if len(got.Tabs) != 1 { + t.Fatalf("tabs len = %d, want 1", len(got.Tabs)) + } + if got.Tabs[0].TokenMode != "economy" { + t.Fatalf("saved token mode = %q, want economy", got.Tabs[0].TokenMode) + } + + tab.tokenMode = "full" + app.mu.Lock() + app.saveTabsLocked() + app.mu.Unlock() + + got = loadTabsFile() + if got.Tabs[0].TokenMode != "" { + t.Fatalf("full token mode should be omitted from persistence, got %q", got.Tabs[0].TokenMode) + } +} + // TestSaveTabsPersistsYoloMode is the regression for #3517: yolo used to be // dropped on save, so relaunching reverted to normal. It now round-trips through // the real saveTabsLocked/loadTabsFile path. @@ -340,18 +374,28 @@ func TestMetaReportsGoalStatus(t *testing.T) { if meta.GoalStatus != control.GoalStatusStopped { t.Fatalf("initial goal status = %q, want stopped", meta.GoalStatus) } + if meta.CollaborationMode != "normal" { + t.Fatalf("initial collaboration mode = %q, want normal", meta.CollaborationMode) + } app.SetGoalForTab(tab.ID, "finish the goal runner") meta = app.MetaForTab(tab.ID) - if meta.Goal != "finish the goal runner" || meta.GoalStatus != control.GoalStatusRunning { + if meta.Goal != "finish the goal runner" || meta.GoalStatus != control.GoalStatusRunning || meta.CollaborationMode != "goal" { t.Fatalf("goal meta = %+v, want running goal", meta) } app.ClearGoalForTab(tab.ID) meta = app.MetaForTab(tab.ID) - if meta.Goal != "" || meta.GoalStatus != control.GoalStatusStopped { + if meta.Goal != "" || meta.GoalStatus != control.GoalStatusStopped || meta.CollaborationMode != "normal" { t.Fatalf("cleared goal meta = %+v, want stopped empty goal", meta) } + + app.SetCollaborationModeForTab(tab.ID, "plan") + tab.tokenMode = boot.TokenModeEconomy + meta = app.MetaForTab(tab.ID) + if meta.CollaborationMode != "plan" || meta.TokenMode != boot.TokenModeEconomy { + t.Fatalf("profile meta = %+v, want plan + economy", meta) + } } func TestSetPlanModePreservesAutoApproveTools(t *testing.T) { diff --git a/desktop/tabs.go b/desktop/tabs.go index 43ba4d76d..8b83caba6 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -58,6 +58,7 @@ type WorkspaceTab struct { model string // active model ref (for meta) effort *string + tokenMode string mode string // "normal" | "plan" | "yolo" | "plan-yolo"; yolo/full access is runtime-only goal string toolApprovalMode string @@ -430,6 +431,7 @@ type TabMeta struct { Mode string `json:"mode"` CollaborationMode string `json:"collaborationMode"` ToolApprovalMode string `json:"toolApprovalMode"` + TokenMode string `json:"tokenMode"` Goal string `json:"goal,omitempty"` GoalStatus string `json:"goalStatus,omitempty"` StartupErr string `json:"startupErr,omitempty"` @@ -450,6 +452,7 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta { Mode: currentTabMode(tab), CollaborationMode: currentTabCollaborationMode(tab), ToolApprovalMode: currentTabToolApprovalMode(tab), + TokenMode: currentTabTokenMode(tab), Goal: currentTabGoal(tab), GoalStatus: currentTabGoalStatus(tab), StartupErr: tab.StartupErr, @@ -514,6 +517,7 @@ func (a *App) OpenProjectTab(workspaceRoot, topicID string) (TabMeta, error) { WorkspaceRoot: workspaceRoot, TopicID: topicID, TopicTitle: topicTitle, + tokenMode: boot.TokenModeFull, mode: "normal", toolApprovalMode: control.ToolApprovalAsk, disabledMCP: map[string]ServerView{}, @@ -558,6 +562,7 @@ func (a *App) OpenGlobalTab(topicID string) (TabMeta, error) { WorkspaceRoot: globalRoot, TopicID: topicID, TopicTitle: topicTitle, + tokenMode: boot.TokenModeFull, mode: "normal", toolApprovalMode: control.ToolApprovalAsk, disabledMCP: map[string]ServerView{}, @@ -622,10 +627,11 @@ func (a *App) EnsureBlankTab(scope, workspaceRoot string) (TabMeta, error) { } } - // Inherit model, effort, mode, tool-approval, and MCP state from the + // Inherit model, effort, token mode, mode, tool-approval, and MCP state from the // active tab so a new blank session keeps the same settings (#4019). var inheritedModel string var inheritedEffort *string + inheritedTokenMode := boot.TokenModeFull inheritedMode := "normal" inheritedToolApprovalMode := control.ToolApprovalAsk inheritedDisabledMCP := map[string]ServerView{} @@ -633,6 +639,7 @@ func (a *App) EnsureBlankTab(scope, workspaceRoot string) (TabMeta, error) { if active := a.activeTabLocked(); active != nil { inheritedModel = active.model inheritedEffort = cloneStringPtr(active.effort) + inheritedTokenMode = currentTabTokenMode(active) inheritedMode = currentTabMode(active) inheritedToolApprovalMode = currentTabToolApprovalMode(active) inheritedDisabledMCP = cloneServerViewMap(active.disabledMCP) @@ -653,6 +660,7 @@ func (a *App) EnsureBlankTab(scope, workspaceRoot string) (TabMeta, error) { TopicTitle: topicTitle, model: inheritedModel, effort: inheritedEffort, + tokenMode: inheritedTokenMode, mode: inheritedMode, toolApprovalMode: inheritedToolApprovalMode, disabledMCP: inheritedDisabledMCP, @@ -700,6 +708,7 @@ func (a *App) EnsureBlankTab(scope, workspaceRoot string) (TabMeta, error) { TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID), model: inheritedModel, effort: inheritedEffort, + tokenMode: inheritedTokenMode, mode: inheritedMode, toolApprovalMode: inheritedToolApprovalMode, disabledMCP: inheritedDisabledMCP, @@ -970,6 +979,7 @@ func (a *App) buildTabController(tab *WorkspaceTab) { WorkspaceRoot: root, SessionDir: sessionDir, EffortOverride: cloneStringPtr(tab.effort), + TokenMode: currentTabTokenMode(tab), }) if err != nil { a.mu.Lock() @@ -1314,6 +1324,7 @@ type desktopTabEntry struct { SessionPath string `json:"sessionPath,omitempty"` Model string `json:"model,omitempty"` Effort *string `json:"effort,omitempty"` + TokenMode string `json:"tokenMode,omitempty"` Mode string `json:"mode,omitempty"` Goal string `json:"goal,omitempty"` ToolApprovalMode string `json:"toolApprovalMode,omitempty"` @@ -1347,6 +1358,7 @@ func (a *App) saveTabsLocked() { SessionPath: tab.currentSessionPath(), Model: tab.model, Effort: cloneStringPtr(tab.effort), + TokenMode: persistedTabTokenMode(currentTabTokenMode(tab)), Mode: persistedTabMode(currentTabMode(tab)), Goal: strings.TrimSpace(currentTabGoal(tab)), ToolApprovalMode: persistedToolApprovalMode(currentTabToolApprovalMode(tab)), @@ -3096,6 +3108,21 @@ func currentTabToolApprovalMode(tab *WorkspaceTab) string { return normalizeToolApprovalMode(tab.toolApprovalMode) } +func currentTabTokenMode(tab *WorkspaceTab) string { + if tab == nil { + return boot.TokenModeFull + } + return boot.NormalizeTokenMode(tab.tokenMode) +} + +func persistedTabTokenMode(mode string) string { + mode = boot.NormalizeTokenMode(mode) + if mode == boot.TokenModeEconomy { + return mode + } + return "" +} + func normalizeToolApprovalMode(mode string) string { switch strings.ToLower(strings.TrimSpace(mode)) { case control.ToolApprovalAuto: diff --git a/docs/COLLABORATION_MODES.zh-CN.md b/docs/COLLABORATION_MODES.zh-CN.md new file mode 100644 index 000000000..fd9730162 --- /dev/null +++ b/docs/COLLABORATION_MODES.zh-CN.md @@ -0,0 +1,106 @@ +# 协作方式:计划模式、目标模式与省 token 模式 + +Reasonix 桌面端输入框左下角的“协作方式”菜单包含三类常用工作模式: + +- **计划模式**:先只读分析并产出计划,确认后再执行。 +- **目标模式**:给 Reasonix 一个目标,让它持续推进直到完成、阻塞或停止。 +- **省 token 模式**:精简初始上下文和工具列表,需要时再按需启用能力。 + +其中,计划模式和目标模式属于同一条“协作方式”轴,通常二选一;省 token 模式是独立开关,可以和普通聊天、计划模式、目标模式一起使用。 + +## 计划模式 + +计划模式适合在动手前先确认方案。开启后,Reasonix 会先读取必要上下文、分析问题并给出计划;在你确认前,不会执行写文件、改代码、提交、删除、发布等有副作用的操作。 + +### 怎么开启 + +- 点击输入框左下角的“协作方式”按钮,选择“计划”。 +- 也可以使用 `Shift+Tab` 切换计划模式。 +- 开启后输入框下方会显示“计划”标签;点击该标签或再次使用 `Shift+Tab` 可退出。 + +### 建议使用场景 + +- 你还不确定实现方案,希望先看 Reasonix 的拆解。 +- 改动范围可能跨多个文件、模块或配置。 +- 需要先评估风险、测试面、兼容性或发布影响。 +- 你希望先让 Reasonix 只读代码和文档,再决定是否继续实施。 + +### 注意事项 + +- 计划模式不是“自动完成任务”。它会先暂停在计划阶段,等待你确认。 +- 计划模式会减少误改风险,但会多一次确认步骤。 +- 如果你已经明确要直接改一个小问题,普通模式通常更快。 +- 计划模式只控制“先规划再执行”的流程,不等于省 token;如果要减少初始上下文开销,可以同时开启省 token 模式。 + +## 目标模式 + +目标模式适合给 Reasonix 一个更长线的目标,让它持续推进。目标启动后,Reasonix 会围绕该目标工作,直到任务完成、遇到阻塞、被你停止,或需要你确认关键决策。 + +### 怎么开启 + +- 点击“协作方式”按钮,选择“目标”。 +- 如果输入框里已有文字,选择“目标”会把当前文字作为目标启动。 +- 如果输入框为空,选择“目标”后会进入目标输入状态,输入目标并发送即可启动。 +- 开启后输入框下方会显示“目标”标签;点击该标签可退出目标模式。 + +### 建议使用场景 + +- 你希望 Reasonix 连续完成一组相关步骤,例如“修复这个问题并补测试”。 +- 任务需要探索、实现、验证多个阶段。 +- 你希望减少中途反复下指令,让 Reasonix 在目标范围内持续推进。 + +### 注意事项 + +- 目标要写得具体。推荐包含范围、成功标准和限制,例如“只改桌面端输入栏,补前端测试,不改后端协议”。 +- 目标模式不是跳过审批。遇到高风险操作、权限限制、阻塞或需要产品判断时,仍可能停下来询问。 +- 如果目标过大或边界不清,Reasonix 可能需要更多探索轮次,也会消耗更多 token。 +- 目标模式和计划模式是同一协作轴。切到计划模式时,会退出目标草稿/目标显示状态;省 token 模式不会因此关闭。 + +## 省 token 模式 + +省 token 模式用于降低每轮请求中固定携带的上下文和工具 schema 开销。开启后,Reasonix 初始不会一次性带上完整技能索引、MCP、CodeGraph/LSP、部分内置工具等能力;当任务确实需要这些能力时,再按需启用。 + +### 怎么开启 + +- 点击“协作方式”按钮,选择“省 token”。 +- 开启后输入框下方会显示“省”标签。 +- 点击“省”标签,或再次在菜单里选择“省 token”,可关闭该模式。 + +### 建议使用场景 + +- 日常问答、解释代码、读小段文件。 +- 修复小问题、普通实现任务。 +- 用户反馈同类问题 token 消耗明显偏高。 +- 你不确定是否需要 Skills、MCP、CodeGraph/LSP 的大多数聊天。 + +### 建议关闭场景 + +- 你明确知道一开始就要重度使用多个 Skills、MCP、CodeGraph 或 LSP。 +- 你更在意第一轮就暴露完整能力,不想多一次按需启用步骤。 +- 复杂自动化任务固定会调用很多外部工具。 + +### 注意事项 + +- 省 token 模式主要省的是“每轮请求固定带上的提示词和工具 schema token”。 +- 它不会强制缩短回答,也不会降低模型推理能力。 +- 复杂任务一旦按需启用了工具,后续该花的 token 仍然会花。 +- 按需启用能力可能让第一次使用某类工具时多一个衔接步骤,这是用更低初始 token 换来的取舍。 + +## 三种模式如何组合 + +| 组合 | 是否支持 | 说明 | +| --- | --- | --- | +| 普通 + 省 token | 支持 | 日常聊天的低开销默认选择。 | +| 计划 + 省 token | 支持 | 先低开销读上下文和出计划,确认后再执行。 | +| 目标 + 省 token | 支持 | 目标推进时先保持初始上下文精简,需要工具再启用。 | +| 计划 + 目标 | 不建议同时使用 | 两者都是协作方式轴,切换计划会退出目标草稿/目标显示状态。 | +| 工具权限(询问/自动/Yolo)+ 任一协作方式 | 支持 | 工具权限控制是否自动批准工具调用,和计划/目标/省 token 是独立概念。 | + +工具权限的详细区别和使用场景,见 [`TOOL_APPROVAL_MODES.zh-CN.md`](./TOOL_APPROVAL_MODES.zh-CN.md)。 + +## 推荐选择 + +- **不确定怎么选**:开启省 token,保持普通模式。 +- **担心 Reasonix 改错**:开启计划模式,必要时同时开启省 token。 +- **想让 Reasonix 持续推进一个明确目标**:开启目标模式,目标写清楚成功标准。 +- **复杂任务且确定要大量工具**:关闭省 token,按需使用计划模式或目标模式。 diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index a19c8d98f..6543dfe1c 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -243,5 +243,11 @@ Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它 `reasonix config auto-plan off|on`。只有明确想写项目级覆盖时,才给 shell 命令加 `--local`。 +桌面端“协作方式”菜单里的计划模式、目标模式和省 token 模式的使用方法与注意事项, +见 [`COLLABORATION_MODES.zh-CN.md`](./COLLABORATION_MODES.zh-CN.md)。 + +桌面端“工具权限”里的询问、自动和 Yolo 模式的区别与使用场景, +见 [`TOOL_APPROVAL_MODES.zh-CN.md`](./TOOL_APPROVAL_MODES.zh-CN.md)。 + 分离 session(让各模型前缀缓存稳定)背后的取舍见 [`SPEC.md` §3.5](./SPEC.md#35-two-model-collaboration-coordinator)。 diff --git a/docs/TOOL_APPROVAL_MODES.zh-CN.md b/docs/TOOL_APPROVAL_MODES.zh-CN.md new file mode 100644 index 000000000..bf21a439c --- /dev/null +++ b/docs/TOOL_APPROVAL_MODES.zh-CN.md @@ -0,0 +1,101 @@ +# 工具权限:询问、自动与 Yolo 模式 + +Reasonix 桌面端输入框下方的“询问 / 自动 / Yolo”控制的是工具权限审批方式。它决定写文件、运行命令、调用带权限的工具时,是否需要先停下来让你批准。 + +工具权限和“协作方式”是两条独立轴: + +- **协作方式**决定 Reasonix 怎么推进任务,例如普通聊天、计划模式、目标模式、省 token 模式。 +- **工具权限**决定 Reasonix 调用受控工具时,是否需要你审批。 + +## 快速对比 + +| 模式 | 行为 | 适合场景 | 不适合场景 | +| --- | --- | --- | --- | +| 询问 | 默认在写文件、跑命令等受控工具前请求批准。 | 不熟悉的仓库、高风险改动、生产相关操作、需要逐步确认的任务。 | 大量低风险重复操作,或你已经明确允许 Reasonix 连续执行。 | +| 自动 | 自动批准普通工具权限;显式 `ask` 规则、`deny` 规则、计划确认和记忆写入/删除等强制审批仍生效。 | 日常读代码、改小问题、跑测试、可信工作区里的常规实现任务。 | 你希望每个写入或命令都人工确认的任务。 | +| Yolo | 跳过普通工具权限审批,让写文件和命令尽量连续执行;`deny` 规则、计划确认、ask 问题和强制审批仍不会被自动回答。 | 临时分支、可回滚工作区、已确认计划后的批量机械改动。 | 生产环境、敏感文件、删除/发布/推送等高风险操作,或需求边界不清时。 | + +## 询问模式 + +询问模式是最保守的工具权限模式。Reasonix 遇到需要审批的工具调用时,会弹出审批卡片,你可以选择允许一次、本会话允许、总是允许或拒绝。 + +### 建议使用场景 + +- 第一次让 Reasonix 处理某个仓库或目录。 +- 任务可能涉及删除文件、改配置、提交、发布、安装依赖或访问外部服务。 +- 你想观察 Reasonix 准备调用什么工具,再决定是否继续。 +- 你正在处理生产数据、密钥、隐私文件或不可轻易回滚的环境。 + +### 注意事项 + +- 询问模式更安全,但会增加交互次数。 +- 审批卡片里的“本会话允许”和“总是允许”是权限规则,不是切换到自动模式。 +- 如果你已经确认任务范围很清楚,且操作都在可信工作区内,自动模式通常更顺手。 + +## 自动模式 + +自动模式适合日常开发。它会把普通工具权限自动放行,减少反复点击审批;但它不是无限制执行。 + +自动模式仍会遵守这些边界: + +- 显式 `deny` 规则仍然阻止对应工具或命令。 +- 显式 `ask` 规则仍然会弹出审批。 +- 计划模式的“开始执行”确认仍然需要你选择。 +- `remember` / `forget` 等记忆写入或删除操作仍需要新鲜审批。 +- ask 问题仍然等待你回答,不会由自动模式代选。 + +### 建议使用场景 + +- 普通代码阅读、搜索、编辑和测试。 +- 你信任当前工作区,且改动可以通过 Git 或测试回滚。 +- 目标模式下希望 Reasonix 连续推进,但仍保留显式规则和关键确认。 +- 计划模式已经确认方案,后续只是常规实现和验证。 + +### 注意事项 + +- 自动模式不等于 Yolo。它不会跳过显式 `ask` 规则,也不会自动回答产品决策问题。 +- 自动模式会减少审批噪音,但仍建议在高风险命令上保留 `deny` 或 `ask` 规则。 +- 如果你发现 Reasonix 反复准备做你不想要的操作,切回询问模式更容易干预。 + +## Yolo 模式 + +Yolo 模式用于最大化连续执行。开启后,普通工具审批会被跳过,写文件、运行命令等操作可以更少中断地执行。 + +### 怎么开启 + +- 点击工具权限区域里的“Yolo”。 +- 也可以使用 `Ctrl+Y` 或 `Cmd+Y` 快捷键切换。 +- 通过快捷键进入 Yolo 时,Reasonix 会记住原来的“询问”或“自动”基线;再次按快捷键退出 Yolo,会恢复之前的基线模式。 + +### 建议使用场景 + +- 当前在临时分支或干净工作树,改坏了也能快速回滚。 +- 任务已经在计划模式里确认过,后续只是按计划执行。 +- 批量机械改动、格式化、补测试、跑一组本地验证命令。 +- 你明确希望减少所有普通工具审批中断。 + +### 注意事项 + +- Yolo 只跳过工具权限审批,不会跳过计划确认和 ask 问题。 +- Yolo 不会绕过后端的 `deny` 规则、系统权限、工作区限制或沙箱限制。 +- 记忆写入/删除、计划确认等强制新鲜审批仍然会等待你处理。 +- 不建议在生产环境、敏感目录、密钥文件、发布流程或需求不清楚的任务里开启。 + +## 与协作方式的组合 + +| 组合 | 行为 | +| --- | --- | +| 计划 + 询问 | 最保守。先只读出计划,确认执行后每个受控工具仍可能请求审批。 | +| 计划 + 自动 | 计划确认仍需要你批准;开始执行后,普通工具权限自动放行。 | +| 计划 + Yolo | 计划确认仍需要你批准;开始执行后,普通工具审批尽量不再打断。 | +| 目标 + 询问 | 目标会持续推进,但遇到工具审批会停下来等你。 | +| 目标 + 自动 | 适合大多数日常目标任务,连续推进且保留显式规则边界。 | +| 目标 + Yolo | 适合边界非常清楚、可回滚的目标任务;风险最高。 | +| 省 token + 任一工具权限 | 支持。省 token 影响初始上下文和工具 schema 开销,不改变工具审批语义。 | + +## 推荐选择 + +- **不确定怎么选**:用“询问”。 +- **日常开发**:用“自动”。 +- **已确认计划、可回滚、想减少中断**:短时间使用“Yolo”。 +- **高风险命令或敏感环境**:用“询问”,并通过 `deny` / `ask` 规则保护关键操作。 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 7fd424986..a03fc9cc9 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -62,13 +62,14 @@ type callContext struct { parentID string sink event.Sink asker Asker + planMode bool } // withCallContext stamps ctx with the executing call's ID, the agent's sink, and // the asker. executeOne sets this before every Execute; `task` reads it (via // CallContext) to nest sub-agent events, and `ask` reads the asker to prompt. -func withCallContext(ctx context.Context, parentID string, sink event.Sink, asker Asker) context.Context { - return context.WithValue(ctx, callContextKey{}, callContext{parentID: parentID, sink: sink, asker: asker}) +func withCallContext(ctx context.Context, parentID string, sink event.Sink, asker Asker, planMode bool) context.Context { + return context.WithValue(ctx, callContextKey{}, callContext{parentID: parentID, sink: sink, asker: asker, planMode: planMode}) } // CallContext returns the executing call's ID, the agent's sink, and the asker, @@ -82,6 +83,14 @@ func CallContext(ctx context.Context) (parentID string, sink event.Sink, asker A return cc.parentID, cc.sink, cc.asker, true } +// PlanModeFromContext reports whether the tool call is executing under the +// agent's read-only planning gate. Tools that are themselves ReadOnly may use +// this to avoid enabling follow-up writer-only surfaces during planning. +func PlanModeFromContext(ctx context.Context) bool { + cc, ok := ctx.Value(callContextKey{}).(callContext) + return ok && cc.planMode +} + // WithParentSession stamps the active parent session ID onto a turn context so // persisted sub-agents can record and enforce their owning conversation. func WithParentSession(ctx context.Context, parentSession string) context.Context { @@ -1329,7 +1338,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc } } } - cctx := withCallContext(ctx, call.ID, a.sink, a.asker) + cctx := withCallContext(ctx, call.ID, a.sink, a.asker, a.planMode.Load()) if a.evidence != nil { cctx = evidence.WithLedger(cctx, a.evidence) cctx = evidence.WithSessionMessages(cctx, a.session.Snapshot()) diff --git a/internal/agent/ask_test.go b/internal/agent/ask_test.go index bf4d47b2d..d02ad37e5 100644 --- a/internal/agent/ask_test.go +++ b/internal/agent/ask_test.go @@ -76,7 +76,7 @@ func TestAskToolRejectsExactDuplicateOptionLabels(t *testing.T) { func TestAskToolTrimsPromptAndOptionsBeforePrompting(t *testing.T) { asker := &recordingAsker{} - ctx := withCallContext(context.Background(), "call_1", event.Discard, asker) + ctx := withCallContext(context.Background(), "call_1", event.Discard, asker, false) out, err := NewAskTool().Execute(ctx, []byte(`{ "questions":[{ "header":" Direction ", diff --git a/internal/boot/boot.go b/internal/boot/boot.go index cfd3b4e4f..f680d5a32 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -78,6 +78,11 @@ 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 + // TokenMode selects how much optional context/tool surface this session exposes + // at boot. Empty/full preserves the normal capability surface. "economy" keeps + // the core coding tools visible and moves skills, MCP, CodeGraph, LSP, web_fetch, + // install_source, and task behind connect_tool_source. + TokenMode string // SessionDir overrides where persisted chat transcripts are written. When // empty, the shared CLI/global session directory is used. SessionDir string @@ -105,6 +110,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { if modelName == "" { modelName = cfg.DefaultModel } + tokenMode := NormalizeTokenMode(opts.TokenMode) + tokenEconomy := tokenMode == TokenModeEconomy entry, ok := cfg.ResolveModel(modelName) if !ok { return nil, fmt.Errorf("%w %q (configured: %s); note: defining [[providers]] replaces the built-in presets, so add a [[providers]] entry for it or use a configured name, or run `reasonix setup` to reconfigure", ErrUnknownModel, modelName, providerNames(cfg)) @@ -167,6 +174,9 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { sysPrompt = outputstyle.Apply(sysPrompt, st) } sysPrompt += "\n\n" + config.LanguagePolicy + if tokenEconomy { + sysPrompt += "\n\n" + tokenEconomyPrompt + } // Persistent memory (REASONIX.md / AGENTS.md hierarchy + auto-memory index) // folds into the system prompt exactly here, once: it becomes part of the @@ -192,7 +202,9 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { skills := skillStore.List() allSkillStore := skill.New(skill.Options{ProjectRoot: root, CustomPaths: cfg.SkillCustomPaths(), ExcludedPaths: cfg.SkillExcludedPaths(), MaxDepth: cfg.SkillMaxDepth(), Stderr: io.Discard}) allSkills := allSkillStore.List() - sysPrompt = skill.ApplyIndex(sysPrompt, skills) + if !tokenEconomy { + sysPrompt = skill.ApplyIndex(sysPrompt, skills) + } sessionDir := opts.SessionDir if sessionDir == "" { @@ -211,7 +223,11 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { } searchSpec := builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, stderr) bashTimeout := time.Duration(cfg.BashTimeoutSeconds()) * time.Second - addBuiltins(reg, cfg.Tools.Enabled, cfg.WriteRootsForRoot(root), bashSpec, bashTimeout, searchSpec, stderr, root, proxySpec) + enabledBuiltins := cfg.Tools.Enabled + if tokenEconomy { + enabledBuiltins = tokenEconomyBuiltins(enabledBuiltins) + } + addBuiltins(reg, enabledBuiltins, cfg.WriteRootsForRoot(root), bashSpec, bashTimeout, searchSpec, stderr, root, proxySpec) // Always construct a host, even with no plugins configured, so the controller's // host pointer is stable for the session and `/mcp add` can hot-add into it. pluginHost := plugin.NewHost() @@ -221,6 +237,21 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { // session starts immediately while enabled MCP servers warm up. autoStartEntries := builtinmcp.AppendEnabled(cfg.AutoStartPlugins(), cfg.Plugins, cfg.BuiltInMCP.EnabledNames(), pluginSpecNames(opts.ExtraPlugins)...) eagerEntries, lazyEntries, bgEntries := partitionByTier(autoStartEntries) + onDemandMCPSpecs := map[string]plugin.Spec{} + onDemandMCPNames := []string{} + if tokenEconomy { + for _, spec := range append(PluginSpecs(autoStartEntries), opts.ExtraPlugins...) { + name := strings.TrimSpace(spec.Name) + if name == "" { + continue + } + if _, exists := onDemandMCPSpecs[name]; !exists { + onDemandMCPNames = append(onDemandMCPNames, name) + } + onDemandMCPSpecs[name] = spec + } + eagerEntries, lazyEntries, bgEntries = nil, nil, nil + } // Auto-demote: any eager plugin that has been chronically slow (recent // samples repeatedly hit the blocking startup budget) drops to lazy @@ -255,7 +286,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { // // CodeGraph is fixed to background startup. Legacy tier values are ignored so // enabling it never blocks chat startup. - if cfg.Codegraph.Enabled { + if cfg.Codegraph.Enabled && !tokenEconomy { bin, ok := codegraph.Resolve(cfg.Codegraph.Path) switch { case ok && !codegraph.IndexableRoot(root): @@ -307,7 +338,9 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { Text: "codegraph: not installed — run `reasonix codegraph install` to enable symbol-graph tools"}) } } - eagerSpecs = append(eagerSpecs, opts.ExtraPlugins...) + if !tokenEconomy { + eagerSpecs = append(eagerSpecs, opts.ExtraPlugins...) + } // Apply caller-supplied stderr override to every spec across tiers. if opts.Stderr != nil { @@ -368,7 +401,11 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { if len(cgTools) > 0 { sysPrompt += "\n\n" + codegraph.SteerText skill.SetExtraReadTools(cgTools) + } else { + skill.SetExtraReadTools(nil) } + } else { + skill.SetExtraReadTools(nil) } for _, msg := range demoteMessages { @@ -381,10 +418,19 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { // registering them is cheap even when no server is installed (a query then // returns an install hint). The manager is session-scoped; chain its shutdown // into the controller's cleanup so servers stop with the session, not the turn. + var lspMgr *lsp.Manager + lspToolsAdded := false + addLSPTools := func() []string { + if lspMgr == nil || lspToolsAdded { + return nil + } + lspToolsAdded = true + return addTools(reg, lsp.Tools(lspMgr)) + } if cfg.LSP.Enabled { - lspMgr := lsp.NewManager(root, LSPSpecs(cfg.LSP)) - for _, t := range lsp.Tools(lspMgr) { - reg.Add(t) + lspMgr = lsp.NewManager(root, LSPSpecs(cfg.LSP)) + if !tokenEconomy { + addLSPTools() } prev := cleanup cleanup = func() { prev(); lspMgr.Close() } @@ -456,12 +502,23 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { } taskModel := firstNonEmpty(cfg.Agent.SubagentModels["task"], cfg.Agent.SubagentModel) taskEffort := firstNonEmpty(cfg.Agent.SubagentEfforts["task"], cfg.Agent.SubagentEffort) - reg.Add(agent.NewTaskTool(execProv, entry.Price, reg, maxSteps, - entry.ContextWindow, cfg.Agent.SoftCompactRatio, cfg.Agent.CompactRatio, cfg.Agent.CompactForceRatio, - cfg.Agent.Temperature, config.ArchiveDir(), "", headlessGate, - taskModel, taskEffort, resolveSubagentProvider). - WithTranscripts(subagentStore, root, modelName, entry.Effort). - WithTranscriptIdentityResolver(subagentIdentity)) + taskToolAdded := false + addTaskTool := func() string { + if taskToolAdded { + return "task tool is already enabled." + } + taskToolAdded = true + reg.Add(agent.NewTaskTool(execProv, entry.Price, reg, maxSteps, + entry.ContextWindow, cfg.Agent.SoftCompactRatio, cfg.Agent.CompactRatio, cfg.Agent.CompactForceRatio, + cfg.Agent.Temperature, config.ArchiveDir(), "", headlessGate, + taskModel, taskEffort, resolveSubagentProvider). + WithTranscripts(subagentStore, root, modelName, entry.Effort). + WithTranscriptIdentityResolver(subagentIdentity)) + return "enabled task." + } + if !tokenEconomy { + addTaskTool() + } // The `memory` tool searches/reads saved facts on demand; `remember` persists // durable facts to the project's auto-memory store; `forget` prunes ones that @@ -567,56 +624,201 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { } return &event.Profile{Model: model, Effort: effort} } - reg.Add(skill.NewRunSkillTool(skillStore, skillRunner, skillProfile)) - reg.Add(skill.NewReadSkillTool(skillStore)) - reg.Add(skill.NewInstallSkillTool(skillStore, nil)) - reg.Add(installsource.NewTool(installsource.Options{ - ProjectRoot: root, - HTTPClient: balanceClient, - ConnectMCP: func(e config.PluginEntry) (installsource.MCPConnectResult, error) { - exp := e.ExpandedPlugin() - spec := plugin.Spec{ - Name: exp.Name, - Type: exp.Type, - Command: exp.Command, - Args: exp.Args, - Env: exp.Env, - URL: exp.URL, - Headers: exp.Headers, - } - if opts.Stderr != nil { - spec.Stderr = opts.Stderr - } - tools, err := pluginHost.Add(ctx, spec) - if err != nil { - return installsource.MCPConnectResult{}, err - } - reg.RemovePrefix(plugin.ToolPrefix(spec.Name)) - for _, t := range tools { - reg.Add(t) + // Custom slash commands (.reasonix/commands + user dir). Best-effort: a malformed + // file is skipped, and a load error never blocks the session. + cmds, _ := command.Load(config.CommandDirsForRoot(root)...) + addSlashCommandTool := func(includeSkills bool) { + // Expose loaded slash commands to the model via slash_command. In economy + // mode skills join this list only after the skills source is enabled. + var slashEntries []command.SlashEntry + if includeSkills { + for _, sk := range skills { + sk := sk + slashEntries = append(slashEntries, command.SlashEntry{ + Name: sk.Name, + Description: sk.Description, + Render: func(args []string) string { return skill.Render(sk, strings.Join(args, " ")) }, + }) } - // Disconnect closes the server and drops its namespaced tools. - // Used by the install_source rollback path when SaveTo fails. - disconnect := func() { - if prefix, ok := pluginHost.Remove(spec.Name); ok { + } + for _, cmd := range cmds { + cmd := cmd + slashEntries = append(slashEntries, command.SlashEntry{ + Name: cmd.Name, + Description: cmd.Description, + ArgHint: cmd.ArgHint, + Render: func(args []string) string { return cmd.Render(args) }, + }) + } + reg.Add(command.NewSlashCommandTool(slashEntries)) + } + installSourceAdded := false + addInstallSourceTool := func() string { + if installSourceAdded { + return "install_source is already enabled." + } + installSourceAdded = true + reg.Add(installsource.NewTool(installsource.Options{ + ProjectRoot: root, + HTTPClient: balanceClient, + ConnectMCP: func(e config.PluginEntry) (installsource.MCPConnectResult, error) { + exp := e.ExpandedPlugin() + spec := plugin.Spec{ + Name: exp.Name, + Type: exp.Type, + Command: exp.Command, + Args: exp.Args, + Env: exp.Env, + URL: exp.URL, + Headers: exp.Headers, + } + if opts.Stderr != nil { + spec.Stderr = opts.Stderr + } + tools, err := pluginHost.Add(ctx, spec) + if err != nil { + return installsource.MCPConnectResult{}, err + } + reg.RemovePrefix(plugin.ToolPrefix(spec.Name)) + for _, t := range tools { + reg.Add(t) + } + // Disconnect closes the server and drops its namespaced tools. + // Used by the install_source rollback path when SaveTo fails. + disconnect := func() { + if prefix, ok := pluginHost.Remove(spec.Name); ok { + reg.RemovePrefix(prefix) + } + } + return installsource.MCPConnectResult{ + ToolCount: len(tools), + Disconnect: disconnect, + }, nil + }, + OnDisconnect: func(serverName string) bool { + if prefix, ok := pluginHost.Remove(serverName); ok { reg.RemovePrefix(prefix) + return true } - } - return installsource.MCPConnectResult{ - ToolCount: len(tools), - Disconnect: disconnect, - }, nil - }, - OnDisconnect: func(serverName string) bool { - if prefix, ok := pluginHost.Remove(serverName); ok { - reg.RemovePrefix(prefix) - return true - } - return false - }, - })) - for _, t := range skill.BuiltinSubagentTools(skillStore, skillRunner, skillProfile) { - reg.Add(t) + return false + }, + })) + return "enabled install_source." + } + skillToolsAdded := false + addSkillTools := func() string { + if skillToolsAdded { + return "skills are already enabled.\n\n" + skill.IndexBlock(skills) + } + skillToolsAdded = true + reg.Add(skill.NewRunSkillTool(skillStore, skillRunner, skillProfile)) + reg.Add(skill.NewReadSkillTool(skillStore)) + reg.Add(skill.NewInstallSkillTool(skillStore, nil)) + for _, t := range skill.BuiltinSubagentTools(skillStore, skillRunner, skillProfile) { + reg.Add(t) + } + addSlashCommandTool(true) + return "enabled skills. Use run_skill/read_skill or the dedicated skill tools on the next model request.\n\n" + skill.IndexBlock(skills) + } + if tokenEconomy { + addSlashCommandTool(false) + } else { + addInstallSourceTool() + addSkillTools() + } + if tokenEconomy { + reg.Add(&toolSourceConnector{ + skills: func(context.Context) (string, error) { + return addSkillTools(), nil + }, + task: func(context.Context) (string, error) { + return addTaskTool(), nil + }, + install: func(context.Context) (string, error) { + return addInstallSourceTool(), nil + }, + webFetch: func(context.Context) (string, error) { + if !builtinToolEnabled(cfg.Tools.Enabled, "web_fetch") { + return "web_fetch is disabled by [tools].enabled.", nil + } + names := addTools(reg, builtin.Workspace{ + Dir: root, + WriteRoots: cfg.WriteRootsForRoot(root), + Bash: bashSpec, + BashTimeout: bashTimeout, + Search: searchSpec, + ProxySpec: proxySpec, + }.Tools("web_fetch")) + if len(names) == 0 { + return "web_fetch is already enabled or unavailable.", nil + } + return "enabled " + strings.Join(names, ", ") + ".", nil + }, + lsp: func(context.Context) (string, error) { + if lspMgr == nil { + return "", fmt.Errorf("LSP is disabled in config") + } + names := addLSPTools() + if len(names) == 0 { + return "LSP tools are already enabled.", nil + } + return "enabled " + strings.Join(names, ", ") + ".", nil + }, + codegraph: func(context.Context) (string, error) { + if !cfg.Codegraph.Enabled { + return "", fmt.Errorf("codegraph is disabled in config") + } + bin, ok := codegraph.Resolve(cfg.Codegraph.Path) + if !ok { + return "", fmt.Errorf("codegraph is not installed") + } + if !codegraph.IndexableRoot(root) { + return "", fmt.Errorf("codegraph: project root is a filesystem root — skipped to avoid indexing the whole volume") + } + if err := codegraph.EnsureInit(ctx, bin, root); err != nil { + return "", fmt.Errorf("codegraph init: %w", err) + } + spec := plugin.Spec{ + Name: "codegraph", + StripRawPrefix: "codegraph_", + Command: bin, + Args: []string{"serve", "--mcp"}, + Dir: root, + ReadOnlyToolNames: codegraph.ReadOnlyToolNames(), + LowPriority: true, + } + if opts.Stderr != nil { + spec.Stderr = opts.Stderr + } + tools, err := pluginHost.Add(ctx, spec) + if err != nil { + return "", err + } + reg.RemovePrefix(plugin.ToolPrefix(spec.Name)) + names := addTools(reg, tools) + return "enabled codegraph tools: " + strings.Join(names, ", ") + ".", nil + }, + mcp: func(_ context.Context, name string) (string, error) { + spec, ok := onDemandMCPSpecs[name] + if !ok { + return "", fmt.Errorf("no configured MCP server named %q", name) + } + if opts.Stderr != nil { + spec.Stderr = opts.Stderr + } + tools, err := pluginHost.Add(ctx, spec) + if err != nil { + return "", err + } + reg.RemovePrefix(plugin.ToolPrefix(spec.Name)) + names := addTools(reg, tools) + if len(names) == 0 { + return fmt.Sprintf("MCP server %q connected but exposed no tools.", spec.Name), nil + } + return fmt.Sprintf("enabled MCP server %q tools: %s.", spec.Name, strings.Join(names, ", ")), nil + }, + mcpNames: onDemandMCPNames, + }) } execSess := agent.NewSession(sysPrompt) @@ -635,34 +837,6 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { ArchiveDir: config.ArchiveDir(), }, sink) - // Custom slash commands (.reasonix/commands + user dir). Best-effort: a malformed - // file is skipped, and a load error never blocks the session. - cmds, _ := command.Load(config.CommandDirsForRoot(root)...) - - // Expose the loaded slash commands (skills + custom commands) to the model via - // the slash_command tool, so it can invoke a project playbook by name the way a - // user types "/name". Skills are added first, then commands, so a command wins - // a name clash — matching the prompt's command-over-skill precedence. - var slashEntries []command.SlashEntry - for _, sk := range skills { - sk := sk - slashEntries = append(slashEntries, command.SlashEntry{ - Name: sk.Name, - Description: sk.Description, - Render: func(args []string) string { return skill.Render(sk, strings.Join(args, " ")) }, - }) - } - for _, cmd := range cmds { - cmd := cmd - slashEntries = append(slashEntries, command.SlashEntry{ - Name: cmd.Name, - Description: cmd.Description, - ArgHint: cmd.ArgHint, - Render: func(args []string) string { return cmd.Render(args) }, - }) - } - reg.Add(command.NewSlashCommandTool(slashEntries)) - var runner agent.Runner = executor label := entry.Model var classifier *control.ProviderAutoPlanClassifier @@ -671,7 +845,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { // Coordinator with its own session, kept separate for cache stability. The // planner gets the same standing memory context and a filtered read-only // research tool set, so it can inspect rules/code without side effects. - if pm := cfg.Agent.PlannerModel; pm != "" { + if pm := cfg.Agent.PlannerModel; pm != "" && !tokenEconomy { pe, ok := cfg.ResolveModel(pm) if !ok { return nil, fmt.Errorf("planner_model %q is not a configured provider", pm) @@ -696,7 +870,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { label = entry.Model + " + planner " + pe.Model } } - if !strings.EqualFold(strings.TrimSpace(cfg.Agent.AutoPlan), "off") && cfg.Agent.AutoPlanClassifier != "" { + if !tokenEconomy && !strings.EqualFold(strings.TrimSpace(cfg.Agent.AutoPlan), "off") && cfg.Agent.AutoPlanClassifier != "" { cm := cfg.Agent.AutoPlanClassifier ce, ok := cfg.ResolveModel(cm) if !ok { @@ -1064,6 +1238,19 @@ func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sand } } +func builtinToolEnabled(enabled []string, name string) bool { + if len(enabled) == 0 { + return true + } + name = strings.TrimSpace(name) + for _, candidate := range enabled { + if strings.TrimSpace(candidate) == name { + return true + } + } + return false +} + // partitionByTier splits configured plugin entries into the three startup // buckets — eager (block boot until ready), lazy (placeholder until first // model use), background (placeholder + start spawn now). Entries with an diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go index c8345133c..629d1172c 100644 --- a/internal/boot/boot_test.go +++ b/internal/boot/boot_test.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "runtime" "strings" "sync" @@ -218,6 +219,41 @@ func setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) { }) } +const bootTokenProfileTestProviderKind = "boot-token-profile-test" + +var ( + bootTokenProfileTestProviderOnce sync.Once + bootTokenProfileTestProviderCurrent *testutil.MockProvider + bootTokenProfileTestProviderMu sync.Mutex +) + +func registerBootTokenProfileTestProvider() { + bootTokenProfileTestProviderOnce.Do(func() { + provider.Register(bootTokenProfileTestProviderKind, func(provider.Config) (provider.Provider, error) { + bootTokenProfileTestProviderMu.Lock() + defer bootTokenProfileTestProviderMu.Unlock() + if bootTokenProfileTestProviderCurrent == nil { + return nil, errors.New("boot token profile test provider is not installed") + } + return bootTokenProfileTestProviderCurrent, nil + }) + }) +} + +func setBootTokenProfileTestProvider(t *testing.T, p *testutil.MockProvider) { + t.Helper() + bootTokenProfileTestProviderMu.Lock() + bootTokenProfileTestProviderCurrent = p + bootTokenProfileTestProviderMu.Unlock() + t.Cleanup(func() { + bootTokenProfileTestProviderMu.Lock() + if bootTokenProfileTestProviderCurrent == p { + bootTokenProfileTestProviderCurrent = nil + } + bootTokenProfileTestProviderMu.Unlock() + }) +} + func requestHasTool(req provider.Request, name string) bool { for _, schema := range req.Tools { if schema.Name == name { @@ -227,6 +263,15 @@ func requestHasTool(req provider.Request, name string) bool { return false } +func requestHasToolPrefix(req provider.Request, prefix string) bool { + for _, schema := range req.Tools { + if strings.HasPrefix(schema.Name, prefix) { + return true + } + } + return false +} + func toolSchemaNames(tools []provider.ToolSchema) []string { names := make([]string, 0, len(tools)) for _, schema := range tools { @@ -249,6 +294,31 @@ func assertToolOrder(t *testing.T, tools []provider.ToolSchema, want []string) { } } +func firstTokenProfileRequest(t *testing.T, tokenMode string) provider.Request { + t.Helper() + registerBootTokenProfileTestProvider() + prov := testutil.NewMock("token-profile", testutil.Turn{Text: "done"}) + setBootTokenProfileTestProvider(t, prov) + + opts := Options{Sink: event.Discard} + if tokenMode != "" { + opts.TokenMode = tokenMode + } + ctrl, err := Build(context.Background(), opts) + if err != nil { + t.Fatalf("Build(%q): %v", tokenMode, err) + } + defer ctrl.Close() + if err := ctrl.Run(context.Background(), "capture request prefix"); err != nil { + t.Fatalf("Run(%q): %v", tokenMode, err) + } + reqs := prov.Requests() + if len(reqs) != 1 { + t.Fatalf("requests(%q) = %d, want 1", tokenMode, len(reqs)) + } + return reqs[0] +} + func TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) { isolateConfigHome(t) dir := robustTempDir(t) @@ -698,6 +768,358 @@ api_key_env = "REASONIX_TEST_KEY_UNSET" } } +func TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + + writeFile(t, dir, "reasonix.toml", ` +default_model = "test-model" + +[codegraph] +enabled = false + +[agent] +system_prompt = "BASE" + +[[providers]] +name = "test-model" +kind = "boot-token-profile-test" +model = "x" +`) + writeFile(t, dir, ".reasonix/skills/projskill.md", "---\ndescription: a project skill\n---\nplaybook") + + defaultReq := firstTokenProfileRequest(t, "") + fullReq := firstTokenProfileRequest(t, TokenModeFull) + + if got, want := systemMessage(defaultReq.Messages), systemMessage(fullReq.Messages); got != want { + t.Fatalf("explicit full mode changed the system prompt\n--- default ---\n%s\n--- full ---\n%s", got, want) + } + if strings.Contains(systemMessage(fullReq.Messages), tokenEconomyPrompt) { + t.Fatalf("full mode system prompt should not include token economy prompt:\n%s", systemMessage(fullReq.Messages)) + } + if !strings.Contains(systemMessage(fullReq.Messages), "# Skills") || !strings.Contains(systemMessage(fullReq.Messages), "projskill") { + t.Fatalf("full mode should preserve the skills index in the system prompt:\n%s", systemMessage(fullReq.Messages)) + } + if got, want := toolSchemaNames(fullReq.Tools), toolSchemaNames(defaultReq.Tools); !reflect.DeepEqual(got, want) { + t.Fatalf("explicit full mode changed tool schema order\nfull=%v\ndefault=%v", got, want) + } + if !reflect.DeepEqual(fullReq.Tools, defaultReq.Tools) { + t.Fatalf("explicit full mode changed provider-visible tool schemas; names=%v", toolSchemaNames(fullReq.Tools)) + } + if requestHasTool(fullReq, "connect_tool_source") { + t.Fatalf("full mode should not expose economy connector; tools=%v", toolSchemaNames(fullReq.Tools)) + } +} + +func TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + + registerBootTokenProfileTestProvider() + prov := testutil.NewMock("token-economy", testutil.Turn{Text: "done"}) + setBootTokenProfileTestProvider(t, prov) + writeFile(t, dir, "reasonix.toml", ` +default_model = "test-model" + +[codegraph] +enabled = false + +[agent] +system_prompt = "BASE" + +[[providers]] +name = "test-model" +kind = "boot-token-profile-test" +model = "x" + +[[plugins]] +name = "mockmcp" +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}) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer ctrl.Close() + if err := ctrl.Run(context.Background(), "use the lean surface"); err != nil { + t.Fatalf("Run: %v", err) + } + reqs := prov.Requests() + if len(reqs) != 1 { + t.Fatalf("requests = %d, want 1", len(reqs)) + } + req := reqs[0] + wantTools := []string{ + "ask", + "bash", + "bash_output", + "complete_step", + "connect_tool_source", + "edit_file", + "forget", + "glob", + "grep", + "history", + "kill_shell", + "ls", + "memory", + "multi_edit", + "read_file", + "remember", + "slash_command", + "todo_write", + "wait", + "write_file", + } + if got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantTools) { + t.Fatalf("economy first request tool order changed\ngot %v\nwant %v", got, wantTools) + } + for _, want := range []string{"connect_tool_source", "read_file", "grep", "edit_file", "bash", "slash_command", "ask"} { + if !requestHasTool(req, want) { + t.Fatalf("economy first request missing tool %q; tools=%v", want, toolSchemaNames(req.Tools)) + } + } + for _, forbidden := range []string{ + "web_fetch", "task", "run_skill", "read_skill", "install_skill", "install_source", + "explore", "research", "review", "security_review", + "lsp_definition", "lsp_references", "lsp_hover", "lsp_diagnostics", + } { + if requestHasTool(req, forbidden) { + t.Fatalf("economy first request should hide %q; tools=%v", forbidden, toolSchemaNames(req.Tools)) + } + } + if requestHasToolPrefix(req, "mcp__mockmcp") { + t.Fatalf("economy first request should not expose MCP placeholders; tools=%v", toolSchemaNames(req.Tools)) + } + sys := systemMessage(req.Messages) + if !strings.Contains(sys, tokenEconomyPrompt) { + t.Fatalf("token economy prompt missing from system message:\n%s", sys) + } + if strings.Contains(sys, "# Skills") || strings.Contains(sys, "projskill") { + t.Fatalf("skills index should not be in economy system prompt:\n%s", sys) + } +} + +func TestBuildTokenEconomyConnectsWebFetchOnDemand(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + + registerBootTokenProfileTestProvider() + prov := testutil.NewMock("token-economy", + testutil.Turn{ToolCalls: []provider.ToolCall{ + {ID: "source-1", Name: "connect_tool_source", Arguments: `{"source":"web_fetch"}`}, + }}, + testutil.Turn{Text: "done"}, + ) + setBootTokenProfileTestProvider(t, prov) + writeFile(t, dir, "reasonix.toml", ` +default_model = "test-model" + +[codegraph] +enabled = false + +[agent] +system_prompt = "BASE" + +[[providers]] +name = "test-model" +kind = "boot-token-profile-test" +model = "x" +`) + + ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy}) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer ctrl.Close() + if err := ctrl.Run(context.Background(), "fetch later"); err != nil { + t.Fatalf("Run: %v", err) + } + reqs := prov.Requests() + if len(reqs) != 2 { + t.Fatalf("requests = %d, want 2", len(reqs)) + } + if requestHasTool(reqs[0], "web_fetch") { + t.Fatalf("first request should hide web_fetch; tools=%v", toolSchemaNames(reqs[0].Tools)) + } + if !requestHasTool(reqs[1], "web_fetch") { + t.Fatalf("second request should expose web_fetch after connect_tool_source; tools=%v", toolSchemaNames(reqs[1].Tools)) + } +} + +func TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + + registerBootTokenProfileTestProvider() + prov := testutil.NewMock("token-economy", + testutil.Turn{ToolCalls: []provider.ToolCall{ + {ID: "source-1", Name: "connect_tool_source", Arguments: `{"source":"web_fetch"}`}, + }}, + testutil.Turn{Text: "done"}, + ) + setBootTokenProfileTestProvider(t, prov) + writeFile(t, dir, "reasonix.toml", ` +default_model = "test-model" + +[codegraph] +enabled = false + +[agent] +system_prompt = "BASE" + +[[providers]] +name = "test-model" +kind = "boot-token-profile-test" +model = "x" +`) + + ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy}) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer ctrl.Close() + ctrl.SetPlanMode(true) + if err := ctrl.Run(context.Background(), "fetch later while planning"); err != nil { + t.Fatalf("Run: %v", err) + } + reqs := prov.Requests() + if len(reqs) != 2 { + t.Fatalf("requests = %d, want 2", len(reqs)) + } + if !requestHasTool(reqs[1], "web_fetch") { + t.Fatalf("second request should expose web_fetch in plan economy mode; tools=%v", toolSchemaNames(reqs[1].Tools)) + } + for _, msg := range ctrl.History() { + if msg.Role == provider.RoleTool && msg.Name == "connect_tool_source" && strings.Contains(msg.Content, "blocked:") { + t.Fatalf("connect_tool_source should not be blocked in plan mode, got:\n%s", msg.Content) + } + } +} + +func TestBuildTokenEconomyWebFetchConnectorHonorsDisabledBuiltin(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + + registerBootTokenProfileTestProvider() + prov := testutil.NewMock("token-economy", + testutil.Turn{ToolCalls: []provider.ToolCall{ + {ID: "source-1", Name: "connect_tool_source", Arguments: `{"source":"web_fetch"}`}, + }}, + testutil.Turn{Text: "done"}, + ) + setBootTokenProfileTestProvider(t, prov) + writeFile(t, dir, "reasonix.toml", ` +default_model = "test-model" + +[tools] +enabled = ["read_file", "grep"] + +[codegraph] +enabled = false + +[agent] +system_prompt = "BASE" + +[[providers]] +name = "test-model" +kind = "boot-token-profile-test" +model = "x" +`) + + ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy}) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer ctrl.Close() + if err := ctrl.Run(context.Background(), "fetch later"); err != nil { + t.Fatalf("Run: %v", err) + } + reqs := prov.Requests() + if len(reqs) != 2 { + t.Fatalf("requests = %d, want 2", len(reqs)) + } + if requestHasTool(reqs[1], "web_fetch") { + t.Fatalf("disabled web_fetch should not be exposed after connect_tool_source; tools=%v", toolSchemaNames(reqs[1].Tools)) + } + var toolOutput string + for _, msg := range ctrl.History() { + if msg.Role == provider.RoleTool && msg.Name == "connect_tool_source" { + toolOutput += msg.Content + } + } + if !strings.Contains(toolOutput, "web_fetch is disabled by [tools].enabled") { + t.Fatalf("connector should explain disabled web_fetch, got:\n%s", toolOutput) + } +} + +func TestBuildTokenEconomyConnectsSkillsOnDemand(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + + registerBootTokenProfileTestProvider() + prov := testutil.NewMock("token-economy", + testutil.Turn{ToolCalls: []provider.ToolCall{ + {ID: "source-1", Name: "connect_tool_source", Arguments: `{"source":"skills"}`}, + }}, + testutil.Turn{Text: "done"}, + ) + setBootTokenProfileTestProvider(t, prov) + writeFile(t, dir, "reasonix.toml", ` +default_model = "test-model" + +[codegraph] +enabled = false + +[agent] +system_prompt = "BASE" + +[[providers]] +name = "test-model" +kind = "boot-token-profile-test" +model = "x" +`) + writeFile(t, dir, ".reasonix/skills/projskill.md", "---\ndescription: a project skill\n---\nplaybook") + + ctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy}) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer ctrl.Close() + if err := ctrl.Run(context.Background(), "use skills later"); err != nil { + t.Fatalf("Run: %v", err) + } + reqs := prov.Requests() + if len(reqs) != 2 { + t.Fatalf("requests = %d, want 2", len(reqs)) + } + for _, name := range []string{"run_skill", "read_skill", "explore"} { + if requestHasTool(reqs[0], name) { + t.Fatalf("first request should hide %q; tools=%v", name, toolSchemaNames(reqs[0].Tools)) + } + if !requestHasTool(reqs[1], name) { + t.Fatalf("second request should expose %q after connect_tool_source; tools=%v", name, toolSchemaNames(reqs[1].Tools)) + } + } + var toolOutput string + for _, msg := range ctrl.History() { + if msg.Role == provider.RoleTool && msg.Name == "connect_tool_source" { + toolOutput += msg.Content + } + } + if !strings.Contains(toolOutput, "projskill") || !strings.Contains(toolOutput, "# Skills") { + t.Fatalf("skills source result should include the skill index, got:\n%s", toolOutput) + } +} + func TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) { reg := tool.NewRegistry() var stderr bytes.Buffer diff --git a/internal/boot/token_profile.go b/internal/boot/token_profile.go new file mode 100644 index 000000000..8ad34f2f5 --- /dev/null +++ b/internal/boot/token_profile.go @@ -0,0 +1,217 @@ +package boot + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + + "reasonix/internal/agent" + "reasonix/internal/tool" +) + +const ( + TokenModeFull = "full" + TokenModeEconomy = "economy" +) + +const tokenEconomyPrompt = `Token economy mode is on. Keep the default tool surface lean. Optional sources are hidden behind connect_tool_source; enable skills, MCP servers, CodeGraph, LSP, web_fetch, install_source, or task only when the current request actually needs them.` + +var tokenEconomyCoreBuiltins = []string{ + "bash", + "bash_output", + "complete_step", + "edit_file", + "glob", + "grep", + "kill_shell", + "ls", + "multi_edit", + "read_file", + "todo_write", + "wait", + "write_file", +} + +func NormalizeTokenMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case TokenModeEconomy, "eco", "save", "saving", "low", "lite", "minimal": + return TokenModeEconomy + default: + return TokenModeFull + } +} + +func tokenEconomyBuiltins(configured []string) []string { + if len(configured) == 0 { + return append([]string(nil), tokenEconomyCoreBuiltins...) + } + core := map[string]bool{} + for _, name := range tokenEconomyCoreBuiltins { + core[name] = true + } + out := make([]string, 0, len(configured)) + seen := map[string]bool{} + for _, name := range configured { + name = strings.TrimSpace(name) + if name == "" || !core[name] || seen[name] { + continue + } + seen[name] = true + out = append(out, name) + } + return out +} + +type toolSourceConnector struct { + mu sync.Mutex + + skills func(context.Context) (string, error) + task func(context.Context) (string, error) + install func(context.Context) (string, error) + webFetch func(context.Context) (string, error) + lsp func(context.Context) (string, error) + codegraph func(context.Context) (string, error) + mcp func(context.Context, string) (string, error) + mcpNames []string +} + +func (*toolSourceConnector) Name() string { return "connect_tool_source" } + +func (*toolSourceConnector) Description() string { + return "Token economy mode only: enable an optional tool source when the task needs it. Sources: skills, mcp, codegraph, lsp, web_fetch, install_source, task. For mcp, pass the configured server name; omit name to list servers. Newly enabled tools are available on the next model request." +} + +func (*toolSourceConnector) ReadOnly() bool { return true } + +func (*toolSourceConnector) Schema() json.RawMessage { + return json.RawMessage(`{ + "type":"object", + "properties":{ + "source":{"type":"string","description":"Tool source to enable: skills, mcp, codegraph, lsp, web_fetch, install_source, or task."}, + "name":{"type":"string","description":"For source=mcp, the configured server name. Omit to list configured MCP servers without connecting them."} + }, + "required":["source"] + }`) +} + +func (t *toolSourceConnector) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var p struct { + Source string `json:"source"` + Name string `json:"name"` + } + if err := json.Unmarshal(args, &p); err != nil { + return "", fmt.Errorf("invalid args: %w", err) + } + source := normalizeToolSource(p.Source) + if source == "" { + return "", fmt.Errorf("unknown tool source %q; available: %s", p.Source, strings.Join(t.availableSources(), ", ")) + } + + t.mu.Lock() + defer t.mu.Unlock() + + switch source { + case "skills": + return runSourceInstaller(ctx, "skills", t.skills) + case "task": + if agent.PlanModeFromContext(ctx) { + return "task is unavailable in plan mode because it exposes a writer-capable sub-agent tool.", nil + } + return runSourceInstaller(ctx, "task", t.task) + case "install_source": + if agent.PlanModeFromContext(ctx) { + return "install_source is unavailable in plan mode because it can install or remove tools.", nil + } + return runSourceInstaller(ctx, "install_source", t.install) + case "web_fetch": + return runSourceInstaller(ctx, "web_fetch", t.webFetch) + case "lsp": + return runSourceInstaller(ctx, "lsp", t.lsp) + case "codegraph": + return runSourceInstaller(ctx, "codegraph", t.codegraph) + case "mcp": + name := strings.TrimSpace(p.Name) + if name == "" { + if len(t.mcpNames) == 0 { + return "No configured MCP servers are available in this session.", nil + } + names := append([]string(nil), t.mcpNames...) + sort.Strings(names) + return "Configured MCP servers: " + strings.Join(names, ", ") + ". Call connect_tool_source again with source=\"mcp\" and name set to connect one server.", nil + } + if t.mcp == nil { + return "", fmt.Errorf("MCP source is unavailable in this session") + } + return t.mcp(ctx, name) + default: + return "", fmt.Errorf("unknown tool source %q", p.Source) + } +} + +func normalizeToolSource(source string) string { + switch strings.ToLower(strings.TrimSpace(source)) { + case "skill", "skills": + return "skills" + case "mcp", "plugin", "plugins", "server", "servers": + return "mcp" + case "codegraph", "code_graph", "symbol", "symbols": + return "codegraph" + case "lsp", "language_server", "language-servers": + return "lsp" + case "web", "web_fetch", "webfetch", "fetch": + return "web_fetch" + case "install", "install_source", "installer": + return "install_source" + case "task", "subagent", "subagents": + return "task" + default: + return "" + } +} + +func (t *toolSourceConnector) availableSources() []string { + var out []string + if t.skills != nil { + out = append(out, "skills") + } + if t.mcp != nil || len(t.mcpNames) > 0 { + out = append(out, "mcp") + } + if t.codegraph != nil { + out = append(out, "codegraph") + } + if t.lsp != nil { + out = append(out, "lsp") + } + if t.webFetch != nil { + out = append(out, "web_fetch") + } + if t.install != nil { + out = append(out, "install_source") + } + if t.task != nil { + out = append(out, "task") + } + sort.Strings(out) + return out +} + +func runSourceInstaller(ctx context.Context, name string, fn func(context.Context) (string, error)) (string, error) { + if fn == nil { + return "", fmt.Errorf("%s source is unavailable in this session", name) + } + return fn(ctx) +} + +func addTools(reg *tool.Registry, tools []tool.Tool) []string { + names := make([]string, 0, len(tools)) + for _, t := range tools { + reg.Add(t) + names = append(names, t.Name()) + } + sort.Strings(names) + return names +} diff --git a/internal/skill/index.go b/internal/skill/index.go index 497f7558e..3a2e83913 100644 --- a/internal/skill/index.go +++ b/internal/skill/index.go @@ -16,12 +16,12 @@ const missingDescPlaceholder = `(no description — frontmatter is missing a "de const indexHeader = "# Skills — playbooks you can invoke\n\n" + "One-liner index. Before non-trivial work, scan it: if an untagged (inline) skill is even plausibly relevant to the task, invoke it before continuing instead of pre-judging — loading one imperfect inline skill is cheap. Skills tagged `[🧬 subagent]` are the heavy path; reach for them only when the task genuinely needs context-heavy work, not on weak relevance. Each entry is a built-in or a user-authored playbook. Call `run_skill({ name: \"\", arguments: \"\" })` — `name` is JUST the identifier (e.g. `\"explore\"`), NOT the `[🧬 subagent]` tag that follows it. Prefer the dedicated top-level tool when one exists for a built-in subagent skill. Entries tagged `[🧬 subagent]` spawn an isolated subagent — its tool calls and reasoning never enter your context, only its final answer does; use them for context-heavy work (deep exploration, multi-step research) where you only need the conclusion. Untagged skills are inlined: the body becomes a tool result you read and act on directly. The user can also invoke a skill via `/`." -// ApplyIndex appends the skills index to basePrompt, or returns it unchanged -// when there are no skills. Only names + descriptions (+ a subagent tag) are -// listed; bodies load on demand via run_skill. -func ApplyIndex(basePrompt string, skills []Skill) string { +// IndexBlock renders the system/tool-result skills listing without attaching it +// to a base prompt. Only names + descriptions (+ a subagent tag) are listed; +// bodies load on demand via run_skill. +func IndexBlock(skills []Skill) string { if len(skills) == 0 { - return basePrompt + return "" } lines := make([]string, 0, len(skills)) for _, sk := range skills { @@ -31,7 +31,18 @@ func ApplyIndex(basePrompt string, skills []Skill) string { if r := []rune(joined); len(r) > IndexMaxChars { joined = string(r[:IndexMaxChars]) + fmt.Sprintf("\n… (truncated %d chars)", len(r)-IndexMaxChars) } - return basePrompt + "\n\n" + indexHeader + "\n\n```\n" + joined + "\n```" + return indexHeader + "\n\n```\n" + joined + "\n```" +} + +// ApplyIndex appends the skills index to basePrompt, or returns it unchanged +// when there are no skills. Only names + descriptions (+ a subagent tag) are +// listed; bodies load on demand via run_skill. +func ApplyIndex(basePrompt string, skills []Skill) string { + block := IndexBlock(skills) + if block == "" { + return basePrompt + } + return basePrompt + "\n\n" + block } // indexLine renders one skill as "- name [tag] — description", clipped to a