takt: task-1771451707814 (#314)
This commit is contained in:
parent
e742897cac
commit
6371b8f3b1
@ -206,6 +206,7 @@ describe('E2E: Retry mode with failure context injection', () => {
|
||||
movementPreviews: [],
|
||||
},
|
||||
run: null,
|
||||
previousOrderContent: null,
|
||||
};
|
||||
|
||||
const result = await runRetryMode(tmpDir, retryContext, null);
|
||||
@ -276,6 +277,7 @@ describe('E2E: Retry mode with failure context injection', () => {
|
||||
movementLogs: formatted.runMovementLogs,
|
||||
reports: formatted.runReports,
|
||||
},
|
||||
previousOrderContent: null,
|
||||
};
|
||||
|
||||
const result = await runRetryMode(tmpDir, retryContext, null);
|
||||
@ -331,6 +333,7 @@ describe('E2E: Retry mode with failure context injection', () => {
|
||||
movementPreviews: [],
|
||||
},
|
||||
run: null,
|
||||
previousOrderContent: null,
|
||||
};
|
||||
|
||||
await runRetryMode(tmpDir, retryContext, null);
|
||||
@ -366,6 +369,7 @@ describe('E2E: Retry mode with failure context injection', () => {
|
||||
movementPreviews: [],
|
||||
},
|
||||
run: null,
|
||||
previousOrderContent: null,
|
||||
};
|
||||
|
||||
const result = await runRetryMode(tmpDir, retryContext, null);
|
||||
@ -404,6 +408,7 @@ describe('E2E: Retry mode with failure context injection', () => {
|
||||
movementPreviews: [],
|
||||
},
|
||||
run: null,
|
||||
previousOrderContent: null,
|
||||
};
|
||||
|
||||
const result = await runRetryMode(tmpDir, retryContext, null);
|
||||
|
||||
106
src/__tests__/loadPreviousOrderContent.test.ts
Normal file
106
src/__tests__/loadPreviousOrderContent.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Tests for loadPreviousOrderContent utility function.
|
||||
*
|
||||
* Verifies order.md loading from run directories,
|
||||
* including happy path, missing slug, and missing file cases.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { loadPreviousOrderContent } from '../features/interactive/runSessionReader.js';
|
||||
|
||||
function createTmpDir(): string {
|
||||
const dir = join(tmpdir(), `takt-order-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function createRunWithOrder(cwd: string, slug: string, taskContent: string, orderContent: string): void {
|
||||
const runDir = join(cwd, '.takt', 'runs', slug);
|
||||
mkdirSync(join(runDir, 'context', 'task'), { recursive: true });
|
||||
|
||||
const meta = {
|
||||
task: taskContent,
|
||||
piece: 'default',
|
||||
status: 'completed',
|
||||
startTime: '2026-02-01T00:00:00.000Z',
|
||||
logsDirectory: `.takt/runs/${slug}/logs`,
|
||||
reportDirectory: `.takt/runs/${slug}/reports`,
|
||||
runSlug: slug,
|
||||
};
|
||||
writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8');
|
||||
writeFileSync(join(runDir, 'context', 'task', 'order.md'), orderContent, 'utf-8');
|
||||
}
|
||||
|
||||
function createRunWithoutOrder(cwd: string, slug: string, taskContent: string): void {
|
||||
const runDir = join(cwd, '.takt', 'runs', slug);
|
||||
mkdirSync(runDir, { recursive: true });
|
||||
|
||||
const meta = {
|
||||
task: taskContent,
|
||||
piece: 'default',
|
||||
status: 'completed',
|
||||
startTime: '2026-02-01T00:00:00.000Z',
|
||||
logsDirectory: `.takt/runs/${slug}/logs`,
|
||||
reportDirectory: `.takt/runs/${slug}/reports`,
|
||||
runSlug: slug,
|
||||
};
|
||||
writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8');
|
||||
}
|
||||
|
||||
describe('loadPreviousOrderContent', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should return order.md content when run and file exist', () => {
|
||||
const taskContent = 'Implement feature X';
|
||||
const orderContent = '# Task\n\nImplement feature X with tests.';
|
||||
createRunWithOrder(tmpDir, 'run-feature-x', taskContent, orderContent);
|
||||
|
||||
const result = loadPreviousOrderContent(tmpDir, taskContent);
|
||||
|
||||
expect(result).toBe(orderContent);
|
||||
});
|
||||
|
||||
it('should return null when no matching run exists', () => {
|
||||
const result = loadPreviousOrderContent(tmpDir, 'Non-existent task');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when run exists but order.md is missing', () => {
|
||||
const taskContent = 'Task without order';
|
||||
createRunWithoutOrder(tmpDir, 'run-no-order', taskContent);
|
||||
|
||||
const result = loadPreviousOrderContent(tmpDir, taskContent);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when .takt/runs directory does not exist', () => {
|
||||
const emptyDir = join(tmpdir(), `takt-empty-${Date.now()}`);
|
||||
mkdirSync(emptyDir, { recursive: true });
|
||||
|
||||
const result = loadPreviousOrderContent(emptyDir, 'any task');
|
||||
|
||||
expect(result).toBeNull();
|
||||
rmSync(emptyDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should match the correct run among multiple runs', () => {
|
||||
createRunWithOrder(tmpDir, 'run-a', 'Task A', '# Order A');
|
||||
createRunWithOrder(tmpDir, 'run-b', 'Task B', '# Order B');
|
||||
|
||||
expect(loadPreviousOrderContent(tmpDir, 'Task A')).toBe('# Order A');
|
||||
expect(loadPreviousOrderContent(tmpDir, 'Task B')).toBe('# Order B');
|
||||
});
|
||||
});
|
||||
@ -24,6 +24,7 @@ function createRetryContext(overrides?: Partial<RetryContext>): RetryContext {
|
||||
movementPreviews: [],
|
||||
},
|
||||
run: null,
|
||||
previousOrderContent: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@ -131,6 +132,22 @@ describe('buildRetryTemplateVars', () => {
|
||||
expect(vars.movementDetails).toContain('Architect');
|
||||
});
|
||||
|
||||
it('should set hasPreviousOrder=false and empty previousOrderContent when previousOrderContent is null', () => {
|
||||
const ctx = createRetryContext({ previousOrderContent: null });
|
||||
const vars = buildRetryTemplateVars(ctx, 'en');
|
||||
|
||||
expect(vars.hasPreviousOrder).toBe(false);
|
||||
expect(vars.previousOrderContent).toBe('');
|
||||
});
|
||||
|
||||
it('should set hasPreviousOrder=true and populate previousOrderContent when provided', () => {
|
||||
const ctx = createRetryContext({ previousOrderContent: '# Order content' });
|
||||
const vars = buildRetryTemplateVars(ctx, 'en');
|
||||
|
||||
expect(vars.hasPreviousOrder).toBe(true);
|
||||
expect(vars.previousOrderContent).toBe('# Order content');
|
||||
});
|
||||
|
||||
it('should include retryNote when present', () => {
|
||||
const ctx = createRetryContext({
|
||||
failure: {
|
||||
|
||||
198
src/__tests__/retrySlashCommand.test.ts
Normal file
198
src/__tests__/retrySlashCommand.test.ts
Normal file
@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Tests for /retry slash command in the conversation loop.
|
||||
*
|
||||
* Verifies:
|
||||
* - /retry with previousOrderContent returns execute action with order content
|
||||
* - /retry without previousOrderContent shows error and continues loop
|
||||
* - /retry in retry mode with order.md context in system prompt
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
setupRawStdin,
|
||||
restoreStdin,
|
||||
toRawInputs,
|
||||
createMockProvider,
|
||||
type MockProviderCapture,
|
||||
} from './helpers/stdinSimulator.js';
|
||||
|
||||
// --- Mocks (infrastructure only) ---
|
||||
|
||||
vi.mock('../infra/fs/session.js', () => ({
|
||||
loadNdjsonLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
|
||||
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/providers/index.js', () => ({
|
||||
getProvider: 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/context.js', () => ({
|
||||
isQuietMode: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/paths.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
loadPersonaSessions: vi.fn(() => ({})),
|
||||
updatePersonaSession: vi.fn(),
|
||||
getProjectConfigDir: vi.fn(() => '/tmp'),
|
||||
loadSessionState: vi.fn(() => null),
|
||||
clearSessionState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
blankLine: vi.fn(),
|
||||
StreamDisplay: vi.fn().mockImplementation(() => ({
|
||||
createHandler: vi.fn(() => vi.fn()),
|
||||
flush: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/prompt/index.js', () => ({
|
||||
selectOption: vi.fn().mockResolvedValue('execute'),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/i18n/index.js', () => ({
|
||||
getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'),
|
||||
getLabelObject: vi.fn(() => ({
|
||||
intro: 'Retry intro',
|
||||
resume: 'Resume',
|
||||
noConversation: 'No conversation',
|
||||
summarizeFailed: 'Summarize failed',
|
||||
continuePrompt: 'Continue?',
|
||||
proposed: 'Proposed:',
|
||||
actionPrompt: 'What next?',
|
||||
playNoTask: 'No task',
|
||||
cancelled: 'Cancelled',
|
||||
retryNoOrder: 'No previous order found.',
|
||||
actions: { execute: 'Execute', saveTask: 'Save', continue: 'Continue' },
|
||||
})),
|
||||
}));
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { getProvider } from '../infra/providers/index.js';
|
||||
import { runRetryMode, type RetryContext } from '../features/interactive/retryMode.js';
|
||||
import { info } from '../shared/ui/index.js';
|
||||
|
||||
const mockGetProvider = vi.mocked(getProvider);
|
||||
const mockInfo = vi.mocked(info);
|
||||
|
||||
function createTmpDir(): string {
|
||||
const dir = join(tmpdir(), `takt-retry-cmd-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function setupProvider(responses: string[]): MockProviderCapture {
|
||||
const { provider, capture } = createMockProvider(responses);
|
||||
mockGetProvider.mockReturnValue(provider);
|
||||
return capture;
|
||||
}
|
||||
|
||||
function buildRetryContext(overrides?: Partial<RetryContext>): RetryContext {
|
||||
return {
|
||||
failure: {
|
||||
taskName: 'test-task',
|
||||
taskContent: 'Test task content',
|
||||
createdAt: '2026-02-15T10:00:00Z',
|
||||
failedMovement: 'implement',
|
||||
error: 'Some error',
|
||||
lastMessage: '',
|
||||
retryNote: '',
|
||||
},
|
||||
branchName: 'takt/test-task',
|
||||
pieceContext: {
|
||||
name: 'default',
|
||||
description: '',
|
||||
pieceStructure: '',
|
||||
movementPreviews: [],
|
||||
},
|
||||
run: null,
|
||||
previousOrderContent: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('/retry slash command', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTmpDir();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreStdin();
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should execute with previous order content when /retry is used', async () => {
|
||||
const orderContent = '# Task Order\n\nImplement feature X with tests.';
|
||||
setupRawStdin(toRawInputs(['/retry']));
|
||||
setupProvider([]);
|
||||
|
||||
const retryContext = buildRetryContext({ previousOrderContent: orderContent });
|
||||
const result = await runRetryMode(tmpDir, retryContext);
|
||||
|
||||
expect(result.action).toBe('execute');
|
||||
expect(result.task).toBe(orderContent);
|
||||
});
|
||||
|
||||
it('should show error and continue when /retry is used without order', async () => {
|
||||
setupRawStdin(toRawInputs(['/retry', '/cancel']));
|
||||
setupProvider([]);
|
||||
|
||||
const retryContext = buildRetryContext({ previousOrderContent: null });
|
||||
const result = await runRetryMode(tmpDir, retryContext);
|
||||
|
||||
expect(mockInfo).toHaveBeenCalledWith('No previous order found.');
|
||||
expect(result.action).toBe('cancel');
|
||||
});
|
||||
|
||||
it('should inject order.md content into retry system prompt', async () => {
|
||||
const orderContent = '# Build login page\n\nWith OAuth2 support.';
|
||||
setupRawStdin(toRawInputs(['check the order', '/cancel']));
|
||||
const capture = setupProvider(['I see the order content.']);
|
||||
|
||||
const retryContext = buildRetryContext({ previousOrderContent: orderContent });
|
||||
await runRetryMode(tmpDir, retryContext);
|
||||
|
||||
expect(capture.systemPrompts.length).toBeGreaterThan(0);
|
||||
const systemPrompt = capture.systemPrompts[0]!;
|
||||
expect(systemPrompt).toContain('Previous Order');
|
||||
expect(systemPrompt).toContain(orderContent);
|
||||
});
|
||||
|
||||
it('should not include order section when no order content', async () => {
|
||||
setupRawStdin(toRawInputs(['check the order', '/cancel']));
|
||||
const capture = setupProvider(['No order found.']);
|
||||
|
||||
const retryContext = buildRetryContext({ previousOrderContent: null });
|
||||
await runRetryMode(tmpDir, retryContext);
|
||||
|
||||
expect(capture.systemPrompts.length).toBeGreaterThan(0);
|
||||
const systemPrompt = capture.systemPrompts[0]!;
|
||||
expect(systemPrompt).not.toContain('Previous Order');
|
||||
});
|
||||
});
|
||||
123
src/features/interactive/aiCaller.ts
Normal file
123
src/features/interactive/aiCaller.ts
Normal file
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* AI call with automatic retry on stale/invalid session.
|
||||
*
|
||||
* Extracted from conversationLoop.ts for single-responsibility:
|
||||
* this module handles only the AI call + retry logic.
|
||||
*/
|
||||
|
||||
import {
|
||||
updatePersonaSession,
|
||||
} from '../../infra/config/index.js';
|
||||
import { isQuietMode } from '../../shared/context.js';
|
||||
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
||||
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
|
||||
import { getLabel } from '../../shared/i18n/index.js';
|
||||
import { EXIT_SIGINT } from '../../shared/exitCodes.js';
|
||||
import type { ProviderType } from '../../infra/providers/index.js';
|
||||
import { getProvider } from '../../infra/providers/index.js';
|
||||
|
||||
const log = createLogger('ai-caller');
|
||||
|
||||
/** Result from a single AI call */
|
||||
export interface CallAIResult {
|
||||
content: string;
|
||||
sessionId?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/** Initialized session context for conversation loops */
|
||||
export interface SessionContext {
|
||||
provider: ReturnType<typeof getProvider>;
|
||||
providerType: ProviderType;
|
||||
model: string | undefined;
|
||||
lang: 'en' | 'ja';
|
||||
personaName: string;
|
||||
sessionId: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call AI with automatic retry on stale/invalid session.
|
||||
*
|
||||
* On session failure, clears sessionId and retries once without session.
|
||||
* Updates sessionId and persists it on success.
|
||||
*/
|
||||
export async function callAIWithRetry(
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
allowedTools: string[],
|
||||
cwd: string,
|
||||
ctx: SessionContext,
|
||||
): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> {
|
||||
const display = new StreamDisplay('assistant', isQuietMode());
|
||||
const abortController = new AbortController();
|
||||
let sigintCount = 0;
|
||||
const onSigInt = (): void => {
|
||||
sigintCount += 1;
|
||||
if (sigintCount === 1) {
|
||||
blankLine();
|
||||
info(getLabel('piece.sigintGraceful', ctx.lang));
|
||||
abortController.abort();
|
||||
return;
|
||||
}
|
||||
blankLine();
|
||||
error(getLabel('piece.sigintForce', ctx.lang));
|
||||
process.exit(EXIT_SIGINT);
|
||||
};
|
||||
process.on('SIGINT', onSigInt);
|
||||
let { sessionId } = ctx;
|
||||
|
||||
try {
|
||||
const agent = ctx.provider.setup({ name: ctx.personaName, systemPrompt });
|
||||
const response = await agent.call(prompt, {
|
||||
cwd,
|
||||
model: ctx.model,
|
||||
sessionId,
|
||||
allowedTools,
|
||||
abortSignal: abortController.signal,
|
||||
onStream: display.createHandler(),
|
||||
});
|
||||
display.flush();
|
||||
const success = response.status !== 'blocked' && response.status !== 'error';
|
||||
|
||||
if (!success && sessionId) {
|
||||
log.info('Session invalid, retrying without session');
|
||||
sessionId = undefined;
|
||||
const retryDisplay = new StreamDisplay('assistant', isQuietMode());
|
||||
const retryAgent = ctx.provider.setup({ name: ctx.personaName, systemPrompt });
|
||||
const retry = await retryAgent.call(prompt, {
|
||||
cwd,
|
||||
model: ctx.model,
|
||||
sessionId: undefined,
|
||||
allowedTools,
|
||||
abortSignal: abortController.signal,
|
||||
onStream: retryDisplay.createHandler(),
|
||||
});
|
||||
retryDisplay.flush();
|
||||
if (retry.sessionId) {
|
||||
sessionId = retry.sessionId;
|
||||
updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType);
|
||||
}
|
||||
return {
|
||||
result: { content: retry.content, sessionId: retry.sessionId, success: retry.status !== 'blocked' && retry.status !== 'error' },
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.sessionId) {
|
||||
sessionId = response.sessionId;
|
||||
updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType);
|
||||
}
|
||||
return {
|
||||
result: { content: response.content, sessionId: response.sessionId, success },
|
||||
sessionId,
|
||||
};
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e);
|
||||
log.error('AI call failed', { error: msg });
|
||||
error(msg);
|
||||
blankLine();
|
||||
return { result: null, sessionId };
|
||||
} finally {
|
||||
process.removeListener('SIGINT', onSigInt);
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
*
|
||||
* Extracts the common patterns:
|
||||
* - Provider/session initialization
|
||||
* - AI call with retry on stale session
|
||||
* - Session state display/clear
|
||||
* - Conversation loop (slash commands, AI messaging, /go summary)
|
||||
*/
|
||||
@ -12,14 +11,12 @@ import chalk from 'chalk';
|
||||
import {
|
||||
resolveConfigValues,
|
||||
loadPersonaSessions,
|
||||
updatePersonaSession,
|
||||
loadSessionState,
|
||||
clearSessionState,
|
||||
} from '../../infra/config/index.js';
|
||||
import { isQuietMode } from '../../shared/context.js';
|
||||
import { getProvider, type ProviderType } from '../../infra/providers/index.js';
|
||||
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
||||
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
|
||||
import { createLogger } from '../../shared/utils/index.js';
|
||||
import { info, error, blankLine } from '../../shared/ui/index.js';
|
||||
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
|
||||
import { readMultilineInput } from './lineEditor.js';
|
||||
import { selectRecentSession } from './sessionSelector.js';
|
||||
@ -35,26 +32,12 @@ import {
|
||||
selectPostSummaryAction,
|
||||
formatSessionStatus,
|
||||
} from './interactive.js';
|
||||
import { callAIWithRetry, type CallAIResult, type SessionContext } from './aiCaller.js';
|
||||
|
||||
export { type CallAIResult, type SessionContext, callAIWithRetry } from './aiCaller.js';
|
||||
|
||||
const log = createLogger('conversation-loop');
|
||||
|
||||
/** Result from a single AI call */
|
||||
export interface CallAIResult {
|
||||
content: string;
|
||||
sessionId?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/** Initialized session context for conversation loops */
|
||||
export interface SessionContext {
|
||||
provider: ReturnType<typeof getProvider>;
|
||||
providerType: ProviderType;
|
||||
model: string | undefined;
|
||||
lang: 'en' | 'ja';
|
||||
personaName: string;
|
||||
sessionId: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize provider and language for interactive conversation.
|
||||
*
|
||||
@ -88,93 +71,6 @@ export function displayAndClearSessionState(cwd: string, lang: 'en' | 'ja'): voi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call AI with automatic retry on stale/invalid session.
|
||||
*
|
||||
* On session failure, clears sessionId and retries once without session.
|
||||
* Updates sessionId and persists it on success.
|
||||
*/
|
||||
export async function callAIWithRetry(
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
allowedTools: string[],
|
||||
cwd: string,
|
||||
ctx: SessionContext,
|
||||
): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> {
|
||||
const display = new StreamDisplay('assistant', isQuietMode());
|
||||
const abortController = new AbortController();
|
||||
let sigintCount = 0;
|
||||
const onSigInt = (): void => {
|
||||
sigintCount += 1;
|
||||
if (sigintCount === 1) {
|
||||
blankLine();
|
||||
info(getLabel('piece.sigintGraceful', ctx.lang));
|
||||
abortController.abort();
|
||||
return;
|
||||
}
|
||||
blankLine();
|
||||
error(getLabel('piece.sigintForce', ctx.lang));
|
||||
process.exit(EXIT_SIGINT);
|
||||
};
|
||||
process.on('SIGINT', onSigInt);
|
||||
let { sessionId } = ctx;
|
||||
|
||||
try {
|
||||
const agent = ctx.provider.setup({ name: ctx.personaName, systemPrompt });
|
||||
const response = await agent.call(prompt, {
|
||||
cwd,
|
||||
model: ctx.model,
|
||||
sessionId,
|
||||
allowedTools,
|
||||
abortSignal: abortController.signal,
|
||||
onStream: display.createHandler(),
|
||||
});
|
||||
display.flush();
|
||||
const success = response.status !== 'blocked' && response.status !== 'error';
|
||||
|
||||
if (!success && sessionId) {
|
||||
log.info('Session invalid, retrying without session');
|
||||
sessionId = undefined;
|
||||
const retryDisplay = new StreamDisplay('assistant', isQuietMode());
|
||||
const retryAgent = ctx.provider.setup({ name: ctx.personaName, systemPrompt });
|
||||
const retry = await retryAgent.call(prompt, {
|
||||
cwd,
|
||||
model: ctx.model,
|
||||
sessionId: undefined,
|
||||
allowedTools,
|
||||
abortSignal: abortController.signal,
|
||||
onStream: retryDisplay.createHandler(),
|
||||
});
|
||||
retryDisplay.flush();
|
||||
if (retry.sessionId) {
|
||||
sessionId = retry.sessionId;
|
||||
updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType);
|
||||
}
|
||||
return {
|
||||
result: { content: retry.content, sessionId: retry.sessionId, success: retry.status !== 'blocked' && retry.status !== 'error' },
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.sessionId) {
|
||||
sessionId = response.sessionId;
|
||||
updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType);
|
||||
}
|
||||
return {
|
||||
result: { content: response.content, sessionId: response.sessionId, success },
|
||||
sessionId,
|
||||
};
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e);
|
||||
log.error('AI call failed', { error: msg });
|
||||
error(msg);
|
||||
blankLine();
|
||||
return { result: null, sessionId };
|
||||
} finally {
|
||||
process.removeListener('SIGINT', onSigInt);
|
||||
}
|
||||
}
|
||||
|
||||
export type { PostSummaryAction } from './interactive.js';
|
||||
|
||||
/** Strategy for customizing conversation loop behavior */
|
||||
@ -196,7 +92,7 @@ export interface ConversationStrategy {
|
||||
/**
|
||||
* Run the shared conversation loop.
|
||||
*
|
||||
* Handles: EOF, /play, /go (summary), /cancel, regular AI messaging.
|
||||
* Handles: EOF, /play, /retry, /go (summary), /cancel, regular AI messaging.
|
||||
* The Strategy object controls system prompt, tool access, and prompt transformation.
|
||||
*/
|
||||
export async function runConversationLoop(
|
||||
@ -271,6 +167,15 @@ export async function runConversationLoop(
|
||||
return { action: 'execute', task };
|
||||
}
|
||||
|
||||
if (trimmed === '/retry') {
|
||||
if (!strategy.previousOrderContent) {
|
||||
info(ui.retryNoOrder);
|
||||
continue;
|
||||
}
|
||||
log.info('Retry command — resubmitting previous order.md');
|
||||
return { action: 'execute', task: strategy.previousOrderContent };
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('/go')) {
|
||||
const userNote = trimmed.slice(3).trim();
|
||||
let summaryPrompt = buildSummaryPrompt(
|
||||
|
||||
@ -22,7 +22,7 @@ export { passthroughMode } from './passthroughMode.js';
|
||||
export { quietMode } from './quietMode.js';
|
||||
export { personaMode } from './personaMode.js';
|
||||
export { selectRun } from './runSelector.js';
|
||||
export { listRecentRuns, findRunForTask, loadRunSessionContext, formatRunSessionForPrompt, getRunPaths, type RunSessionContext, type RunPaths } from './runSessionReader.js';
|
||||
export { listRecentRuns, findRunForTask, loadRunSessionContext, formatRunSessionForPrompt, getRunPaths, loadPreviousOrderContent, type RunSessionContext, type RunPaths } from './runSessionReader.js';
|
||||
export { runRetryMode, buildRetryTemplateVars, type RetryContext, type RetryFailureInfo, type RetryRunInfo } from './retryMode.js';
|
||||
export { dispatchConversationAction, type ConversationActionResult } from './actionDispatcher.js';
|
||||
export { findPreviousOrderContent } from './orderReader.js';
|
||||
|
||||
@ -45,6 +45,7 @@ export interface InteractiveUIText {
|
||||
};
|
||||
cancelled: string;
|
||||
playNoTask: string;
|
||||
retryNoOrder: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -52,6 +52,7 @@ export interface RetryContext {
|
||||
readonly branchName: string;
|
||||
readonly pieceContext: PieceContext;
|
||||
readonly run: RetryRunInfo | null;
|
||||
readonly previousOrderContent: string | null;
|
||||
}
|
||||
|
||||
const RETRY_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
@ -66,6 +67,7 @@ export function buildRetryTemplateVars(ctx: RetryContext, lang: 'en' | 'ja', pre
|
||||
: '';
|
||||
|
||||
const hasRun = ctx.run !== null;
|
||||
const hasPreviousOrder = ctx.previousOrderContent !== null;
|
||||
|
||||
return {
|
||||
taskName: ctx.failure.taskName,
|
||||
|
||||
@ -216,6 +216,28 @@ export function loadRunSessionContext(cwd: string, slug: string): RunSessionCont
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the previous order.md content from the run directory.
|
||||
*
|
||||
* Uses findRunForTask to locate the matching run by task content,
|
||||
* then reads order.md from its context/task directory.
|
||||
*
|
||||
* @returns The order.md content if found, null otherwise.
|
||||
*/
|
||||
export function loadPreviousOrderContent(cwd: string, taskContent: string): string | null {
|
||||
const slug = findRunForTask(cwd, taskContent);
|
||||
if (!slug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orderPath = join(cwd, '.takt', 'runs', slug, 'context', 'task', 'order.md');
|
||||
if (!existsSync(orderPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readFileSync(orderPath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format run session context into a text block for the system prompt.
|
||||
*/
|
||||
|
||||
@ -69,6 +69,8 @@ function buildInstructTemplateVars(
|
||||
? formatRunSessionForPrompt(runSessionContext)
|
||||
: { runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '' };
|
||||
|
||||
const hasPreviousOrder = !!previousOrderContent;
|
||||
|
||||
return {
|
||||
taskName,
|
||||
taskContent,
|
||||
|
||||
@ -166,6 +166,7 @@ export async function retryFailedTask(
|
||||
branchName,
|
||||
pieceContext,
|
||||
run: runInfo,
|
||||
previousOrderContent,
|
||||
};
|
||||
|
||||
const retryResult = await runRetryMode(worktreePath, retryContext, previousOrderContent);
|
||||
|
||||
@ -10,7 +10,7 @@ interactive:
|
||||
conversationLabel: "Conversation:"
|
||||
noTranscript: "(No local transcript. Summarize the current session context.)"
|
||||
ui:
|
||||
intro: "Interactive mode - describe your task. Commands: /go (execute), /play (run now), /resume (load session), /cancel (exit)"
|
||||
intro: "Interactive mode - describe your task. Commands: /go (execute), /play (run now), /resume (load session), /retry (rerun previous order), /cancel (exit)"
|
||||
resume: "Resuming previous session"
|
||||
noConversation: "No conversation yet. Please describe your task first."
|
||||
summarizeFailed: "Failed to summarize conversation. Please try again."
|
||||
@ -24,6 +24,7 @@ interactive:
|
||||
continue: "Continue editing"
|
||||
cancelled: "Cancelled"
|
||||
playNoTask: "Please specify task content: /play <task>"
|
||||
retryNoOrder: "No previous order (order.md) found. /retry is only available during retry."
|
||||
personaFallback: "No persona available for the first movement. Falling back to assistant mode."
|
||||
modeSelection:
|
||||
prompt: "Select interactive mode:"
|
||||
@ -76,7 +77,7 @@ piece:
|
||||
# ===== Instruct Mode UI (takt list -> instruct) =====
|
||||
instruct:
|
||||
ui:
|
||||
intro: "Instruct mode - describe additional instructions. Commands: /go (summarize), /cancel (exit)"
|
||||
intro: "Instruct mode - describe additional instructions. Commands: /go (summarize), /retry (rerun previous order), /cancel (exit)"
|
||||
resume: "Resuming previous session"
|
||||
noConversation: "No conversation yet. Please describe your instructions first."
|
||||
summarizeFailed: "Failed to summarize conversation. Please try again."
|
||||
|
||||
@ -10,7 +10,7 @@ interactive:
|
||||
conversationLabel: "会話:"
|
||||
noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)"
|
||||
ui:
|
||||
intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /play(即実行), /resume(セッション読込), /cancel(終了)"
|
||||
intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /play(即実行), /resume(セッション読込), /retry(前回の指示書で再実行), /cancel(終了)"
|
||||
resume: "前回のセッションを再開します"
|
||||
noConversation: "まだ会話がありません。まずタスク内容を入力してください。"
|
||||
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
|
||||
@ -24,6 +24,7 @@ interactive:
|
||||
continue: "会話を続ける"
|
||||
cancelled: "キャンセルしました"
|
||||
playNoTask: "タスク内容を指定してください: /play <タスク内容>"
|
||||
retryNoOrder: "前回の指示書(order.md)が見つかりません。/retry はリトライ時のみ使用できます。"
|
||||
personaFallback: "先頭ムーブメントにペルソナがありません。アシスタントモードにフォールバックします。"
|
||||
modeSelection:
|
||||
prompt: "対話モードを選択してください:"
|
||||
@ -76,7 +77,7 @@ piece:
|
||||
# ===== Instruct Mode UI (takt list -> instruct) =====
|
||||
instruct:
|
||||
ui:
|
||||
intro: "指示モード - 追加指示を入力してください。コマンド: /go(要約), /cancel(終了)"
|
||||
intro: "指示モード - 追加指示を入力してください。コマンド: /go(要約), /retry(前回の指示書で再実行), /cancel(終了)"
|
||||
resume: "前回のセッションを再開します"
|
||||
noConversation: "まだ会話がありません。まず追加指示を入力してください。"
|
||||
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user