* takt: fix-broken-issue-title-session * takt: fix-broken-issue-title-session
This commit is contained in:
parent
61f0be34b5
commit
deca6a2f3d
@ -226,3 +226,38 @@ describe('/resume command', () => {
|
|||||||
expect(result.action).toBe('cancel');
|
expect(result.action).toBe('cancel');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// /go command: summary AI session isolation
|
||||||
|
// =================================================================
|
||||||
|
describe('/go command', () => {
|
||||||
|
it('should pass sessionId as undefined to summary AI even when conversation has an active session', async () => {
|
||||||
|
// Given: send message (AI responds with sessionId) → /go triggers summary
|
||||||
|
setupRawStdin(toRawInputs(['hello', '/go']));
|
||||||
|
|
||||||
|
const { provider, capture } = createScenarioProvider([
|
||||||
|
// Call 0: user message → AI responds and sets sessionId
|
||||||
|
{ content: 'AI response', sessionId: 'session-abc' },
|
||||||
|
// Call 1: /go summary → should NOT inherit sessionId
|
||||||
|
{ content: '## Fix broken title\nDetails here' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ctx: SessionContext = {
|
||||||
|
provider: provider as SessionContext['provider'],
|
||||||
|
providerType: 'mock' as SessionContext['providerType'],
|
||||||
|
model: undefined,
|
||||||
|
lang: 'en',
|
||||||
|
personaName: 'interactive',
|
||||||
|
sessionId: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await runConversationLoop('/test', ctx, defaultStrategy, undefined, undefined);
|
||||||
|
|
||||||
|
// Then: first AI call had no session (initial state)
|
||||||
|
expect(capture.sessionIds[0]).toBeUndefined();
|
||||||
|
// Then: summary call must NOT inherit the conversation session
|
||||||
|
expect(capture.sessionIds[1]).toBeUndefined();
|
||||||
|
expect(result.action).toBe('execute');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -34,6 +34,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|||||||
|
|
||||||
import { success, error } from '../shared/ui/index.js';
|
import { success, error } from '../shared/ui/index.js';
|
||||||
import { createIssueFromTask } from '../features/tasks/index.js';
|
import { createIssueFromTask } from '../features/tasks/index.js';
|
||||||
|
import { extractTitle } from '../features/tasks/add/index.js';
|
||||||
|
|
||||||
const mockSuccess = vi.mocked(success);
|
const mockSuccess = vi.mocked(success);
|
||||||
const mockError = vi.mocked(error);
|
const mockError = vi.mocked(error);
|
||||||
@ -229,3 +230,149 @@ describe('createIssueFromTask', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extractTitle', () => {
|
||||||
|
describe('Markdown heading extraction', () => {
|
||||||
|
it('should strip # and return text for h1 heading', () => {
|
||||||
|
// Given
|
||||||
|
const task = '# Fix broken title\nDetails here';
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('Fix broken title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strip ## and return text for h2 heading', () => {
|
||||||
|
// Given
|
||||||
|
const task = '## Fix broken title\nDetails here';
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('Fix broken title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strip ### and return text for h3 heading', () => {
|
||||||
|
// Given
|
||||||
|
const task = '### Fix broken title\nDetails here';
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('Fix broken title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer first Markdown heading over preceding plain text', () => {
|
||||||
|
// Given: AI preamble followed by heading
|
||||||
|
const task = '失礼しました。修正します。\n## Fix broken title\nDetails here';
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then: heading wins over first line
|
||||||
|
expect(result).toBe('Fix broken title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find heading even when multiple empty lines precede it', () => {
|
||||||
|
// Given
|
||||||
|
const task = '\n\n## Fix broken title\nDetails here';
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('Fix broken title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fallback to first non-empty line', () => {
|
||||||
|
it('should return first non-empty line when no Markdown heading exists', () => {
|
||||||
|
// Given: plain text without heading
|
||||||
|
const task = 'Fix broken title\nSecond line details';
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('Fix broken title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip leading empty lines when no heading exists', () => {
|
||||||
|
// Given: leading blank lines
|
||||||
|
const task = '\n\nFix broken title\nDetails here';
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('Fix broken title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not treat h4+ headings as Markdown headings', () => {
|
||||||
|
// Given: #### is not matched (only h1-h3 are recognized)
|
||||||
|
const task = '#### h4 heading\nDetails here';
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then: falls back to first non-empty line as-is
|
||||||
|
expect(result).toBe('#### h4 heading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not treat heading without space after hash as Markdown heading', () => {
|
||||||
|
// Given: #Title has no space, so not recognized as heading
|
||||||
|
const task = '#NoSpace\nDetails here';
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then: falls back to first non-empty line
|
||||||
|
expect(result).toBe('#NoSpace');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('title truncation', () => {
|
||||||
|
it('should truncate heading title to 97 chars + ellipsis when over 100 chars', () => {
|
||||||
|
// Given: heading text over 100 characters
|
||||||
|
const longTitle = 'a'.repeat(102);
|
||||||
|
const task = `## ${longTitle}\nDetails here`;
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then: truncated to 97 + "..."
|
||||||
|
expect(result).toBe(`${'a'.repeat(97)}...`);
|
||||||
|
expect(result).toHaveLength(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate plain text title to 97 chars + ellipsis when over 100 chars', () => {
|
||||||
|
// Given: plain text over 100 characters
|
||||||
|
const longTitle = 'b'.repeat(102);
|
||||||
|
const task = `${longTitle}\nDetails here`;
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then: truncated to 97 + "..."
|
||||||
|
expect(result).toBe(`${'b'.repeat(97)}...`);
|
||||||
|
expect(result).toHaveLength(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not truncate title of exactly 100 characters', () => {
|
||||||
|
// Given: title exactly 100 chars
|
||||||
|
const title100 = 'c'.repeat(100);
|
||||||
|
const task = `## ${title100}\nDetails here`;
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = extractTitle(task);
|
||||||
|
|
||||||
|
// Then: not truncated
|
||||||
|
expect(result).toBe(title100);
|
||||||
|
expect(result).toHaveLength(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
160
src/__tests__/quietMode-session.test.ts
Normal file
160
src/__tests__/quietMode-session.test.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Tests for quietMode summary AI session isolation.
|
||||||
|
*
|
||||||
|
* Verifies that the summary AI call in quietMode does not inherit the
|
||||||
|
* conversation session (sessionId must be undefined), even when ctx
|
||||||
|
* carries an active sessionId. This matches the fix already applied to
|
||||||
|
* conversationLoop.ts's /go command.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// ── Mocks ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('../features/interactive/conversationLoop.js', () => ({
|
||||||
|
initializeSession: vi.fn(),
|
||||||
|
callAIWithRetry: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../features/interactive/interactive.js', () => ({
|
||||||
|
DEFAULT_INTERACTIVE_TOOLS: ['Read'],
|
||||||
|
buildSummaryPrompt: vi.fn(),
|
||||||
|
selectPostSummaryAction: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
createLogger: () => ({ info: vi.fn(), debug: vi.fn(), error: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/ui/index.js', () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
blankLine: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/i18n/index.js', () => ({
|
||||||
|
getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'),
|
||||||
|
getLabelObject: vi.fn(() => ({
|
||||||
|
intro: 'Intro',
|
||||||
|
proposed: 'Proposed:',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Imports (after mocks) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { quietMode } from '../features/interactive/quietMode.js';
|
||||||
|
import { initializeSession, callAIWithRetry } from '../features/interactive/conversationLoop.js';
|
||||||
|
import { buildSummaryPrompt, selectPostSummaryAction } from '../features/interactive/interactive.js';
|
||||||
|
import type { SessionContext } from '../features/interactive/conversationLoop.js';
|
||||||
|
|
||||||
|
const mockInitializeSession = vi.mocked(initializeSession);
|
||||||
|
const mockCallAIWithRetry = vi.mocked(callAIWithRetry);
|
||||||
|
const mockBuildSummaryPrompt = vi.mocked(buildSummaryPrompt);
|
||||||
|
const mockSelectPostSummaryAction = vi.mocked(selectPostSummaryAction);
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createMockSessionContext(sessionId: string | undefined): SessionContext {
|
||||||
|
return {
|
||||||
|
provider: {} as SessionContext['provider'],
|
||||||
|
providerType: 'mock' as SessionContext['providerType'],
|
||||||
|
model: undefined,
|
||||||
|
lang: 'en',
|
||||||
|
personaName: 'interactive',
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// quietMode: summary AI session isolation
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
describe('quietMode: summary AI session isolation', () => {
|
||||||
|
it('should pass sessionId as undefined to callAIWithRetry even when ctx carries an active sessionId', async () => {
|
||||||
|
// Given: initializeSession returns a ctx with an active session
|
||||||
|
const ctxWithSession = createMockSessionContext('active-session-123');
|
||||||
|
mockInitializeSession.mockReturnValue(ctxWithSession);
|
||||||
|
|
||||||
|
// Given: buildSummaryPrompt returns a non-null prompt
|
||||||
|
mockBuildSummaryPrompt.mockReturnValue('Summary prompt for task');
|
||||||
|
|
||||||
|
// Given: callAIWithRetry returns a successful result
|
||||||
|
mockCallAIWithRetry.mockResolvedValue({
|
||||||
|
result: { content: '## Fix the bug\nDetails here', success: true, sessionId: undefined },
|
||||||
|
sessionId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Given: user selects execute action
|
||||||
|
mockSelectPostSummaryAction.mockResolvedValue('execute');
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await quietMode('/test/cwd', 'fix the bug');
|
||||||
|
|
||||||
|
// Then: callAIWithRetry was called exactly once
|
||||||
|
expect(mockCallAIWithRetry).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
// Then: 5th argument (ctx) must NOT inherit the conversation sessionId
|
||||||
|
const calledCtx = mockCallAIWithRetry.mock.calls[0]![4] as SessionContext;
|
||||||
|
expect(calledCtx.sessionId).toBeUndefined();
|
||||||
|
|
||||||
|
// Then: result is as expected
|
||||||
|
expect(result.action).toBe('execute');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve other ctx fields while clearing sessionId', async () => {
|
||||||
|
// Given: ctx with active session and specific lang
|
||||||
|
const ctxWithSession = createMockSessionContext('session-xyz');
|
||||||
|
ctxWithSession.lang = 'ja';
|
||||||
|
mockInitializeSession.mockReturnValue(ctxWithSession);
|
||||||
|
mockBuildSummaryPrompt.mockReturnValue('要約プロンプト');
|
||||||
|
mockCallAIWithRetry.mockResolvedValue({
|
||||||
|
result: { content: '## タスク\n詳細', success: true, sessionId: undefined },
|
||||||
|
sessionId: undefined,
|
||||||
|
});
|
||||||
|
mockSelectPostSummaryAction.mockResolvedValue('execute');
|
||||||
|
|
||||||
|
// When
|
||||||
|
await quietMode('/test/cwd', 'バグを修正する');
|
||||||
|
|
||||||
|
// Then: sessionId is cleared but other fields are preserved
|
||||||
|
const calledCtx = mockCallAIWithRetry.mock.calls[0]![4] as SessionContext;
|
||||||
|
expect(calledCtx.sessionId).toBeUndefined();
|
||||||
|
expect(calledCtx.lang).toBe('ja');
|
||||||
|
expect(calledCtx.personaName).toBe('interactive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return cancel when callAIWithRetry returns null result', async () => {
|
||||||
|
// Given
|
||||||
|
mockInitializeSession.mockReturnValue(createMockSessionContext('session-abc'));
|
||||||
|
mockBuildSummaryPrompt.mockReturnValue('Summary prompt');
|
||||||
|
mockCallAIWithRetry.mockResolvedValue({ result: null, sessionId: undefined });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await quietMode('/test/cwd', 'some input');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.action).toBe('cancel');
|
||||||
|
expect(result.task).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return cancel when buildSummaryPrompt returns null', async () => {
|
||||||
|
// Given: no conversation history leads to null prompt
|
||||||
|
mockInitializeSession.mockReturnValue(createMockSessionContext(undefined));
|
||||||
|
mockBuildSummaryPrompt.mockReturnValue(null);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await quietMode('/test/cwd', 'some input');
|
||||||
|
|
||||||
|
// Then: short-circuits before callAIWithRetry
|
||||||
|
expect(mockCallAIWithRetry).not.toHaveBeenCalled();
|
||||||
|
expect(result.action).toBe('cancel');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -192,7 +192,11 @@ export async function runConversationLoop(
|
|||||||
if (userNote) {
|
if (userNote) {
|
||||||
summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`;
|
summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`;
|
||||||
}
|
}
|
||||||
const summaryResult = await doCallAI(summaryPrompt, summaryPrompt, strategy.allowedTools);
|
// Summary AI must not inherit the conversation session to avoid chat-mode behavior.
|
||||||
|
const { result: summaryResult } = await callAIWithRetry(
|
||||||
|
summaryPrompt, summaryPrompt, strategy.allowedTools, cwd,
|
||||||
|
{ ...ctx, sessionId: undefined },
|
||||||
|
);
|
||||||
if (!summaryResult) {
|
if (!summaryResult) {
|
||||||
info(ui.summarizeFailed);
|
info(ui.summarizeFailed);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -85,7 +85,8 @@ export async function quietMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { result } = await callAIWithRetry(
|
const { result } = await callAIWithRetry(
|
||||||
summaryPrompt, summaryPrompt, DEFAULT_INTERACTIVE_TOOLS, cwd, ctx,
|
summaryPrompt, summaryPrompt, DEFAULT_INTERACTIVE_TOOLS, cwd,
|
||||||
|
{ ...ctx, sessionId: undefined },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|||||||
@ -70,16 +70,38 @@ export async function saveTaskFile(
|
|||||||
return { taskName: created.name, tasksFile };
|
return { taskName: created.name, tasksFile };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TITLE_MAX_LENGTH = 100;
|
||||||
|
const TITLE_TRUNCATE_LENGTH = 97;
|
||||||
|
const MARKDOWN_HEADING_PATTERN = /^#{1,3}\s+\S/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a clean title from a task description.
|
||||||
|
*
|
||||||
|
* Prefers the first Markdown heading (h1-h3) if present.
|
||||||
|
* Falls back to the first non-empty line otherwise.
|
||||||
|
* Truncates to 100 characters (97 + "...") when exceeded.
|
||||||
|
*/
|
||||||
|
export function extractTitle(task: string): string {
|
||||||
|
const lines = task.split('\n');
|
||||||
|
const headingLine = lines.find((l) => MARKDOWN_HEADING_PATTERN.test(l));
|
||||||
|
const titleLine = headingLine
|
||||||
|
? headingLine.replace(/^#{1,3}\s+/, '')
|
||||||
|
: (lines.find((l) => l.trim().length > 0) ?? task);
|
||||||
|
return titleLine.length > TITLE_MAX_LENGTH
|
||||||
|
? `${titleLine.slice(0, TITLE_TRUNCATE_LENGTH)}...`
|
||||||
|
: titleLine;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a GitHub Issue from a task description.
|
* Create a GitHub Issue from a task description.
|
||||||
*
|
*
|
||||||
* Extracts the first line as the issue title (truncated to 100 chars),
|
* Extracts the first Markdown heading (h1-h3) as the issue title,
|
||||||
* uses the full task as the body, and displays success/error messages.
|
* falling back to the first non-empty line. Truncates to 100 chars.
|
||||||
|
* Uses the full task as the body, and displays success/error messages.
|
||||||
*/
|
*/
|
||||||
export function createIssueFromTask(task: string, options?: { labels?: string[] }): number | undefined {
|
export function createIssueFromTask(task: string, options?: { labels?: string[] }): number | undefined {
|
||||||
info('Creating GitHub Issue...');
|
info('Creating GitHub Issue...');
|
||||||
const titleLine = task.split('\n')[0] || task;
|
const title = extractTitle(task);
|
||||||
const title = titleLine.length > 100 ? `${titleLine.slice(0, 97)}...` : titleLine;
|
|
||||||
const effectiveLabels = options?.labels?.filter((l) => l.length > 0) ?? [];
|
const effectiveLabels = options?.labels?.filter((l) => l.length > 0) ?? [];
|
||||||
const labels = effectiveLabels.length > 0 ? effectiveLabels : undefined;
|
const labels = effectiveLabels.length > 0 ? effectiveLabels : undefined;
|
||||||
|
|
||||||
@ -259,7 +281,6 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. ワークツリー/ブランチ/PR設定
|
|
||||||
const settings = await promptWorktreeSettings();
|
const settings = await promptWorktreeSettings();
|
||||||
|
|
||||||
// YAMLファイル作成
|
// YAMLファイル作成
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user