mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Fix provider-key rebuild lease handling
This commit is contained in:
1 parent
250b9f6f91
commit
39270df554
6 files changed
+237
-48
No files matched your search
+11
-7
@@ -7916,6 +7916,8 @@ const onboardingKeyEnv = "DEEPSEEK_API_KEY"
|
||||
// billing.FetchWithClient surfaces 401/403 for a bad key.
|
||||
const onboardingBalanceURL = "https://api.deepseek.com/user/balance"
|
||||
|
||||
var connectKeyBalanceFetch = billing.FetchWithClient
|
||||
|
||||
// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation
|
||||
// dialog that replaces web-style confirm() for destructive or important actions.
|
||||
type NativeConfirmRequest struct {
|
||||
@@ -7994,20 +7996,22 @@ func (a *App) ConnectKey(apiKey string) (string, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
if _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {
|
||||
if _, err := connectKeyBalanceFetch(ctx, nil, onboardingBalanceURL, apiKey); err != nil {
|
||||
return "", fmt.Errorf("validate: %w", err)
|
||||
}
|
||||
warning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("save: %w", err)
|
||||
}
|
||||
if err := a.rebuild(); err != nil {
|
||||
// Key is persisted; surface the failure but let the next rebuild load it.
|
||||
a.mu.Lock()
|
||||
if tab := a.activeTabLocked(); tab != nil {
|
||||
tab.StartupErr = err.Error()
|
||||
if err := a.rebuildSetting("provider key"); err != nil {
|
||||
// Key is persisted. Keep the current session usable and let a later
|
||||
// rebuild pick up the credential.
|
||||
slog.Warn("desktop: connect key rebuild failed", "err", err)
|
||||
if strings.TrimSpace(warning) == "" {
|
||||
warning = err.Error()
|
||||
} else {
|
||||
warning = warning + "\n" + err.Error()
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
return warning, nil
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/billing"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
@@ -1705,6 +1706,78 @@ api_key_env = "PROXY_DEEPSEEK_KEY"
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderKeyLeaseHeldKeepsCurrentController(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "old/old-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"old"}
|
||||
cfg.Providers = []config.ProviderEntry{
|
||||
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
|
||||
{Name: "longcat", Kind: "openai", BaseURL: "https://longcat.example/v1", Model: "longcat-chat", APIKeyEnv: "LONGCAT_API_KEY"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(dir, "externally-leased-provider-key.jsonl")
|
||||
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
||||
t.Fatalf("write placeholder session: %v", err)
|
||||
}
|
||||
externalLease, err := agent.TryAcquireSessionLease(sessionPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
defer externalLease.Release()
|
||||
|
||||
oldSession := agent.NewSession("old system prompt")
|
||||
oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
|
||||
oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
|
||||
oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
|
||||
defer oldCtrl.Close()
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_provider",
|
||||
Scope: "global",
|
||||
SessionPath: sessionPath,
|
||||
Ready: true,
|
||||
model: "old/old-model",
|
||||
Ctrl: oldCtrl,
|
||||
sink: &tabEventSink{tabID: "tab_provider", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
_, err = app.SetProviderKey("LONGCAT_API_KEY", "sk-longcat")
|
||||
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
t.Fatalf("SetProviderKey err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), sessionPath) || strings.Contains(err.Error(), "held by") {
|
||||
t.Fatalf("SetProviderKey surfaced raw lease details: %v", err)
|
||||
}
|
||||
if tab.Ctrl != oldCtrl {
|
||||
t.Fatalf("tab controller changed after failed provider-key rebuild")
|
||||
}
|
||||
if tab.StartupErr != "" {
|
||||
t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
|
||||
}
|
||||
if got := tab.Ctrl.History(); len(got) < 2 || got[1].Content != "hello" {
|
||||
t.Fatalf("history after failed provider-key rebuild = %+v", got)
|
||||
}
|
||||
if access := providerAccessSet(config.LoadForEditWithoutCredentials(config.UserConfigPath()).Desktop.ProviderAccess); !access["longcat"] {
|
||||
t.Fatalf("provider_access should still persist longcat after key save")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
|
||||
@@ -3059,6 +3132,82 @@ func TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectKeyRebuildLeaseHeldKeepsCurrentController(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv(onboardingKeyEnv, "")
|
||||
os.Unsetenv(onboardingKeyEnv)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
|
||||
oldFetch := connectKeyBalanceFetch
|
||||
connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
|
||||
return &billing.Balance{Available: true}, nil
|
||||
}
|
||||
t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "old/old-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"old"}
|
||||
cfg.Providers = []config.ProviderEntry{
|
||||
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(dir, "externally-leased-connect-key.jsonl")
|
||||
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
||||
t.Fatalf("write placeholder session: %v", err)
|
||||
}
|
||||
externalLease, err := agent.TryAcquireSessionLease(sessionPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
defer externalLease.Release()
|
||||
|
||||
oldSession := agent.NewSession("old system prompt")
|
||||
oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
|
||||
oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
|
||||
oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
|
||||
defer oldCtrl.Close()
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_connect",
|
||||
Scope: "global",
|
||||
SessionPath: sessionPath,
|
||||
Ready: true,
|
||||
model: "old/old-model",
|
||||
Ctrl: oldCtrl,
|
||||
sink: &tabEventSink{tabID: "tab_connect", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
warning, err := app.ConnectKey("sk-test")
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectKey: %v", err)
|
||||
}
|
||||
if !strings.Contains(warning, "another Reasonix window") {
|
||||
t.Fatalf("ConnectKey warning = %q, want user-facing lease warning", warning)
|
||||
}
|
||||
if tab.Ctrl != oldCtrl {
|
||||
t.Fatalf("tab controller changed after failed connect-key rebuild")
|
||||
}
|
||||
if tab.StartupErr != "" {
|
||||
t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
|
||||
}
|
||||
if !config.CredentialStored(onboardingKeyEnv) {
|
||||
t.Fatal("onboarding key should be persisted even when hot rebuild is deferred")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
|
||||
@@ -5665,10 +5665,17 @@ function ProviderEditor({
|
||||
|
||||
const save = async () => {
|
||||
if (extraBodyInvalid) return;
|
||||
setFetchStatus(null);
|
||||
setFetchFallback(null);
|
||||
const ms = parseProviderListInput(models);
|
||||
const vms = parseProviderListInput(visionModels).filter((model) => ms.includes(model));
|
||||
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
|
||||
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
|
||||
try {
|
||||
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
|
||||
} catch (e) {
|
||||
setFetchFallback(String((e as Error)?.message ?? e));
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
builtIn: initial?.builtIn ?? false,
|
||||
|
||||
@@ -91,7 +91,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
app.ToolResultForTab(tabId, item.id).then((d) => {
|
||||
if (!cancelled && d) setFullData(d);
|
||||
}).catch(() => {}),
|
||||
);
|
||||
).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [open, item.id, item.dataArchived, fullData, tabId]);
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [open, startNew]);
|
||||
|
||||
@@ -210,18 +210,22 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(async () => {
|
||||
const id = await heartbeatGenerateID();
|
||||
setEditing({
|
||||
id,
|
||||
title: "",
|
||||
prompt: "",
|
||||
interval: "30m",
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
try {
|
||||
const id = await heartbeatGenerateID();
|
||||
setEditing({
|
||||
id,
|
||||
title: "",
|
||||
prompt: "",
|
||||
interval: "30m",
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback((task: HeartbeatTask) => {
|
||||
@@ -238,8 +242,12 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
|
||||
const handleTrigger = useCallback(
|
||||
async (id: string) => {
|
||||
await heartbeatTriggerNow(id);
|
||||
void loadTasks();
|
||||
try {
|
||||
await heartbeatTriggerNow(id);
|
||||
void loadTasks();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[loadTasks],
|
||||
);
|
||||
|
||||
+45
-24
@@ -1361,10 +1361,14 @@ func sameConfigPath(a, b string) bool {
|
||||
return filepath.Clean(a) == filepath.Clean(b)
|
||||
}
|
||||
|
||||
// rebuild tears down the controller and rebuilds it from the (just-changed)
|
||||
// config, carrying the conversation forward. It keeps the active model if it
|
||||
// still resolves; otherwise it falls back to the new default. Mirrors SetModel.
|
||||
// rebuild builds a replacement controller from the (just-changed) config and
|
||||
// swaps it in only after the target session lease is available. The old
|
||||
// controller stays usable if the rebuild fails.
|
||||
func (a *App) rebuild() error {
|
||||
return a.rebuildSetting("settings")
|
||||
}
|
||||
|
||||
func (a *App) rebuildSetting(setting string) error {
|
||||
if a.ctx == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1373,22 +1377,41 @@ func (a *App) rebuild() error {
|
||||
return fmt.Errorf("no active tab")
|
||||
}
|
||||
if controllerHasActiveRuntimeWork(tab.Ctrl) {
|
||||
return rebuildControllerActiveWorkError("settings")
|
||||
return rebuildControllerActiveWorkError(setting)
|
||||
}
|
||||
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
||||
return err
|
||||
}
|
||||
prevPath := a.reconciledSessionPathForTab(tab)
|
||||
var carried []provider.Message
|
||||
if tab.Ctrl != nil {
|
||||
if prevPath == "" {
|
||||
prevPath = strings.TrimSpace(tab.currentSessionPath())
|
||||
}
|
||||
if tab.Ctrl == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
|
||||
prevPath = a.reconciledSessionPathForTab(tab)
|
||||
if prevPath == "" {
|
||||
prevPath = tab.Ctrl.SessionPath()
|
||||
prevPath = strings.TrimSpace(tab.currentSessionPath())
|
||||
}
|
||||
}
|
||||
if controllerHasActiveRuntimeWork(tab.Ctrl) {
|
||||
return rebuildControllerActiveWorkError(setting)
|
||||
}
|
||||
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var carried []provider.Message
|
||||
oldCtrl := tab.Ctrl
|
||||
if oldCtrl != nil {
|
||||
if prevPath == "" {
|
||||
prevPath = oldCtrl.SessionPath()
|
||||
}
|
||||
if err := tab.ensureSessionLease(prevPath); err != nil {
|
||||
return userFacingSessionLeaseError(setting, err)
|
||||
}
|
||||
if err := a.snapshotTabForAction(tab, "rebuilding settings"); err != nil {
|
||||
return err
|
||||
}
|
||||
carried = tab.Ctrl.History()
|
||||
tab.Ctrl.Close()
|
||||
carried = oldCtrl.History()
|
||||
}
|
||||
model := tab.model
|
||||
if cfg, err := config.LoadForRoot(tab.WorkspaceRoot); err == nil {
|
||||
@@ -1413,12 +1436,13 @@ func (a *App) rebuild() error {
|
||||
OnSessionRecovered: a.handleTabSessionRecovered(tab),
|
||||
})
|
||||
if err != nil {
|
||||
tab.releaseSessionLease()
|
||||
a.mu.Lock()
|
||||
tab.StartupErr = err.Error()
|
||||
tab.Ready = true
|
||||
a.mu.Unlock()
|
||||
a.emitReady(a.ctx)
|
||||
if oldCtrl == nil {
|
||||
a.mu.Lock()
|
||||
tab.StartupErr = err.Error()
|
||||
tab.Ready = true
|
||||
a.mu.Unlock()
|
||||
a.emitReady(a.ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
a.bindControllerDisplayRecorder(ctrl)
|
||||
@@ -1434,14 +1458,11 @@ func (a *App) rebuild() error {
|
||||
path := agent.ContinueSessionPath(prevPath, ctrl.SessionDir(), ctrl.Label())
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
ctrl.Close()
|
||||
tab.releaseSessionLease()
|
||||
return err
|
||||
return userFacingSessionLeaseError(setting, err)
|
||||
}
|
||||
if len(carried) > 0 {
|
||||
carried = withFreshSystemPrompt(carried, systemPromptFrom(ctrl.History()))
|
||||
ctrl.Resume(&agent.Session{Messages: carried}, path)
|
||||
} else if path != "" {
|
||||
ctrl.SetSessionPath(path)
|
||||
resumeWithFreshSystemPrompt(ctrl, carried, path)
|
||||
if oldCtrl != nil {
|
||||
oldCtrl.Close()
|
||||
}
|
||||
a.persistTabSessionPath(tab, path)
|
||||
a.mu.Lock()
|
||||
@@ -2230,7 +2251,7 @@ func (a *App) SetProviderKey(apiKeyEnv, value string) (string, error) {
|
||||
if err := a.ensureProviderAccessForKey(apiKeyEnv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := a.rebuild(); err != nil {
|
||||
if err := a.rebuildSetting("provider key"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return warning, nil
|
||||
@@ -2299,7 +2320,7 @@ func (a *App) ClearProviderKey(apiKeyEnv string) error {
|
||||
if err := removeDotEnv(apiKeyEnv); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.rebuild()
|
||||
return a.rebuildSetting("provider key")
|
||||
}
|
||||
|
||||
// SetPermissionMode sets the writer-fallback mode (ask|allow|deny).
|
||||
|
||||
Reference in new issue
Block a user