Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/desktop/src/main/services/ai/aiIntegrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2007,6 +2007,7 @@ export function createAiIntegrationService(args: {
prompt: string;
timeoutMs?: number;
model?: string;
reasoningEffort?: string | null;
jsonSchema?: unknown;
systemPrompt?: string;
taskType?: Extract<AiTaskType, "terminal_summary" | "session_title" | "session_summary" | "handoff_summary" | "continuity_summary" | "context_compaction">;
Expand All @@ -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
});
Expand Down
59 changes: 45 additions & 14 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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(() => ({
Expand All @@ -18836,7 +18836,9 @@ describe("suggestLaneNameFromPrompt", () => {
cli: { mode: "edit" },
inProcess: { mode: "edit" },
},
chat: {},
chat: {
...(options.legacyTitleModelId ? { autoTitleModelId: options.legacyTitleModelId } : {}),
},
sessionIntelligence,
},
},
Expand All @@ -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),
});
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 },
]);
Expand All @@ -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: "!!!",
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 },
Expand Down
72 changes: 42 additions & 30 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
});
};
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -7683,9 +7693,11 @@ export function createAgentChatService(args: {
sessionBudgetUsd,
titleGenerationEnabled,
titleModelId,
titleReasoningEffort,
titleRefreshOnComplete,
summaryEnabled,
summaryModelId,
summaryReasoningEffort,
};
};

Expand Down
Loading
Loading