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: [],
|
movementPreviews: [],
|
||||||
},
|
},
|
||||||
run: null,
|
run: null,
|
||||||
|
previousOrderContent: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await runRetryMode(tmpDir, retryContext, null);
|
const result = await runRetryMode(tmpDir, retryContext, null);
|
||||||
@ -276,6 +277,7 @@ describe('E2E: Retry mode with failure context injection', () => {
|
|||||||
movementLogs: formatted.runMovementLogs,
|
movementLogs: formatted.runMovementLogs,
|
||||||
reports: formatted.runReports,
|
reports: formatted.runReports,
|
||||||
},
|
},
|
||||||
|
previousOrderContent: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await runRetryMode(tmpDir, retryContext, null);
|
const result = await runRetryMode(tmpDir, retryContext, null);
|
||||||
@ -331,6 +333,7 @@ describe('E2E: Retry mode with failure context injection', () => {
|
|||||||
movementPreviews: [],
|
movementPreviews: [],
|
||||||
},
|
},
|
||||||
run: null,
|
run: null,
|
||||||
|
previousOrderContent: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
await runRetryMode(tmpDir, retryContext, null);
|
await runRetryMode(tmpDir, retryContext, null);
|
||||||
@ -366,6 +369,7 @@ describe('E2E: Retry mode with failure context injection', () => {
|
|||||||
movementPreviews: [],
|
movementPreviews: [],
|
||||||
},
|
},
|
||||||
run: null,
|
run: null,
|
||||||
|
previousOrderContent: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await runRetryMode(tmpDir, retryContext, null);
|
const result = await runRetryMode(tmpDir, retryContext, null);
|
||||||
@ -404,6 +408,7 @@ describe('E2E: Retry mode with failure context injection', () => {
|
|||||||
movementPreviews: [],
|
movementPreviews: [],
|
||||||
},
|
},
|
||||||
run: null,
|
run: null,
|
||||||
|
previousOrderContent: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await runRetryMode(tmpDir, retryContext, 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: [],
|
movementPreviews: [],
|
||||||
},
|
},
|
||||||
run: null,
|
run: null,
|
||||||
|
previousOrderContent: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -131,6 +132,22 @@ describe('buildRetryTemplateVars', () => {
|
|||||||
expect(vars.movementDetails).toContain('Architect');
|
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', () => {
|
it('should include retryNote when present', () => {
|
||||||
const ctx = createRetryContext({
|
const ctx = createRetryContext({
|
||||||
failure: {
|
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:
|
* Extracts the common patterns:
|
||||||
* - Provider/session initialization
|
* - Provider/session initialization
|
||||||
* - AI call with retry on stale session
|
|
||||||
* - Session state display/clear
|
* - Session state display/clear
|
||||||
* - Conversation loop (slash commands, AI messaging, /go summary)
|
* - Conversation loop (slash commands, AI messaging, /go summary)
|
||||||
*/
|
*/
|
||||||
@ -12,14 +11,12 @@ import chalk from 'chalk';
|
|||||||
import {
|
import {
|
||||||
resolveConfigValues,
|
resolveConfigValues,
|
||||||
loadPersonaSessions,
|
loadPersonaSessions,
|
||||||
updatePersonaSession,
|
|
||||||
loadSessionState,
|
loadSessionState,
|
||||||
clearSessionState,
|
clearSessionState,
|
||||||
} from '../../infra/config/index.js';
|
} from '../../infra/config/index.js';
|
||||||
import { isQuietMode } from '../../shared/context.js';
|
|
||||||
import { getProvider, type ProviderType } from '../../infra/providers/index.js';
|
import { getProvider, type ProviderType } from '../../infra/providers/index.js';
|
||||||
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
import { createLogger } from '../../shared/utils/index.js';
|
||||||
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
|
import { info, error, blankLine } from '../../shared/ui/index.js';
|
||||||
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
|
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
|
||||||
import { readMultilineInput } from './lineEditor.js';
|
import { readMultilineInput } from './lineEditor.js';
|
||||||
import { selectRecentSession } from './sessionSelector.js';
|
import { selectRecentSession } from './sessionSelector.js';
|
||||||
@ -35,26 +32,12 @@ import {
|
|||||||
selectPostSummaryAction,
|
selectPostSummaryAction,
|
||||||
formatSessionStatus,
|
formatSessionStatus,
|
||||||
} from './interactive.js';
|
} 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');
|
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.
|
* 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';
|
export type { PostSummaryAction } from './interactive.js';
|
||||||
|
|
||||||
/** Strategy for customizing conversation loop behavior */
|
/** Strategy for customizing conversation loop behavior */
|
||||||
@ -196,7 +92,7 @@ export interface ConversationStrategy {
|
|||||||
/**
|
/**
|
||||||
* Run the shared conversation loop.
|
* 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.
|
* The Strategy object controls system prompt, tool access, and prompt transformation.
|
||||||
*/
|
*/
|
||||||
export async function runConversationLoop(
|
export async function runConversationLoop(
|
||||||
@ -271,6 +167,15 @@ export async function runConversationLoop(
|
|||||||
return { action: 'execute', task };
|
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')) {
|
if (trimmed.startsWith('/go')) {
|
||||||
const userNote = trimmed.slice(3).trim();
|
const userNote = trimmed.slice(3).trim();
|
||||||
let summaryPrompt = buildSummaryPrompt(
|
let summaryPrompt = buildSummaryPrompt(
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export { passthroughMode } from './passthroughMode.js';
|
|||||||
export { quietMode } from './quietMode.js';
|
export { quietMode } from './quietMode.js';
|
||||||
export { personaMode } from './personaMode.js';
|
export { personaMode } from './personaMode.js';
|
||||||
export { selectRun } from './runSelector.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 { runRetryMode, buildRetryTemplateVars, type RetryContext, type RetryFailureInfo, type RetryRunInfo } from './retryMode.js';
|
||||||
export { dispatchConversationAction, type ConversationActionResult } from './actionDispatcher.js';
|
export { dispatchConversationAction, type ConversationActionResult } from './actionDispatcher.js';
|
||||||
export { findPreviousOrderContent } from './orderReader.js';
|
export { findPreviousOrderContent } from './orderReader.js';
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export interface InteractiveUIText {
|
|||||||
};
|
};
|
||||||
cancelled: string;
|
cancelled: string;
|
||||||
playNoTask: string;
|
playNoTask: string;
|
||||||
|
retryNoOrder: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -52,6 +52,7 @@ export interface RetryContext {
|
|||||||
readonly branchName: string;
|
readonly branchName: string;
|
||||||
readonly pieceContext: PieceContext;
|
readonly pieceContext: PieceContext;
|
||||||
readonly run: RetryRunInfo | null;
|
readonly run: RetryRunInfo | null;
|
||||||
|
readonly previousOrderContent: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RETRY_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
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 hasRun = ctx.run !== null;
|
||||||
|
const hasPreviousOrder = ctx.previousOrderContent !== null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskName: ctx.failure.taskName,
|
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.
|
* Format run session context into a text block for the system prompt.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -69,6 +69,8 @@ function buildInstructTemplateVars(
|
|||||||
? formatRunSessionForPrompt(runSessionContext)
|
? formatRunSessionForPrompt(runSessionContext)
|
||||||
: { runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '' };
|
: { runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '' };
|
||||||
|
|
||||||
|
const hasPreviousOrder = !!previousOrderContent;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskName,
|
taskName,
|
||||||
taskContent,
|
taskContent,
|
||||||
|
|||||||
@ -166,6 +166,7 @@ export async function retryFailedTask(
|
|||||||
branchName,
|
branchName,
|
||||||
pieceContext,
|
pieceContext,
|
||||||
run: runInfo,
|
run: runInfo,
|
||||||
|
previousOrderContent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryResult = await runRetryMode(worktreePath, retryContext, previousOrderContent);
|
const retryResult = await runRetryMode(worktreePath, retryContext, previousOrderContent);
|
||||||
|
|||||||
@ -10,7 +10,7 @@ interactive:
|
|||||||
conversationLabel: "Conversation:"
|
conversationLabel: "Conversation:"
|
||||||
noTranscript: "(No local transcript. Summarize the current session context.)"
|
noTranscript: "(No local transcript. Summarize the current session context.)"
|
||||||
ui:
|
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"
|
resume: "Resuming previous session"
|
||||||
noConversation: "No conversation yet. Please describe your task first."
|
noConversation: "No conversation yet. Please describe your task first."
|
||||||
summarizeFailed: "Failed to summarize conversation. Please try again."
|
summarizeFailed: "Failed to summarize conversation. Please try again."
|
||||||
@ -24,6 +24,7 @@ interactive:
|
|||||||
continue: "Continue editing"
|
continue: "Continue editing"
|
||||||
cancelled: "Cancelled"
|
cancelled: "Cancelled"
|
||||||
playNoTask: "Please specify task content: /play <task>"
|
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."
|
personaFallback: "No persona available for the first movement. Falling back to assistant mode."
|
||||||
modeSelection:
|
modeSelection:
|
||||||
prompt: "Select interactive mode:"
|
prompt: "Select interactive mode:"
|
||||||
@ -76,7 +77,7 @@ piece:
|
|||||||
# ===== Instruct Mode UI (takt list -> instruct) =====
|
# ===== Instruct Mode UI (takt list -> instruct) =====
|
||||||
instruct:
|
instruct:
|
||||||
ui:
|
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"
|
resume: "Resuming previous session"
|
||||||
noConversation: "No conversation yet. Please describe your instructions first."
|
noConversation: "No conversation yet. Please describe your instructions first."
|
||||||
summarizeFailed: "Failed to summarize conversation. Please try again."
|
summarizeFailed: "Failed to summarize conversation. Please try again."
|
||||||
|
|||||||
@ -10,7 +10,7 @@ interactive:
|
|||||||
conversationLabel: "会話:"
|
conversationLabel: "会話:"
|
||||||
noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)"
|
noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)"
|
||||||
ui:
|
ui:
|
||||||
intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /play(即実行), /resume(セッション読込), /cancel(終了)"
|
intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /play(即実行), /resume(セッション読込), /retry(前回の指示書で再実行), /cancel(終了)"
|
||||||
resume: "前回のセッションを再開します"
|
resume: "前回のセッションを再開します"
|
||||||
noConversation: "まだ会話がありません。まずタスク内容を入力してください。"
|
noConversation: "まだ会話がありません。まずタスク内容を入力してください。"
|
||||||
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
|
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
|
||||||
@ -24,6 +24,7 @@ interactive:
|
|||||||
continue: "会話を続ける"
|
continue: "会話を続ける"
|
||||||
cancelled: "キャンセルしました"
|
cancelled: "キャンセルしました"
|
||||||
playNoTask: "タスク内容を指定してください: /play <タスク内容>"
|
playNoTask: "タスク内容を指定してください: /play <タスク内容>"
|
||||||
|
retryNoOrder: "前回の指示書(order.md)が見つかりません。/retry はリトライ時のみ使用できます。"
|
||||||
personaFallback: "先頭ムーブメントにペルソナがありません。アシスタントモードにフォールバックします。"
|
personaFallback: "先頭ムーブメントにペルソナがありません。アシスタントモードにフォールバックします。"
|
||||||
modeSelection:
|
modeSelection:
|
||||||
prompt: "対話モードを選択してください:"
|
prompt: "対話モードを選択してください:"
|
||||||
@ -76,7 +77,7 @@ piece:
|
|||||||
# ===== Instruct Mode UI (takt list -> instruct) =====
|
# ===== Instruct Mode UI (takt list -> instruct) =====
|
||||||
instruct:
|
instruct:
|
||||||
ui:
|
ui:
|
||||||
intro: "指示モード - 追加指示を入力してください。コマンド: /go(要約), /cancel(終了)"
|
intro: "指示モード - 追加指示を入力してください。コマンド: /go(要約), /retry(前回の指示書で再実行), /cancel(終了)"
|
||||||
resume: "前回のセッションを再開します"
|
resume: "前回のセッションを再開します"
|
||||||
noConversation: "まだ会話がありません。まず追加指示を入力してください。"
|
noConversation: "まだ会話がありません。まず追加指示を入力してください。"
|
||||||
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
|
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user