takt: github-issue-180-ai (#219)
This commit is contained in:
parent
de6b5b5c2c
commit
621b8bd507
@ -47,6 +47,7 @@ vi.mock('../features/pipeline/index.js', () => ({
|
||||
vi.mock('../features/interactive/index.js', () => ({
|
||||
interactiveMode: vi.fn(),
|
||||
selectInteractiveMode: vi.fn(() => 'assistant'),
|
||||
selectRecentSession: vi.fn(() => null),
|
||||
passthroughMode: vi.fn(),
|
||||
quietMode: vi.fn(),
|
||||
personaMode: vi.fn(),
|
||||
@ -85,7 +86,8 @@ vi.mock('../app/cli/helpers.js', () => ({
|
||||
|
||||
import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js';
|
||||
import { selectAndExecuteTask, determinePiece, createIssueAndSaveTask } from '../features/tasks/index.js';
|
||||
import { interactiveMode } from '../features/interactive/index.js';
|
||||
import { interactiveMode, selectRecentSession } from '../features/interactive/index.js';
|
||||
import { loadGlobalConfig } from '../infra/config/index.js';
|
||||
import { isDirectTask } from '../app/cli/helpers.js';
|
||||
import { executeDefaultAction } from '../app/cli/routing.js';
|
||||
import type { GitHubIssue } from '../infra/github/types.js';
|
||||
@ -98,6 +100,8 @@ const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask);
|
||||
const mockDeterminePiece = vi.mocked(determinePiece);
|
||||
const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask);
|
||||
const mockInteractiveMode = vi.mocked(interactiveMode);
|
||||
const mockSelectRecentSession = vi.mocked(selectRecentSession);
|
||||
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
|
||||
const mockIsDirectTask = vi.mocked(isDirectTask);
|
||||
|
||||
function createMockIssue(number: number): GitHubIssue {
|
||||
@ -144,6 +148,7 @@ describe('Issue resolution in routing', () => {
|
||||
'/test/cwd',
|
||||
'## GitHub Issue #131: Issue #131',
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Then: selectAndExecuteTask should receive issues in options
|
||||
@ -196,6 +201,7 @@ describe('Issue resolution in routing', () => {
|
||||
'/test/cwd',
|
||||
'## GitHub Issue #131: Issue #131',
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Then: selectAndExecuteTask should receive issues
|
||||
@ -220,6 +226,7 @@ describe('Issue resolution in routing', () => {
|
||||
'/test/cwd',
|
||||
'refactor the code',
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Then: no issue fetching should occur
|
||||
@ -239,6 +246,7 @@ describe('Issue resolution in routing', () => {
|
||||
'/test/cwd',
|
||||
undefined,
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Then: no issue fetching should occur
|
||||
@ -291,4 +299,45 @@ describe('Issue resolution in routing', () => {
|
||||
expect(mockSelectAndExecuteTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session selection with provider=claude', () => {
|
||||
it('should pass selected session ID to interactiveMode when provider is claude', async () => {
|
||||
// Given
|
||||
mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' });
|
||||
mockSelectRecentSession.mockResolvedValue('session-xyz');
|
||||
|
||||
// When
|
||||
await executeDefaultAction();
|
||||
|
||||
// Then: selectRecentSession should be called
|
||||
expect(mockSelectRecentSession).toHaveBeenCalledWith('/test/cwd', 'en');
|
||||
|
||||
// Then: interactiveMode should receive the session ID as 4th argument
|
||||
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
||||
'/test/cwd',
|
||||
undefined,
|
||||
expect.anything(),
|
||||
'session-xyz',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call selectRecentSession when provider is not claude', async () => {
|
||||
// Given
|
||||
mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'openai' });
|
||||
|
||||
// When
|
||||
await executeDefaultAction();
|
||||
|
||||
// Then: selectRecentSession should NOT be called
|
||||
expect(mockSelectRecentSession).not.toHaveBeenCalled();
|
||||
|
||||
// Then: interactiveMode should be called with undefined session ID
|
||||
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
||||
'/test/cwd',
|
||||
undefined,
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -369,6 +369,42 @@ describe('interactiveMode', () => {
|
||||
expect(result.task).toBe('Fix login page with clarified scope.');
|
||||
});
|
||||
|
||||
it('should pass sessionId to provider when sessionId parameter is given', async () => {
|
||||
// Given
|
||||
setupRawStdin(toRawInputs(['hello', '/cancel']));
|
||||
setupMockProvider(['AI response']);
|
||||
|
||||
// When
|
||||
await interactiveMode('/project', undefined, undefined, 'test-session-id');
|
||||
|
||||
// Then: provider call should include the overridden sessionId
|
||||
const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
|
||||
expect(mockProvider._call).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
sessionId: 'test-session-id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use saved sessionId from initializeSession when no sessionId parameter is given', async () => {
|
||||
// Given
|
||||
setupRawStdin(toRawInputs(['hello', '/cancel']));
|
||||
setupMockProvider(['AI response']);
|
||||
|
||||
// When: no sessionId parameter
|
||||
await interactiveMode('/project');
|
||||
|
||||
// Then: provider call should include sessionId from initializeSession (undefined in mock)
|
||||
const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
|
||||
expect(mockProvider._call).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
sessionId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('/play command', () => {
|
||||
it('should return action=execute with task on /play command', async () => {
|
||||
// Given
|
||||
|
||||
261
src/__tests__/session-reader.test.ts
Normal file
261
src/__tests__/session-reader.test.ts
Normal file
@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Tests for Claude Code session reader
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
// Mock getClaudeProjectSessionsDir to point to our temp directory
|
||||
let mockSessionsDir: string;
|
||||
|
||||
vi.mock('../infra/config/project/sessionStore.js', () => ({
|
||||
getClaudeProjectSessionsDir: vi.fn(() => mockSessionsDir),
|
||||
}));
|
||||
|
||||
import { loadSessionIndex, extractLastAssistantResponse } from '../infra/claude/session-reader.js';
|
||||
|
||||
describe('loadSessionIndex', () => {
|
||||
beforeEach(() => {
|
||||
mockSessionsDir = mkdtempSync(join(tmpdir(), 'session-reader-test-'));
|
||||
});
|
||||
|
||||
it('returns empty array when sessions-index.json does not exist', () => {
|
||||
const result = loadSessionIndex('/nonexistent');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads and parses sessions-index.json correctly', () => {
|
||||
const indexData = {
|
||||
version: 1,
|
||||
entries: [
|
||||
{
|
||||
sessionId: 'aaa',
|
||||
firstPrompt: 'First session',
|
||||
modified: '2026-01-28T10:00:00.000Z',
|
||||
messageCount: 5,
|
||||
gitBranch: 'main',
|
||||
isSidechain: false,
|
||||
fullPath: '/path/to/aaa.jsonl',
|
||||
},
|
||||
{
|
||||
sessionId: 'bbb',
|
||||
firstPrompt: 'Second session',
|
||||
modified: '2026-01-29T10:00:00.000Z',
|
||||
messageCount: 10,
|
||||
gitBranch: '',
|
||||
isSidechain: false,
|
||||
fullPath: '/path/to/bbb.jsonl',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify(indexData));
|
||||
|
||||
const result = loadSessionIndex('/any');
|
||||
expect(result).toHaveLength(2);
|
||||
// Sorted by modified descending: bbb (Jan 29) first, then aaa (Jan 28)
|
||||
expect(result[0]!.sessionId).toBe('bbb');
|
||||
expect(result[1]!.sessionId).toBe('aaa');
|
||||
});
|
||||
|
||||
it('filters out sidechain sessions', () => {
|
||||
const indexData = {
|
||||
version: 1,
|
||||
entries: [
|
||||
{
|
||||
sessionId: 'main-session',
|
||||
firstPrompt: 'User conversation',
|
||||
modified: '2026-01-28T10:00:00.000Z',
|
||||
messageCount: 5,
|
||||
gitBranch: '',
|
||||
isSidechain: false,
|
||||
fullPath: '/path/to/main.jsonl',
|
||||
},
|
||||
{
|
||||
sessionId: 'sidechain-session',
|
||||
firstPrompt: 'Sub-agent work',
|
||||
modified: '2026-01-29T10:00:00.000Z',
|
||||
messageCount: 20,
|
||||
gitBranch: '',
|
||||
isSidechain: true,
|
||||
fullPath: '/path/to/sidechain.jsonl',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify(indexData));
|
||||
|
||||
const result = loadSessionIndex('/any');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.sessionId).toBe('main-session');
|
||||
});
|
||||
|
||||
it('returns empty array when entries is missing', () => {
|
||||
writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify({ version: 1 }));
|
||||
|
||||
const result = loadSessionIndex('/any');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when sessions-index.json contains corrupted JSON', () => {
|
||||
writeFileSync(join(mockSessionsDir, 'sessions-index.json'), '{corrupted json content');
|
||||
|
||||
const result = loadSessionIndex('/any');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractLastAssistantResponse', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'session-reader-extract-'));
|
||||
});
|
||||
|
||||
it('returns null when file does not exist', () => {
|
||||
const result = extractLastAssistantResponse('/nonexistent/file.jsonl', 200);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts text from last assistant message', () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'Hello' }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'First response' }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'Follow up' }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Last response here' }] },
|
||||
}),
|
||||
];
|
||||
|
||||
const filePath = join(tempDir, 'session.jsonl');
|
||||
writeFileSync(filePath, lines.join('\n'));
|
||||
|
||||
const result = extractLastAssistantResponse(filePath, 200);
|
||||
expect(result).toBe('Last response here');
|
||||
});
|
||||
|
||||
it('skips assistant messages with only tool_use content', () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Text response' }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id: 'tool1', name: 'Read', input: {} }] },
|
||||
}),
|
||||
];
|
||||
|
||||
const filePath = join(tempDir, 'session.jsonl');
|
||||
writeFileSync(filePath, lines.join('\n'));
|
||||
|
||||
const result = extractLastAssistantResponse(filePath, 200);
|
||||
expect(result).toBe('Text response');
|
||||
});
|
||||
|
||||
it('returns null when no assistant messages have text', () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'Hello' }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id: 'tool1', name: 'Read', input: {} }] },
|
||||
}),
|
||||
];
|
||||
|
||||
const filePath = join(tempDir, 'session.jsonl');
|
||||
writeFileSync(filePath, lines.join('\n'));
|
||||
|
||||
const result = extractLastAssistantResponse(filePath, 200);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('truncates long responses', () => {
|
||||
const longText = 'A'.repeat(300);
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: longText }] },
|
||||
}),
|
||||
];
|
||||
|
||||
const filePath = join(tempDir, 'session.jsonl');
|
||||
writeFileSync(filePath, lines.join('\n'));
|
||||
|
||||
const result = extractLastAssistantResponse(filePath, 200);
|
||||
expect(result).toHaveLength(201); // 200 chars + '…'
|
||||
expect(result!.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('concatenates multiple text blocks in a single message', () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Part one' },
|
||||
{ type: 'tool_use', id: 'tool1', name: 'Read', input: {} },
|
||||
{ type: 'text', text: 'Part two' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const filePath = join(tempDir, 'session.jsonl');
|
||||
writeFileSync(filePath, lines.join('\n'));
|
||||
|
||||
const result = extractLastAssistantResponse(filePath, 200);
|
||||
expect(result).toBe('Part one\nPart two');
|
||||
});
|
||||
|
||||
it('handles malformed JSON lines gracefully', () => {
|
||||
const lines = [
|
||||
'not valid json',
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Valid response' }] },
|
||||
}),
|
||||
'{also broken',
|
||||
];
|
||||
|
||||
const filePath = join(tempDir, 'session.jsonl');
|
||||
writeFileSync(filePath, lines.join('\n'));
|
||||
|
||||
const result = extractLastAssistantResponse(filePath, 200);
|
||||
expect(result).toBe('Valid response');
|
||||
});
|
||||
|
||||
it('handles progress and other non-assistant record types', () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Response' }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { type: 'hook_progress' },
|
||||
}),
|
||||
];
|
||||
|
||||
const filePath = join(tempDir, 'session.jsonl');
|
||||
writeFileSync(filePath, lines.join('\n'));
|
||||
|
||||
const result = extractLastAssistantResponse(filePath, 200);
|
||||
expect(result).toBe('Response');
|
||||
});
|
||||
});
|
||||
156
src/__tests__/sessionSelector.test.ts
Normal file
156
src/__tests__/sessionSelector.test.ts
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Tests for session selector
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { SessionIndexEntry } from '../infra/claude/session-reader.js';
|
||||
|
||||
const mockLoadSessionIndex = vi.fn<(dir: string) => SessionIndexEntry[]>();
|
||||
const mockExtractLastAssistantResponse = vi.fn<(path: string, maxLen: number) => string | null>();
|
||||
|
||||
vi.mock('../infra/claude/session-reader.js', () => ({
|
||||
loadSessionIndex: (...args: [string]) => mockLoadSessionIndex(...args),
|
||||
extractLastAssistantResponse: (...args: [string, number]) => mockExtractLastAssistantResponse(...args),
|
||||
}));
|
||||
|
||||
const mockSelectOption = vi.fn<(prompt: string, options: unknown[]) => Promise<string | null>>();
|
||||
|
||||
vi.mock('../shared/prompt/index.js', () => ({
|
||||
selectOption: (...args: [string, unknown[]]) => mockSelectOption(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/i18n/index.js', () => ({
|
||||
getLabel: (key: string, _lang: string, params?: Record<string, string>) => {
|
||||
if (key === 'interactive.sessionSelector.newSession') return 'New session';
|
||||
if (key === 'interactive.sessionSelector.newSessionDescription') return 'Start a new conversation';
|
||||
if (key === 'interactive.sessionSelector.messages') return `${params?.count} messages`;
|
||||
if (key === 'interactive.sessionSelector.lastResponse') return `Last: ${params?.response}`;
|
||||
if (key === 'interactive.sessionSelector.prompt') return 'Select a session';
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
import { selectRecentSession } from '../features/interactive/sessionSelector.js';
|
||||
|
||||
describe('selectRecentSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return null when no sessions exist', async () => {
|
||||
mockLoadSessionIndex.mockReturnValue([]);
|
||||
|
||||
const result = await selectRecentSession('/project', 'en');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockSelectOption).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null when user selects __new__', async () => {
|
||||
mockLoadSessionIndex.mockReturnValue([
|
||||
createSession('session-1', 'Hello world', '2026-01-28T10:00:00.000Z'),
|
||||
]);
|
||||
mockExtractLastAssistantResponse.mockReturnValue(null);
|
||||
mockSelectOption.mockResolvedValue('__new__');
|
||||
|
||||
const result = await selectRecentSession('/project', 'en');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when user cancels selection', async () => {
|
||||
mockLoadSessionIndex.mockReturnValue([
|
||||
createSession('session-1', 'Hello world', '2026-01-28T10:00:00.000Z'),
|
||||
]);
|
||||
mockExtractLastAssistantResponse.mockReturnValue(null);
|
||||
mockSelectOption.mockResolvedValue(null);
|
||||
|
||||
const result = await selectRecentSession('/project', 'en');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return sessionId when user selects a session', async () => {
|
||||
mockLoadSessionIndex.mockReturnValue([
|
||||
createSession('session-abc', 'Fix the bug', '2026-01-28T10:00:00.000Z'),
|
||||
]);
|
||||
mockExtractLastAssistantResponse.mockReturnValue(null);
|
||||
mockSelectOption.mockResolvedValue('session-abc');
|
||||
|
||||
const result = await selectRecentSession('/project', 'en');
|
||||
|
||||
expect(result).toBe('session-abc');
|
||||
});
|
||||
|
||||
it('should pass correct options to selectOption with new session first', async () => {
|
||||
mockLoadSessionIndex.mockReturnValue([
|
||||
createSession('s1', 'First prompt', '2026-01-28T10:00:00.000Z', 5),
|
||||
]);
|
||||
mockExtractLastAssistantResponse.mockReturnValue('Some response');
|
||||
mockSelectOption.mockResolvedValue('s1');
|
||||
|
||||
await selectRecentSession('/project', 'en');
|
||||
|
||||
expect(mockSelectOption).toHaveBeenCalledWith(
|
||||
'Select a session',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ value: '__new__', label: 'New session' }),
|
||||
expect.objectContaining({ value: 's1' }),
|
||||
]),
|
||||
);
|
||||
|
||||
const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string }>;
|
||||
expect(options[0]!.value).toBe('__new__');
|
||||
expect(options[1]!.value).toBe('s1');
|
||||
});
|
||||
|
||||
it('should limit display to MAX_DISPLAY_SESSIONS (10)', async () => {
|
||||
const sessions = Array.from({ length: 15 }, (_, i) =>
|
||||
createSession(`s${i}`, `Prompt ${i}`, `2026-01-${String(i + 10).padStart(2, '0')}T10:00:00.000Z`),
|
||||
);
|
||||
mockLoadSessionIndex.mockReturnValue(sessions);
|
||||
mockExtractLastAssistantResponse.mockReturnValue(null);
|
||||
mockSelectOption.mockResolvedValue(null);
|
||||
|
||||
await selectRecentSession('/project', 'en');
|
||||
|
||||
const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string }>;
|
||||
// 1 new session + 10 display sessions = 11 total
|
||||
expect(options).toHaveLength(11);
|
||||
});
|
||||
|
||||
it('should include last response details when available', async () => {
|
||||
mockLoadSessionIndex.mockReturnValue([
|
||||
createSession('s1', 'Hello', '2026-01-28T10:00:00.000Z', 3, '/path/to/s1.jsonl'),
|
||||
]);
|
||||
mockExtractLastAssistantResponse.mockReturnValue('AI response text');
|
||||
mockSelectOption.mockResolvedValue('s1');
|
||||
|
||||
await selectRecentSession('/project', 'en');
|
||||
|
||||
expect(mockExtractLastAssistantResponse).toHaveBeenCalledWith('/path/to/s1.jsonl', 200);
|
||||
|
||||
const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string; details?: string[] }>;
|
||||
const sessionOption = options[1]!;
|
||||
expect(sessionOption.details).toBeDefined();
|
||||
expect(sessionOption.details![0]).toContain('AI response text');
|
||||
});
|
||||
});
|
||||
|
||||
function createSession(
|
||||
sessionId: string,
|
||||
firstPrompt: string,
|
||||
modified: string,
|
||||
messageCount = 5,
|
||||
fullPath = `/path/to/${sessionId}.jsonl`,
|
||||
): SessionIndexEntry {
|
||||
return {
|
||||
sessionId,
|
||||
firstPrompt,
|
||||
modified,
|
||||
messageCount,
|
||||
gitBranch: 'main',
|
||||
isSidechain: false,
|
||||
fullPath,
|
||||
};
|
||||
}
|
||||
@ -14,6 +14,7 @@ import { executePipeline } from '../../features/pipeline/index.js';
|
||||
import {
|
||||
interactiveMode,
|
||||
selectInteractiveMode,
|
||||
selectRecentSession,
|
||||
passthroughMode,
|
||||
quietMode,
|
||||
personaMode,
|
||||
@ -162,9 +163,18 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
let result: InteractiveModeResult;
|
||||
|
||||
switch (selectedMode) {
|
||||
case 'assistant':
|
||||
result = await interactiveMode(resolvedCwd, initialInput, pieceContext);
|
||||
case 'assistant': {
|
||||
let selectedSessionId: string | undefined;
|
||||
const provider = globalConfig.provider;
|
||||
if (provider === 'claude') {
|
||||
const sessionId = await selectRecentSession(resolvedCwd, lang);
|
||||
if (sessionId) {
|
||||
selectedSessionId = sessionId;
|
||||
}
|
||||
}
|
||||
result = await interactiveMode(resolvedCwd, initialInput, pieceContext, selectedSessionId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'passthrough':
|
||||
result = await passthroughMode(lang, initialInput);
|
||||
|
||||
@ -15,6 +15,7 @@ export {
|
||||
} from './interactive.js';
|
||||
|
||||
export { selectInteractiveMode } from './modeSelection.js';
|
||||
export { selectRecentSession } from './sessionSelector.js';
|
||||
export { passthroughMode } from './passthroughMode.js';
|
||||
export { quietMode } from './quietMode.js';
|
||||
export { personaMode } from './personaMode.js';
|
||||
|
||||
@ -221,8 +221,10 @@ export async function interactiveMode(
|
||||
cwd: string,
|
||||
initialInput?: string,
|
||||
pieceContext?: PieceContext,
|
||||
sessionId?: string,
|
||||
): Promise<InteractiveModeResult> {
|
||||
const ctx = initializeSession(cwd, 'interactive');
|
||||
const baseCtx = initializeSession(cwd, 'interactive');
|
||||
const ctx = sessionId ? { ...baseCtx, sessionId } : baseCtx;
|
||||
|
||||
displayAndClearSessionState(cwd, ctx.lang);
|
||||
|
||||
|
||||
103
src/features/interactive/sessionSelector.ts
Normal file
103
src/features/interactive/sessionSelector.ts
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Session selector for interactive mode
|
||||
*
|
||||
* Presents recent Claude Code sessions for the user to choose from,
|
||||
* allowing them to resume a previous conversation as the assistant.
|
||||
*/
|
||||
|
||||
import { loadSessionIndex, extractLastAssistantResponse } from '../../infra/claude/session-reader.js';
|
||||
import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js';
|
||||
import { getLabel } from '../../shared/i18n/index.js';
|
||||
|
||||
/** Maximum number of sessions to display */
|
||||
const MAX_DISPLAY_SESSIONS = 10;
|
||||
|
||||
/** Maximum length for last response preview */
|
||||
const MAX_RESPONSE_PREVIEW_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Format a modified date for display.
|
||||
*/
|
||||
function formatModifiedDate(modified: string, lang: 'en' | 'ja'): string {
|
||||
const date = new Date(modified);
|
||||
return date.toLocaleString(lang === 'ja' ? 'ja-JP' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a single-line string for use as a label.
|
||||
*/
|
||||
function truncateForLabel(text: string, maxLength: number): string {
|
||||
const singleLine = text.replace(/\n/g, ' ').trim();
|
||||
if (singleLine.length <= maxLength) {
|
||||
return singleLine;
|
||||
}
|
||||
return singleLine.slice(0, maxLength) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to select from recent Claude Code sessions.
|
||||
*
|
||||
* @param cwd - Current working directory (project directory)
|
||||
* @param lang - Display language
|
||||
* @returns Selected session ID, or null for new session / no sessions
|
||||
*/
|
||||
export async function selectRecentSession(
|
||||
cwd: string,
|
||||
lang: 'en' | 'ja',
|
||||
): Promise<string | null> {
|
||||
const sessions = loadSessionIndex(cwd);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displaySessions = sessions.slice(0, MAX_DISPLAY_SESSIONS);
|
||||
|
||||
const options: SelectOptionItem<string>[] = [
|
||||
{
|
||||
label: getLabel('interactive.sessionSelector.newSession', lang),
|
||||
value: '__new__',
|
||||
description: getLabel('interactive.sessionSelector.newSessionDescription', lang),
|
||||
},
|
||||
];
|
||||
|
||||
for (const session of displaySessions) {
|
||||
const label = truncateForLabel(session.firstPrompt, 60);
|
||||
const dateStr = formatModifiedDate(session.modified, lang);
|
||||
const messagesStr = getLabel('interactive.sessionSelector.messages', lang, {
|
||||
count: String(session.messageCount),
|
||||
});
|
||||
const description = `${dateStr} | ${messagesStr}`;
|
||||
|
||||
const details: string[] = [];
|
||||
const lastResponse = extractLastAssistantResponse(session.fullPath, MAX_RESPONSE_PREVIEW_LENGTH);
|
||||
if (lastResponse) {
|
||||
const previewLine = lastResponse.replace(/\n/g, ' ').trim();
|
||||
const preview = getLabel('interactive.sessionSelector.lastResponse', lang, {
|
||||
response: previewLine,
|
||||
});
|
||||
details.push(preview);
|
||||
}
|
||||
|
||||
options.push({
|
||||
label,
|
||||
value: session.sessionId,
|
||||
description,
|
||||
details: details.length > 0 ? details : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const prompt = getLabel('interactive.sessionSelector.prompt', lang);
|
||||
const selected = await selectOption<string>(prompt, options);
|
||||
|
||||
if (selected === null || selected === '__new__') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
@ -70,3 +70,4 @@ export {
|
||||
detectRuleIndex,
|
||||
isRegexSafe,
|
||||
} from './client.js';
|
||||
|
||||
|
||||
123
src/infra/claude/session-reader.ts
Normal file
123
src/infra/claude/session-reader.ts
Normal file
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Claude Code session reader
|
||||
*
|
||||
* Reads Claude Code's sessions-index.json and individual .jsonl session files
|
||||
* to extract session metadata and last assistant responses.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getClaudeProjectSessionsDir } from '../config/project/sessionStore.js';
|
||||
|
||||
/** Entry in Claude Code's sessions-index.json */
|
||||
export interface SessionIndexEntry {
|
||||
sessionId: string;
|
||||
firstPrompt: string;
|
||||
modified: string;
|
||||
messageCount: number;
|
||||
gitBranch: string;
|
||||
isSidechain: boolean;
|
||||
fullPath: string;
|
||||
}
|
||||
|
||||
/** Shape of sessions-index.json */
|
||||
interface SessionsIndex {
|
||||
version: number;
|
||||
entries: SessionIndexEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the session index for a project directory.
|
||||
*
|
||||
* Reads ~/.claude/projects/{encoded-path}/sessions-index.json,
|
||||
* filters out sidechain sessions, and sorts by modified descending.
|
||||
*/
|
||||
export function loadSessionIndex(projectDir: string): SessionIndexEntry[] {
|
||||
const sessionsDir = getClaudeProjectSessionsDir(projectDir);
|
||||
const indexPath = join(sessionsDir, 'sessions-index.json');
|
||||
|
||||
if (!existsSync(indexPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = readFileSync(indexPath, 'utf-8');
|
||||
|
||||
let index: SessionsIndex;
|
||||
try {
|
||||
index = JSON.parse(content) as SessionsIndex;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!index.entries || !Array.isArray(index.entries)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return index.entries
|
||||
.filter((entry) => !entry.isSidechain)
|
||||
.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
||||
}
|
||||
|
||||
/** Content block with text type from Claude API */
|
||||
interface TextContentBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Message structure in JSONL records */
|
||||
interface AssistantMessage {
|
||||
content: Array<TextContentBlock | { type: string }>;
|
||||
}
|
||||
|
||||
/** JSONL record for assistant messages */
|
||||
interface SessionRecord {
|
||||
type: string;
|
||||
message?: AssistantMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last assistant text response from a session JSONL file.
|
||||
*
|
||||
* Reads the file and scans from the end to find the last `type: "assistant"`
|
||||
* record with a text content block. Returns the truncated text.
|
||||
*/
|
||||
export function extractLastAssistantResponse(sessionFilePath: string, maxLength: number): string | null {
|
||||
if (!existsSync(sessionFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = readFileSync(sessionFilePath, 'utf-8');
|
||||
const lines = content.split('\n').filter((line) => line.trim());
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (!line) continue;
|
||||
|
||||
let record: SessionRecord;
|
||||
try {
|
||||
record = JSON.parse(line) as SessionRecord;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.type !== 'assistant' || !record.message?.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const textBlocks = record.message.content.filter(
|
||||
(block): block is TextContentBlock => block.type === 'text',
|
||||
);
|
||||
|
||||
if (textBlocks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullText = textBlocks.map((b) => b.text).join('\n');
|
||||
if (fullText.length <= maxLength) {
|
||||
return fullText;
|
||||
}
|
||||
return fullText.slice(0, maxLength) + '…';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -35,6 +35,12 @@ interactive:
|
||||
quietDescription: "Generate instructions without asking questions"
|
||||
passthrough: "Passthrough"
|
||||
passthroughDescription: "Pass your input directly as task text"
|
||||
sessionSelector:
|
||||
prompt: "Resume from a recent session?"
|
||||
newSession: "New session"
|
||||
newSessionDescription: "Start a fresh conversation"
|
||||
lastResponse: "Last: {response}"
|
||||
messages: "{count} messages"
|
||||
previousTask:
|
||||
success: "✅ Previous task completed successfully"
|
||||
error: "❌ Previous task failed: {error}"
|
||||
|
||||
@ -35,6 +35,12 @@ interactive:
|
||||
quietDescription: "質問なしでベストエフォートの指示書を生成"
|
||||
passthrough: "パススルー"
|
||||
passthroughDescription: "入力をそのままタスクとして渡す"
|
||||
sessionSelector:
|
||||
prompt: "直近のセッションを引き継ぎますか?"
|
||||
newSession: "新しいセッション"
|
||||
newSessionDescription: "新しい会話を始める"
|
||||
lastResponse: "最後: {response}"
|
||||
messages: "{count}メッセージ"
|
||||
previousTask:
|
||||
success: "✅ 前回のタスクは正常に完了しました"
|
||||
error: "❌ 前回のタスクはエラーで終了しました: {error}"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user