diff --git a/src/config.ts b/src/config.ts index f2ee3055..5836284b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -138,6 +138,29 @@ export const config = { catch { return {}; } })() as Record, }, + worktreeSlugAI: { + /** + * Optional AI slugger for `/repo wt` auto branch/worktree naming. Disabled + * by default because it sends the topic title / first prompt to the + * configured OpenAI-compatible endpoint. When disabled or misconfigured, + * botmux falls back to deterministic local slugification. + */ + enabled: (process.env.BOTMUX_WORKTREE_SLUG_AI_ENABLED ?? '').toLowerCase() === 'true', + baseUrl: process.env.BOTMUX_WORKTREE_SLUG_AI_BASE_URL ?? '', + apiKey: process.env.BOTMUX_WORKTREE_SLUG_AI_API_KEY ?? '', + model: process.env.BOTMUX_WORKTREE_SLUG_AI_MODEL ?? '', + timeoutMs: Number(process.env.BOTMUX_WORKTREE_SLUG_AI_TIMEOUT_MS) || 5_000, + /** Extra headers for the API request (JSON string). */ + extraHeaders: (() => { + try { return JSON.parse(process.env.BOTMUX_WORKTREE_SLUG_AI_EXTRA_HEADERS ?? '{}'); } + catch { return {}; } + })() as Record, + /** Extra body params for the API request (JSON string). */ + extraBody: (() => { + try { return JSON.parse(process.env.BOTMUX_WORKTREE_SLUG_AI_EXTRA_BODY ?? '{}'); } + catch { return {}; } + })() as Record, + }, }; // allowedUsers is mutable — daemon resolves email prefixes to open_ids at startup diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index c909aef9..c8d936a0 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -13,6 +13,7 @@ import * as scheduleStore from '../services/schedule-store.js'; import * as scheduler from './scheduler.js'; import { scanProjects, scanMultipleProjects, describeProjectDir } from '../services/project-scanner.js'; import { createRepoWorktree } from '../services/git-worktree.js'; +import { worktreeSlugFromContextAI } from '../services/worktree-slug-ai.js'; import { buildRepoSelectCard, buildAdoptSelectCard, buildCodexAppThreadSelectCard, buildSlashListCard, getCliDisplayName, buildConfigCard, buildLandCard } from '../im/lark/card-builder.js'; import { computeSandboxDiff } from '../services/sandbox-land.js'; import { createCliAdapterSync } from '../adapters/cli/registry.js'; @@ -1185,7 +1186,8 @@ export async function handleCommand( // `/repo wt [branch]` → create a worktree off the repo's // remote default branch and open THAT as the session repo. Without a - // branch arg the branch/dir are auto-named (wt/N, -wt-N). + // branch arg the branch/dir are auto-named from the topic title / first + // pending prompt when possible (fallback: wt/N, -wt-N). if (ds && /^wt(\s|$)/i.test(repoArg)) { const rest = repoArg.replace(/^wt\s*/i, '').trim().split(/\s+/).filter(Boolean); if (rest.length < 1 || rest.length > 2) { @@ -1237,7 +1239,11 @@ export async function handleCommand( await sessionReply(rootId, t('cmd.repo.worktree_creating', { repo: repoPath }, loc)); let creation; try { - creation = await createRepoWorktree(repoPath, { branch: branchArg }); + const slug = branchArg ? undefined : await worktreeSlugFromContextAI(ds!.session.title, ds!.pendingPrompt); + creation = await createRepoWorktree(repoPath, { + branch: branchArg, + slug, + }); } catch (e) { await sessionReply(rootId, t('cmd.repo.worktree_failed', { error: e instanceof Error ? e.message : String(e) }, loc)); break; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index cba59720..fb6b402e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -230,7 +230,7 @@ export const messages: Record = { 'cmd.repo.scan_dir_not_exist': 'Scan dirs do not exist: {dirs}\nCheck that workingDir in bots.json points to a valid directory.', 'cmd.repo.working_dir_not_exist': '❌ Configured working directory does not exist or is not a directory: {dirs}\nCheck workingDir / workingDirs in ~/.botmux/bots.json, or run `botmux setup` and choose an existing directory.', 'cmd.repo.no_git_repos': 'No git repositories found under {dirs}.', - 'cmd.repo.worktree_usage': 'Usage: `/repo wt [new-branch]` — create a worktree off the repo\'s remote default branch and open it.', + 'cmd.repo.worktree_usage': 'Usage: `/repo wt [new-branch]` — create a worktree off the repo\'s remote default branch and open it; without a branch, Botmux auto-names it from the topic title / first prompt when possible.', 'cmd.repo.worktree_creating': '🌿 Creating a worktree for `{repo}` (includes a git fetch, may take a few seconds)…', 'cmd.repo.worktree_created': '🌿 Worktree created: `{path}`\nBranch `{branch}`, based on `{base}`', 'cmd.repo.worktree_failed': '❌ Worktree creation failed: {error}', @@ -407,7 +407,7 @@ export const messages: Record = { 'help.repo_list': '/repo - Pending selection: start in default dir; mid-session: show project picker', 'help.repo_n': '/repo - Switch to project #N', 'help.repo_path': '/repo - Use a path or a project name under workingDir, skipping the card', - 'help.repo_wt': '/repo wt [branch] - Create a worktree off the remote default branch and open it', + 'help.repo_wt': '/repo wt [branch] - Create a worktree off the remote default branch and open it (auto semantic name when branch is omitted)', 'help.status': '/status - Show current session status (incl. terminal URL)', 'help.card': '/card - Manually post the streaming card for this session (summons it even when streaming is off, and resumes live updates; with private-card mode on, sends a static snapshot visible only to authorized users instead)', 'help.term': '/term - Get the operable (write-enabled) terminal link for this session, delivered privately to the owner (visible-to-you in-chat, falling back to DM in topic/p2p — never exposed in the group)', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index a667f02f..61318746 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -233,7 +233,7 @@ export const messages: Record = { 'cmd.repo.scan_dir_not_exist': '扫描目录不存在:{dirs}\n请检查 bots.json 中的 workingDir 是否指向有效目录。', 'cmd.repo.working_dir_not_exist': '❌ 配置的工作目录不存在或不是目录:{dirs}\n请检查 ~/.botmux/bots.json 中的 workingDir / workingDirs,或重新运行 `botmux setup` 修改为已存在的目录。', 'cmd.repo.no_git_repos': '在 {dirs} 下未找到 git 仓库。', - 'cmd.repo.worktree_usage': '用法:`/repo wt <编号|项目名|路径> [新分支名]` — 基于该仓库的远端默认分支新建 worktree 并打开。', + 'cmd.repo.worktree_usage': '用法:`/repo wt <编号|项目名|路径> [新分支名]` — 基于该仓库的远端默认分支新建 worktree 并打开;未指定分支时会优先根据话题标题/首条需求自动命名。', 'cmd.repo.worktree_creating': '🌿 正在为 `{repo}` 创建 worktree(含 git fetch,可能需要几秒)…', 'cmd.repo.worktree_created': '🌿 worktree 已创建:`{path}`\n分支 `{branch}`,基于 `{base}`', 'cmd.repo.worktree_failed': '❌ 创建 worktree 失败:{error}', @@ -410,7 +410,7 @@ export const messages: Record = { 'help.repo_list': '/repo - 仓库待选时直接在默认目录开会话;会话中则弹项目选择卡片', 'help.repo_n': '/repo - 切换到第 N 个项目', 'help.repo_path': '/repo <路径|项目名> - 直接指定路径或 workingDir 下的项目名,跳过选择卡片', - 'help.repo_wt': '/repo wt <编号|项目名> [分支] - 基于远端默认分支新建 worktree 并打开', + 'help.repo_wt': '/repo wt <编号|项目名> [分支] - 基于远端默认分支新建 worktree 并打开(未指定分支时自动语义命名)', 'help.status': '/status - 查看当前会话状态(含终端链接)', 'help.card': '/card - 手动弹出当前会话的流式卡片(关流式时也能临时召唤,并恢复实时刷新;开了私密卡片则改发仅授权人可见的静态快照)', 'help.term': '/term - 获取当前会话的「可操作终端」(带写权限)链接,私密发给 owner(群内仅你可见,话题/单聊回退私信,不在群里暴露)', diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index a1291044..eef9c7fe 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -40,6 +40,7 @@ import type { DaemonSession } from '../../core/types.js'; import { buildTerminalUrl } from '../../core/terminal-url.js'; import type { ProjectInfo } from '../../services/project-scanner.js'; import { createRepoWorktree } from '../../services/git-worktree.js'; +import { worktreeSlugFromContextAI } from '../../services/worktree-slug-ai.js'; import { t, localeForBot, isLocale, type Locale } from '../../i18n/index.js'; // ─── Types ──────────────────────────────────────────────────────────────── @@ -1766,7 +1767,8 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe try { let creation; try { - creation = await createRepoWorktree(selectedPath); + const slug = await worktreeSlugFromContextAI(targetDs.session.title, targetDs.pendingPrompt); + creation = await createRepoWorktree(selectedPath, { slug }); } catch (e) { logger.warn(`[${tag(targetDs)}] Worktree creation failed for ${selectedPath}: ${e instanceof Error ? e.message : e}`); await sessionReply(rootId, t('cmd.repo.worktree_failed', { error: e instanceof Error ? e.message : String(e) }, locTarget)); diff --git a/src/services/git-worktree.ts b/src/services/git-worktree.ts index 64ba3e87..267ae2bf 100644 --- a/src/services/git-worktree.ts +++ b/src/services/git-worktree.ts @@ -8,6 +8,7 @@ * runs inside the daemon's event loop. */ import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { promisify } from 'node:util'; import { existsSync } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; @@ -25,6 +26,13 @@ export interface WorktreeCreation { baseRef: string; } +export interface CreateRepoWorktreeOptions { + /** Explicit branch to check out/create. Takes precedence over `slug`. */ + branch?: string; + /** Semantic auto-name seed; creates `wt/` and dir `-wt-`. */ + slug?: string; +} + async function git(args: string[], cwd: string, timeoutMs = 10_000): Promise { try { const { stdout } = await execFileP('git', args, { cwd, timeout: timeoutMs, encoding: 'utf-8' }); @@ -65,7 +73,33 @@ async function resolveBaseRef(repo: string): Promise { /** Branch names may contain `/` etc. — flatten to a filesystem-safe suffix. */ function dirSuffixForBranch(branch: string): string { - return branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); + return branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'branch'; +} + +function shortHash(text: string): string { + return createHash('sha1').update(text).digest('hex').slice(0, 8); +} + +/** + * Build a git/filesystem-safe semantic slug from a session title or the first + * prompt. Keep it ASCII so branch and directory names are portable; when the + * source text has no latin/digit tokens (for example, all-CJK text), fall back + * to a stable hash instead of returning an empty name. + */ +export function slugFromWorktreeText(text: string | undefined | null, fallback = 'task'): string | undefined { + const raw = text?.trim(); + if (!raw) return undefined; + const slug = raw + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) + .replace(/-+$/g, ''); + if (slug) return slug; + return `${fallback}-${shortHash(raw)}`; } /** A linked worktree resolves to its repo's MAIN checkout (entry 0 of @@ -81,7 +115,9 @@ async function resolveMainWorktree(dir: string): Promise { * Create a linked worktree for `repoPath`, as a sibling of the repo's MAIN * checkout (a linked-worktree input is resolved back to the main one first). * - * - No `branch` given → auto-pick `wt/N` (first free N), dir `-wt-N`. + * - No `branch` given, `slug` given → auto-pick `wt/` (or `-2` etc.), + * dir `-wt-`. + * - No `branch`/`slug` given → auto-pick `wt/N` (first free N), dir `-wt-N`. * - `branch` given and exists locally → check it out into the worktree. * - `branch` given and exists remotely → create a local tracking branch from it. * - `branch` given and new → create it from the remote default branch. @@ -91,7 +127,7 @@ async function resolveMainWorktree(dir: string): Promise { */ export async function createRepoWorktree( repoPath: string, - opts: { branch?: string } = {}, + opts: CreateRepoWorktreeOptions = {}, ): Promise { const startDir = resolve(repoPath); await git(['rev-parse', '--git-dir'], startDir); // not a repo → throw early @@ -115,6 +151,21 @@ export async function createRepoWorktree( if (branch) { wtPath = join(parent, `${repoBase}-${dirSuffixForBranch(branch)}`); if (existsSync(wtPath)) throw new Error(`worktree target already exists: ${wtPath}`); + } else if (opts.slug) { + const slug = slugFromWorktreeText(opts.slug, 'task'); + if (!slug) throw new Error('invalid worktree slug'); + for (let n = 1;; n++) { + if (n > 1000) throw new Error(`no free wt/${slug} slot under 1000`); + const candidateSlug = n === 1 ? slug : `${slug}-${n}`; + const candidateBranch = `wt/${candidateSlug}`; + const candPath = join(parent, `${repoBase}-${dirSuffixForBranch(candidateBranch)}`); + if (existsSync(candPath) || + (await localBranchExists(repo, candidateBranch)) || + (await remoteBranchExists(repo, candidateBranch))) continue; + branch = candidateBranch; + wtPath = candPath; + break; + } } else { let n = 1; for (;; n++) { diff --git a/src/services/worktree-slug-ai.ts b/src/services/worktree-slug-ai.ts new file mode 100644 index 00000000..80c09d4f --- /dev/null +++ b/src/services/worktree-slug-ai.ts @@ -0,0 +1,82 @@ +import { config } from '../config.js'; +import { logger } from '../utils/logger.js'; +import { slugFromWorktreeText } from './git-worktree.js'; + +const SYSTEM_PROMPT = `You generate short, stable git branch slugs for coding tasks. +Return ONLY one lowercase ASCII slug, no markdown, no quotes. +Rules: +- Translate Chinese or any non-English task into concise English keywords. +- Use 2 to 5 words when possible. +- Use only a-z, 0-9, and hyphen. +- Start and end with a letter or digit. +- Max 48 characters. +- Prefer concrete engineering terms over generic words. +Examples: +中文 worktree 命名逻辑 -> worktree-naming-logic +远端同名分支已存在时 checkout 逻辑 -> remote-branch-checkout +创建 PR 前自动跑测试 -> pre-pr-test-run +修复飞书卡片重复点击 -> lark-card-double-click`; + +function firstText(title?: string, firstPrompt?: string): string | undefined { + const t = title?.trim(); + if (t) return t; + const p = firstPrompt?.trim(); + return p || undefined; +} + +function sanitizeModelSlug(raw: string | undefined): string | undefined { + return slugFromWorktreeText(raw)?.slice(0, 48).replace(/-+$/g, '') || undefined; +} + +function aiSlugConfigured(): boolean { + const c = config.worktreeSlugAI; + return !!(c.enabled && c.baseUrl && c.apiKey && c.model); +} + +export function localWorktreeSlugFromContext(title?: string, firstPrompt?: string): string | undefined { + return slugFromWorktreeText(title) ?? slugFromWorktreeText(firstPrompt); +} + +export async function worktreeSlugFromContextAI(title?: string, firstPrompt?: string): Promise { + const fallback = localWorktreeSlugFromContext(title, firstPrompt); + if (!aiSlugConfigured()) return fallback; + + const text = firstText(title, firstPrompt); + if (!text) return fallback; + + const c = config.worktreeSlugAI; + try { + const url = `${c.baseUrl.replace(/\/+$/, '')}/chat/completions`; + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${c.apiKey}`, + ...c.extraHeaders, + }, + body: JSON.stringify({ + model: c.model, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: text.slice(0, 1_000) }, + ], + temperature: 0, + max_tokens: 32, + ...c.extraBody, + }), + signal: AbortSignal.timeout(Math.max(500, c.timeoutMs)), + }); + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + throw new Error(`AI slug API ${resp.status}: ${body.slice(0, 200)}`); + } + const json = await resp.json() as any; + const content = json?.choices?.[0]?.message?.content; + const slug = sanitizeModelSlug(typeof content === 'string' ? content : undefined); + if (slug) return slug; + logger.warn('[worktree-slug-ai] empty or invalid AI slug, using local fallback'); + } catch (e) { + logger.warn(`[worktree-slug-ai] failed, using local fallback: ${e instanceof Error ? e.message : e}`); + } + return fallback; +} diff --git a/test/card-handler-repo-select.test.ts b/test/card-handler-repo-select.test.ts index 3d954f19..388d0fb3 100644 --- a/test/card-handler-repo-select.test.ts +++ b/test/card-handler-repo-select.test.ts @@ -93,6 +93,13 @@ vi.mock('../src/services/git-worktree.js', () => ({ createRepoWorktree: vi.fn(), })); +vi.mock('../src/services/worktree-slug-ai.js', () => ({ + worktreeSlugFromContextAI: vi.fn(async (title?: string, firstPrompt?: string) => { + const text = title?.trim() || firstPrompt?.trim(); + return text?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + }), +})); + vi.mock('@larksuiteoapi/node-sdk', () => ({ Client: class { constructor() {} }, WSClient: class { start() {} }, @@ -256,6 +263,7 @@ describe('repo select card — worktree open', () => { const second = await handleCardAction(makeSelectEvent('repo_worktree', '/repos/alpha'), deps, APP_ID); expect(createRepoWorktree).toHaveBeenCalledTimes(1); + expect(createRepoWorktree).toHaveBeenCalledWith('/repos/alpha', { slug: 'repo-test' }); expect(first?.toast?.content).toContain('正在创建'); expect(second?.toast?.content).toContain('已有一个 worktree 正在创建'); expect(ds.worktreeCreating).toBe(true); diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index 86d6eb6d..5de95557 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -137,6 +137,13 @@ vi.mock('../src/services/git-worktree.js', () => ({ createRepoWorktree: vi.fn(), })); +vi.mock('../src/services/worktree-slug-ai.js', () => ({ + worktreeSlugFromContextAI: vi.fn(async (title?: string, firstPrompt?: string) => { + const text = title?.trim() || firstPrompt?.trim(); + return text?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + }), +})); + vi.mock('../src/im/lark/card-builder.js', () => ({ buildRepoSelectCard: vi.fn(() => '{"card":"json"}'), buildAdoptSelectCard: vi.fn(() => '{"card":"adopt-select"}'), @@ -1234,7 +1241,10 @@ describe('handleCommand', () => { await handleCommand('/repo', ROOT_ID, makeLarkMessage('/repo wt 1'), deps, LARK_APP_ID); - expect(createRepoWorktree).toHaveBeenCalledWith('/home/testuser/project-a', { branch: undefined }); + expect(createRepoWorktree).toHaveBeenCalledWith('/home/testuser/project-a', { + branch: undefined, + slug: 'test-session', + }); expect(ds.workingDir).toBe('/home/testuser/project-a-wt-1'); expect(forkWorker).toHaveBeenCalledWith(ds, '', false); expect(ds.worktreeCreating).toBe(false); @@ -1242,6 +1252,20 @@ describe('handleCommand', () => { expect(replies).toContain('worktree 已创建'); }); + it('keeps an explicit branch instead of auto semantic naming', async () => { + const ds = makeDaemonSession({ pendingRepo: false }); + const deps = makeDeps(ds); + deps.lastRepoScan.set(CHAT_ID, SCAN as any); + vi.mocked(createRepoWorktree).mockResolvedValue({ ...CREATION, branch: 'feat/manual' }); + + await handleCommand('/repo', ROOT_ID, makeLarkMessage('/repo wt 1 feat/manual'), deps, LARK_APP_ID); + + expect(createRepoWorktree).toHaveBeenCalledWith('/home/testuser/project-a', { + branch: 'feat/manual', + slug: undefined, + }); + }); + it('holds the in-flight lock through the created-notice reply (post-git window)', async () => { const ds = makeDaemonSession({ pendingRepo: false }); const deps = makeDeps(ds); diff --git a/test/git-worktree.test.ts b/test/git-worktree.test.ts index 1c30ec12..f18c4ad2 100644 --- a/test/git-worktree.test.ts +++ b/test/git-worktree.test.ts @@ -12,7 +12,8 @@ import { mkdtempSync, mkdirSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createRepoWorktree } from '../src/services/git-worktree.js'; +import { createRepoWorktree, slugFromWorktreeText } from '../src/services/git-worktree.js'; +import { localWorktreeSlugFromContext } from '../src/services/worktree-slug-ai.js'; let tempRoot: string; @@ -91,6 +92,32 @@ describe('createRepoWorktree', () => { expect(second.path).toBe(join(tempRoot, 'proj-wt-2')); }); + it('uses a semantic slug for auto-named worktrees and increments on collisions', async () => { + const upstream = makeUpstream('upstream'); + const repo = makeClone(upstream, 'proj'); + + const first = await createRepoWorktree(repo, { slug: 'Fix Repo WT naming!' }); + const second = await createRepoWorktree(repo, { slug: 'Fix Repo WT naming!' }); + + expect(first.branch).toBe('wt/fix-repo-wt-naming'); + expect(first.path).toBe(join(tempRoot, 'proj-wt-fix-repo-wt-naming')); + expect(second.branch).toBe('wt/fix-repo-wt-naming-2'); + expect(second.path).toBe(join(tempRoot, 'proj-wt-fix-repo-wt-naming-2')); + }); + + it('skips a remote semantic branch instead of tracking it for auto-names', async () => { + const upstream = makeUpstream('upstream'); + git(upstream, 'switch', '-c', 'wt/fix-repo-wt-naming'); + git(upstream, 'commit', '--allow-empty', '-m', 'remote semantic branch'); + git(upstream, 'switch', 'master'); + const repo = makeClone(upstream, 'proj'); + + const res = await createRepoWorktree(repo, { slug: 'Fix Repo WT naming!' }); + + expect(res.branch).toBe('wt/fix-repo-wt-naming-2'); + expect(res.path).toBe(join(tempRoot, 'proj-wt-fix-repo-wt-naming-2')); + }); + it('uses an explicit new branch name (sanitized into the dir name)', async () => { const upstream = makeUpstream('upstream'); const repo = makeClone(upstream, 'proj'); @@ -187,3 +214,15 @@ describe('createRepoWorktree', () => { await expect(createRepoWorktree(plain)).rejects.toThrow(); }); }); + +describe('worktree semantic slug helpers', () => { + it('prefers the title and falls back to the first prompt', () => { + expect(localWorktreeSlugFromContext('Fix Repo WT naming!', 'first prompt')).toBe('fix-repo-wt-naming'); + expect(localWorktreeSlugFromContext(' ', 'Implement the picker')).toBe('implement-the-picker'); + }); + + it('uses a stable hash fallback for non-ascii-only text', () => { + expect(slugFromWorktreeText('新建 worktree 和分支')).toBe('worktree'); + expect(slugFromWorktreeText('看下新开工作树的命名逻辑')).toMatch(/^task-[a-f0-9]{8}$/); + }); +}); diff --git a/test/worktree-slug-ai.test.ts b/test/worktree-slug-ai.test.ts new file mode 100644 index 00000000..1d97a97c --- /dev/null +++ b/test/worktree-slug-ai.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { config } from '../src/config.js'; +import { worktreeSlugFromContextAI } from '../src/services/worktree-slug-ai.js'; + +describe('worktreeSlugFromContextAI', () => { + const original = { ...config.worktreeSlugAI }; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + Object.assign(config.worktreeSlugAI, { + enabled: true, + baseUrl: 'https://ai.example/v1', + apiKey: 'test-key', + model: 'test-model', + timeoutMs: 1000, + extraHeaders: {}, + extraBody: {}, + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + Object.assign(config.worktreeSlugAI, original); + vi.restoreAllMocks(); + }); + + it('uses the AI generated English slug for Chinese input', async () => { + globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ + choices: [{ message: { content: 'worktree-naming-logic' } }], + }), { status: 200, headers: { 'content-type': 'application/json' } })) as any; + + await expect(worktreeSlugFromContextAI('看下新开 worktree 的时候,命名逻辑是啥?')).resolves.toBe('worktree-naming-logic'); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it('sanitizes invalid model output', async () => { + globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ + choices: [{ message: { content: ' Worktree Naming Logic!!! ' } }], + }), { status: 200, headers: { 'content-type': 'application/json' } })) as any; + + await expect(worktreeSlugFromContextAI('看下新开 worktree 的时候,命名逻辑是啥?')).resolves.toBe('worktree-naming-logic'); + }); + + it('falls back locally when AI is disabled or fails', async () => { + config.worktreeSlugAI.enabled = false; + globalThis.fetch = vi.fn() as any; + await expect(worktreeSlugFromContextAI('看下新开 worktree 的时候,命名逻辑是啥?')).resolves.toBe('worktree'); + expect(globalThis.fetch).not.toHaveBeenCalled(); + + config.worktreeSlugAI.enabled = true; + globalThis.fetch = vi.fn(async () => new Response('bad gateway', { status: 502 })) as any; + await expect(worktreeSlugFromContextAI('看下新开 worktree 的时候,命名逻辑是啥?')).resolves.toBe('worktree'); + }); +});