diff --git a/CHANGELOG.md b/CHANGELOG.md index 38fb1147a1..968b304c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ See `changelogs/v1.5.0-beta.1.md` for the full themed summary with credits. - **core**: always emit absolute paths from `SaveFilesToDisk` / `AppendFileRefs` — fixes relative `work_dir` attachments silently dropped by agent (#1462 fixing #1459, @chenhg5). - **core**: create queue placeholder before session lock — prevents concurrent message queue miss (#1389, @xxb). - **codex**: time out blocked app-server writes (#1448, @AaronZ345). +- **codex**: surface GPT-5.x and future chat-model families from API model discovery instead of filtering them through a stale static allowlist (#1546, @cg33). - **slack**: suppress `NO_REPLY` marker on streaming-card silent replies (#1397, @spinsirr). ## v1.4.1 (2026-06-28) diff --git a/agent/codex/appserver_session.go b/agent/codex/appserver_session.go index 51a7f39743..e40f75595c 100644 --- a/agent/codex/appserver_session.go +++ b/agent/codex/appserver_session.go @@ -161,6 +161,7 @@ type appServerSession struct { workDir string model string effort string + effortOverride bool mode string baseURL string modelProvider string @@ -216,6 +217,7 @@ func newAppServerSession(ctx context.Context, url, workDir, model, effort, mode, workDir: workDir, model: model, effort: effort, + effortOverride: strings.TrimSpace(effort) != "", mode: mode, baseURL: baseURL, modelProvider: modelProvider, @@ -408,7 +410,9 @@ func (s *appServerSession) applyThreadRuntimeState(workDir, model string, effort if m := strings.TrimSpace(model); m != "" { s.model = m } - s.effort = normalizeRuntimeReasoningEffort(stringValue(effort)) + if !s.effortOverride { + s.effort = normalizeRuntimeReasoningEffort(stringValue(effort)) + } } func (s *appServerSession) refreshUsage(ctx context.Context) error { @@ -1003,6 +1007,18 @@ func (s *appServerSession) GetReasoningEffort() string { return strings.TrimSpace(s.effort) } +func (s *appServerSession) SetLiveReasoningEffort(effort string) bool { + normalized := normalizeReasoningEffort(effort) + if normalized == "" && strings.TrimSpace(effort) != "" { + return false + } + s.runtimeMu.Lock() + s.effort = normalized + s.effortOverride = true + s.runtimeMu.Unlock() + return true +} + func (s *appServerSession) GetUsage(ctx context.Context) (*core.UsageReport, error) { if err := s.refreshUsage(ctx); err != nil { if cached := s.cachedUsage(); cached != nil { diff --git a/agent/codex/appserver_session_test.go b/agent/codex/appserver_session_test.go index 71ccc72372..50468433fe 100644 --- a/agent/codex/appserver_session_test.go +++ b/agent/codex/appserver_session_test.go @@ -31,6 +31,39 @@ func TestAppServerSession_ApplyThreadRuntimeState(t *testing.T) { } } +func TestAppServerSession_SetLiveReasoningEffortPreservesThread(t *testing.T) { + s := &appServerSession{effort: "high"} + s.threadID.Store("thread-existing") + + if !s.SetLiveReasoningEffort("ultra") { + t.Fatal("SetLiveReasoningEffort(ultra) = false, want true") + } + + if got := s.CurrentSessionID(); got != "thread-existing" { + t.Fatalf("CurrentSessionID() = %q, want thread-existing", got) + } + if got := s.GetReasoningEffort(); got != "ultra" { + t.Fatalf("GetReasoningEffort() = %q, want ultra", got) + } + + previousThreadEffort := "high" + s.applyThreadRuntimeState("/tmp/project", "gpt-5.6-sol", &previousThreadEffort) + if got := s.GetReasoningEffort(); got != "ultra" { + t.Fatalf("GetReasoningEffort() after thread resume = %q, want explicit ultra override", got) + } +} + +func TestAppServerSession_ExplicitEffortSurvivesThreadResume(t *testing.T) { + s := &appServerSession{effort: "ultra", effortOverride: true} + previousThreadEffort := "low" + + s.applyThreadRuntimeState("/tmp/project", "gpt-5.6-sol", &previousThreadEffort) + + if got := s.GetReasoningEffort(); got != "ultra" { + t.Fatalf("GetReasoningEffort() = %q, want explicit ultra override", got) + } +} + func TestAppServerSession_HandleRateLimitsUpdatedCachesUsage(t *testing.T) { s := &appServerSession{} raw, err := json.Marshal(appServerRateLimitsResponse{ diff --git a/agent/codex/codex.go b/agent/codex/codex.go index 83fe4a8942..da95b2c491 100644 --- a/agent/codex/codex.go +++ b/agent/codex/codex.go @@ -145,14 +145,22 @@ func normalizeReasoningEffort(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "": return "" + case "none", "off", "disabled", "disable": + return "none" + case "minimal", "min": + return "minimal" case "low": return "low" case "medium", "med": return "medium" case "high": return "high" - case "xhigh", "x-high", "very-high": + case "xhigh", "x-high", "extra-high", "extra_high", "very-high": return "xhigh" + case "max", "maximum": + return "max" + case "ultra": + return "ultra" default: return "" } @@ -200,7 +208,11 @@ func (a *Agent) GetReasoningEffort() string { } func (a *Agent) AvailableReasoningEfforts() []string { - return []string{"low", "medium", "high", "xhigh"} + return []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"} +} + +func (a *Agent) PreservesSessionOnReasoningEffortChange() bool { + return true } func (a *Agent) configuredModels() []core.ModelOption { @@ -222,7 +234,19 @@ func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { if models := readCodexCachedModels(); len(models) > 0 { return models } + return defaultCodexModels() +} + +func defaultCodexModels() []core.ModelOption { return []core.ModelOption{ + {Name: "gpt-5.6-sol", Desc: "GPT-5.6 Sol (strongest for complex Codex work)"}, + {Name: "gpt-5.6-terra", Desc: "GPT-5.6 Terra (balanced everyday Codex work)"}, + {Name: "gpt-5.6-luna", Desc: "GPT-5.6 Luna (fast, efficient GPT-5.6 model)"}, + {Name: "gpt-5.6", Desc: "GPT-5.6 (recommended Codex model family default)"}, + {Name: "gpt-5.5", Desc: "GPT-5.5 (previous frontier Codex model)"}, + {Name: "gpt-5.4", Desc: "GPT-5.4 (frontier Codex model)"}, + {Name: "gpt-5.4-mini", Desc: "GPT-5.4 Mini (fast Codex model)"}, + {Name: "gpt-5.3-codex-spark", Desc: "GPT-5.3 Codex Spark (fast text-only iteration)"}, {Name: "o4-mini", Desc: "O4 Mini (fast reasoning)"}, {Name: "o3", Desc: "O3 (most capable reasoning)"}, {Name: "gpt-4.1", Desc: "GPT-4.1 (balanced)"}, @@ -232,11 +256,53 @@ func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { } } -var openaiChatModels = map[string]bool{ - "o4-mini": true, "o3": true, "o3-mini": true, "o1": true, "o1-mini": true, - "gpt-4.1": true, "gpt-4.1-mini": true, "gpt-4.1-nano": true, - "gpt-4o": true, "gpt-4o-mini": true, - "codex-mini-latest": true, +// nonChatSubstrings identifies non chat/completion modalities returned by +// GET /v1/models that must not appear in the codex /model chooser. +var nonChatSubstrings = []string{ + "embedding", "whisper", "tts", "moderation", "dall-e", + "realtime", "transcribe", "search-preview", "image", + "audio-preview", +} + +// isCodexChatModel reports whether an OpenAI-compatible model ID names a +// chat/completion model that Codex CLI can drive. Used to filter the +// /v1/models response into the /model command suggestion list. +// +// Rules (case-insensitive): +// - Reject any ID containing a non-chat modality substring (embedding, +// whisper, tts, dall-e, audio-preview, realtime, transcribe, moderation, +// image, search-preview). +// - Accept known chat family prefixes: gpt-*, chatgpt-*, codex-*, o1-*, +// o3-*, o4-*, o5-*. +// - Accept bare reasoning family IDs: o1 / o3 / o4 / o5. +// +// Uses pattern matching rather than a static allowlist so new frontier models +// (gpt-5.x, gpt-6, o5-*, codex-*, etc.) are picked up automatically. +func isCodexChatModel(id string) bool { + if id == "" { + return false + } + lower := strings.ToLower(id) + for _, s := range nonChatSubstrings { + if strings.Contains(lower, s) { + return false + } + } + switch { + case strings.HasPrefix(lower, "gpt-"), + strings.HasPrefix(lower, "chatgpt-"), + strings.HasPrefix(lower, "codex-"), + strings.HasPrefix(lower, "o1-"), + strings.HasPrefix(lower, "o3-"), + strings.HasPrefix(lower, "o4-"), + strings.HasPrefix(lower, "o5-"): + return true + } + switch lower { + case "o1", "o3", "o4", "o5": + return true + } + return false } func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption { @@ -290,7 +356,7 @@ func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption { var models []core.ModelOption for _, m := range result.Data { - if openaiChatModels[m.ID] { + if isCodexChatModel(m.ID) { models = append(models, core.ModelOption{Name: m.ID}) } } @@ -311,7 +377,6 @@ func readCodexCachedModels() []core.ModelOption { return parseCodexModelsJSON(b) } - // parseCodexModelsJSON parses a Codex models JSON file (model_catalog.json // or models_cache.json) into a deduplicated, filtered slice of ModelOption. // It is shared by readCodexCachedModels and readCodexModelCatalog. @@ -357,7 +422,6 @@ func parseCodexModelsJSON(data []byte) []core.ModelOption { return models } - // readCodexModelCatalog reads $CODEX_HOME/config.toml to find the // model_catalog_json setting, then reads and parses that JSON file. // This is the authoritative source of model metadata for Codex CLI, diff --git a/agent/codex/codex_cache_test.go b/agent/codex/codex_cache_test.go index 9266d4e3e1..b013997faa 100644 --- a/agent/codex/codex_cache_test.go +++ b/agent/codex/codex_cache_test.go @@ -105,8 +105,14 @@ func TestReadCodexModelCatalog_NoConfigFile(t *testing.T) { models := a.AvailableModels(context.Background()) // No config.toml → no model_catalog.json → no models_cache.json - // → no OPENAI_API_KEY → all the way to hardcoded fallback (6 models) - if len(models) != 6 { - t.Fatalf("expected 6 hardcoded fallback models, got %d: %v", len(models), models) + // → no OPENAI_API_KEY → all the way to hardcoded fallback. + want := []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6"} + if len(models) != len(defaultCodexModels()) { + t.Fatalf("expected %d hardcoded fallback models, got %d: %v", len(defaultCodexModels()), len(models), models) + } + for i, name := range want { + if models[i].Name != name { + t.Fatalf("fallback model %d = %q, want %q; models=%v", i, models[i].Name, name, models) + } } } diff --git a/agent/codex/codex_model_test.go b/agent/codex/codex_model_test.go index 1f7d01af28..60fbe9d3a0 100644 --- a/agent/codex/codex_model_test.go +++ b/agent/codex/codex_model_test.go @@ -81,3 +81,77 @@ func TestWorkspaceAgentOptions_PreservesStdIOAppServerURL(t *testing.T) { t.Fatalf("WorkspaceAgentOptions()[app_server_url] = %#v, want stdio://", got) } } + +func TestIsCodexChatModel(t *testing.T) { + tests := []struct { + id string + want bool + }{ + {"", false}, + + // Legacy / current chat families (must keep working). + {"gpt-4o", true}, + {"gpt-4o-mini", true}, + {"gpt-4.1", true}, + {"gpt-4.1-mini", true}, + {"gpt-4.1-nano", true}, + {"gpt-3.5-turbo", true}, + {"chatgpt-4o-latest", true}, + {"o1", true}, + {"o1-mini", true}, + {"o1-preview", true}, + {"o3", true}, + {"o3-mini", true}, + {"o4", true}, + {"o4-mini", true}, + {"codex-mini-latest", true}, + + // GPT-5 series — the regression that motivated this change. + {"gpt-5", true}, + {"gpt-5-mini", true}, + {"gpt-5.3", true}, + {"gpt-5.3-codex", true}, + {"gpt-5.4", true}, + {"gpt-5.5", true}, + {"gpt-5.6", true}, + {"gpt-5.6-sol", true}, + {"gpt-5.6-terra", true}, + {"gpt-5.6-luna", true}, + + // Case insensitivity (defensive; ids from /v1/models are usually lower). + {"GPT-5.6", true}, + {"Codex-Mini-Latest", true}, + + // Non-chat modalities that /v1/models returns — must be rejected so + // they never show up in the /model chooser. + {"text-embedding-ada-002", false}, + {"text-embedding-3-small", false}, + {"text-embedding-3-large", false}, + {"whisper-1", false}, + {"tts-1", false}, + {"tts-1-hd", false}, + {"gpt-4o-realtime-preview", false}, + {"gpt-4o-audio-preview", false}, + {"gpt-4o-transcribe", false}, + {"gpt-4o-search-preview", false}, + {"dall-e-2", false}, + {"dall-e-3", false}, + {"gpt-image-1", false}, + {"text-moderation-latest", false}, + {"omni-moderation-latest", false}, + + // Unrelated model families that should not be surfaced. + {"babbage-002", false}, + {"davinci-002", false}, + {"claude-3-5-sonnet", false}, + {"gemini-1.5-pro", false}, + } + + for _, tc := range tests { + t.Run(tc.id, func(t *testing.T) { + if got := isCodexChatModel(tc.id); got != tc.want { + t.Fatalf("isCodexChatModel(%q) = %v, want %v", tc.id, got, tc.want) + } + }) + } +} diff --git a/agent/codex/session.go b/agent/codex/session.go index fee349e592..86cac54acd 100644 --- a/agent/codex/session.go +++ b/agent/codex/session.go @@ -43,6 +43,7 @@ type codexSession struct { closeOnce sync.Once cmdMu sync.Mutex cmds map[*exec.Cmd]struct{} + effortMu sync.RWMutex pendingMsgs []string // buffered agent_message texts awaiting classification @@ -262,8 +263,8 @@ func (cs *codexSession) buildExecArgs(prompt string, imagePaths []string) []stri if cs.baseURL != "" { args = append(args, "-c", fmt.Sprintf("openai_base_url=%q", cs.baseURL)) } - if cs.effort != "" { - args = append(args, "-c", fmt.Sprintf("model_reasoning_effort=%q", cs.effort)) + if effort := cs.explicitReasoningEffort(); effort != "" { + args = append(args, "-c", fmt.Sprintf("model_reasoning_effort=%q", effort)) } if isResume { @@ -811,13 +812,30 @@ func (cs *codexSession) GetModel() string { } func (cs *codexSession) GetReasoningEffort() string { - if effort := strings.TrimSpace(cs.effort); effort != "" { + if effort := cs.explicitReasoningEffort(); effort != "" { return effort } _, effort := cs.runtimeConfig() return effort } +func (cs *codexSession) explicitReasoningEffort() string { + cs.effortMu.RLock() + defer cs.effortMu.RUnlock() + return strings.TrimSpace(cs.effort) +} + +func (cs *codexSession) SetLiveReasoningEffort(effort string) bool { + normalized := normalizeReasoningEffort(effort) + if normalized == "" && strings.TrimSpace(effort) != "" { + return false + } + cs.effortMu.Lock() + cs.effort = normalized + cs.effortMu.Unlock() + return true +} + func (cs *codexSession) Alive() bool { return cs.alive.Load() } diff --git a/agent/codex/session_test.go b/agent/codex/session_test.go index 3be54c2270..c8699e38f3 100644 --- a/agent/codex/session_test.go +++ b/agent/codex/session_test.go @@ -14,19 +14,36 @@ import ( "github.com/chenhg5/cc-connect/core" ) -func TestNormalizeReasoningEffort_RejectsMinimal(t *testing.T) { - if got := normalizeReasoningEffort("minimal"); got != "" { - t.Fatalf("normalizeReasoningEffort(minimal) = %q, want empty", got) - } - if got := normalizeReasoningEffort("min"); got != "" { - t.Fatalf("normalizeReasoningEffort(min) = %q, want empty", got) +func TestNormalizeReasoningEffort_CodexGPT56Levels(t *testing.T) { + tests := map[string]string{ + "off": "none", + "none": "none", + "minimal": "minimal", + "min": "minimal", + "low": "low", + "med": "medium", + "medium": "medium", + "high": "high", + "x-high": "xhigh", + "extra-high": "xhigh", + "xhigh": "xhigh", + "max": "max", + "maximum": "max", + "ultra": "ultra", + "unknown": "", + } + + for raw, want := range tests { + if got := normalizeReasoningEffort(raw); got != want { + t.Fatalf("normalizeReasoningEffort(%q) = %q, want %q", raw, got, want) + } } } -func TestAvailableReasoningEfforts_ExcludesMinimal(t *testing.T) { +func TestAvailableReasoningEfforts_IncludesCodexGPT56Levels(t *testing.T) { agent := &Agent{} got := agent.AvailableReasoningEfforts() - want := []string{"low", "medium", "high", "xhigh"} + want := []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"} if len(got) != len(want) { t.Fatalf("AvailableReasoningEfforts len = %d, want %d, got=%v", len(got), len(want), got) } @@ -71,6 +88,41 @@ func TestBuildExecArgs_IncludesReasoningEffort(t *testing.T) { } } +func TestCodexSession_SetLiveReasoningEffortPreservesResumeThread(t *testing.T) { + cs, err := newCodexSession(context.Background(), "codex", nil, "/tmp/project", "gpt-5.6-sol", "high", "full-auto", "thread-existing", "", nil, "", "", "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + + if !cs.SetLiveReasoningEffort("ultra") { + t.Fatal("SetLiveReasoningEffort(ultra) = false, want true") + } + + args := cs.buildExecArgs("continue", nil) + if got := cs.CurrentSessionID(); got != "thread-existing" { + t.Fatalf("CurrentSessionID() = %q, want thread-existing", got) + } + if got := cs.GetReasoningEffort(); got != "ultra" { + t.Fatalf("GetReasoningEffort() = %q, want ultra", got) + } + if !containsSequence(args, []string{"-c", `model_reasoning_effort="ultra"`}) { + t.Fatalf("args missing live ultra effort: %v", args) + } + if len(args) < 2 || args[0] != "exec" || args[1] != "resume" { + t.Fatalf("args do not resume existing thread: %v", args) + } + foundThread := false + for _, arg := range args { + if arg == "thread-existing" { + foundThread = true + break + } + } + if !foundThread { + t.Fatalf("args missing existing thread id: %v", args) + } +} + func TestBuildExecArgs_IncludesBaseURL(t *testing.T) { cs, err := newCodexSession(context.Background(), "codex", nil, "/tmp/project", "o3", "high", "full-auto", "", "https://custom.api.example.com", nil, "", "", "") if err != nil { diff --git a/config.example.toml b/config.example.toml index fd5ec4ec5c..0de7f4ef1e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1567,7 +1567,9 @@ app_secret = "your-feishu-app-secret" # model = "o3" # # Optional: specify Codex reasoning effort / 可选:指定 Codex 推理强度 -# reasoning_effort = "high" # "minimal" | "low" | "medium" | "high" | "xhigh" +# reasoning_effort = "high" # "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra" +# Supported levels depend on the selected model; an unsupported level can make the next turn fail. +# 支持的等级取决于所选模型;使用模型不支持的等级可能导致下一轮失败。 # # Optional: project system prompt / 可选:项目系统提示 # Codex has no native system-prompt flag, so cc-connect injects this as a @@ -2186,4 +2188,3 @@ app_secret = "your-feishu-app-secret" # wps_sid = "your-wps-sid" # WPS登录凭证 (Cookie中的wps_sid) # device_name = "cc-connect" # 设备名称(可选) # device_uuid = "" # 设备UUID(可选,留空自动生成) - diff --git a/core/cuj_test.go b/core/cuj_test.go index 268459742f..b4f85f05f6 100644 --- a/core/cuj_test.go +++ b/core/cuj_test.go @@ -51,6 +51,7 @@ type cujAgent struct { mu sync.Mutex sessions []*cujAgentSession nextID int + effort string // failStartCount lets tests simulate "agent process won't start" — the // next N StartSession calls return failStartErr. Set both > 0 to use. @@ -64,8 +65,8 @@ type cujAgent struct { // Used by tests that need to drive a multi-event turn (text chunks + // permission request + result) from a single Send call. See // setNextSessionEvents on cujAgent. - nextSessionEvents []Event - nextSessionDelayMs int + nextSessionEvents []Event + nextSessionDelayMs int } func (a *cujAgent) Name() string { return "cuj" } @@ -96,6 +97,26 @@ func (a *cujAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) { } func (a *cujAgent) Stop() error { return nil } +func (a *cujAgent) SetReasoningEffort(effort string) { + a.mu.Lock() + a.effort = effort + a.mu.Unlock() +} + +func (a *cujAgent) GetReasoningEffort() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.effort +} + +func (a *cujAgent) AvailableReasoningEfforts() []string { + return []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"} +} + +func (a *cujAgent) PreservesSessionOnReasoningEffortChange() bool { + return true +} + // cujAgentSession is an AgentSession whose reply is controllable per-Send. // Tests can set reply (and optionally toolEvent) before each Send to drive // scenarios like "agent calls tool", "agent returns error", "agent succeeds". @@ -127,6 +148,7 @@ type cujAgentSession struct { // observed sentPrompts []string closeCount int + efforts []string } // atomic_bool is intentionally lowercase to avoid clash with stdlib atomic.Bool @@ -194,6 +216,12 @@ func (s *cujAgentSession) RespondPermission(_ string, _ PermissionResult) error func (s *cujAgentSession) Events() <-chan Event { return s.events } func (s *cujAgentSession) CurrentSessionID() string { return "cuj-agent-session" } func (s *cujAgentSession) Alive() bool { return !s.closed.Get() } +func (s *cujAgentSession) SetLiveReasoningEffort(effort string) bool { + s.mu.Lock() + s.efforts = append(s.efforts, effort) + s.mu.Unlock() + return true +} func (s *cujAgentSession) Close() error { s.closed.Set(true) s.mu.Lock() @@ -1119,6 +1147,56 @@ func TestCUJ_A2_MultiTurnAgentReceivesHistory(t *testing.T) { } } +// CUJ-A2b · Changing Codex reasoning effort preserves the conversation: +// chat → /reasoning ultra → chat continues through the same agent session. +func TestCUJ_A2b_ReasoningChangePreservesConversation(t *testing.T) { + env := newCUJEnv(t) + sessionKey := env.userSends("alex", "remember the codeword juniper") + env.waitFor("first turn settled", 2*time.Second, func() bool { + session := env.engine.sessions.GetOrCreateActive(sessionKey) + return len(env.plat.getSent()) >= 1 && !session.Busy() && len(session.GetHistory(0)) >= 2 + }) + + env.agent.mu.Lock() + if len(env.agent.sessions) != 1 { + env.agent.mu.Unlock() + t.Fatalf("agent sessions = %d, want 1 after first turn", len(env.agent.sessions)) + } + liveSession := env.agent.sessions[0] + env.agent.mu.Unlock() + + liveSession.mu.Lock() + liveSession.reply = "The codeword is juniper." + liveSession.mu.Unlock() + + env.userSends("alex", "/reasoning ultra") + env.waitFor("preserved-conversation confirmation", 2*time.Second, func() bool { + for _, sent := range env.plat.getSent() { + if strings.Contains(sent, "current conversation was preserved") { + return true + } + } + return false + }) + + env.userSends("alex", "what was the codeword?") + env.waitFor("context-aware reply after reasoning change", 2*time.Second, func() bool { + for _, sent := range env.plat.getSent() { + if strings.Contains(sent, "The codeword is juniper.") { + return true + } + } + return false + }) + + env.agent.mu.Lock() + sessionCount := len(env.agent.sessions) + env.agent.mu.Unlock() + if sessionCount != 1 { + t.Fatalf("agent sessions = %d, want the original conversation session", sessionCount) + } +} + // CUJ-A3 · User uploads image → engine routes it to the agent. // (No real vision LLM; we assert the image attachment reaches the agent.) func TestCUJ_A3_ImageReachesAgent(t *testing.T) { @@ -1130,8 +1208,8 @@ func TestCUJ_A3_ImageReachesAgent(t *testing.T) { msg := &Message{ SessionKey: "test:img", Platform: "test", MessageID: "img1", UserID: "img", UserName: "img", - Content: "what is in this image", - Images: []ImageAttachment{{MimeType: "image/png", Data: []byte("\x89PNG fake"), FileName: "chart.png"}}, + Content: "what is in this image", + Images: []ImageAttachment{{MimeType: "image/png", Data: []byte("\x89PNG fake"), FileName: "chart.png"}}, ReplyCtx: "ctx", } e.ReceiveMessage(plat, msg) @@ -1194,8 +1272,8 @@ func TestCUJ_A5_FileReachesAgent(t *testing.T) { msg := &Message{ SessionKey: "test:file", Platform: "test", MessageID: "f1", UserID: "file", UserName: "file", - Content: "read this file", - Files: []FileAttachment{{MimeType: "text/plain", Data: []byte("hello world"), FileName: "note.txt"}}, + Content: "read this file", + Files: []FileAttachment{{MimeType: "text/plain", Data: []byte("hello world"), FileName: "note.txt"}}, ReplyCtx: "ctx", } e.ReceiveMessage(plat, msg) @@ -2325,4 +2403,3 @@ func TestCUJ_STREAM1_StreamingResumesAfterPermissionPrompt(t *testing.T) { } } } - diff --git a/core/engine.go b/core/engine.go index f65da19e0f..27f6f3ef2f 100644 --- a/core/engine.go +++ b/core/engine.go @@ -9796,11 +9796,11 @@ func (e *Engine) cmdReasoning(p Platform, msg *Message, args []string) { buttons = append(buttons, row) } sb.WriteString("\n") - sb.WriteString(e.i18n.T(MsgReasoningUsage)) + sb.WriteString(e.reasoningUsage(efforts)) e.replyWithButtons(p, msg.ReplyCtx, sb.String(), buttons) return } - e.replyWithCard(p, msg.ReplyCtx, e.renderReasoningCard()) + e.replyWithCard(p, msg.ReplyCtx, e.renderReasoningCard(msg.SessionKey)) return } @@ -9818,19 +9818,26 @@ func (e *Engine) cmdReasoning(p Platform, msg *Message, args []string) { } } if !valid { - e.reply(p, msg.ReplyCtx, e.i18n.T(MsgReasoningUsage)) + e.reply(p, msg.ReplyCtx, e.reasoningUsage(efforts)) return } switcher.SetReasoningEffort(target) - e.cleanupInteractiveState(e.interactiveKeyForSessionKey(msg.SessionKey)) + appliedLive := e.applyLiveReasoningEffortChange(msg.SessionKey, target) + preserved := appliedLive || preservesSessionOnReasoningEffortChange(agent) + if !preserved { + e.resetSessionForReasoningChange(msg.SessionKey, sessions) + } - s := sessions.GetOrCreateActive(msg.SessionKey) - s.SetAgentSessionID("", "") - s.ClearHistory() - sessions.Save() + msgKey := MsgReasoningChanged + if preserved { + msgKey = MsgReasoningChangedLive + } + e.reply(p, msg.ReplyCtx, e.i18n.Tf(msgKey, target)) +} - e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgReasoningChanged, target)) +func (e *Engine) reasoningUsage(efforts []string) string { + return e.i18n.Tf(MsgReasoningUsage, strings.Join(efforts, "|")) } func (e *Engine) cmdMode(p Platform, msg *Message, args []string) { @@ -9944,6 +9951,40 @@ func (e *Engine) applyLiveModeChange(sessionKey, mode string) bool { return switcher.SetLiveMode(mode) } +func (e *Engine) applyLiveReasoningEffortChange(sessionKey, effort string) bool { + iKey := e.interactiveKeyForSessionKey(sessionKey) + e.interactiveMu.Lock() + state, ok := e.interactiveStates[iKey] + e.interactiveMu.Unlock() + if !ok || state == nil { + return false + } + state.mu.Lock() + defer state.mu.Unlock() + agentSession := state.agentSession + if agentSession == nil || !agentSession.Alive() { + return false + } + switcher, ok := agentSession.(LiveReasoningEffortSwitcher) + if !ok { + return false + } + return switcher.SetLiveReasoningEffort(effort) +} + +func preservesSessionOnReasoningEffortChange(agent Agent) bool { + preserver, ok := agent.(ReasoningEffortSessionPreserver) + return ok && preserver.PreservesSessionOnReasoningEffortChange() +} + +func (e *Engine) resetSessionForReasoningChange(sessionKey string, sessions *SessionManager) { + e.cleanupInteractiveState(e.interactiveKeyForSessionKey(sessionKey)) + s := sessions.GetOrCreateActive(sessionKey) + s.SetAgentSessionID("", "") + s.ClearHistory() + sessions.Save() +} + func (e *Engine) cmdQuiet(p Platform, msg *Message, args []string) { // /quiet [full|compact|quiet] // Without argument: cycle full → quiet → compact → full. @@ -11993,7 +12034,7 @@ func (e *Engine) handleCardNav(action string, sessionKey string) *Card { case "/model": return e.renderModelCard(sessionKey) case "/reasoning": - return e.renderReasoningCard() + return e.renderReasoningCard(sessionKey) case "/mode": return e.renderModeCard() case "/lang": @@ -12176,7 +12217,8 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { if args == "" { return } - switcher, ok := e.agent.(ReasoningEffortSwitcher) + agent, sessions := e.sessionContextForKey(sessionKey) + switcher, ok := agent.(ReasoningEffortSwitcher) if !ok { return } @@ -12188,11 +12230,10 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { for _, effort := range efforts { if effort == target { switcher.SetReasoningEffort(target) - e.cleanupInteractiveState(interactiveKey) - s := e.sessions.GetOrCreateActive(sessionKey) - s.SetAgentSessionID("", "") - s.ClearHistory() - e.sessions.Save() + appliedLive := e.applyLiveReasoningEffortChange(sessionKey, target) + if !appliedLive && !preservesSessionOnReasoningEffortChange(agent) { + e.resetSessionForReasoningChange(sessionKey, sessions) + } return } } @@ -12994,8 +13035,13 @@ func (e *Engine) renderModelSwitchResultCard(target string, err error) *Card { Build() } -func (e *Engine) renderReasoningCard() *Card { - switcher, ok := e.agent.(ReasoningEffortSwitcher) +func (e *Engine) renderReasoningCard(sessionKey string) *Card { + agent := e.agent + if sessionKey != "" { + agent, _ = e.sessionContextForKey(sessionKey) + } + + switcher, ok := agent.(ReasoningEffortSwitcher) if !ok { return e.simpleCard(e.i18n.T(MsgCardTitleReasoning), "orange", e.i18n.T(MsgReasoningNotSupported)) } @@ -13024,7 +13070,7 @@ func (e *Engine) renderReasoningCard() *Card { Markdown(sb.String()). Select(e.i18n.T(MsgReasoningSelectPlaceholder), opts, initVal). Buttons(e.cardBackButton()) - cb.Note(e.i18n.T(MsgReasoningUsage)) + cb.Note(e.reasoningUsage(efforts)) return cb.Build() } diff --git a/core/engine_test.go b/core/engine_test.go index 4b113df0cc..ed358ab328 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -484,11 +484,12 @@ func (p *stubAskQuestionRichCardPlatform) BuildRichCard(status CardStatus, title type stubModelModeAgent struct { stubAgent - model string - mode string - reasoningEffort string - providers []ProviderConfig - active string + model string + mode string + reasoningEffort string + reasoningEfforts []string + providers []ProviderConfig + active string } type stubStrictModelAgent struct { @@ -507,6 +508,42 @@ func (s *stubLiveModeSession) SetLiveMode(mode string) bool { return true } +type stubLiveReasoningSession struct { + stubAgentSession + efforts []string +} + +type blockingAliveReasoningSession struct { + stubAgentSession + aliveEntered chan struct{} + releaseAlive chan struct{} + effortSet chan string +} + +func (s *blockingAliveReasoningSession) Alive() bool { + close(s.aliveEntered) + <-s.releaseAlive + return true +} + +func (s *blockingAliveReasoningSession) SetLiveReasoningEffort(effort string) bool { + s.effortSet <- effort + return true +} + +type stubPreservingReasoningAgent struct { + stubModelModeAgent +} + +func (a *stubPreservingReasoningAgent) PreservesSessionOnReasoningEffortChange() bool { + return true +} + +func (s *stubLiveReasoningSession) SetLiveReasoningEffort(effort string) bool { + s.efforts = append(s.efforts, effort) + return true +} + func (a *stubModelModeAgent) SetModel(model string) { a.model = model } @@ -587,6 +624,9 @@ func (a *stubModelModeAgent) GetReasoningEffort() string { } func (a *stubModelModeAgent) AvailableReasoningEfforts() []string { + if len(a.reasoningEfforts) > 0 { + return append([]string(nil), a.reasoningEfforts...) + } return []string{"low", "medium", "high", "xhigh"} } @@ -5526,7 +5566,81 @@ func TestCmdReasoning_UsesInlineButtonsOnButtonOnlyPlatform(t *testing.T) { } } -func TestCmdReasoning_SwitchesEffortAndResetsSession(t *testing.T) { +func TestCmdReasoning_UsageListsAgentEfforts(t *testing.T) { + efforts := []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"} + wantUsage := "Usage: `/reasoning ` or `/reasoning <" + strings.Join(efforts, "|") + ">`" + + t.Run("list", func(t *testing.T) { + p := &stubPlatformEngine{n: "plain"} + agent := &stubModelModeAgent{reasoningEfforts: efforts} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + + e.cmdReasoning(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, nil) + + if len(p.sent) != 1 || !strings.Contains(p.sent[0], wantUsage) { + t.Fatalf("sent = %v, want dynamic usage %q", p.sent, wantUsage) + } + }) + + t.Run("invalid value", func(t *testing.T) { + p := &stubPlatformEngine{n: "plain"} + agent := &stubModelModeAgent{reasoningEfforts: efforts} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + + e.cmdReasoning(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, []string{"invalid"}) + + if len(p.sent) != 1 || !strings.Contains(p.sent[0], wantUsage) { + t.Fatalf("sent = %v, want dynamic usage %q", p.sent, wantUsage) + } + }) + + t.Run("card note", func(t *testing.T) { + agent := &stubModelModeAgent{reasoningEfforts: efforts} + e := NewEngine("test", agent, nil, "", LangEnglish) + card := e.renderReasoningCard("") + + for _, element := range card.Elements { + if note, ok := element.(CardNote); ok && note.Text == wantUsage { + return + } + } + t.Fatalf("card elements = %#v, want dynamic usage note %q", card.Elements, wantUsage) + }) +} + +func TestReasoningCard_MultiWorkspaceRendersWorkspaceEffort(t *testing.T) { + globalAgent := &stubModelModeAgent{reasoningEffort: "low"} + e := NewEngine("test", globalAgent, nil, "", LangEnglish) + + baseDir := t.TempDir() + e.SetMultiWorkspace(baseDir, filepath.Join(t.TempDir(), "bindings.json")) + + wsDir := normalizeWorkspacePath(t.TempDir()) + channelID := "C-reasoning-card-ws" + e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir) + + ws := e.workspacePool.GetOrCreate(wsDir) + ws.agent = &stubModelModeAgent{ + reasoningEffort: "ultra", + reasoningEfforts: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}, + } + ws.sessions = NewSessionManager("") + + sessionKey := "feishu:" + channelID + ":u1" + card := e.renderReasoningCard(sessionKey) + + for _, element := range card.Elements { + if selectElement, ok := element.(CardSelect); ok { + if selectElement.InitValue != "act:/reasoning 8" { + t.Fatalf("reasoning card init value = %q, want workspace effort ultra", selectElement.InitValue) + } + return + } + } + t.Fatalf("reasoning card elements = %#v, want select", card.Elements) +} + +func TestCmdReasoning_SwitchesEffortAndResetsSessionWithoutLiveSupport(t *testing.T) { p := &stubPlatformEngine{n: "plain"} agent := &stubModelModeAgent{} e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) @@ -5552,19 +5666,142 @@ func TestCmdReasoning_SwitchesEffortAndResetsSession(t *testing.T) { } } -func TestCmdReasoning_RejectsMinimal(t *testing.T) { +func TestCmdReasoning_AppliesUltraWithoutResettingLiveSession(t *testing.T) { p := &stubPlatformEngine{n: "plain"} - agent := &stubModelModeAgent{} + efforts := []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"} + agent := &stubModelModeAgent{reasoningEfforts: efforts} e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) - msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"} + key := "test:user1" + msg := &Message{SessionKey: key, ReplyCtx: "ctx"} + + live := &stubLiveReasoningSession{} + e.interactiveMu.Lock() + e.interactiveStates[key] = &interactiveState{agentSession: live, platform: p, replyCtx: "ctx"} + e.interactiveMu.Unlock() - e.cmdReasoning(p, msg, []string{"minimal"}) + s := e.sessions.GetOrCreateActive(key) + s.SetAgentSessionID("existing-session", "codex") + s.AddHistory("user", "important context") - if agent.reasoningEffort != "" { - t.Fatalf("reasoning effort = %q, want unchanged empty", agent.reasoningEffort) + e.cmdReasoning(p, msg, []string{"ultra"}) + + if agent.reasoningEffort != "ultra" { + t.Fatalf("reasoning effort = %q, want ultra", agent.reasoningEffort) + } + if len(live.efforts) != 1 || live.efforts[0] != "ultra" { + t.Fatalf("live efforts = %v, want [ultra]", live.efforts) + } + if got := s.GetAgentSessionID(); got != "existing-session" { + t.Fatalf("agent session id = %q, want existing-session", got) + } + if got := len(s.GetHistory(0)); got != 1 { + t.Fatalf("history len = %d, want 1", got) + } + if len(p.sent) != 1 || !strings.Contains(p.sent[0], "current conversation was preserved") { + t.Fatalf("sent = %v, want preserved-conversation confirmation", p.sent) } - if len(p.sent) != 1 || !strings.Contains(p.sent[0], "/reasoning ") || strings.Contains(p.sent[0], "minimal") { - t.Fatalf("sent = %v, want usage without minimal", p.sent) +} + +func TestCmdReasoning_PreservesIdleResumableSession(t *testing.T) { + p := &stubPlatformEngine{n: "plain"} + agent := &stubPreservingReasoningAgent{stubModelModeAgent: stubModelModeAgent{ + reasoningEfforts: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}, + }} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + key := "test:user1" + msg := &Message{SessionKey: key, ReplyCtx: "ctx"} + + s := e.sessions.GetOrCreateActive(key) + s.SetAgentSessionID("idle-resumable-session", "codex") + s.AddHistory("user", "important context") + + e.cmdReasoning(p, msg, []string{"ultra"}) + + if got := s.GetAgentSessionID(); got != "idle-resumable-session" { + t.Fatalf("agent session id = %q, want idle resumable session preserved", got) + } + if got := len(s.GetHistory(0)); got != 1 { + t.Fatalf("history len = %d, want 1", got) + } + if len(p.sent) != 1 || !strings.Contains(p.sent[0], "current conversation was preserved") { + t.Fatalf("sent = %v, want preserved-conversation confirmation", p.sent) + } +} + +func TestApplyLiveReasoningEffortChange_SerializesWithCleanup(t *testing.T) { + e := newTestEngine() + key := "test:user1" + live := &blockingAliveReasoningSession{ + aliveEntered: make(chan struct{}), + releaseAlive: make(chan struct{}), + effortSet: make(chan string, 1), + } + state := &interactiveState{agentSession: live} + e.interactiveMu.Lock() + e.interactiveStates[key] = state + e.interactiveMu.Unlock() + + applied := make(chan bool, 1) + go func() { + applied <- e.applyLiveReasoningEffortChange(key, "ultra") + }() + <-live.aliveEntered + + cleanupDone := make(chan struct{}) + go func() { + e.cleanupInteractiveState(key) + close(cleanupDone) + }() + + select { + case <-cleanupDone: + t.Fatal("cleanup completed while reasoning change was using agentSession") + case <-time.After(50 * time.Millisecond): + } + + close(live.releaseAlive) + if !<-applied { + t.Fatal("live reasoning effort was not applied") + } + if got := <-live.effortSet; got != "ultra" { + t.Fatalf("live effort = %q, want ultra", got) + } + + select { + case <-cleanupDone: + case <-time.After(time.Second): + t.Fatal("cleanup did not complete after reasoning change released state lock") + } +} + +func TestExecuteCardAction_ReasoningPreservesLiveSession(t *testing.T) { + agent := &stubModelModeAgent{ + reasoningEfforts: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}, + } + e := NewEngine("test", agent, nil, "", LangEnglish) + key := "test:user1" + live := &stubLiveReasoningSession{} + e.interactiveMu.Lock() + e.interactiveStates[key] = &interactiveState{agentSession: live} + e.interactiveMu.Unlock() + + s := e.sessions.GetOrCreateActive(key) + s.SetAgentSessionID("existing-session", "codex") + s.AddHistory("user", "important context") + + e.executeCardAction("/reasoning", "8", key) + + if agent.reasoningEffort != "ultra" { + t.Fatalf("reasoning effort = %q, want ultra", agent.reasoningEffort) + } + if len(live.efforts) != 1 || live.efforts[0] != "ultra" { + t.Fatalf("live efforts = %v, want [ultra]", live.efforts) + } + if got := s.GetAgentSessionID(); got != "existing-session" { + t.Fatalf("agent session id = %q, want existing-session", got) + } + if got := len(s.GetHistory(0)); got != 1 { + t.Fatalf("history len = %d, want 1", got) } } diff --git a/core/i18n.go b/core/i18n.go index 6717cca611..f67c1a091b 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -306,6 +306,7 @@ const ( MsgModelNotSupported MsgKey = "model_not_supported" MsgReasoningCurrent MsgKey = "reasoning_current" MsgReasoningChanged MsgKey = "reasoning_changed" + MsgReasoningChangedLive MsgKey = "reasoning_changed_live" MsgReasoningNotSupported MsgKey = "reasoning_not_supported" MsgCompressNotSupported MsgKey = "compress_not_supported" @@ -2263,6 +2264,13 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "推論強度を `%s` に切り替えました。新しいセッションで使用されます。", LangSpanish: "Esfuerzo de razonamiento cambiado a `%s`. Las nuevas sesiones usarán esta configuración.", }, + MsgReasoningChangedLive: { + LangEnglish: "Reasoning effort switched to `%s`. The current conversation was preserved; the next turn will use this setting.", + LangChinese: "推理强度已切换为 `%s`。当前会话已保留,下一轮将使用此设置。", + LangTraditionalChinese: "推理強度已切換為 `%s`。目前會話已保留,下一輪將使用此設定。", + LangJapanese: "推論強度を `%s` に切り替えました。現在の会話は保持され、次のターンからこの設定が使用されます。", + LangSpanish: "Esfuerzo de razonamiento cambiado a `%s`. La conversación actual se conservó; el siguiente turno usará esta configuración.", + }, MsgReasoningNotSupported: { LangEnglish: "This agent does not support reasoning effort switching.", LangChinese: "当前 Agent 不支持推理强度切换。", @@ -2524,11 +2532,11 @@ var messages = map[MsgKey]map[Language]string{ LangSpanish: "Niveles de razonamiento disponibles:\n", }, MsgReasoningUsage: { - LangEnglish: "Usage: `/reasoning ` or `/reasoning `", - LangChinese: "用法: `/reasoning <序号>` 或 `/reasoning `", - LangTraditionalChinese: "用法: `/reasoning <序號>` 或 `/reasoning `", - LangJapanese: "使い方: `/reasoning <番号>` または `/reasoning `", - LangSpanish: "Uso: `/reasoning ` o `/reasoning `", + LangEnglish: "Usage: `/reasoning ` or `/reasoning <%s>`", + LangChinese: "用法: `/reasoning <序号>` 或 `/reasoning <%s>`", + LangTraditionalChinese: "用法: `/reasoning <序號>` 或 `/reasoning <%s>`", + LangJapanese: "使い方: `/reasoning <番号>` または `/reasoning <%s>`", + LangSpanish: "Uso: `/reasoning ` o `/reasoning <%s>`", }, MsgModeUsage: { LangEnglish: "\nUse `/mode ` to switch.\nAvailable: %s", diff --git a/core/interfaces.go b/core/interfaces.go index c89c151488..874b3d52c8 100644 --- a/core/interfaces.go +++ b/core/interfaces.go @@ -501,6 +501,21 @@ type ReasoningEffortSwitcher interface { AvailableReasoningEfforts() []string } +// LiveReasoningEffortSwitcher is an optional interface for running agent +// sessions that can apply a reasoning-effort change to the next turn without +// replacing the underlying conversation. +type LiveReasoningEffortSwitcher interface { + SetLiveReasoningEffort(effort string) bool +} + +// ReasoningEffortSessionPreserver is implemented by agents whose persisted +// conversation IDs can be resumed with a different reasoning effort. It lets +// the engine preserve an idle conversation even when no live agent process is +// available to receive the change directly. +type ReasoningEffortSessionPreserver interface { + PreservesSessionOnReasoningEffortChange() bool +} + // ModelOption describes a selectable model. type ModelOption struct { Name string // model identifier passed to CLI