diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index e5590830d..2351331e0 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -1505,7 +1505,7 @@ export function createAiIntegrationService(args: { throw new Error("No AI provider is available. Install and authenticate Claude Code and/or Codex CLI."); } - if (!getFeatureFlag(args.feature)) { + if (args.taskType !== "session_title" && !getFeatureFlag(args.feature)) { logger.warn("ai.task.skipped_feature_disabled", { requestId, taskType: args.taskType, @@ -2007,6 +2007,7 @@ export function createAiIntegrationService(args: { prompt: string; timeoutMs?: number; model?: string; + reasoningEffort?: string | null; jsonSchema?: unknown; systemPrompt?: string; taskType?: Extract; @@ -2018,6 +2019,7 @@ export function createAiIntegrationService(args: { prompt: args.prompt, timeoutMs: args.timeoutMs, model: args.model, + reasoningEffort: args.reasoningEffort, jsonSchema: args.jsonSchema, systemPrompt: args.systemPrompt }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 3d2a12108..4dd2d0153 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -18822,11 +18822,11 @@ describe("createAgentChatService", () => { describe("suggestLaneNameFromPrompt", () => { function createProjectConfigServiceWithTitleOptions( - options: { titleGenerationEnabled?: boolean; titleModelId?: string } = {}, + options: { titleGenerationEnabled?: boolean; titleModelId?: string | null; legacyTitleModelId?: string } = {}, ) { const titleOptions: Record = {}; if (typeof options.titleGenerationEnabled === "boolean") titleOptions.enabled = options.titleGenerationEnabled; - if (options.titleModelId) titleOptions.modelId = options.titleModelId; + if (options.titleModelId !== undefined) titleOptions.modelId = options.titleModelId; const sessionIntelligence = Object.keys(titleOptions).length ? { titles: titleOptions } : {}; return { get: vi.fn(() => ({ @@ -18836,7 +18836,9 @@ describe("suggestLaneNameFromPrompt", () => { cli: { mode: "edit" }, inProcess: { mode: "edit" }, }, - chat: {}, + chat: { + ...(options.legacyTitleModelId ? { autoTitleModelId: options.legacyTitleModelId } : {}), + }, sessionIntelligence, }, }, @@ -18846,7 +18848,7 @@ describe("suggestLaneNameFromPrompt", () => { } as any; } - function createSuggestService(options: { titleGenerationEnabled?: boolean; titleModelId?: string } = {}) { + function createSuggestService(options: { titleGenerationEnabled?: boolean; titleModelId?: string | null; legacyTitleModelId?: string } = {}) { return createService({ projectConfigService: createProjectConfigServiceWithTitleOptions(options), }); @@ -18879,17 +18881,17 @@ describe("suggestLaneNameFromPrompt", () => { modelId: "anthropic/claude-haiku-4-5", laneId: "lane-1", }); - expect(result).toBe("fix-the-login-bug"); + expect(result).toBe("fix-login-bug"); }); - it("takes only first 4 words of a long prompt", async () => { + it("takes the first 5 meaningful words of a long prompt", async () => { const { service } = createSuggestService(); const result = await service.suggestLaneNameFromPrompt({ prompt: "Refactor the authentication service to use JWT tokens", modelId: "anthropic/claude-haiku-4-5", laneId: "lane-1", }); - expect(result).toBe("refactor-the-authentication-service"); + expect(result).toBe("refactor-authentication-service-jwt-tokens"); }); it("strips special characters from the prompt slug", async () => { @@ -18899,7 +18901,7 @@ describe("suggestLaneNameFromPrompt", () => { modelId: "anthropic/claude-haiku-4-5", laneId: "lane-1", }); - expect(result).toBe("fix-bug-123-in"); + expect(result).toBe("fix-bug-123-module"); }); it("truncates the fallback slug to 48 characters", async () => { @@ -18919,7 +18921,7 @@ describe("suggestLaneNameFromPrompt", () => { modelId: "anthropic/claude-haiku-4-5", laneId: "lane-1", }); - expect(result).toBe("fix-the-bug-now"); + expect(result).toBe("fix-bug-now"); }); it("falls back when the model runtime throws an error", async () => { @@ -18935,14 +18937,14 @@ describe("suggestLaneNameFromPrompt", () => { laneId: "lane-1", }); - expect(result).toBe("write-a-test-suite"); + expect(result).toBe("write-test-suite"); expect(logger.warn).toHaveBeenCalledWith( "agent_chat.suggest_lane_name_failed", expect.objectContaining({ error: "API rate limited" }), ); }); - it("keeps the prompt fallback readable while adding the temporary suffix when title generation is disabled", async () => { + it("uses the deterministic prompt fallback when title generation is disabled", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, ]); @@ -18955,11 +18957,11 @@ describe("suggestLaneNameFromPrompt", () => { fallbackName: "chat-20260514-010203", }); - expect(result).toBe("fix-the-authentication-login-20260514-010203"); + expect(result).toBe("fix-authentication-login-failure-dashboard"); expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); }); - it("uses the explicit fallback directly when the prompt fallback is generic", async () => { + it("preserves the generated suffix when the prompt fallback is generic", async () => { const { service } = createSuggestService(); const result = await service.suggestLaneNameFromPrompt({ prompt: "!!!", @@ -18968,7 +18970,7 @@ describe("suggestLaneNameFromPrompt", () => { fallbackName: "chat-20260514-010203", }); - expect(result).toBe("chat-20260514-010203"); + expect(result).toBe("parallel-task-20260514-010203"); }); it("uses AI-generated name when the model runtime succeeds", async () => { @@ -19022,6 +19024,35 @@ describe("suggestLaneNameFromPrompt", () => { })); }); + it("does not fall back to a legacy title model when session intelligence model is explicitly cleared", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, + ]); + const { service, aiIntegrationService } = createSuggestService({ + titleModelId: null, + legacyTitleModelId: "openai/gpt-5.4-mini", + }); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValueOnce({ + text: "Fallback Title", + inputTokens: 10, + outputTokens: 5, + } as any); + + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Fix null model clearing for background jobs", + modelId: "", + laneId: "lane-1", + }); + + expect(result).toBe("fallback-title"); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalledWith(expect.objectContaining({ + model: "openai/gpt-5.4-mini", + })); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ + model: "anthropic/claude-haiku-4-5", + })); + }); + it("normalizes AI-generated name: strips special chars and lowercases", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 5a19dc9df..1f0213e4d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -261,6 +261,12 @@ import { buildAdeCliAgentGuidance } from "../../../shared/adeCliGuidance"; import { getAdeAgentSkillRootsForPrompt } from "../../../shared/agentSkillRoots"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { extractLeadingSlashCommand, isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; +import { + deriveDeterministicLaneNameFromPrompt, + GENERIC_LANE_FALLBACK_NAME, + genericLaneFallbackName, + genericSuffixFromLaneFallbackName, +} from "../../../shared/laneNameFallback"; import { resolveSubagentCapability } from "../../../shared/subagentCapabilities"; import { stripAnsi } from "../../utils/ansiStrip"; import type { createCtoStateService } from "../cto/ctoStateService"; @@ -1833,9 +1839,11 @@ type ResolvedChatConfig = { sessionBudgetUsd: number | null; titleGenerationEnabled: boolean; titleModelId: string | null; + titleReasoningEffort: string | null; titleRefreshOnComplete: boolean; summaryEnabled: boolean; summaryModelId: string | null; + summaryReasoningEffort: string | null; }; const MAX_PENDING_STEERS = 10; @@ -3068,40 +3076,21 @@ function sanitizeAutoTitle(raw: string, maxChars = AUTO_TITLE_MAX_CHARS): string return normalized.length > maxChars ? normalized.slice(0, maxChars).trimEnd() : normalized; } -const GENERIC_PROMPT_LANE_NAME = "parallel-task"; - function fallbackLaneNameFromPrompt(prompt: string): string { - const collapsed = prompt.replace(/\s+/g, " "); - if (!collapsed.length) return GENERIC_PROMPT_LANE_NAME; - const words = collapsed.split(/\s+/).filter(Boolean).slice(0, 4); - const slug = words - .join("-") - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); - return slug.length ? slug.slice(0, 48) : GENERIC_PROMPT_LANE_NAME; + return deriveDeterministicLaneNameFromPrompt(prompt); } function uniquePromptFallbackLaneName(promptFallback: string, explicitFallback: string | null): string { - if (promptFallback === GENERIC_PROMPT_LANE_NAME) { - return explicitFallback ?? promptFallback; + if (promptFallback !== GENERIC_LANE_FALLBACK_NAME) { + return promptFallback; } - if (!explicitFallback) return promptFallback; - - const uniqueSuffix = explicitFallback - .replace(/^chat-?/u, "") - .replace(/[^a-z0-9-]+/giu, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, "") - .toLowerCase(); - if (!uniqueSuffix.length) return promptFallback; - - const maxPrefixLength = Math.max(1, 60 - uniqueSuffix.length - 1); - const prefix = promptFallback - .slice(0, maxPrefixLength) - .replace(/-+$/g, ""); - return `${prefix}-${uniqueSuffix}`; + const suffix = genericSuffixFromLaneFallbackName(explicitFallback); + if (suffix) { + return genericLaneFallbackName(suffix); + } + return explicitFallback && !/^chat(?:-|$)/u.test(explicitFallback) + ? explicitFallback + : promptFallback; } function normalizeSuggestedLaneName(raw: string): string | null { @@ -5357,14 +5346,23 @@ export function createAgentChatService(args: { prompt: string; systemPrompt?: string; timeoutMs?: number; + reasoningEffort?: string | null; taskType: "session_title" | "session_summary" | "handoff_summary" | "continuity_summary"; }) => { + const config = resolveChatConfig(); + const reasoningEffort = args.reasoningEffort + ?? (args.taskType === "session_title" + ? config.titleReasoningEffort + : args.taskType === "session_summary" + ? config.summaryReasoningEffort + : null); return await aiIntegrationService.summarizeTerminal({ cwd: args.cwd, model: args.modelId, prompt: args.prompt, systemPrompt: args.systemPrompt, timeoutMs: args.timeoutMs, + reasoningEffort, taskType: args.taskType, }); }; @@ -7660,10 +7658,18 @@ export function createAgentChatService(args: { const titleGenerationEnabled = si?.titles?.enabled ?? (typeof legacyChat.autoTitleEnabled === "boolean" ? legacyChat.autoTitleEnabled : undefined) ?? true; - const titleModelIdRaw = si?.titles?.modelId ?? legacyChat.autoTitleModelId; + const titleModelIdRaw = si?.titles && Object.prototype.hasOwnProperty.call(si.titles, "modelId") + ? si.titles.modelId + : legacyChat.autoTitleModelId; const titleModelId = typeof titleModelIdRaw === "string" && titleModelIdRaw.trim().length ? titleModelIdRaw.trim() : null; + const titleReasoningEffortRaw = si?.titles && Object.prototype.hasOwnProperty.call(si.titles, "reasoningEffort") + ? si.titles.reasoningEffort + : legacyChat.autoTitleReasoningEffort; + const titleReasoningEffort = typeof titleReasoningEffortRaw === "string" && titleReasoningEffortRaw.trim().length + ? titleReasoningEffortRaw.trim() + : null; const titleRefreshOnComplete = si?.titles?.refreshOnComplete ?? (typeof legacyChat.autoTitleRefreshOnComplete === "boolean" ? legacyChat.autoTitleRefreshOnComplete : undefined) ?? true; @@ -7674,6 +7680,10 @@ export function createAgentChatService(args: { const summaryModelId = typeof summaryModelIdRaw === "string" && summaryModelIdRaw.trim().length ? summaryModelIdRaw.trim() : null; + const summaryReasoningEffortRaw = si?.summaries?.reasoningEffort; + const summaryReasoningEffort = typeof summaryReasoningEffortRaw === "string" && summaryReasoningEffortRaw.trim().length + ? summaryReasoningEffortRaw.trim() + : null; return { codexApprovalPolicy: approvalPolicy, @@ -7683,9 +7693,11 @@ export function createAgentChatService(args: { sessionBudgetUsd, titleGenerationEnabled, titleModelId, + titleReasoningEffort, titleRefreshOnComplete, summaryEnabled, summaryModelId, + summaryReasoningEffort, }; }; diff --git a/apps/desktop/src/main/services/config/projectConfigService.test.ts b/apps/desktop/src/main/services/config/projectConfigService.test.ts index 3757bb16f..004806ab8 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import YAML from "yaml"; import { afterEach, describe, expect, it, vi } from "vitest"; import { openKvDb } from "../state/kvDb"; -import { createProjectConfigService } from "./projectConfigService"; +import { createProjectConfigService, mergeAiConfig } from "./projectConfigService"; function makeDb() { const store = new Map(); @@ -528,10 +528,16 @@ describe("projectConfigService - AI mode migration", () => { }, featureModelOverrides: { commit_messages: "openai/gpt-5.4-mini", + terminal_summaries: null, + }, + featureReasoningOverrides: { + commit_messages: "minimal", + terminal_summaries: null, }, chat: { autoTitleEnabled: true, autoTitleModelId: "openai/gpt-5.4-mini", + autoTitleReasoningEffort: "minimal", autoTitleRefreshOnComplete: false, autoAllowAskUser: false, codexSandbox: "workspace-write", @@ -552,8 +558,12 @@ describe("projectConfigService - AI mode migration", () => { const snapshot = service.get(); expect(snapshot.effective.ai?.features?.commit_messages).toBe(true); expect(snapshot.effective.ai?.featureModelOverrides?.commit_messages).toBe("openai/gpt-5.4-mini"); + expect(snapshot.effective.ai?.featureModelOverrides?.terminal_summaries).toBeNull(); + expect(snapshot.effective.ai?.featureReasoningOverrides?.commit_messages).toBe("minimal"); + expect(snapshot.effective.ai?.featureReasoningOverrides?.terminal_summaries).toBeNull(); expect(snapshot.effective.ai?.sessionIntelligence?.titles?.enabled).toBe(true); expect(snapshot.effective.ai?.sessionIntelligence?.titles?.modelId).toBe("openai/gpt-5.4-mini"); + expect(snapshot.effective.ai?.sessionIntelligence?.titles?.reasoningEffort).toBe("minimal"); expect(snapshot.effective.ai?.sessionIntelligence?.titles?.refreshOnComplete).toBe(false); expect(snapshot.effective.ai?.chat?.autoAllowAskUser).toBe(false); expect(snapshot.effective.ai?.chat?.codexSandbox).toBe("workspace-write"); @@ -566,15 +576,53 @@ describe("projectConfigService - AI mode migration", () => { const persisted = YAML.parse(fs.readFileSync(localPath, "utf8")) as Record; expect(persisted.ai?.features?.commit_messages).toBe(true); expect(persisted.ai?.featureModelOverrides?.commit_messages).toBe("openai/gpt-5.4-mini"); + expect(persisted.ai?.featureModelOverrides?.terminal_summaries).toBeNull(); + expect(persisted.ai?.featureReasoningOverrides?.commit_messages).toBe("minimal"); + expect(persisted.ai?.featureReasoningOverrides?.terminal_summaries).toBeNull(); expect(persisted.ai?.chat?.autoTitleEnabled).toBeUndefined(); expect(persisted.ai?.chat?.autoTitleModelId).toBeUndefined(); + expect(persisted.ai?.chat?.autoTitleReasoningEffort).toBeUndefined(); expect(persisted.ai?.chat?.autoTitleRefreshOnComplete).toBeUndefined(); expect(persisted.ai?.sessionIntelligence?.titles?.enabled).toBe(true); expect(persisted.ai?.sessionIntelligence?.titles?.modelId).toBe("openai/gpt-5.4-mini"); + expect(persisted.ai?.sessionIntelligence?.titles?.reasoningEffort).toBe("minimal"); expect(persisted.ai?.sessionIntelligence?.titles?.refreshOnComplete).toBe(false); expect(persisted.ai?.chat?.autoAllowAskUser).toBe(false); expect(persisted.ai?.chat?.codexSandbox).toBe("workspace-write"); }); + + it("clears session intelligence reasoning overrides with null", () => { + const merged = mergeAiConfig({ + sessionIntelligence: { + titles: { modelId: "openai/gpt-5.4-mini", reasoningEffort: "minimal" }, + summaries: { modelId: "openai/gpt-5.4-mini", reasoningEffort: "low" }, + }, + featureModelOverrides: { + terminal_summaries: "openai/gpt-5.4-mini", + }, + featureReasoningOverrides: { + terminal_summaries: "low", + }, + }, { + sessionIntelligence: { + titles: { modelId: null, reasoningEffort: null }, + summaries: { modelId: null, reasoningEffort: null }, + }, + featureModelOverrides: { + terminal_summaries: null, + }, + featureReasoningOverrides: { + terminal_summaries: null, + }, + }); + + expect(merged?.sessionIntelligence?.titles?.modelId).toBeNull(); + expect(merged?.sessionIntelligence?.summaries?.modelId).toBeNull(); + expect(merged?.featureModelOverrides?.terminal_summaries).toBeNull(); + expect(merged?.sessionIntelligence?.titles?.reasoningEffort).toBeNull(); + expect(merged?.sessionIntelligence?.summaries?.reasoningEffort).toBeNull(); + expect(merged?.featureReasoningOverrides?.terminal_summaries).toBeNull(); + }); }); describe("projectConfigService - PR transcript gists", () => { diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 85cff7d5d..e27e5ad76 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -365,6 +365,10 @@ function coerceSessionIntelligenceConfig(value: unknown): AiConfig["sessionIntel if (enabled != null) titles.enabled = enabled; const modelId = asString(titlesRaw.modelId)?.trim(); if (modelId) titles.modelId = modelId; + else if (titlesRaw.modelId === null) titles.modelId = null; + const reasoningEffort = asString(titlesRaw.reasoningEffort)?.trim(); + if (reasoningEffort) titles.reasoningEffort = reasoningEffort; + else if (titlesRaw.reasoningEffort === null) titles.reasoningEffort = null; const refreshOnComplete = asBool(titlesRaw.refreshOnComplete); if (refreshOnComplete != null) titles.refreshOnComplete = refreshOnComplete; if (Object.keys(titles).length) out.titles = titles; @@ -377,6 +381,10 @@ function coerceSessionIntelligenceConfig(value: unknown): AiConfig["sessionIntel if (enabled != null) summaries.enabled = enabled; const modelId = asString(summariesRaw.modelId)?.trim(); if (modelId) summaries.modelId = modelId; + else if (summariesRaw.modelId === null) summaries.modelId = null; + const reasoningEffort = asString(summariesRaw.reasoningEffort)?.trim(); + if (reasoningEffort) summaries.reasoningEffort = reasoningEffort; + else if (summariesRaw.reasoningEffort === null) summaries.reasoningEffort = null; if (Object.keys(summaries).length) out.summaries = summaries; } @@ -385,14 +393,31 @@ function coerceSessionIntelligenceConfig(value: unknown): AiConfig["sessionIntel function coerceFeatureModelOverrides(value: unknown): AiConfig["featureModelOverrides"] { if (!isRecord(value)) return undefined; - const featureModelOverrides: Partial> = {}; + const featureModelOverrides: Partial> = {}; for (const key of AI_FEATURE_KEYS) { - const modelId = asString(value[key])?.trim(); + const rawValue = value[key]; + const modelId = asString(rawValue)?.trim(); if (modelId) featureModelOverrides[key] = modelId; + else if (rawValue === null) featureModelOverrides[key] = null; } return Object.keys(featureModelOverrides).length ? featureModelOverrides : undefined; } +function coerceFeatureReasoningOverrides(value: unknown): AiConfig["featureReasoningOverrides"] { + if (!isRecord(value)) return undefined; + const featureReasoningOverrides: Partial> = {}; + for (const key of AI_FEATURE_KEYS) { + const rawValue = value[key]; + const reasoningEffort = asString(rawValue)?.trim(); + if (reasoningEffort) { + featureReasoningOverrides[key] = reasoningEffort; + } else if (rawValue === null) { + featureReasoningOverrides[key] = null; + } + } + return Object.keys(featureReasoningOverrides).length ? featureReasoningOverrides : undefined; +} + function parseReadiness(value: unknown): ConfigProcessReadiness | undefined { if (!isRecord(value)) return undefined; const type = asString(value.type); @@ -1322,6 +1347,8 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { const featureModelOverrides = coerceFeatureModelOverrides(value.featureModelOverrides); if (featureModelOverrides) out.featureModelOverrides = featureModelOverrides; + const featureReasoningOverrides = coerceFeatureReasoningOverrides(value.featureReasoningOverrides); + if (featureReasoningOverrides) out.featureReasoningOverrides = featureReasoningOverrides; const permissionsRaw = isRecord(value.permissions) ? value.permissions : null; if (permissionsRaw) { @@ -1506,12 +1533,15 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { if (autoTitleEnabled != null) titles.enabled = autoTitleEnabled; const autoTitleModelId = asString(chatRaw?.autoTitleModelId)?.trim(); if (autoTitleModelId) titles.modelId = autoTitleModelId; + const autoTitleReasoningEffort = asString(chatRaw?.autoTitleReasoningEffort)?.trim(); + if (autoTitleReasoningEffort) titles.reasoningEffort = autoTitleReasoningEffort; const autoTitleRefreshOnComplete = asBool(chatRaw?.autoTitleRefreshOnComplete); if (autoTitleRefreshOnComplete != null) titles.refreshOnComplete = autoTitleRefreshOnComplete; const summaries: NonNullable["summaries"]> = {}; - if (featureModelOverrides?.terminal_summaries) { - summaries.modelId = featureModelOverrides.terminal_summaries; + const terminalSummariesModelId = featureModelOverrides?.terminal_summaries; + if (typeof terminalSummariesModelId === "string" && terminalSummariesModelId.trim().length) { + summaries.modelId = terminalSummariesModelId.trim(); } const migrated: NonNullable = { @@ -1882,6 +1912,10 @@ export function mergeAiConfig(sharedAi?: AiConfig, localAi?: Partial): ...(sharedAi?.featureModelOverrides ?? {}), ...(localAi?.featureModelOverrides ?? {}) }; + const featureReasoningOverrides = { + ...(sharedAi?.featureReasoningOverrides ?? {}), + ...(localAi?.featureReasoningOverrides ?? {}) + }; const apiKeys = { ...(sharedAi?.apiKeys ?? {}), ...(localAi?.apiKeys ?? {}) @@ -1910,6 +1944,7 @@ export function mergeAiConfig(sharedAi?: AiConfig, localAi?: Partial): ...(Object.keys(chat).length ? { chat } : {}), ...(sessionIntelligence ? { sessionIntelligence } : {}), ...(Object.keys(featureModelOverrides).length ? { featureModelOverrides } : {}), + ...(Object.keys(featureReasoningOverrides).length ? { featureReasoningOverrides } : {}), ...(Object.keys(apiKeys).length ? { apiKeys } : {}), ...(localProvidersEntries.length ? { localProviders } : {}), ...(workerSafety ? { workerSafety } : {}), diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index e3046d6a6..2cb1c1cc6 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -994,6 +994,46 @@ describe("ptyService", () => { } }); + it("uses agent CLI initialInput as the first-prompt title seed", async () => { + vi.useFakeTimers(); + try { + const aiIntegrationService = { + getMode: vi.fn(() => "subscription"), + summarizeTerminal: vi.fn(async () => ({ text: "Print cwd" })), + }; + const { service, mockPty, sessionService } = createHarness({ aiIntegrationService }); + + await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + startupCommand: "codex --no-alt-screen", + initialInput: "print cwd", + }); + + const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; + expect(createdSessionId).toBeTruthy(); + + mockPty._emitter.emit("data", "\x1b[2J\x1b[Hmodel: gpt-5.4 medium\nMCP startup incomplete (failed: linear)\n› "); + await vi.advanceTimersByTimeAsync(600); + await Promise.resolve(); + + expect(sessionService.get(createdSessionId!)?.goal).toBe("print cwd"); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining("print cwd"), + taskType: "session_title", + }), + ); + } finally { + vi.useRealTimers(); + } + }); + it("does not send Codex initialInput into the update prompt", async () => { vi.useFakeTimers(); try { @@ -3118,6 +3158,7 @@ describe("ptyService", () => { }); it.each([ + ["claude", "Claude Code"], ["codex", "Codex session"], ["cursor-cli", "Cursor Agent CLI"], ["droid", "Factory Droid CLI"], @@ -3289,7 +3330,7 @@ describe("ptyService", () => { } }); - it("does not call ADE AI title generation for Claude CLI sessions", async () => { + it("uses ADE first-prompt AI title generation for Claude CLI sessions", async () => { const aiIntegrationService = { getMode: vi.fn(() => "subscription"), summarizeTerminal: vi.fn(async () => ({ text: "ADE generated title" })), @@ -3310,7 +3351,12 @@ describe("ptyService", () => { await Promise.resolve(); expect(sessionService.get(createdSessionId)?.title).toBe("Fix the flaky login tests"); - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining("Fix the flaky login tests"), + taskType: "session_title", + }), + ); }); it("treats legacy slash-command CLI titles as placeholders", async () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 610161b1e..4e5543959 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -1376,6 +1376,12 @@ export function createPtyService({ return typeof raw === "string" && raw.trim().length ? raw.trim() : undefined; }; + const resolveTitleReasoningEffort = (): string | null => { + const si = getSessionIntelligence(); + const raw = si?.titles?.reasoningEffort; + return typeof raw === "string" && raw.trim().length ? raw.trim() : null; + }; + const tryCliUserTitleFromWrite = (entry: PtyEntry, data: string): void => { if (!CLI_USER_TITLE_TOOL_TYPES.has(entry.toolTypeHint ?? "shell")) return; if (entry.cliUserTitleCommitted || entry.disposed) return; @@ -1411,15 +1417,12 @@ export function createPtyService({ sessionService.updateMeta({ sessionId: entry.sessionId, title: fallbackTitle, manuallyNamed: false }); } } - // Claude Code writes its own generated `ai-title` into local session - // storage. Keep ADE's prompt summarizer out of this path so that native - // Claude names win when they arrive. - if (isClaudeTrackedCliToolType(entry.toolTypeHint)) return; if (!aiIntegrationService || aiIntegrationService.getMode() === "guest") return; if (!isTitleGenerationEnabled()) return; const laneName = session.laneName?.trim() || "Current lane"; const titleModelId = resolveTitleModelId(); + const titleReasoningEffort = resolveTitleReasoningEffort(); const prompt = [ "Write a concise title for this CLI coding session.", "Return only plain text, max 80 characters, no punctuation at the end.", @@ -1438,6 +1441,7 @@ export function createPtyService({ taskType: "session_title", timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, ...(titleModelId ? { model: titleModelId } : {}), + ...(titleReasoningEffort ? { reasoningEffort: titleReasoningEffort } : {}), }) .then((result) => { if (entry.disposed) return; @@ -1624,11 +1628,15 @@ export function createPtyService({ const summaryModelId = typeof si?.summaries?.modelId === "string" && si.summaries.modelId.trim().length ? si.summaries.modelId.trim() : undefined; + const summaryReasoningEffort = typeof si?.summaries?.reasoningEffort === "string" && si.summaries.reasoningEffort.trim().length + ? si.summaries.reasoningEffort.trim() + : undefined; const aiSummary = await aiIntegrationService!.summarizeTerminal({ cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, prompt, ...(summaryModelId ? { model: summaryModelId } : {}), + ...(summaryReasoningEffort ? { reasoningEffort: summaryReasoningEffort } : {}), }); const text = aiSummary.text.trim(); if (text.length) { @@ -1664,12 +1672,14 @@ export function createPtyService({ ].filter(Boolean).join("\n"); const titleModelId = resolveTitleModelId(); + const titleReasoningEffort = resolveTitleReasoningEffort(); const titleResult = await aiIntegrationService!.summarizeTerminal({ cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, prompt: titlePrompt, taskType: "session_title", timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, ...(titleModelId ? { model: titleModelId } : {}), + ...(titleReasoningEffort ? { reasoningEffort: titleReasoningEffort } : {}), }); const finalTitle = sanitizeGeneratedCliTitle(titleResult.text); if (finalTitle) { @@ -3965,6 +3975,7 @@ export function createPtyService({ if (provider) { const submittedInitialInput = normalizedInitialInput.trim(); if (submittedInitialInput.length > 0) { + tryCliUserTitleFromWrite(entry, `${submittedInitialInput}\r`); const wrote = await writeAgentCliInput((data) => { pty.write(data); return true; @@ -4084,6 +4095,7 @@ export function createPtyService({ ].join("\n"); const titleModelId = resolveTitleModelId(); + const titleReasoningEffort = resolveTitleReasoningEffort(); capturedAi .summarizeTerminal({ cwd: entry.boundCwd || entry.laneWorktreePath, @@ -4091,6 +4103,7 @@ export function createPtyService({ taskType: "session_title", timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, ...(titleModelId ? { model: titleModelId } : {}), + ...(titleReasoningEffort ? { reasoningEffort: titleReasoningEffort } : {}), }) .then((result) => { const title = sanitizeGeneratedCliTitle(result.text); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index b651bba6f..7c118129e 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -636,7 +636,6 @@ describe("bootstrapRemoteRuntime upload flow", () => { command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version") ) return ok(""); if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 2.0.0\n"); - if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); return defaultRemoteBootstrapCommand(command); }); @@ -670,12 +669,10 @@ describe("bootstrapRemoteRuntime upload flow", () => { expect(commands).toContain( 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade --version', ); - expect(commands).toContain( - 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade runtime stop --text >/dev/null 2>&1 || true', - ); + expect(commands.some((command) => command.includes("runtime stop --text"))).toBe(false); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, - 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade --socket $HOME/.ade/sock/ade.sock rpc --stdio', ); expect(initializeMock).toHaveBeenCalledWith("ade-desktop-remote", APP_VERSION); expect(callMock).toHaveBeenCalledWith("projects.list", {}); @@ -773,7 +770,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { )).toBe(true); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, - 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PTY_HOST_WORKER_PATH="$HOME/.ade/runtime/ptyHostWorker.cjs" ADE_PTY_HOST_WORKER_NODE=\'/usr/local/bin/node\' $HOME/.ade/bin/ade rpc --stdio', + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PTY_HOST_WORKER_PATH="$HOME/.ade/runtime/ptyHostWorker.cjs" ADE_PTY_HOST_WORKER_NODE=\'/usr/local/bin/node\' $HOME/.ade/bin/ade --socket $HOME/.ade/sock/ade.sock rpc --stdio', ); expect(connected.result).toMatchObject({ arch: "linux-x64", @@ -829,7 +826,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { expect(fakeSsh.sftpWrapper.fastPut).not.toHaveBeenCalled(); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, - 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PTY_HOST_WORKER_COMMAND="$HOME/.ade/bin/ade" $HOME/.ade/bin/ade rpc --stdio', + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PTY_HOST_WORKER_COMMAND="$HOME/.ade/bin/ade" $HOME/.ade/bin/ade --socket $HOME/.ade/sock/ade.sock rpc --stdio', ); expect(connected.result).toMatchObject({ arch: "linux-x64", @@ -948,7 +945,6 @@ describe("bootstrapRemoteRuntime upload flow", () => { command.includes(`printf '%s\\n' '${APP_VERSION}' > $HOME/.ade/bin/ade.version`) ) return ok(""); if (command.includes("$HOME/.ade/bin/ade --version")) return ok(`ade ${APP_VERSION}\n`); - if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); return defaultRemoteBootstrapCommand(command); }); @@ -968,7 +964,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { )).toBe(true); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, - 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade --socket $HOME/.ade/sock/ade.sock rpc --stdio', ); expect(connected.result).toMatchObject({ arch: "linux-x64", @@ -1018,7 +1014,6 @@ describe("bootstrapRemoteRuntime upload flow", () => { command.includes(`printf '%s\\n' '${APP_VERSION}' > $HOME/.ade/bin/ade.version`) ) return ok(""); if (command.includes("$HOME/.ade/bin/ade --version")) return ok(`ade ${APP_VERSION}\n`); - if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); return defaultRemoteBootstrapCommand(command); }); @@ -1037,7 +1032,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { )).toBe(true); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, - 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade --socket $HOME/.ade/sock/ade.sock rpc --stdio', ); expect(connected.result).toMatchObject({ arch: "linux-x64", @@ -1214,7 +1209,6 @@ describe("bootstrapRemoteRuntime upload flow", () => { ) return ok(""); if (command === "codesign --force --sign - $HOME/.ade-alpha/bin/ade") return ok(""); if (command.includes("$HOME/.ade-alpha/bin/ade --version")) return ok("ade 2.0.0\n"); - if (command.includes("$HOME/.ade-alpha/bin/ade runtime stop --text")) return ok(""); return defaultRemoteBootstrapCommand(command); }); @@ -1249,7 +1243,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { ); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, - 'ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 NODE_PATH="$HOME/.ade-alpha/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}" $HOME/.ade-alpha/bin/ade rpc --stdio', + 'ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 NODE_PATH="$HOME/.ade-alpha/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}" $HOME/.ade-alpha/bin/ade --socket $HOME/.ade-alpha/sock/ade.sock rpc --stdio', ); }); @@ -1302,7 +1296,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { expect(fakeSsh.exec).not.toHaveBeenCalled(); expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( fakeSsh.ssh, - 'ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 ade rpc --stdio', + 'ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 ade --socket $HOME/.ade-alpha/sock/ade.sock rpc --stdio', ); expect(connected.result.version).toBe("1.9.0-alpha.4"); expect(connected.result.compatibilityWarnings).toEqual([ @@ -1365,12 +1359,12 @@ describe("bootstrapRemoteRuntime upload flow", () => { expect(openSshRuntimeTransportMock).toHaveBeenNthCalledWith( 1, fakeSsh.ssh, - 'ADE_HOME="$HOME/.ade-beta" PATH="$HOME/.ade-beta/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="beta" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 ade rpc --stdio', + 'ADE_HOME="$HOME/.ade-beta" PATH="$HOME/.ade-beta/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="beta" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 ade --socket $HOME/.ade-beta/sock/ade.sock rpc --stdio', ); expect(openSshRuntimeTransportMock).toHaveBeenNthCalledWith( 2, fakeSsh.ssh, - 'ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 NODE_PATH="$HOME/.ade-alpha/runtime/linux-x64/node_modules${NODE_PATH:+:$NODE_PATH}" ade rpc --stdio', + 'ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 NODE_PATH="$HOME/.ade-alpha/runtime/linux-x64/node_modules${NODE_PATH:+:$NODE_PATH}" ade --socket $HOME/.ade-alpha/sock/ade.sock rpc --stdio', ); expect(connected.result).toMatchObject({ version: "1.9.0-alpha.4", @@ -1434,7 +1428,7 @@ describe("bootstrapRemoteRuntime upload flow", () => { expect(openSshRuntimeTransportMock).toHaveBeenNthCalledWith( 2, fakeSsh.ssh, - 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 NODE_PATH="$HOME/.ade/runtime/linux-x64/node_modules${NODE_PATH:+:$NODE_PATH}" ade rpc --stdio', + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 NODE_PATH="$HOME/.ade/runtime/linux-x64/node_modules${NODE_PATH:+:$NODE_PATH}" ade --socket $HOME/.ade/sock/ade.sock rpc --stdio', ); }); @@ -1463,7 +1457,6 @@ describe("bootstrapRemoteRuntime upload flow", () => { command.includes("shasum -a 256 $HOME/.ade/bin/ade") && command.includes("echo ok") ) return ok("ok\n"); - if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); return defaultRemoteBootstrapCommand(command); }); initializeMock.mockResolvedValueOnce({ @@ -1494,8 +1487,6 @@ describe("bootstrapRemoteRuntime upload flow", () => { expect(fakeSsh.exec).not.toHaveBeenCalled(); expect(openSshRuntimeTransportMock).toHaveBeenCalledTimes(1); - expect(commands).not.toContain( - 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade runtime stop --text >/dev/null 2>&1 || true', - ); + expect(commands.some((command) => command.includes("runtime stop --text"))).toBe(false); }); }); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index f020a8fe4..c149ed28e 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -170,6 +170,7 @@ type RemoteRuntimeLayout = { binDirRelative: string; runtimeDirExpr: string; runtimeDirRelative: string; + socketExpr: string; binaryExpr: string; binaryRelative: string; versionExpr: string; @@ -202,6 +203,7 @@ export function resolveRemoteRuntimeLayout(env: NodeJS.ProcessEnv = process.env) binDirRelative: `${homeDirName}/bin`, runtimeDirExpr, runtimeDirRelative: `${homeDirName}/runtime`, + socketExpr: `${homeDirExpr}/sock/ade.sock`, binaryExpr: `${binDirExpr}/ade`, binaryRelative: `${homeDirName}/bin/ade`, versionExpr: `${binDirExpr}/ade.version`, @@ -1077,17 +1079,16 @@ async function signUploadedRuntimeBinaryIfNeeded(client: Client, layout: RemoteR } } -async function stopRemoteRuntimeDaemon(client: Client, layout: RemoteRuntimeLayout, runtimeEnvPrefix: string): Promise { - await execSsh( - client, - `${runtimeEnvPrefix}${layout.binaryExpr} runtime stop --text >/dev/null 2>&1 || true`, - ); -} - function runtimeErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function remoteRuntimeRpcCommand(layout: RemoteRuntimeLayout, runtimeEnvPrefix: string, binaryExpr: string): string { + // Use the channel's explicit socket so a freshly uploaded helper CLI does not + // treat the desktop-owned brain as a build-hash mismatch and recycle it. + return `${runtimeEnvPrefix}${binaryExpr} --socket ${layout.socketExpr} rpc --stdio`; +} + async function openValidatedRuntimeClient(args: { ssh: Client; command: string; @@ -1336,7 +1337,6 @@ export async function bootstrapRemoteRuntime(args: { if (runtimeUploaded) { await verifyUploadedRuntime(); - await stopRemoteRuntimeDaemon(ssh, layout, runtimeEnvPrefix); } if (!runtimeVersion) { @@ -1348,8 +1348,8 @@ export async function bootstrapRemoteRuntime(args: { } const command = localBinary || runtimeUploaded - ? `${runtimeEnvPrefix}${layout.binaryExpr} rpc --stdio` - : `${runtimeEnvPrefix}ade rpc --stdio`; + ? remoteRuntimeRpcCommand(layout, runtimeEnvPrefix, layout.binaryExpr) + : remoteRuntimeRpcCommand(layout, runtimeEnvPrefix, "ade"); let openedRuntime: Awaited> | null = null; const expectedVersion = localBinary || runtimeUploaded ? args.appVersion : null; try { @@ -1382,7 +1382,7 @@ export async function bootstrapRemoteRuntime(args: { layout: candidateLayout, disableRuntimeServiceInstall: true, }); - const candidateCommand = `${candidateRuntimeEnvPrefix}ade rpc --stdio`; + const candidateCommand = remoteRuntimeRpcCommand(candidateLayout, candidateRuntimeEnvPrefix, "ade"); try { openedRuntime = await openValidatedRuntimeClient({ ssh, diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index 686461094..f7b1042ad 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -6,10 +6,29 @@ import { isCrsqliteAvailable } from "../state/crsqliteExtension"; import { openKvDb } from "../state/kvDb"; import { createSyncService } from "./syncService"; -const { createDefaultSyncHostServiceMock, createSyncHostServiceMock, syncHostServiceMockState } = vi.hoisted(() => { +const { acquireSyncHostSingletonMock, createDefaultSyncHostServiceMock, createSyncHostServiceMock, syncHostServiceMockState } = vi.hoisted(() => { const syncHostServiceMockState = { port: 8787, }; + const acquireSyncHostSingletonMock = vi.fn(() => ({ + owner: { + id: "test-sync-host", + pid: process.pid, + port: null, + appName: "ADE Test", + packageChannel: "test", + adeHome: null, + serviceName: null, + socketPath: null, + projectRoot: null, + commandLine: null, + quitCommand: "", + createdAt: "2026-03-15T00:00:00.000Z", + updatedAt: "2026-03-15T00:00:00.000Z", + }, + updatePort: vi.fn(), + dispose: vi.fn(), + })); const createDefaultSyncHostServiceMock = () => ({ async waitUntilListening() { return syncHostServiceMockState.port; @@ -44,6 +63,7 @@ const { createDefaultSyncHostServiceMock, createSyncHostServiceMock, syncHostSer async dispose() {}, }); return { + acquireSyncHostSingletonMock, syncHostServiceMockState, createDefaultSyncHostServiceMock, createSyncHostServiceMock: vi.fn(createDefaultSyncHostServiceMock), @@ -58,6 +78,10 @@ vi.mock("../../../../../ade-cli/src/services/sync/syncHostService", () => ({ SYNC_TAILNET_DISCOVERY_SERVICE_PORT: 8787, })); +vi.mock("../../../../../ade-cli/src/services/sync/syncHostSingleton", () => ({ + acquireSyncHostSingleton: acquireSyncHostSingletonMock, +})); + function createLogger() { return { debug: () => {}, @@ -853,8 +877,18 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { await service.initialize(); - expect(createSyncHostServiceMock.mock.calls.map((call: any[]) => call[0]?.port)).toEqual([8787, 8788]); - expect(disposeFirstAttempt).toHaveBeenCalledTimes(1); + expect(createSyncHostServiceMock.mock.calls.map((call: any[]) => call[0]?.port)).toEqual([ + 8787, + 8787, + 8787, + 8787, + 8787, + 8787, + 8787, + 8787, + 8788, + ]); + expect(disposeFirstAttempt).toHaveBeenCalledTimes(8); expect(service.getHostService()?.getPort()).toBe(8788); }, 30_000); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 1837efe0b..bbc6c8f4a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -3435,7 +3435,7 @@ describe("AgentChatPane submit recovery", () => { laneId: "lane-primary", prompt: "Fix auto create lane routing.", modelId: "openai/gpt-5.4", - fallbackName: expect.stringMatching(/^chat-\d{8}-\d{6}$/), + fallbackName: "fix-auto-create-lane-routing", })); expect(createLane).toHaveBeenCalledWith({ name: "fix-auto-create-flow", @@ -3568,7 +3568,7 @@ describe("AgentChatPane submit recovery", () => { await waitFor(() => { expect(createLane).toHaveBeenCalledWith({ - name: expect.stringMatching(/^chat-\d{8}-\d{6}$/), + name: "keep-going-even-if-naming", baseBranch: "origin/main", }); expect(create).toHaveBeenCalledWith(expect.objectContaining({ laneId: "lane-created" })); @@ -4127,7 +4127,7 @@ describe("AgentChatPane submit recovery", () => { await waitFor(() => { expect(suggestLaneName).toHaveBeenCalled(); - expect(screen.getByText(/Choosing a branch name/i)).toBeTruthy(); + expect(screen.getByText(/Naming lane with/i)).toBeTruthy(); expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(true); expect((screen.getByRole("button", { name: "Auto-create in background" }) as HTMLButtonElement).disabled).toBe(true); }); @@ -4182,14 +4182,14 @@ describe("AgentChatPane submit recovery", () => { await waitFor(() => { expect(suggestLaneName).toHaveBeenCalledTimes(1); - expect(screen.getByText(/Choosing a branch name/i)).toBeTruthy(); + expect(screen.getByText(/Naming lane with/i)).toBeTruthy(); }); expect(screen.queryByRole("button", { name: "Dismiss launch status" })).toBeNull(); rendered.unmount(); renderAutoCreateDraftPane(); - expect(await screen.findByText(/Choosing a branch name/i)).toBeTruthy(); + expect(await screen.findByText(/Naming lane with/i)).toBeTruthy(); expect(screen.queryByRole("button", { name: "Dismiss launch status" })).toBeNull(); await act(async () => { @@ -4226,7 +4226,9 @@ describe("AgentChatPane submit recovery", () => { laneId: null, laneName: null, sessionId: null, + namingModelId: null, error: null, + warning: null, autoOpen: false, createdAtMs: Date.now() - DRAFT_LAUNCH_JOB_STALE_AFTER_MS - 1, snapshot: { @@ -4289,7 +4291,7 @@ describe("AgentChatPane submit recovery", () => { await waitFor(() => { expect(suggestLaneName).toHaveBeenCalledTimes(1); - expect(screen.getByText(/Choosing a branch name/i)).toBeTruthy(); + expect(screen.getByText(/Naming lane with/i)).toBeTruthy(); }); const scopeKey = draftLaunchJobsScopeKeyForTest({ @@ -4395,7 +4397,7 @@ describe("AgentChatPane submit recovery", () => { await waitFor(() => { expect(suggestLaneName).toHaveBeenCalledTimes(1); - expect(screen.getAllByText(/Choosing a branch name/i)).toHaveLength(1); + expect(screen.getAllByText(/Naming lane with/i)).toHaveLength(1); }); }); @@ -4476,9 +4478,9 @@ describe("AgentChatPane submit recovery", () => { await waitFor(() => { expect(suggestLaneName).toHaveBeenCalledTimes(1); - expect(within(paneOne).getByText(/Choosing a branch name/i)).toBeTruthy(); + expect(within(paneOne).getByText(/Naming lane with/i)).toBeTruthy(); }); - expect(within(paneTwo).queryByText(/Choosing a branch name/i)).toBeNull(); + expect(within(paneTwo).queryByText(/Naming lane with/i)).toBeNull(); expect(within(paneTwo).queryByTestId("draft-launch-job")).toBeNull(); }); @@ -4510,7 +4512,7 @@ describe("AgentChatPane submit recovery", () => { } expect(screen.getAllByTestId("draft-launch-job")).toHaveLength(9); - expect(screen.getAllByText(/Choosing a branch name/i)).toHaveLength(9); + expect(screen.getAllByText(/Naming lane with/i)).toHaveLength(9); }); it("allows multiple background auto-create launches to stay pending at the same time", async () => { @@ -4552,7 +4554,7 @@ describe("AgentChatPane submit recovery", () => { fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); await waitFor(() => { expect(suggestLaneName).toHaveBeenCalledTimes(2); - expect(screen.getAllByText(/Choosing a branch name/i)).toHaveLength(2); + expect(screen.getAllByText(/Naming lane with/i)).toHaveLength(2); }); await act(async () => { @@ -5785,7 +5787,7 @@ describe("AgentChatPane submit recovery", () => { laneId: "lane-1", prompt: "Fix the login bug", modelId: "openai/gpt-5.4", - fallbackName: expect.stringMatching(/^chat-\d{8}-\d{6}$/), + fallbackName: "fix-login-bug", })); expect(createChild).toHaveBeenCalledTimes(2); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 1c12df43e..c62a32bee 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -61,6 +61,7 @@ import type { } from "../../../shared/types/orchestration"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; +import { deriveDeterministicLaneNameFromPrompt } from "../../../shared/laneNameFallback"; import { LOCAL_PROVIDER_LABELS, MODEL_REGISTRY, @@ -650,23 +651,33 @@ function draftLaunchRequestKey(args: { }); } -function createTemporaryAutoLaneName(date = new Date()): string { +function autoLaneGenericSuffix(date = new Date()): string { const pad = (value: number) => String(value).padStart(2, "0"); return [ - "chat", - `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`, - `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`, - ].join("-"); + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + ].join("") + "-" + [ + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join(""); } -const AUTO_LANE_NAME_SUGGEST_TIMEOUT_MS = 3_500; +function createDeterministicAutoLaneName(prompt: string, options: { genericSuffix?: string | null } = {}): string { + return deriveDeterministicLaneNameFromPrompt(prompt, options); +} + +const AUTO_LANE_NAME_SUGGEST_TIMEOUT_MS = 10_000; async function suggestAutoLaneName(args: { laneId: string; prompt: string; modelId: string; + genericSuffix?: string | null; + onFallback?: (message: string) => void; }): Promise { - const fallbackName = createTemporaryAutoLaneName(); + const fallbackName = createDeterministicAutoLaneName(args.prompt, { genericSuffix: args.genericSuffix }); let timeoutId: ReturnType | null = null; try { const suggested = await Promise.race([ @@ -677,12 +688,16 @@ async function suggestAutoLaneName(args: { fallbackName, }), new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(fallbackName), AUTO_LANE_NAME_SUGGEST_TIMEOUT_MS); + timeoutId = setTimeout(() => { + args.onFallback?.(`Lane naming with ${formatLocalModelLabel(args.modelId)} took longer than 10s; using a deterministic name.`); + resolve(fallbackName); + }, AUTO_LANE_NAME_SUGGEST_TIMEOUT_MS); }), ]); return suggested.trim() || fallbackName; } catch (error) { console.warn("draft launch lane name suggestion failed; using fallback name", error); + args.onFallback?.("Lane naming failed; using a deterministic name."); return fallbackName; } finally { if (timeoutId) clearTimeout(timeoutId); @@ -737,10 +752,11 @@ function draftLaunchPromptSnippet(job: DraftLaunchJob): string { function draftLaunchJobMessage(job: DraftLaunchJob): string { const laneSuffix = job.laneName ? ` in ${job.laneName}` : ""; - if (job.status === "naming-lane") return `Creating lane for ${draftLaunchKindLabel(job.draftKind)}... Choosing a branch name.`; - if (job.status === "creating-lane") return `Creating lane for ${draftLaunchKindLabel(job.draftKind)}...`; - if (job.status === "starting-session") return `Starting ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}...`; - if (job.status === "sending-prompt") return `Sending prompt to ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}...`; + const warningSuffix = job.warning ? ` ${job.warning}` : ""; + if (job.status === "naming-lane") return `Naming lane with ${formatLocalModelLabel(job.namingModelId ?? job.snapshot.modelId)}...${warningSuffix}`; + if (job.status === "creating-lane") return `Creating lane for ${draftLaunchKindLabel(job.draftKind)}...${warningSuffix}`; + if (job.status === "starting-session") return `Starting ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}...${warningSuffix}`; + if (job.status === "sending-prompt") return `Sending prompt to ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}...${warningSuffix}`; if (job.status === "failed") return job.error ? `Launch failed: ${job.error}` : "Launch failed."; return job.mode === "background" ? `Launched ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}.` @@ -6744,6 +6760,8 @@ export function AgentChatPane({ const resolveDraftLaunchLane = useCallback(async ( snapshot: DraftLaunchSnapshot, onAutoCreateNameResolved?: () => void, + onAutoCreateNameFallback?: (message: string) => void, + onAutoCreateNameModelResolved?: (modelId: string) => void, ): Promise => { if (draftLaunchTargetIsAutoCreate) { if (!laneId) throw new Error("Select a lane before auto-creating a new lane."); @@ -6751,13 +6769,23 @@ export function AgentChatPane({ ?? availableLanes?.find((candidate) => candidate.name.trim().toLowerCase() === "primary") ?? null; if (!primaryLane) throw new Error("Auto-create requires a primary lane."); - const laneName = await suggestAutoLaneName({ - laneId: primaryLane.id, - prompt: buildDraftLaunchNamingSeed(snapshot), - modelId: snapshot.modelId, - }); - onAutoCreateNameResolved?.(); + const namingSeed = buildDraftLaunchNamingSeed(snapshot); const projectConfigSnapshot = await getProjectConfigCached({ projectRoot, force: true }).catch(() => null); + const titleSettings = projectConfigSnapshot?.effective?.ai?.sessionIntelligence?.titles; + const titleModelId = typeof titleSettings?.modelId === "string" ? titleSettings.modelId.trim() : ""; + const namingModelId = titleModelId || snapshot.modelId; + onAutoCreateNameModelResolved?.(namingModelId); + const genericSuffix = autoLaneGenericSuffix(); + const laneName = titleSettings?.enabled === false + ? createDeterministicAutoLaneName(namingSeed, { genericSuffix }) + : await suggestAutoLaneName({ + laneId: primaryLane.id, + prompt: namingSeed, + modelId: namingModelId, + genericSuffix, + onFallback: onAutoCreateNameFallback, + }); + onAutoCreateNameResolved?.(); const baseSource = effectiveNewLaneBaseSource(projectConfigSnapshot); const branches = await fetchNewLaneBaseBranches({ source: baseSource, @@ -6990,7 +7018,9 @@ export function AgentChatPane({ laneId: null, laneName: null, sessionId: null, + namingModelId: null, error: null, + warning: null, autoOpen: mode === "foreground", createdAtMs: Date.now(), snapshot, @@ -7012,6 +7042,10 @@ export function AgentChatPane({ try { targetLane = await resolveDraftLaunchLane(snapshot, () => { patchDraftLaunchJob(jobId, { status: "creating-lane" }); + }, (message) => { + patchDraftLaunchJob(jobId, { warning: message }); + }, (modelId) => { + patchDraftLaunchJob(jobId, { namingModelId: modelId }); }); patchDraftLaunchJob(jobId, { status: "starting-session", @@ -7391,11 +7425,23 @@ export function AgentChatPane({ issueCount ? `${issueCount} issue${issueCount === 1 ? "" : "s"}` : null, ].filter(Boolean).join(" · "); } - const baseName = await suggestAutoLaneName({ - laneId, - prompt: namingSeed, - modelId: parallelModelSlots[0]!.modelId, - }); + const projectConfigSnapshot = await getProjectConfigCached({ projectRoot, force: false }).catch(() => null); + const titleSettings = projectConfigSnapshot?.effective?.ai?.sessionIntelligence?.titles; + const titleModelId = typeof titleSettings?.modelId === "string" ? titleSettings.modelId.trim() : ""; + const namingModelId = titleModelId || parallelModelSlots[0]!.modelId; + if (titleSettings?.enabled !== false) { + setParallelLaunchStatus(`Naming lanes with ${formatLocalModelLabel(namingModelId)}...`); + } + const genericSuffix = autoLaneGenericSuffix(); + const baseName = titleSettings?.enabled === false + ? createDeterministicAutoLaneName(namingSeed, { genericSuffix }) + : await suggestAutoLaneName({ + laneId, + prompt: namingSeed, + modelId: namingModelId, + genericSuffix, + onFallback: setParallelLaunchStatus, + }); setParallelLaunchStatus(`Creating ${parallelModelSlots.length} child lanes…`); for (const slot of parallelModelSlots) { @@ -9511,6 +9557,15 @@ export function AgentChatPane({ Restore ) : null} + {isActiveJob && job.status === "naming-lane" ? ( + + ) : null} {canOpen ? (