takt: github-issue-180-ai (#219)

This commit is contained in:
nrs 2026-02-10 23:44:03 +09:00 committed by GitHub
parent de6b5b5c2c
commit 621b8bd507
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 758 additions and 4 deletions

View File

@ -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,
);
});
});
});

View File

@ -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

View 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');
});
});

View 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,
};
}

View File

@ -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);

View File

@ -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';

View File

@ -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);

View 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;
}

View File

@ -70,3 +70,4 @@ export {
detectRuleIndex,
isRegexSafe,
} from './client.js';

View 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;
}

View File

@ -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}"

View File

@ -35,6 +35,12 @@ interactive:
quietDescription: "質問なしでベストエフォートの指示書を生成"
passthrough: "パススルー"
passthroughDescription: "入力をそのままタスクとして渡す"
sessionSelector:
prompt: "直近のセッションを引き継ぎますか?"
newSession: "新しいセッション"
newSessionDescription: "新しい会話を始める"
lastResponse: "最後: {response}"
messages: "{count}メッセージ"
previousTask:
success: "✅ 前回のタスクは正常に完了しました"
error: "❌ 前回のタスクはエラーで終了しました: {error}"