diff --git a/docs/task-management.ja.md b/docs/task-management.ja.md index e820b5954..10d3fdfca 100644 --- a/docs/task-management.ja.md +++ b/docs/task-management.ja.md @@ -133,9 +133,9 @@ concurrency が 1 より大きい場合、TAKT はワーカープールを使用 - タスクごとに色分けされたプレフィックス付き出力で読みやすさを確保 - Ctrl+C でのグレースフルシャットダウン(実行中タスクの完了を待機) -### 中断されたタスクの復旧 +### 中断されたタスクのクリーンアップ -`takt run` が中断された場合(プロセスクラッシュ、Ctrl+C など)、`running` ステータスのまま残ったタスクは次回の `takt run` または `takt watch` 起動時に自動的に `pending` に復旧されます。 +`takt run` が中断された場合(プロセスクラッシュ、Ctrl+C など)、`running` ステータスのまま残ったタスクは次回の `takt run` または `takt watch` 起動時に自動的に `failed` にマークされます。再実行する場合は明示的に requeue してください。 ## タスクの監視(`takt watch`) @@ -150,7 +150,7 @@ watch コマンドの動作は次の通りです。 - Ctrl+C(SIGINT)まで実行を継続 - `tasks.yaml` の新しい `pending` タスクを監視 - タスクが現れるたびに実行 -- 起動時に中断された `running` タスクを復旧 +- 起動時に中断された `running` タスクを `failed` にマーク - 終了時に合計/成功/失敗タスク数のサマリを表示 これは「プロデューサー-コンシューマー」ワークフローに便利です。一方のターミナルで `takt add` でタスクを追加し、もう一方で `takt watch` がそれらを自動実行します。 diff --git a/docs/task-management.md b/docs/task-management.md index 9ae317bf4..8542d9699 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -133,9 +133,9 @@ When concurrency is greater than 1, TAKT uses a worker pool that: - Displays color-coded prefixed output per task for readability - Supports graceful shutdown on Ctrl+C (waits for in-flight tasks to complete) -### Interrupted Task Recovery +### Interrupted Task Cleanup -If `takt run` is interrupted (e.g., process crash, Ctrl+C), tasks left in `running` status are automatically recovered to `pending` on the next `takt run` or `takt watch` invocation. +If `takt run` is interrupted (e.g., process crash, Ctrl+C), tasks left in `running` status are automatically marked as `failed` on the next `takt run` or `takt watch` invocation. Requeue them explicitly to run them again. ## Watching Tasks (`takt watch`) @@ -150,7 +150,7 @@ The watch command: - Stays running until Ctrl+C (SIGINT) - Monitors `tasks.yaml` for new `pending` tasks - Executes each task as it appears -- Recovers interrupted `running` tasks on startup +- Marks interrupted `running` tasks as `failed` on startup - Displays a summary of total/success/failed tasks on exit This is useful for a "producer-consumer" workflow where you add tasks with `takt add` in one terminal and let `takt watch` execute them automatically in another. diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index 3fb2f0f1a..075fdd831 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -122,20 +122,20 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `.takt/tasks.yaml` に pending タスクを追加する(`workflow` に `e2e/fixtures/workflows/mock-single-step.yaml` を指定)。 - 出力に `Task "watch-task" completed` が含まれることを確認する。 - `Ctrl+C` で終了する。 -- Run recovery and high-priority run flows(`e2e/specs/run-recovery.e2e.ts`) - - 目的: 高優先度ユースケース(異常終了リカバリー、並列実行、初期化〜add〜run)をまとめて確認。 +- Run interrupted task cleanup and high-priority run flows(`e2e/specs/run-recovery.e2e.ts`) + - 目的: 高優先度ユースケース(異常終了したrunningタスクのfailed化、並列実行、初期化〜add〜run)をまとめて確認。 - LLM: 呼び出さない(`--provider mock` 固定) - 手順(ユーザー行動/コマンド): - - 異常終了リカバリー: + - 異常終了したrunningタスクのfailed化: - `.takt/tasks.yaml` に pending タスク2件を投入し、`takt run --provider mock` 実行中にプロセスを強制終了する。 - - 再度 `takt run --provider mock` を実行し、`Recovered 1 interrupted running task(s) to pending.` が出力されることを確認する。 - - 復旧対象を含む全タスクが完了し、`.takt/tasks.yaml` が空になることを確認する。 + - 再度 `takt run --provider mock` を実行し、`Marked 1 interrupted running task(s) as failed.` が出力されることを確認する。 + - 異常終了時にrunningだったタスクはfailedで残り、残りのpendingタスクだけが完了することを確認する。 - 高並列実行: - `concurrency: 10` を設定し、pending タスク12件を投入して `takt run --provider mock` を実行する。 - - 出力に `Concurrency: 10` と `Tasks Summary` が含まれること、および `.takt/tasks.yaml` が空になることを確認する。 + - 出力に `Concurrency: 10` と `Tasks Summary` が含まれること、および全タスクが completed 履歴として残ることを確認する。 - 初期化〜add〜run: - グローバル `config.yaml` 不在の環境で `takt add` を2回実行し、`takt run --provider mock` を実行する。 - - タスク実行完了後に `.takt/tasks/` 配下の2タスクディレクトリ生成、`.takt/.gitignore` 生成、`.takt/tasks.yaml` の空状態を確認する。 + - タスク実行完了後に `.takt/tasks/` 配下の2タスクディレクトリ生成、`.takt/.gitignore` 生成、`.takt/tasks.yaml` に2件の completed 履歴が残ることを確認する。 - Run tasks graceful shutdown on SIGINT(`e2e/specs/run-sigint-graceful.e2e.ts`) - 目的: `takt run` を並列実行中に `Ctrl+C` した際、新規クローン投入を止めてグレースフルに終了することを確認。 - LLM: 呼び出さない(`--provider mock` 固定) diff --git a/e2e/specs/run-recovery.e2e.ts b/e2e/specs/run-recovery.e2e.ts index 4df167e85..944a14200 100644 --- a/e2e/specs/run-recovery.e2e.ts +++ b/e2e/specs/run-recovery.e2e.ts @@ -18,7 +18,7 @@ import { updateIsolatedConfig, type IsolatedEnv, } from '../helpers/isolated-env'; -import { runTakt } from '../helpers/takt-runner'; +import { formatTaktRunResult, runTakt } from '../helpers/takt-runner'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -33,6 +33,9 @@ interface TaskRecord { status: 'pending' | 'running' | 'failed' | 'completed'; owner_pid?: number | null; workflow?: string; + failure?: { + error?: string; + }; } function createLocalRepo(): LocalRepo { @@ -118,6 +121,7 @@ function createEnvWithoutGlobalConfig(): { TAKT_CONFIG_DIR: globalConfigDir, GIT_CONFIG_GLOBAL: globalGitConfigPath, TAKT_NO_TTY: '1', + TAKT_NOTIFY_WEBHOOK: undefined, }, globalConfigPath, cleanup: () => { @@ -127,7 +131,7 @@ function createEnvWithoutGlobalConfig(): { } // E2E更新時は docs/testing/e2e.md も更新すること -describe('E2E: Run interrupted task recovery and high-priority run flows', () => { +describe('E2E: Run interrupted task cleanup and high-priority run flows', () => { let isolatedEnv: IsolatedEnv; let repo: LocalRepo; @@ -141,13 +145,13 @@ describe('E2E: Run interrupted task recovery and high-priority run flows', () => isolatedEnv.cleanup(); }); - it('should recover stale running task generated by forced process termination', async () => { + it('should fail stale running task generated by forced process termination', async () => { // Given: 2 pending tasks exist, then first run is force-killed while task is running updateIsolatedConfig(isolatedEnv.taktDir, { provider: 'mock', model: 'mock-model', concurrency: 1, - task_poll_interval_ms: 50, + task_poll_interval_ms: 100, }); const workflowPath = resolve(__dirname, '../fixtures/workflows/mock-slow-multi-step.yaml'); @@ -176,49 +180,71 @@ describe('E2E: Run interrupted task recovery and high-priority run flows', () => firstStderr += chunk.toString(); }); - const runningObserved = await waitFor(() => { - if (!existsSync(tasksFile)) { - return false; - } - const tasks = readTasks(tasksFile); - return tasks.some((task) => task.status === 'running'); - }, 30_000, 20); - - expect(runningObserved, `stdout:\n${firstStdout}\n\nstderr:\n${firstStderr}`).toBe(true); - - child.kill('SIGKILL'); - - await new Promise((resolvePromise) => { + let childClosed = false; + const childClosedPromise = new Promise((resolvePromise) => { child.once('close', () => { + childClosed = true; resolvePromise(); }); }); - const staleTasks = readTasks(tasksFile); - const runningTask = staleTasks.find((task) => task.status === 'running'); - expect(runningTask).toBeDefined(); - expect(runningTask?.owner_pid).toBeTypeOf('number'); - - // When: run is executed again - const rerunResult = runTakt({ - args: ['run', '--provider', 'mock'], - cwd: repo.path, - env: { - ...isolatedEnv.env, - TAKT_MOCK_SCENARIO: scenarioPath, - }, - timeout: 240_000, - }); - - // Then: stale running task is recovered and all tasks complete - expect(rerunResult.exitCode).toBe(0); - const combined = rerunResult.stdout + rerunResult.stderr; - expect(combined).toContain('Recovered 1 interrupted running task(s) to pending.'); - expect(combined).toContain('recovery-target-1'); - expect(combined).toContain('recovery-target-2'); + try { + const runningObserved = await waitFor(() => { + if (!existsSync(tasksFile)) { + return false; + } + const tasks = readTasks(tasksFile); + return tasks.some((task) => task.status === 'running'); + }, 30_000, 20); + + expect(runningObserved, `stdout:\n${firstStdout}\n\nstderr:\n${firstStderr}`).toBe(true); + + child.kill('SIGKILL'); + await childClosedPromise; + + const staleTasks = readTasks(tasksFile); + const runningTask = staleTasks.find((task) => task.status === 'running'); + expect(runningTask).toBeDefined(); + expect(runningTask?.owner_pid).toBeTypeOf('number'); + + const rerunResult = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); - const finalTasks = readTasks(tasksFile); - expect(finalTasks).toEqual([]); + expect(rerunResult.exitCode, formatTaktRunResult(rerunResult)).toBe(0); + const combined = rerunResult.stdout + rerunResult.stderr; + expect(combined).toContain('Marked 1 interrupted running task(s) as failed.'); + expect(combined).toContain('recovery-target-2'); + + const finalTasks = readTasks(tasksFile); + expect(finalTasks).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'recovery-target-1', + status: 'failed', + owner_pid: null, + failure: { + error: 'Task was interrupted before this TAKT run started. Requeue it explicitly to run again.', + }, + }), + expect.objectContaining({ + name: 'recovery-target-2', + status: 'completed', + owner_pid: null, + }), + ])); + expect(finalTasks).toHaveLength(2); + } finally { + if (!childClosed) { + child.kill('SIGKILL'); + await childClosedPromise; + } + } }, 240_000); it('should process high-concurrency batch without leaving inconsistent task state', () => { @@ -227,7 +253,7 @@ describe('E2E: Run interrupted task recovery and high-priority run flows', () => provider: 'mock', model: 'mock-model', concurrency: 10, - task_poll_interval_ms: 50, + task_poll_interval_ms: 100, }); const workflowPath = resolve(__dirname, '../fixtures/workflows/mock-single-step.yaml'); @@ -248,46 +274,55 @@ describe('E2E: Run interrupted task recovery and high-priority run flows', () => timeout: 240_000, }); - // Then: all tasks complete and queue becomes empty - expect(result.exitCode).toBe(0); + expect(result.exitCode, formatTaktRunResult(result)).toBe(0); expect(result.stdout).toContain('Concurrency: 10'); expect(result.stdout).toContain('Tasks Summary'); const finalTasks = readTasks(tasksFile); - expect(finalTasks).toEqual([]); + expect(finalTasks).toHaveLength(12); + expect(finalTasks).toEqual( + expect.arrayContaining( + Array.from({ length: 12 }, (_, index) => expect.objectContaining({ + name: `parallel-load-${String(index + 1)}`, + status: 'completed', + owner_pid: null, + })), + ), + ); }, 240_000); it('should initialize project dirs and execute tasks after add+run when global config is absent', () => { const envWithoutConfig = createEnvWithoutGlobalConfig(); try { - // Given: global config.yaml is absent and project config points to a mock workflow path const workflowPath = resolve(__dirname, '../fixtures/workflows/mock-single-step.yaml'); const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); const projectConfigDir = join(repo.path, '.takt'); const projectConfigPath = join(projectConfigDir, 'config.yaml'); mkdirSync(projectConfigDir, { recursive: true }); - writeFileSync(projectConfigPath, `workflow: ${workflowPath}\npermissionMode: default\n`, 'utf-8'); + writeFileSync(projectConfigPath, 'provider: mock\nmodel: mock-model\n', 'utf-8'); expect(existsSync(envWithoutConfig.globalConfigPath)).toBe(false); // When: add 2 tasks and run once const addResult1 = runTakt({ - args: ['--provider', 'mock', 'add', 'Initialize flow task 1'], + args: ['--provider', 'mock', '--workflow', workflowPath, 'add', 'Initialize flow task 1'], cwd: repo.path, env: { ...envWithoutConfig.env, TAKT_MOCK_SCENARIO: scenarioPath, }, + input: 'n\n', timeout: 240_000, }); const addResult2 = runTakt({ - args: ['--provider', 'mock', 'add', 'Initialize flow task 2'], + args: ['--provider', 'mock', '--workflow', workflowPath, 'add', 'Initialize flow task 2'], cwd: repo.path, env: { ...envWithoutConfig.env, TAKT_MOCK_SCENARIO: scenarioPath, }, + input: 'n\n', timeout: 240_000, }); @@ -302,13 +337,26 @@ describe('E2E: Run interrupted task recovery and high-priority run flows', () => }); // Then: tasks are persisted/executed correctly and project init artifacts exist - expect(addResult1.exitCode).toBe(0); - expect(addResult2.exitCode).toBe(0); - expect(runResult.exitCode).toBe(0); + expect(addResult1.exitCode, formatTaktRunResult(addResult1)).toBe(0); + expect(addResult2.exitCode, formatTaktRunResult(addResult2)).toBe(0); + expect(runResult.exitCode, formatTaktRunResult(runResult)).toBe(0); const tasksFile = join(repo.path, '.takt', 'tasks.yaml'); const parsedFinal = parseYaml(readFileSync(tasksFile, 'utf-8')) as { tasks?: TaskRecord[] }; - expect(parsedFinal.tasks).toEqual([]); + expect(parsedFinal.tasks).toEqual([ + expect.objectContaining({ + name: 'initialize-flow-task-1', + summary: 'Initialize flow task 1', + status: 'completed', + owner_pid: null, + }), + expect.objectContaining({ + name: 'initialize-flow-task-2', + summary: 'Initialize flow task 2', + status: 'completed', + owner_pid: null, + }), + ]); const taskDirsRoot = join(repo.path, '.takt', 'tasks'); const taskDirs = readdirSync(taskDirsRoot, { withFileTypes: true }) diff --git a/src/__tests__/clone.test.ts b/src/__tests__/clone.test.ts index 5798bf728..9f4b76a99 100644 --- a/src/__tests__/clone.test.ts +++ b/src/__tests__/clone.test.ts @@ -1412,11 +1412,17 @@ describe('autoFetch: true — fetch, rev-parse origin/, reset --hard', ( taskSlug: 'autofetch-task', }); - expect(fetchCalls).toHaveLength(2); + expect(fetchCalls).toHaveLength(3); expect(fetchCalls[0]![0]).toBe('fetch'); expect(fetchCalls[0]![1]).toBe('origin'); expect(fetchCalls[0]![2]).toMatch(/^takt\/\d{8}T\d{4}-autofetch-task$/); expect(fetchCalls[1]).toEqual(['fetch', 'origin']); + expect(fetchCalls[2]).toEqual([ + 'fetch', + '--no-write-fetch-head', + '/project-autofetch-test', + 'refs/remotes/origin/main:refs/takt/base/main', + ]); expect(revParseOriginCalls).toHaveLength(1); expect(revParseOriginCalls[0]).toEqual(['rev-parse', 'origin/main']); diff --git a/src/__tests__/codex-client-retry.test.ts b/src/__tests__/codex-client-retry.test.ts index 5279c1438..7d563afcf 100644 --- a/src/__tests__/codex-client-retry.test.ts +++ b/src/__tests__/codex-client-retry.test.ts @@ -10,6 +10,7 @@ let runPlans: RunPlan[] = []; let runPlanIndex = 0; let startThreadCalls: Array | undefined> = []; let resumeThreadCalls: Array<{ threadId: string; options?: Record }> = []; +let runStreamedInputs: unknown[] = []; const CODEX_STREAM_IDLE_TIMEOUT_MS = 10 * 60 * 1000; const CODEX_RECONNECT_FAILURE_MESSAGE = 'Reconnecting... 2/5 (timeout waiting for child process to exit)'; const CODEX_RETRY_MAX_DELAY_MS = 30_000; @@ -82,7 +83,8 @@ function createReconnectCommandFailureEvents(message: string, command: string): function createThread(id: string) { return { id, - runStreamed: async (_prompt: string, turnOptions?: { signal?: AbortSignal }) => { + runStreamed: async (input: unknown, turnOptions?: { signal?: AbortSignal }) => { + runStreamedInputs.push(input); const plan = runPlans[runPlanIndex]; runPlanIndex += 1; if (!plan) { @@ -125,6 +127,7 @@ describe('CodexClient retry', () => { runPlanIndex = 0; startThreadCalls = []; resumeThreadCalls = []; + runStreamedInputs = []; }); afterEach(() => { @@ -153,6 +156,33 @@ describe('CodexClient retry', () => { expect(result.content).toBe(''); }); + it('imageAttachments がある場合は Codex SDK に local_image 入力として渡す', async () => { + runPlans = [ + { + type: 'events', + events: [ + { type: 'thread.started', thread_id: 'thread-1' }, + { type: 'item.completed', item: { id: 'msg-1', type: 'agent_message', text: 'saw image' } }, + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 2 } }, + ], + }, + ]; + + const client = new CodexClient(); + + const result = await client.call('coder', 'この画像を見て [Image #1]', { + cwd: '/tmp', + imageAttachments: [{ placeholder: '[Image #1]', path: '/tmp/image-1.png' }], + }); + + expect(result.status).toBe('done'); + expect(runStreamedInputs[0]).toEqual([ + { type: 'text', text: 'この画像を見て [Image #1]' }, + { type: 'text', text: '[Image #1] path: `/tmp/image-1.png`' }, + { type: 'local_image', path: '/tmp/image-1.png' }, + ]); + }); + it('turn.failed の at capacity を 1 秒後に retry して成功を返す', async () => { vi.useFakeTimers(); diff --git a/src/__tests__/commandMatcher.test.ts b/src/__tests__/commandMatcher.test.ts index ec9bf9423..5a224fb36 100644 --- a/src/__tests__/commandMatcher.test.ts +++ b/src/__tests__/commandMatcher.test.ts @@ -56,6 +56,11 @@ describe('start-of-line detection', () => { const result = matchSlashCommand('/accept'); expect(result).toEqual({ command: '/accept', text: '' }); }); + + it('should detect /paste-image', () => { + const result = matchSlashCommand('/paste-image'); + expect(result).toEqual({ command: '/paste-image', text: '' }); + }); }); // ================================================================= diff --git a/src/__tests__/conversationLoop-resume.test.ts b/src/__tests__/conversationLoop-resume.test.ts index 371f4a283..970ae5f8b 100644 --- a/src/__tests__/conversationLoop-resume.test.ts +++ b/src/__tests__/conversationLoop-resume.test.ts @@ -393,7 +393,9 @@ describe('/go command', () => { const result = await runConversationLoop('/test', ctx, defaultStrategy, undefined, undefined); expect(capture.callCount).toBe(2); - expect(capture.prompts[0]).toContain('use [Image #1] please'); + expect(capture.prompts[0]).toMatch(/use \[Image #1\] \(`.*image-1\.png`\) please/); + expect(capture.imageAttachments[0]).toBeUndefined(); + expect(capture.imageAttachments[1]).toBeUndefined(); expect(result.action).toBe('execute'); expect(result.task).toBe('Generated task using [Image #1].'); expect(result.attachments?.[0]?.fileName).toBe('image-1.png'); @@ -403,6 +405,37 @@ describe('/go command', () => { expect(fs.existsSync(result.attachments![0]!.tempPath)).toBe(true); }); + it('should pass image attachment bodies only to native image providers', async () => { + setupRawStdin([ + `use ${createOscImagePaste()} please\r`, + '/go\r', + ]); + + const { provider, capture } = createScenarioProvider([ + { content: 'AI response using [Image #1].' }, + { content: 'Generated task using [Image #1].' }, + ], { supportsNativeImageInput: true }); + + const ctx: SessionContext = { + provider: provider as SessionContext['provider'], + providerType: 'codex' as SessionContext['providerType'], + model: undefined, + lang: 'en', + personaName: 'interactive', + sessionId: undefined, + }; + + const result = await runConversationLoop('/test', ctx, defaultStrategy, undefined, undefined); + + expect(capture.callCount).toBe(2); + expect(capture.prompts[0]).toMatch(/use \[Image #1\] \(`.*image-1\.png`\) please/); + expect(capture.imageAttachments[0]?.[0]?.placeholder).toBe('[Image #1]'); + expect(capture.imageAttachments[0]?.[0]?.path).toBeDefined(); + expect(capture.imageAttachments[1]?.[0]?.placeholder).toBe('[Image #1]'); + expect(result.action).toBe('execute'); + trackAttachmentSession(result.attachments![0]!.tempPath); + }); + it('should not create formal task assets when image input is cancelled', async () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-cancel-image-test-')); try { diff --git a/src/__tests__/helpers/stdinSimulator.ts b/src/__tests__/helpers/stdinSimulator.ts index 260b2c5d3..065b5c651 100644 --- a/src/__tests__/helpers/stdinSimulator.ts +++ b/src/__tests__/helpers/stdinSimulator.ts @@ -114,6 +114,7 @@ export interface MockProviderCapture { callCount: number; prompts: string[]; sessionIds: Array; + imageAttachments: Array | undefined>; } /** @@ -132,20 +133,37 @@ export interface CallScenario { throws?: Error; } +interface ScenarioProviderOptions { + supportsNativeImageInput?: boolean; +} + /** * Create a mock provider with per-call scenario control. * * Each scenario controls what the AI returns for that call index. * Captures system prompts, call arguments, and session IDs for assertions. */ -export function createScenarioProvider(scenarios: CallScenario[]): { provider: unknown; capture: MockProviderCapture } { - const capture: MockProviderCapture = { systemPrompts: [], callCount: 0, prompts: [], sessionIds: [] }; +export function createScenarioProvider( + scenarios: CallScenario[], + options: ScenarioProviderOptions = {}, +): { provider: unknown; capture: MockProviderCapture } { + const capture: MockProviderCapture = { + systemPrompts: [], + callCount: 0, + prompts: [], + sessionIds: [], + imageAttachments: [], + }; - const mockCall = vi.fn(async (prompt: string, options?: { sessionId?: string }) => { + const mockCall = vi.fn(async (prompt: string, options?: { + sessionId?: string; + imageAttachments?: Array<{ placeholder: string; path: string }>; + }) => { const idx = capture.callCount; capture.callCount++; capture.prompts.push(prompt); capture.sessionIds.push(options?.sessionId); + capture.imageAttachments.push(options?.imageAttachments); const scenario = idx < scenarios.length ? scenarios[idx]! @@ -165,6 +183,8 @@ export function createScenarioProvider(scenarios: CallScenario[]): { provider: u }); const provider = { + supportsStructuredOutput: true, + supportsNativeImageInput: options.supportsNativeImageInput === true, setup: vi.fn(({ systemPrompt }: { systemPrompt: string }) => { capture.systemPrompts.push(systemPrompt); return { call: mockCall }; diff --git a/src/__tests__/imageAttachments.test.ts b/src/__tests__/imageAttachments.test.ts index 4fc0726b9..b373a5e43 100644 --- a/src/__tests__/imageAttachments.test.ts +++ b/src/__tests__/imageAttachments.test.ts @@ -7,6 +7,7 @@ import { createImageAttachmentStore, createImagePasteHandler, createSessionImageAttachmentStore, + resolvePromptImageAttachments, } from '../features/interactive/imageAttachments.js'; const tempRoots = new Set(); @@ -108,6 +109,46 @@ describe('createImageAttachmentStore', () => { }); }); +describe('resolvePromptImageAttachments', () => { + it('should return only attachments referenced by placeholders in the prompt', () => { + const first = { + placeholder: '[Image #1]', + tempPath: '/tmp/image-1.png', + fileName: 'image-1.png', + }; + const second = { + placeholder: '[Image #2]', + tempPath: '/tmp/image-2.png', + fileName: 'image-2.png', + }; + + const result = resolvePromptImageAttachments('Please inspect [Image #2].', [first, second]); + + expect(result).toEqual([ + { placeholder: '[Image #2]', path: '/tmp/image-2.png' }, + ]); + }); + + it('should not match a prefix placeholder when only a later image is referenced', () => { + const first = { + placeholder: '[Image #1]', + tempPath: '/tmp/image-1.png', + fileName: 'image-1.png', + }; + const tenth = { + placeholder: '[Image #10]', + tempPath: '/tmp/image-10.png', + fileName: 'image-10.png', + }; + + const result = resolvePromptImageAttachments('Please inspect [Image #10].', [first, tenth]); + + expect(result).toEqual([ + { placeholder: '[Image #10]', path: '/tmp/image-10.png' }, + ]); + }); +}); + describe('buildInteractiveResultWithAttachments', () => { it('should not add attachments when no images were pasted', () => { const tmpRoot = createTempRoot(); diff --git a/src/__tests__/interactiveInput.test.ts b/src/__tests__/interactiveInput.test.ts index ef983f717..3fedc57c5 100644 --- a/src/__tests__/interactiveInput.test.ts +++ b/src/__tests__/interactiveInput.test.ts @@ -54,6 +54,18 @@ describe('interactiveInput', () => { }, ]); }); + + it('should return localized /paste-image descriptions with apply values', () => { + const result = getSlashCommandCompletions('/paste-image', 'ja'); + + expect(result).toEqual([ + { + value: '/paste-image', + applyValue: '/paste-image ', + description: 'クリップボード画像を添付', + }, + ]); + }); }); describe('createSlashCommandCompletionProvider', () => { diff --git a/src/__tests__/kiro-provider.test.ts b/src/__tests__/kiro-provider.test.ts index c38de8316..8babc337b 100644 --- a/src/__tests__/kiro-provider.test.ts +++ b/src/__tests__/kiro-provider.test.ts @@ -4,6 +4,15 @@ const { mockCallKiro } = vi.hoisted(() => ({ mockCallKiro: vi.fn(), })); +const { mockLogger } = vi.hoisted(() => ({ + mockLogger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + const { mockResolveKiroApiKey, mockResolveKiroCliPath, @@ -21,6 +30,14 @@ vi.mock('../infra/config/index.js', () => ({ resolveKiroCliPath: mockResolveKiroCliPath, })); +vi.mock('../shared/utils/index.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createLogger: vi.fn(() => mockLogger), + }; +}); + import { KiroProvider } from '../infra/providers/kiro.js'; import { ProviderRegistry } from '../infra/providers/index.js'; @@ -117,7 +134,7 @@ describe('KiroProvider', () => { ); }); - it('Given unsupported provider options, When agent is called, Then does not pass model, allowedTools, mcpServers, maxTurns, or outputSchema to callKiro', async () => { + it('Given unsupported provider options, When agent is called, Then does not pass them to callKiro', async () => { mockCallKiro.mockResolvedValue(doneResponse('coder')); const provider = new KiroProvider(); @@ -135,6 +152,7 @@ describe('KiroProvider', () => { }, maxTurns: 5, outputSchema: { type: 'object' }, + imageAttachments: [{ placeholder: '[Image #1]', path: '/tmp/image-1.png' }], permissionMode: 'edit', }); @@ -144,7 +162,26 @@ describe('KiroProvider', () => { expect(options.mcpServers).toBeUndefined(); expect(options.maxTurns).toBeUndefined(); expect(options.outputSchema).toBeUndefined(); + expect(options.imageAttachments).toBeUndefined(); expect(options.permissionMode).toBe('edit'); + expect(mockLogger.info).toHaveBeenCalledWith('Kiro provider does not support imageAttachments; ignoring'); + }); + + it('Given empty or missing image attachments, When agent is called, Then does not log unsupported image attachments', async () => { + mockCallKiro.mockResolvedValue(doneResponse('coder')); + + const provider = new KiroProvider(); + const agent = provider.setup({ name: 'coder' }); + + await agent.call('implement', { + cwd: '/tmp/work', + imageAttachments: [], + }); + await agent.call('implement', { + cwd: '/tmp/work', + }); + + expect(mockLogger.info).not.toHaveBeenCalledWith('Kiro provider does not support imageAttachments; ignoring'); }); }); diff --git a/src/__tests__/lineEditor.test.ts b/src/__tests__/lineEditor.test.ts index 1c7d21730..0daceb484 100644 --- a/src/__tests__/lineEditor.test.ts +++ b/src/__tests__/lineEditor.test.ts @@ -305,6 +305,7 @@ describe('readMultilineInput cursor navigation', () => { async function flushQueuedInput(): Promise { await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); } async function callReadMultilineInput( @@ -312,6 +313,8 @@ describe('readMultilineInput cursor navigation', () => { options?: { completionProvider?: CompletionProvider; onImagePaste?: (image: { mimeType: string; data: Buffer }) => Promise; + onClipboardImagePaste?: () => Promise; + onClipboardImagePasteError?: (error: unknown) => void; }, ): Promise { return readMultilineInput(prompt, options); @@ -476,6 +479,101 @@ describe('readMultilineInput cursor navigation', () => { expect(onImagePaste).toHaveBeenCalledTimes(1); }); + it('should replace /paste-image with a clipboard image placeholder and keep editing', async () => { + const onClipboardImagePaste = vi.fn(async () => { + await Promise.resolve(); + return '[Image #1]'; + }); + setupRawStdin([ + '/paste-image\r', + ' please\r', + ]); + + const result = await callReadMultilineInput('> ', { onClipboardImagePaste }); + + expect(result).toBe('[Image #1] please'); + expect(onClipboardImagePaste).toHaveBeenCalledTimes(1); + }); + + it('should insert a clipboard image placeholder on Ctrl+V and keep editing', async () => { + const onClipboardImagePaste = vi.fn(async () => '[Image #1]'); + setupRawStdin([ + 'before \x16 after\r', + ]); + + const result = await callReadMultilineInput('> ', { onClipboardImagePaste }); + + expect(result).toBe('before [Image #1] after'); + expect(onClipboardImagePaste).toHaveBeenCalledTimes(1); + }); + + it('should insert a clipboard image placeholder on Kitty CSI-u Ctrl+V', async () => { + const onClipboardImagePaste = vi.fn(async () => '[Image #1]'); + setupRawStdin([ + 'before \x1B[118;5u after\r', + ]); + + const result = await callReadMultilineInput('> ', { onClipboardImagePaste }); + + expect(result).toBe('before [Image #1] after'); + expect(onClipboardImagePaste).toHaveBeenCalledTimes(1); + }); + + it('should insert a clipboard image placeholder on xterm modifyOtherKeys Ctrl+V', async () => { + const onClipboardImagePaste = vi.fn(async () => '[Image #1]'); + setupRawStdin([ + 'before \x1B[27;5;118~ after\r', + ]); + + const result = await callReadMultilineInput('> ', { onClipboardImagePaste }); + + expect(result).toBe('before [Image #1] after'); + expect(onClipboardImagePaste).toHaveBeenCalledTimes(1); + }); + + it('should keep ignoring Ctrl+V when clipboard image handling is unavailable', async () => { + setupRawStdin(['before \x16 after\r']); + + const result = await callReadMultilineInput('> '); + + expect(result).toBe('before after'); + }); + + it('should keep bracketed paste Ctrl+V as pasted text', async () => { + const onClipboardImagePaste = vi.fn(async () => '[Image #1]'); + setupRawStdin([ + '\x1B[200~a\x16b\x1B[201~\r', + ]); + + const result = await callReadMultilineInput('> ', { onClipboardImagePaste }); + + expect(result).toBe('a\x16b'); + expect(onClipboardImagePaste).not.toHaveBeenCalled(); + }); + + it('should keep editing when Ctrl+V clipboard image handling fails', async () => { + const pasteError = new Error('no image'); + const onClipboardImagePaste = vi.fn(async () => { + throw pasteError; + }); + const onClipboardImagePasteError = vi.fn(); + setupRawStdin(['before \x16 after\r']); + + const result = await callReadMultilineInput('> ', { onClipboardImagePaste, onClipboardImagePasteError }); + + expect(result).toBe('before after'); + expect(onClipboardImagePaste).toHaveBeenCalledTimes(1); + expect(onClipboardImagePasteError).toHaveBeenCalledWith(pasteError); + }); + + it('should submit /paste-image as text when clipboard image handling is unavailable', async () => { + setupRawStdin(['/paste-image\r']); + + const result = await callReadMultilineInput('> '); + + expect(result).toBe('/paste-image'); + }); + it('should not keep a second Esc timeout after resolving a bare Esc with image paste enabled', async () => { vi.useFakeTimers(); setupRawStdin([]); @@ -489,6 +587,7 @@ describe('readMultilineInput cursor navigation', () => { await flushQueuedInput(); emitRawInput?.('\x1B'); await flushQueuedInput(); + await vi.advanceTimersByTimeAsync(0); expect(vi.getTimerCount()).toBe(1); await vi.advanceTimersByTimeAsync(50); @@ -1588,11 +1687,11 @@ describe('readMultilineInput cursor navigation', () => { expect(cb.calls).toEqual(['up']); }); - it('should emit onEsc for bare Esc on flush', () => { + it('should emit onEsc for bare Esc on flush', async () => { const cb = createCallbacks(); const parser = createEscapeParser(cb); - parser.feed('\x1B'); + await parser.feed('\x1B'); parser.flush(); expect(cb.calls).toEqual(['esc']); @@ -1602,58 +1701,58 @@ describe('readMultilineInput cursor navigation', () => { const cb = createCallbacks(); const parser = createEscapeParser(cb); - parser.feed('\x1B'); + await parser.feed('\x1B'); await new Promise((resolve) => setTimeout(resolve, 100)); expect(cb.calls).toEqual(['esc']); }); - it('should handle normal characters without pending state', () => { + it('should handle normal characters without pending state', async () => { const cb = createCallbacks(); const parser = createEscapeParser(cb); - parser.feed('abc'); + await parser.feed('abc'); expect(cb.calls).toEqual(['char:a', 'char:b', 'char:c']); }); - it('should resolve CSI split across chunks (ESC+[ then A)', () => { + it('should resolve CSI split across chunks (ESC+[ then A)', async () => { const cb = createCallbacks(); const parser = createEscapeParser(cb); - parser.feed('\x1B['); - parser.feed('A'); + await parser.feed('\x1B['); + await parser.feed('A'); expect(cb.calls).toEqual(['up']); }); - it('should resolve CSI split across three chunks (ESC then [ then B)', () => { + it('should resolve CSI split across three chunks (ESC then [ then B)', async () => { const cb = createCallbacks(); const parser = createEscapeParser(cb); - parser.feed('\x1B'); - parser.feed('['); - parser.feed('B'); + await parser.feed('\x1B'); + await parser.feed('['); + await parser.feed('B'); expect(cb.calls).toEqual(['down']); }); - it('should handle text after resolved split escape sequence', () => { + it('should handle text after resolved split escape sequence', async () => { const cb = createCallbacks(); const parser = createEscapeParser(cb); - parser.feed('\x1B['); - parser.feed('Cabc'); + await parser.feed('\x1B['); + await parser.feed('Cabc'); expect(cb.calls).toEqual(['right', 'char:a', 'char:b', 'char:c']); }); - it('should flush incomplete CSI fragment as bare Esc', () => { + it('should flush incomplete CSI fragment as bare Esc', async () => { const cb = createCallbacks(); const parser = createEscapeParser(cb); - parser.feed('\x1B['); + await parser.feed('\x1B['); parser.flush(); expect(cb.calls).toEqual(['esc']); diff --git a/src/__tests__/provider-capabilities.test.ts b/src/__tests__/provider-capabilities.test.ts index e1cac90b4..5dda257e0 100644 --- a/src/__tests__/provider-capabilities.test.ts +++ b/src/__tests__/provider-capabilities.test.ts @@ -5,6 +5,7 @@ import { providerSupportsClaudeAllowedTools, providerSupportsMaxTurns, providerSupportsMcpServers, + providerSupportsNativeImageInput, } from '../infra/providers/provider-capabilities.js'; function readModuleSource(path: string): string { @@ -20,6 +21,7 @@ describe('provider capabilities module boundary', () => { expect(source).toContain('export function providerSupportsMcpServers'); expect(source).toContain('export function providerSupportsClaudeAllowedTools'); expect(source).toContain('export function providerSupportsMaxTurns'); + expect(source).toContain('export function providerSupportsNativeImageInput'); expect(source).not.toContain('export interface ProviderCapabilities'); expect(source).not.toContain('export function resolveProviderCapabilities'); }); @@ -53,4 +55,12 @@ describe('provider capabilities module boundary', () => { expect(providerSupportsMaxTurns('claude')).toBe(true); expect(providerSupportsMaxTurns('claude-terminal')).toBe(false); }); + + it('native image input capability は SDK に実画像を渡せる provider だけを許可する', () => { + expect(providerSupportsNativeImageInput('codex')).toBe(true); + expect(providerSupportsNativeImageInput('claude-sdk')).toBe(true); + expect(providerSupportsNativeImageInput('claude')).toBe(false); + expect(providerSupportsNativeImageInput('claude-terminal')).toBe(false); + expect(providerSupportsNativeImageInput('opencode')).toBe(false); + }); }); diff --git a/src/__tests__/provider-image-attachments.test.ts b/src/__tests__/provider-image-attachments.test.ts new file mode 100644 index 000000000..7afd45e74 --- /dev/null +++ b/src/__tests__/provider-image-attachments.test.ts @@ -0,0 +1,89 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { expandImageAttachmentPlaceholders } from '../infra/providers/imageAttachmentPrompt.js'; +import { buildClaudePromptInput } from '../infra/claude/image-input.js'; + +const tempRoots = new Set(); + +afterEach(() => { + for (const root of tempRoots) { + fs.rmSync(root, { recursive: true, force: true }); + } + tempRoots.clear(); +}); + +function createTempImage(extension = '.png'): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-provider-image-test-')); + tempRoots.add(root); + const filePath = path.join(root, `image${extension}`); + fs.writeFileSync(filePath, Buffer.from('image-bytes')); + return filePath; +} + +describe('provider image attachment prompt support', () => { + it('should expand image placeholders to local image path references', () => { + const result = expandImageAttachmentPlaceholders('見て [Image #1]', [ + { placeholder: '[Image #1]', path: '/tmp/image-1.png' }, + ]); + + expect(result).toBe('見て [Image #1] (`/tmp/image-1.png`)'); + }); +}); + +describe('buildClaudePromptInput', () => { + it('should return plain text when no image attachments are provided', () => { + expect(buildClaudePromptInput('prompt', undefined)).toBe('prompt'); + }); + + it('should build an SDK user message stream with base64 image blocks', async () => { + const imagePath = createTempImage('.png'); + const input = buildClaudePromptInput('見て [Image #1]', [ + { placeholder: '[Image #1]', path: imagePath }, + ]); + + expect(typeof input).not.toBe('string'); + const messages = []; + for await (const message of input as AsyncIterable) { + messages.push(message); + } + + expect(messages).toEqual([ + { + type: 'user', + message: { + role: 'user', + content: [ + { type: 'text', text: '見て [Image #1]' }, + { type: 'text', text: `[Image #1] path: \`${imagePath}\`` }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: Buffer.from('image-bytes').toString('base64'), + }, + }, + ], + }, + parent_tool_use_id: null, + }, + ]); + }); + + it('should include the attachment path when reading an image fails', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-provider-image-missing-test-')); + tempRoots.add(root); + const missingPath = path.join(root, 'missing.png'); + const input = buildClaudePromptInput('見て [Image #1]', [ + { placeholder: '[Image #1]', path: missingPath }, + ]); + + await expect(async () => { + for await (const _message of input as AsyncIterable) { + throw new Error('Expected image read failure before yielding Claude message'); + } + }).rejects.toThrow(`Failed to read image attachment at ${missingPath}`); + }); +}); diff --git a/src/__tests__/provider-structured-output.test.ts b/src/__tests__/provider-structured-output.test.ts index b1255d843..9d48ca30f 100644 --- a/src/__tests__/provider-structured-output.test.ts +++ b/src/__tests__/provider-structured-output.test.ts @@ -165,6 +165,17 @@ describe('ClaudeProvider — structured output', () => { const opts = mockCallClaude.mock.calls[0]?.[2]; expect(opts.outputSchema).toBeUndefined(); }); + + it('imageAttachments を callClaude に渡す', async () => { + mockCallClaude.mockResolvedValue(doneResponse('coder')); + const imageAttachments = [{ placeholder: '[Image #1]', path: '/tmp/image-1.png' }]; + + const agent = new ClaudeProvider().setup({ name: 'coder' }); + await agent.call('prompt', { cwd: '/tmp', imageAttachments }); + + const opts = mockCallClaude.mock.calls[0]?.[2]; + expect(opts).toHaveProperty('imageAttachments', imageAttachments); + }); }); // ---------- Codex ---------- diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index b1ae7d631..b8d9fb965 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -82,7 +82,7 @@ const { mockClaimNextTasks, mockCompleteTask, mockFailTask, - mockRecoverInterruptedRunningTasks, + mockFailInterruptedRunningTasks, mockListAllTaskItems, mockUpdateRunningTaskExecution, mockNotifySuccess, @@ -93,7 +93,7 @@ const { mockClaimNextTasks: vi.fn(), mockCompleteTask: vi.fn(), mockFailTask: vi.fn(), - mockRecoverInterruptedRunningTasks: vi.fn(), + mockFailInterruptedRunningTasks: vi.fn(), mockListAllTaskItems: vi.fn().mockReturnValue([]), mockUpdateRunningTaskExecution: vi.fn(buildUpdatedTaskInfo), mockNotifySuccess: vi.fn(), @@ -108,7 +108,7 @@ vi.mock('../infra/task/index.js', async (importOriginal) => ({ claimNextTasks: mockClaimNextTasks, completeTask: mockCompleteTask, failTask: mockFailTask, - recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks, + failInterruptedRunningTasks: mockFailInterruptedRunningTasks, listAllTaskItems: mockListAllTaskItems, updateRunningTaskExecution: mockUpdateRunningTaskExecution, })), @@ -226,7 +226,7 @@ function createTask(name: string): TaskInfo { beforeEach(() => { vi.clearAllMocks(); - mockRecoverInterruptedRunningTasks.mockReturnValue(0); + mockFailInterruptedRunningTasks.mockReturnValue(0); mockUpdateRunningTaskExecution.mockImplementation(buildUpdatedTaskInfo); }); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index c66d0f6c9..bf1e7bb0d 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -281,6 +281,37 @@ describe('saveTaskFile', () => { expect(fs.readFileSync(path.join(taskDir, 'attachments', 'image-1.png'), 'utf-8')).toBe('png-data'); }); + it('should replace pasted image temp paths in generated task content with task attachment paths', async () => { + const attachment = createTempAttachment(testDir, 'image-1.png', 'png-data'); + + await saveTaskFile(testDir, `Use [Image #1] (\`${attachment.tempPath}\`) as the visual reference.`, { + attachments: [attachment], + }); + + const task = loadTasks(testDir).tasks[0]!; + const taskDir = path.join(testDir, String(task.task_dir)); + const orderContent = fs.readFileSync(path.join(taskDir, 'order.md'), 'utf-8'); + + expect(orderContent).toContain('Use [Image #1] (`attachments/image-1.png`) as the visual reference.'); + expect(orderContent).not.toContain(attachment.tempPath); + expect(orderContent).toContain('- [Image #1]: `attachments/image-1.png`'); + }); + + it('should wrap bare pasted image temp paths when normalizing generated task content', async () => { + const attachment = createTempAttachment(testDir, 'image-1.png', 'png-data'); + + await saveTaskFile(testDir, `Use the visual reference at ${attachment.tempPath}.`, { + attachments: [attachment], + }); + + const task = loadTasks(testDir).tasks[0]!; + const taskDir = path.join(testDir, String(task.task_dir)); + const orderContent = fs.readFileSync(path.join(taskDir, 'order.md'), 'utf-8'); + + expect(orderContent).toContain('Use the visual reference at `attachments/image-1.png`.'); + expect(orderContent).not.toContain(attachment.tempPath); + }); + it('should not create task artifacts when attachment promotion fails', async () => { const attachment: TestTaskAttachment = { placeholder: '[Image #1]', diff --git a/src/__tests__/selectAndExecute-skipTaskList.test.ts b/src/__tests__/selectAndExecute-skipTaskList.test.ts index b4f99ef21..1d441602e 100644 --- a/src/__tests__/selectAndExecute-skipTaskList.test.ts +++ b/src/__tests__/selectAndExecute-skipTaskList.test.ts @@ -174,7 +174,7 @@ describe('skipTaskList option in selectAndExecuteTask', () => { }) => { const runContextTaskDir = path.join(projectCwd, '.takt', 'runs', arg.reportDirName, 'context', 'task'); expect(fs.readFileSync(path.join(runContextTaskDir, 'order.md'), 'utf-8')).toContain( - '- [Image #1]: `attachments/image-1.png`', + `- [Image #1]: \`.takt/runs/${arg.reportDirName}/context/task/attachments/image-1.png\``, ); expect(fs.readFileSync(path.join(runContextTaskDir, 'attachments', 'image-1.png'), 'utf-8')).toBe('png-data'); return true; @@ -200,8 +200,8 @@ describe('skipTaskList option in selectAndExecuteTask', () => { expect(executeArg.reportDirName).toBeDefined(); const runContextTaskDir = path.join(projectCwd, '.takt', 'runs', executeArg.reportDirName, 'context', 'task'); - expect(fs.existsSync(runContextTaskDir)).toBe(false); - expect(fs.existsSync(path.join(projectCwd, '.takt', 'tasks', executeArg.reportDirName))).toBe(false); + expect(fs.existsSync(runContextTaskDir)).toBe(true); + expect(fs.existsSync(path.join(projectCwd, '.takt', 'tasks'))).toBe(false); }); it('attachments 付き skipTaskList: false では task record が参照する task spec を残す', async () => { @@ -236,7 +236,7 @@ describe('skipTaskList option in selectAndExecuteTask', () => { ); expect(fs.readFileSync(path.join(taskSpecDir, 'attachments', 'image-1.png'), 'utf-8')).toBe('png-data'); expect(fs.readFileSync(path.join(runContextTaskDir, 'order.md'), 'utf-8')).toContain( - '- [Image #1]: `attachments/image-1.png`', + `- [Image #1]: \`.takt/runs/${executeArg.reportDirName}/context/task/attachments/image-1.png\``, ); expect(fs.readFileSync(path.join(runContextTaskDir, 'attachments', 'image-1.png'), 'utf-8')).toBe('png-data'); expect(mockPersistTaskResult).toHaveBeenCalled(); @@ -280,7 +280,7 @@ describe('skipTaskList option in selectAndExecuteTask', () => { expect(fs.existsSync(path.join(projectCwd, '.takt', 'runs', secondExecuteArg.reportDirName, 'context', 'task', 'order.md'))).toBe(true); }); - it('attachments 付き skipTaskList: true で executeTask が失敗しても prepared task spec を削除する', async () => { + it('attachments 付き skipTaskList: true で executeTask が失敗しても prepared task spec を削除し、run context は残す', async () => { const projectCwd = createTempProject(); const tempAttachmentDir = path.join(projectCwd, 'tmp-attachments'); fs.mkdirSync(tempAttachmentDir, { recursive: true }); @@ -302,11 +302,11 @@ describe('skipTaskList option in selectAndExecuteTask', () => { const executeArg = mockExecuteTask.mock.calls[0]?.[0] as { reportDirName: string }; expect(fs.existsSync(path.join(projectCwd, '.takt', 'tasks'))).toBe(false); - expect(fs.existsSync(path.join(projectCwd, '.takt', 'runs', executeArg.reportDirName, 'context', 'task'))).toBe(false); + expect(fs.existsSync(path.join(projectCwd, '.takt', 'runs', executeArg.reportDirName, 'context', 'task'))).toBe(true); expect(mockPersistTaskError).not.toHaveBeenCalled(); }); - it('attachments 付き skipTaskList: true で taskSuccess が false でも prepared task spec を削除する', async () => { + it('attachments 付き skipTaskList: true で taskSuccess が false でも prepared task spec を削除し、run context は残す', async () => { const projectCwd = createTempProject(); const tempAttachmentDir = path.join(projectCwd, 'tmp-attachments'); fs.mkdirSync(tempAttachmentDir, { recursive: true }); @@ -335,7 +335,7 @@ describe('skipTaskList option in selectAndExecuteTask', () => { const executeArg = mockExecuteTask.mock.calls[0]?.[0] as { reportDirName: string }; expect(fs.existsSync(path.join(projectCwd, '.takt', 'tasks'))).toBe(false); - expect(fs.existsSync(path.join(projectCwd, '.takt', 'runs', executeArg.reportDirName, 'context', 'task'))).toBe(false); + expect(fs.existsSync(path.join(projectCwd, '.takt', 'runs', executeArg.reportDirName, 'context', 'task'))).toBe(true); expect(mockPersistTaskResult).not.toHaveBeenCalled(); }); diff --git a/src/__tests__/slashCommandRegistry.test.ts b/src/__tests__/slashCommandRegistry.test.ts index 6566a8574..7f51ce730 100644 --- a/src/__tests__/slashCommandRegistry.test.ts +++ b/src/__tests__/slashCommandRegistry.test.ts @@ -8,7 +8,7 @@ import { filterSlashCommands } from '../features/interactive/slashCommandRegistr describe('filterSlashCommands', () => { it('should return all commands when prefix is "/"', () => { const result = filterSlashCommands('/'); - expect(result.length).toBe(7); + expect(result.length).toBe(8); }); it('should filter by prefix "/a"', () => { @@ -42,7 +42,7 @@ describe('filterSlashCommands', () => { it('should return all commands for empty string prefix', () => { const result = filterSlashCommands(''); - expect(result.length).toBe(7); + expect(result.length).toBe(8); }); it('should not match prefix without leading slash', () => { @@ -74,4 +74,14 @@ describe('filterSlashCommands', () => { const result = filterSlashCommands('/accept'); expect(result[0]!.labelKey).toBe('interactive.commands.accept'); }); + + it('should include /paste-image labelKey for i18n lookup', () => { + const result = filterSlashCommands('/paste'); + expect(result).toEqual([ + { + command: '/paste-image', + labelKey: 'interactive.commands.pasteImage', + }, + ]); + }); }); diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index 9bb558c9f..03c39b95b 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -172,7 +172,7 @@ describe('TaskRunner (tasks.yaml)', () => { }); }); - it('should recover interrupted running tasks to pending', () => { + it('should fail interrupted running tasks', () => { runner.addTask('Task A'); runner.claimNextTasks(1); const current = loadTasksFile(testDir); @@ -180,15 +180,19 @@ describe('TaskRunner (tasks.yaml)', () => { running.owner_pid = 999999999; writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml(current), 'utf-8'); - const recovered = runner.recoverInterruptedRunningTasks(); - expect(recovered).toBe(1); + const failed = runner.failInterruptedRunningTasks(); + expect(failed).toBe(1); - const tasks = runner.listTasks(); - expect(tasks).toHaveLength(1); - expect(tasks[0]?.status).toBe('pending'); + const file = loadTasksFile(testDir); + expect(file.tasks[0]?.status).toBe('failed'); + expect(file.tasks[0]?.owner_pid).toBeNull(); + expect(file.tasks[0]?.completed_at).toEqual(expect.any(String)); + expect(file.tasks[0]?.failure).toEqual({ + error: 'Task was interrupted before this TAKT run started. Requeue it explicitly to run again.', + }); }); - it('should recover interrupted running tasks with start_movement from run meta', () => { + it('should fail interrupted running tasks with start_movement from run meta', () => { runner.addTask('Task A', { workflow: 'default', start_step: 'draft', @@ -235,20 +239,23 @@ describe('TaskRunner (tasks.yaml)', () => { running.owner_pid = 999999999; writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml(current), 'utf-8'); - const recovered = runner.recoverInterruptedRunningTasks(); + const failed = runner.failInterruptedRunningTasks(); - expect(recovered).toBe(1); + expect(failed).toBe(1); const file = loadTasksFile(testDir); - expect(file.tasks[0]?.status).toBe('pending'); - expect(file.tasks[0]?.run_slug).toBeUndefined(); + expect(file.tasks[0]?.status).toBe('failed'); + expect(file.tasks[0]?.run_slug).toBe('20260413-task-a'); expect(file.tasks[0]?.start_movement).toBe('delegate'); expect(file.tasks[0]?.start_step).toBeUndefined(); expect(file.tasks[0]?.resume_point).toBeUndefined(); expect(file.tasks[0]?.exceeded_current_iteration).toBeUndefined(); + expect(file.tasks[0]?.failure).toEqual({ + error: 'Task was interrupted before this TAKT run started. Requeue it explicitly to run again.', + }); }); - it('should preserve existing retry metadata when recovering a stale running task without run_slug', () => { + it('should clear existing retry metadata when failing a stale running task without run_slug', () => { const staleResumePoint = { version: 1 as const, stack: [ @@ -272,22 +279,21 @@ describe('TaskRunner (tasks.yaml)', () => { running.owner_pid = 999999999; writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml(current), 'utf-8'); - const recovered = runner.recoverInterruptedRunningTasks(); + const failed = runner.failInterruptedRunningTasks(); - expect(recovered).toBe(1); + expect(failed).toBe(1); const file = loadTasksFile(testDir); - expect(file.tasks[0]?.status).toBe('pending'); + expect(file.tasks[0]?.status).toBe('failed'); expect(file.tasks[0]?.run_slug).toBeUndefined(); - expectRetryMetadataPreserved(file.tasks[0], { - startStep: 'delegate', - currentIteration: 4, - maxSteps: 30, - resumePoint: staleResumePoint, - }); + expect(file.tasks[0]?.start_movement).toBeUndefined(); + expect(file.tasks[0]?.start_step).toBeUndefined(); + expect(file.tasks[0]?.exceeded_current_iteration).toBeUndefined(); + expect(file.tasks[0]?.exceeded_max_steps).toBeUndefined(); + expect(file.tasks[0]?.resume_point).toBeUndefined(); }); - it('should preserve existing retry metadata when recovering a stale running task without run meta', () => { + it('should clear existing retry metadata when failing a stale running task without run meta', () => { const staleResumePoint = { version: 1 as const, stack: [ @@ -314,27 +320,26 @@ describe('TaskRunner (tasks.yaml)', () => { running.owner_pid = 999999999; writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml(current), 'utf-8'); - const recovered = runner.recoverInterruptedRunningTasks(); + const failed = runner.failInterruptedRunningTasks(); - expect(recovered).toBe(1); + expect(failed).toBe(1); const file = loadTasksFile(testDir); - expect(file.tasks[0]?.status).toBe('pending'); - expect(file.tasks[0]?.run_slug).toBeUndefined(); - expectRetryMetadataPreserved(file.tasks[0], { - startStep: 'delegate', - currentIteration: 4, - maxSteps: 30, - resumePoint: staleResumePoint, - }); + expect(file.tasks[0]?.status).toBe('failed'); + expect(file.tasks[0]?.run_slug).toBe('20260413-task-a'); + expect(file.tasks[0]?.start_movement).toBeUndefined(); + expect(file.tasks[0]?.start_step).toBeUndefined(); + expect(file.tasks[0]?.exceeded_current_iteration).toBeUndefined(); + expect(file.tasks[0]?.exceeded_max_steps).toBeUndefined(); + expect(file.tasks[0]?.resume_point).toBeUndefined(); }); it('should keep running tasks owned by a live process', () => { runner.addTask('Task A'); runner.claimNextTasks(1); - const recovered = runner.recoverInterruptedRunningTasks(); - expect(recovered).toBe(0); + const failed = runner.failInterruptedRunningTasks(); + expect(failed).toBe(0); }); it('should preserve corrupted tasks.yaml and throw', () => { diff --git a/src/__tests__/taskSpecContext.test.ts b/src/__tests__/taskSpecContext.test.ts index 52b10fe27..5ddf250c5 100644 --- a/src/__tests__/taskSpecContext.test.ts +++ b/src/__tests__/taskSpecContext.test.ts @@ -26,17 +26,18 @@ describe('stageTaskSpecForExecution', () => { const execCwd = createTempProjectDir(); const taskDir = '.takt/tasks/spec-task'; const sourceTaskDir = path.join(projectCwd, taskDir); - const orderContent = '# Task\n\nImplement exactly this.'; + const sourceOrderContent = '# Task\n\nImplement exactly this.'; fs.mkdirSync(sourceTaskDir, { recursive: true }); - fs.writeFileSync(path.join(sourceTaskDir, 'order.md'), orderContent, 'utf-8'); + fs.writeFileSync(path.join(sourceTaskDir, 'order.md'), sourceOrderContent, 'utf-8'); - const { taskPrompt, orderContent: stagedOrderContent } = stageTaskSpecForExecution(projectCwd, execCwd, taskDir, '20260216-spec-task'); + const { taskPrompt, orderContent, stagedOrderContent } = stageTaskSpecForExecution(projectCwd, execCwd, taskDir, '20260216-spec-task'); const stagedOrderPath = path.join(execCwd, '.takt', 'runs', '20260216-spec-task', 'context', 'task', 'order.md'); expect(taskPrompt).toContain('Implement using only the files in `.takt/runs/20260216-spec-task/context/task`.'); expect(taskPrompt).toContain('Primary spec: `.takt/runs/20260216-spec-task/context/task/order.md`.'); - expect(stagedOrderContent).toBe(orderContent); - expect(fs.readFileSync(stagedOrderPath, 'utf-8')).toBe(orderContent); + expect(orderContent).toBe(sourceOrderContent); + expect(stagedOrderContent).toBe(sourceOrderContent); + expect(fs.readFileSync(stagedOrderPath, 'utf-8')).toBe(sourceOrderContent); }); it('run コンテキストへ task 添付画像を配置する', () => { @@ -47,7 +48,7 @@ describe('stageTaskSpecForExecution', () => { const orderContent = [ '# Task', '', - 'Use [Image #1] as the reference.', + 'Use [Image #1] (`attachments/image-1.png`) as the reference.', '', '## 添付画像', '', @@ -59,11 +60,55 @@ describe('stageTaskSpecForExecution', () => { const result = stageTaskSpecForExecution(projectCwd, execCwd, taskDir, '20260216-spec-task'); const stagedAttachmentPath = path.join(execCwd, '.takt', 'runs', '20260216-spec-task', 'context', 'task', 'attachments', 'image-1.png'); + const stagedOrderContent = fs.readFileSync(path.join(execCwd, '.takt', 'runs', '20260216-spec-task', 'context', 'task', 'order.md'), 'utf-8'); expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260216-spec-task/context/task/order.md`.'); + expect(result.orderContent).toContain('Use [Image #1] (`attachments/image-1.png`) as the reference.'); + expect(result.orderContent).toContain('- [Image #1]: `attachments/image-1.png`'); + expect(result.stagedOrderContent).toContain('Use [Image #1] (`.takt/runs/20260216-spec-task/context/task/attachments/image-1.png`) as the reference.'); + expect(result.stagedOrderContent).toContain('- [Image #1]: `.takt/runs/20260216-spec-task/context/task/attachments/image-1.png`'); + expect(stagedOrderContent).toContain('Use [Image #1] (`.takt/runs/20260216-spec-task/context/task/attachments/image-1.png`) as the reference.'); + expect(stagedOrderContent).toContain('- [Image #1]: `.takt/runs/20260216-spec-task/context/task/attachments/image-1.png`'); expect(fs.readFileSync(stagedAttachmentPath, 'utf-8')).toBe('png-data'); }); + it('裸の attachments path も run コンテキスト path へ書き換える', () => { + const projectCwd = createTempProjectDir(); + const execCwd = createTempProjectDir(); + const taskDir = '.takt/tasks/spec-task'; + const sourceTaskDir = path.join(projectCwd, taskDir); + const orderContent = [ + '# Task', + '', + 'Use attachments/image-1.png as the reference.', + '', + '- [Image #1]: attachments/image-1.png', + ].join('\n'); + fs.mkdirSync(path.join(sourceTaskDir, 'attachments'), { recursive: true }); + fs.writeFileSync(path.join(sourceTaskDir, 'order.md'), orderContent, 'utf-8'); + fs.writeFileSync(path.join(sourceTaskDir, 'attachments', 'image-1.png'), 'png-data', 'utf-8'); + + const result = stageTaskSpecForExecution(projectCwd, execCwd, taskDir, '20260216-spec-task'); + + expect(result.orderContent).toContain('Use attachments/image-1.png as the reference.'); + expect(result.orderContent).toContain('- [Image #1]: attachments/image-1.png'); + expect(result.stagedOrderContent).toContain('Use `.takt/runs/20260216-spec-task/context/task/attachments/image-1.png` as the reference.'); + expect(result.stagedOrderContent).toContain('- [Image #1]: `.takt/runs/20260216-spec-task/context/task/attachments/image-1.png`'); + }); + + it('task 添付 path が attachments 外へ出る場合は拒否する', () => { + const projectCwd = createTempProjectDir(); + const execCwd = createTempProjectDir(); + const taskDir = '.takt/tasks/spec-task'; + const sourceTaskDir = path.join(projectCwd, taskDir); + fs.mkdirSync(sourceTaskDir, { recursive: true }); + fs.writeFileSync(path.join(sourceTaskDir, 'order.md'), 'Use `attachments/../secret.png`.', 'utf-8'); + + expect(() => stageTaskSpecForExecution(projectCwd, execCwd, taskDir, '20260216-spec-task')).toThrow( + 'Invalid task attachment path: attachments/../secret.png', + ); + }); + it('symlink の order.md は拒否する', () => { const projectCwd = createTempProjectDir(); const execCwd = createTempProjectDir(); diff --git a/src/__tests__/watchTasks.test.ts b/src/__tests__/watchTasks.test.ts index 9470617a1..5aa0e4a08 100644 --- a/src/__tests__/watchTasks.test.ts +++ b/src/__tests__/watchTasks.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TaskInfo } from '../infra/task/index.js'; const { - mockRecoverInterruptedRunningTasks, + mockFailInterruptedRunningTasks, mockGetTasksFilePath, mockWatch, mockStop, @@ -16,7 +16,7 @@ const { mockWarn, mockError, } = vi.hoisted(() => ({ - mockRecoverInterruptedRunningTasks: vi.fn(), + mockFailInterruptedRunningTasks: vi.fn(), mockGetTasksFilePath: vi.fn(), mockWatch: vi.fn(), mockStop: vi.fn(), @@ -33,7 +33,7 @@ const { vi.mock('../infra/task/index.js', () => ({ TaskRunner: vi.fn().mockImplementation(() => ({ - recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks, + failInterruptedRunningTasks: mockFailInterruptedRunningTasks, getTasksFilePath: mockGetTasksFilePath, })), TaskWatcher: vi.fn().mockImplementation(() => ({ @@ -69,7 +69,7 @@ import { watchTasks } from '../features/tasks/watch/index.js'; describe('watchTasks', () => { beforeEach(() => { vi.clearAllMocks(); - mockRecoverInterruptedRunningTasks.mockReturnValue(0); + mockFailInterruptedRunningTasks.mockReturnValue(0); mockGetTasksFilePath.mockReturnValue('/project/.takt/tasks.yaml'); mockExecuteRunTaskAndComplete.mockResolvedValue(true); @@ -85,13 +85,13 @@ describe('watchTasks', () => { }); }); - it('watch開始時に中断されたrunningタスクをpendingへ復旧する', async () => { - mockRecoverInterruptedRunningTasks.mockReturnValue(1); + it('watch開始時に中断されたrunningタスクをfailedへ倒す', async () => { + mockFailInterruptedRunningTasks.mockReturnValue(1); await watchTasks('/project'); - expect(mockRecoverInterruptedRunningTasks).toHaveBeenCalledTimes(1); - expect(mockInfo).toHaveBeenCalledWith('Recovered 1 interrupted running task(s) to pending.'); + expect(mockFailInterruptedRunningTasks).toHaveBeenCalledTimes(1); + expect(mockInfo).toHaveBeenCalledWith('Marked 1 interrupted running task(s) as failed.'); expect(mockWatch).toHaveBeenCalledTimes(1); expect(mockExecuteRunTaskAndComplete).toHaveBeenCalledTimes(1); }); diff --git a/src/features/interactive/aiCaller.ts b/src/features/interactive/aiCaller.ts index bf28f5fdf..98aa7b1c5 100644 --- a/src/features/interactive/aiCaller.ts +++ b/src/features/interactive/aiCaller.ts @@ -15,6 +15,8 @@ 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'; +import type { ProviderImageAttachment } from '../../infra/providers/types.js'; +import { expandImageAttachmentPlaceholders } from '../../infra/providers/imageAttachmentPrompt.js'; const log = createLogger('ai-caller'); @@ -35,6 +37,10 @@ export interface SessionContext { sessionId: string | undefined; } +interface CallAIWithRetryOptions { + imageAttachments?: ProviderImageAttachment[]; +} + /** * Call AI with automatic retry on stale/invalid session. * @@ -47,6 +53,7 @@ export async function callAIWithRetry( allowedTools: string[], cwd: string, ctx: SessionContext, + options: CallAIWithRetryOptions = {}, ): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> { const display = new StreamDisplay('assistant', isQuietMode()); const abortController = new AbortController(); @@ -68,13 +75,18 @@ export async function callAIWithRetry( try { const agent = ctx.provider.setup({ name: ctx.personaName, systemPrompt }); - const response = await agent.call(prompt, { + const promptForProvider = expandImageAttachmentPlaceholders(prompt, options.imageAttachments); + const nativeImageAttachments = ctx.provider.supportsNativeImageInput + ? options.imageAttachments + : undefined; + const response = await agent.call(promptForProvider, { cwd, model: ctx.model, sessionId, allowedTools, abortSignal: abortController.signal, onStream: display.createHandler(), + imageAttachments: nativeImageAttachments, }); display.flush(); const success = response.status !== 'blocked' && response.status !== 'error'; @@ -84,13 +96,14 @@ export async function callAIWithRetry( sessionId = undefined; const retryDisplay = new StreamDisplay('assistant', isQuietMode()); const retryAgent = ctx.provider.setup({ name: ctx.personaName, systemPrompt }); - const retry = await retryAgent.call(prompt, { + const retry = await retryAgent.call(promptForProvider, { cwd, model: ctx.model, sessionId: undefined, allowedTools, abortSignal: abortController.signal, onStream: retryDisplay.createHandler(), + imageAttachments: nativeImageAttachments, }); retryDisplay.flush(); if (retry.sessionId) { diff --git a/src/features/interactive/clipboardImage.ts b/src/features/interactive/clipboardImage.ts new file mode 100644 index 000000000..3bc571dde --- /dev/null +++ b/src/features/interactive/clipboardImage.ts @@ -0,0 +1,105 @@ +import * as childProcess from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { MAX_INLINE_IMAGE_BYTES, type PastedImage } from './inlineImagePaste.js'; + +const CLIPBOARD_COMMAND_TIMEOUT_MS = 10_000; +const CLIPBOARD_COMMAND_MAX_BUFFER = 1024 * 1024; + +const MACOS_CLIPBOARD_IMAGE_SCRIPT = ` +ObjC.import('AppKit'); +ObjC.import('Foundation'); + +function writePasteboardData(type, outputPath) { + const data = $.NSPasteboard.generalPasteboard.dataForType($(type)); + if (!data) { + return false; + } + if (!data.writeToFileAtomically($(outputPath), true)) { + throw new Error('Failed to write clipboard image data.'); + } + return true; +} + +function run(argv) { + const pngPath = argv[0]; + const tiffPath = argv[1]; + if (writePasteboardData('public.png', pngPath)) { + return 'png'; + } + if (writePasteboardData('public.tiff', tiffPath)) { + return 'tiff'; + } + throw new Error('Clipboard does not contain a PNG or TIFF image.'); +} +`; + +async function assertClipboardImageWithinLimit(filePath: string): Promise { + const stats = await stat(filePath); + if (stats.size > MAX_INLINE_IMAGE_BYTES) { + throw new Error(`Clipboard image exceeds the ${MAX_INLINE_IMAGE_BYTES} byte limit.`); + } +} + +async function execClipboardCommand(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + if (!childProcess.execFile) { + throw new Error('node:child_process.execFile is required to read clipboard images.'); + } + + const execFileAsync = promisify(childProcess.execFile) as ( + command: string, + commandArgs: string[], + options: { timeout: number; maxBuffer: number }, + ) => Promise<{ stdout: string; stderr: string }>; + + return execFileAsync(file, args, { + timeout: CLIPBOARD_COMMAND_TIMEOUT_MS, + maxBuffer: CLIPBOARD_COMMAND_MAX_BUFFER, + }); +} + +async function readMacOSClipboardImage(): Promise { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'takt-clipboard-image-')); + const pngPath = path.join(tempDir, 'clipboard.png'); + const tiffPath = path.join(tempDir, 'clipboard.tiff'); + + try { + const { stdout } = await execClipboardCommand('osascript', [ + '-l', + 'JavaScript', + '-e', + MACOS_CLIPBOARD_IMAGE_SCRIPT, + pngPath, + tiffPath, + ]); + + if (stdout.trim() === 'tiff') { + await execClipboardCommand('sips', [ + '-s', + 'format', + 'png', + tiffPath, + '--out', + pngPath, + ]); + } + + await assertClipboardImageWithinLimit(pngPath); + return { + mimeType: 'image/png', + data: await readFile(pngPath), + }; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +export async function readClipboardImage(): Promise { + if (process.platform !== 'darwin') { + throw new Error('Clipboard image paste is currently supported only on macOS.'); + } + + return readMacOSClipboardImage(); +} diff --git a/src/features/interactive/clipboardImageFeedback.ts b/src/features/interactive/clipboardImageFeedback.ts new file mode 100644 index 000000000..ed1c5ba2a --- /dev/null +++ b/src/features/interactive/clipboardImageFeedback.ts @@ -0,0 +1,6 @@ +import { getErrorMessage } from '../../shared/utils/index.js'; +import { warn } from '../../shared/ui/index.js'; + +export function reportClipboardImagePasteError(error: unknown): void { + warn(`Clipboard image paste failed: ${getErrorMessage(error)}`); +} diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index 892357f84..1633e1d55 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -38,7 +38,11 @@ import { createSessionLogMeta, } from './conversationLogMeta.js'; import { prependInitialPromptContext } from './promptSections.js'; -import { buildInteractiveResultWithAttachments, createSessionImageAttachmentStore } from './imageAttachments.js'; +import { + buildInteractiveResultWithAttachments, + createSessionImageAttachmentStore, + resolvePromptImageAttachments, +} from './imageAttachments.js'; export { type CallAIResult, type SessionContext, callAIWithRetry } from './aiCaller.js'; @@ -142,8 +146,9 @@ export async function runConversationLoop( /** Helper: call AI with current session and update session state */ async function doCallAI(prompt: string, sysPrompt: string, tools: string[]): Promise { + const imageAttachments = resolvePromptImageAttachments(prompt, attachmentStore.listAttachments()); const { result, sessionId: newSessionId } = await callAIWithRetry( - prompt, sysPrompt, tools, cwd, { ...ctx, sessionId }, + prompt, sysPrompt, tools, cwd, { ...ctx, sessionId }, { imageAttachments }, ); sessionId = newSessionId; return result; @@ -286,6 +291,7 @@ export async function runConversationLoop( const { result: summaryResult } = await callAIWithRetry( summaryPrompt, summaryPrompt, strategy.allowedTools, cwd, { ...ctx, sessionId: undefined }, + { imageAttachments: resolvePromptImageAttachments(summaryPrompt, attachmentStore.listAttachments()) }, ); if (!summaryResult) { info(ui.summarizeFailed); @@ -327,6 +333,11 @@ export async function runConversationLoop( } continue; } + + case SlashCommand.PasteImage: { + info(ui.pasteImageUnavailable); + continue; + } } } } diff --git a/src/features/interactive/imageAttachments.ts b/src/features/interactive/imageAttachments.ts index 8ebd11af7..c112d6fc0 100644 --- a/src/features/interactive/imageAttachments.ts +++ b/src/features/interactive/imageAttachments.ts @@ -2,8 +2,10 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import type { ProviderImageAttachment } from '../../infra/providers/types.js'; import type { InteractiveModeResult } from './interactive.js'; import type { ImagePasteHandler } from './inlineImagePaste.js'; +import { readClipboardImage } from './clipboardImage.js'; export interface InteractiveImageAttachment { placeholder: string; @@ -23,6 +25,7 @@ export interface ImageAttachmentStoreOptions { const PRIVATE_DIRECTORY_MODE = 0o700; const PRIVATE_FILE_MODE = 0o600; +const IMAGE_PLACEHOLDER_PATTERN = /\[Image #\d+\]/g; function extensionForMimeType(mimeType: string): string { switch (mimeType) { @@ -106,3 +109,24 @@ export function createImagePasteHandler(attachmentStore: ImageAttachmentStore): return attachment.placeholder; }; } + +export function createClipboardImagePasteHandler(attachmentStore: ImageAttachmentStore): () => Promise { + return async () => { + const image = await readClipboardImage(); + const attachment = await attachmentStore.saveImage(image.data, image.mimeType); + return attachment.placeholder; + }; +} + +export function resolvePromptImageAttachments( + prompt: string, + attachments: readonly InteractiveImageAttachment[], +): ProviderImageAttachment[] { + const referencedPlaceholders = new Set(prompt.match(IMAGE_PLACEHOLDER_PATTERN) ?? []); + return attachments + .filter((attachment) => referencedPlaceholders.has(attachment.placeholder)) + .map((attachment) => ({ + placeholder: attachment.placeholder, + path: attachment.tempPath, + })); +} diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 0e6631b85..9e02d1ef9 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -56,6 +56,7 @@ export interface InteractiveUIText { playNoTask: string; retryNoOrder: string; retryUnavailable: string; + pasteImageUnavailable: string; } /** diff --git a/src/features/interactive/interactiveInput.ts b/src/features/interactive/interactiveInput.ts index 4379f524b..366b3d440 100644 --- a/src/features/interactive/interactiveInput.ts +++ b/src/features/interactive/interactiveInput.ts @@ -3,7 +3,8 @@ import { getLabel } from '../../shared/i18n/index.js'; import { readMultilineInput } from './lineEditor.js'; import { filterSlashCommands, type CommandAvailability } from './slashCommandRegistry.js'; import type { CompletionCandidate, CompletionContext, CompletionProvider } from './completionMenu.js'; -import { createImagePasteHandler, type ImageAttachmentStore } from './imageAttachments.js'; +import { createClipboardImagePasteHandler, createImagePasteHandler, type ImageAttachmentStore } from './imageAttachments.js'; +import { reportClipboardImagePasteError } from './clipboardImageFeedback.js'; /** * Build localized slash-command completion candidates for the current input. @@ -77,5 +78,7 @@ export const readInteractiveInput = ( completionProvider: createSlashCommandCompletionProvider(lang, availability), ...(imageAttachmentStore ? { onImagePaste: createImagePasteHandler(imageAttachmentStore), + onClipboardImagePaste: createClipboardImagePasteHandler(imageAttachmentStore), + onClipboardImagePasteError: reportClipboardImagePasteError, } : {}), }); diff --git a/src/features/interactive/lineEditor.ts b/src/features/interactive/lineEditor.ts index 90500a3f7..3c4399242 100644 --- a/src/features/interactive/lineEditor.ts +++ b/src/features/interactive/lineEditor.ts @@ -10,6 +10,7 @@ import * as readline from 'node:readline'; import { StringDecoder } from 'node:string_decoder'; import { stripAnsi, getDisplayWidth } from '../../shared/utils/text.js'; +import { SlashCommand } from '../../shared/constants.js'; import { createCompletionController } from './completionController.js'; import type { CompletionProvider } from './completionMenu.js'; import { @@ -32,8 +33,19 @@ const KITTY_KB_DISABLE = '\x1B[; +type EscapeCallbackName = Exclude; +type DecodedEscapeSequence = + | { kind: 'callback'; callback: EscapeCallbackName; consumed: number } + | { kind: 'char'; ch: string; consumed: number } + | { kind: 'ignore'; consumed: number } + | { kind: 'bareEsc' } + | { kind: 'incomplete' }; function splitTrailingInlineImagePrefix(input: string): { ready: string; pending: string } { const maxCandidateLength = Math.min(OSC_IMAGE_PREFIX.length - 1, input.length); @@ -92,95 +104,117 @@ function decodeCtrlKey(rest: string): { ch: string; consumed: number } | null { /** Callbacks for parsed input events */ export interface InputCallbacks { - onPasteStart: () => void; - onPasteEnd: () => void; - onShiftEnter: () => void; - onArrowLeft: () => void; - onArrowRight: () => void; - onArrowUp: () => void; - onArrowDown: () => void; - onWordLeft: () => void; - onWordRight: () => void; - onHome: () => void; - onEnd: () => void; - onEsc: () => void; - onChar: (ch: string) => void; + onPasteStart: () => InputCallbackResult; + onPasteEnd: () => InputCallbackResult; + onShiftEnter: () => InputCallbackResult; + onArrowLeft: () => InputCallbackResult; + onArrowRight: () => InputCallbackResult; + onArrowUp: () => InputCallbackResult; + onArrowDown: () => InputCallbackResult; + onWordLeft: () => InputCallbackResult; + onWordRight: () => InputCallbackResult; + onHome: () => InputCallbackResult; + onEnd: () => InputCallbackResult; + onEsc: () => InputCallbackResult; + onChar: (ch: string) => InputCallbackResult; } -/** - * Try to consume an escape sequence starting after the leading \x1B. - * - * Returns the number of characters consumed (excluding the \x1B itself), - * or -1 if the rest is a potential incomplete CSI/SS3 prefix that needs - * more data, or 0 if the \x1B is a bare Esc. - */ -const tryConsumeEscapeSequence = ( - rest: string, - callbacks: InputCallbacks, -): number => { +const decodeEscapeSequence = (rest: string): DecodedEscapeSequence => { if (rest.startsWith(ESC_PASTE_START)) { - callbacks.onPasteStart(); - return ESC_PASTE_START.length; + return { kind: 'callback', callback: 'onPasteStart', consumed: ESC_PASTE_START.length }; } if (rest.startsWith(ESC_PASTE_END)) { - callbacks.onPasteEnd(); - return ESC_PASTE_END.length; + return { kind: 'callback', callback: 'onPasteEnd', consumed: ESC_PASTE_END.length }; } if (rest.startsWith(ESC_SHIFT_ENTER)) { - callbacks.onShiftEnter(); - return ESC_SHIFT_ENTER.length; + return { kind: 'callback', callback: 'onShiftEnter', consumed: ESC_SHIFT_ENTER.length }; } const ctrlKey = decodeCtrlKey(rest); if (ctrlKey) { - callbacks.onChar(ctrlKey.ch); - return ctrlKey.consumed; + return { kind: 'char', ch: ctrlKey.ch, consumed: ctrlKey.consumed }; } - // Arrow keys - if (rest.startsWith('[D')) { callbacks.onArrowLeft(); return 2; } - if (rest.startsWith('[C')) { callbacks.onArrowRight(); return 2; } - if (rest.startsWith('[A')) { callbacks.onArrowUp(); return 2; } - if (rest.startsWith('[B')) { callbacks.onArrowDown(); return 2; } + if (rest.startsWith('[D')) return { kind: 'callback', callback: 'onArrowLeft', consumed: 2 }; + if (rest.startsWith('[C')) return { kind: 'callback', callback: 'onArrowRight', consumed: 2 }; + if (rest.startsWith('[A')) return { kind: 'callback', callback: 'onArrowUp', consumed: 2 }; + if (rest.startsWith('[B')) return { kind: 'callback', callback: 'onArrowDown', consumed: 2 }; - // Option+Arrow (CSI modified): \x1B[1;3D (left), \x1B[1;3C (right) - if (rest.startsWith('[1;3D')) { callbacks.onWordLeft(); return 5; } - if (rest.startsWith('[1;3C')) { callbacks.onWordRight(); return 5; } + if (rest.startsWith('[1;3D')) return { kind: 'callback', callback: 'onWordLeft', consumed: 5 }; + if (rest.startsWith('[1;3C')) return { kind: 'callback', callback: 'onWordRight', consumed: 5 }; - // Option+Arrow (SS3/alt): \x1Bb (left), \x1Bf (right) - if (rest.startsWith('b')) { callbacks.onWordLeft(); return 1; } - if (rest.startsWith('f')) { callbacks.onWordRight(); return 1; } + if (rest.startsWith('b')) return { kind: 'callback', callback: 'onWordLeft', consumed: 1 }; + if (rest.startsWith('f')) return { kind: 'callback', callback: 'onWordRight', consumed: 1 }; - // Home: \x1B[H (CSI) or \x1BOH (SS3/application mode) - if (rest.startsWith('[H') || rest.startsWith('OH')) { callbacks.onHome(); return 2; } + if (rest.startsWith('[H') || rest.startsWith('OH')) return { kind: 'callback', callback: 'onHome', consumed: 2 }; - // End: \x1B[F (CSI) or \x1BOF (SS3/application mode) - if (rest.startsWith('[F') || rest.startsWith('OF')) { callbacks.onEnd(); return 2; } + if (rest.startsWith('[F') || rest.startsWith('OF')) return { kind: 'callback', callback: 'onEnd', consumed: 2 }; - // Kitty keyboard protocol: ESC key → \x1B[27u or \x1B[27;1u const kittyEscMatch = rest.match(/^\[27(?:;1)?u/); if (kittyEscMatch) { - callbacks.onEsc(); - return kittyEscMatch[0].length; + return { kind: 'callback', callback: 'onEsc', consumed: kittyEscMatch[0].length }; } - // Unknown CSI sequences: skip if (rest.startsWith('[')) { const csiMatch = rest.match(/^\[[0-9;]*[A-Za-z~]/); - if (csiMatch) return csiMatch[0].length; - // Incomplete CSI — need more data - return -1; + if (csiMatch) return { kind: 'ignore', consumed: csiMatch[0].length }; + return { kind: 'incomplete' }; } - // SS3 prefix ('O') without a recognized follower — could be incomplete - if (rest.startsWith('O') && rest.length === 1) return -1; + if (rest.startsWith('O') && rest.length === 1) return { kind: 'incomplete' }; + if (rest.length === 0) return { kind: 'incomplete' }; + + return { kind: 'bareEsc' }; +}; - // Bare Esc (followed by a non-sequence character or nothing) - if (rest.length === 0) return -1; +const dispatchEscapeSequence = (decoded: DecodedEscapeSequence, callbacks: InputCallbacks): number => { + switch (decoded.kind) { + case 'callback': + callbacks[decoded.callback](); + return decoded.consumed; + case 'char': + callbacks.onChar(decoded.ch); + return decoded.consumed; + case 'ignore': + return decoded.consumed; + case 'bareEsc': + callbacks.onEsc(); + return 0; + case 'incomplete': + return -1; + } +}; - callbacks.onEsc(); - return 0; +const dispatchEscapeSequenceAsync = async ( + decoded: DecodedEscapeSequence, + callbacks: InputCallbacks, +): Promise => { + switch (decoded.kind) { + case 'callback': + await callbacks[decoded.callback](); + return decoded.consumed; + case 'char': + await callbacks.onChar(decoded.ch); + return decoded.consumed; + case 'ignore': + return decoded.consumed; + case 'bareEsc': + await callbacks.onEsc(); + return 0; + case 'incomplete': + return -1; + } }; +const tryConsumeEscapeSequence = ( + rest: string, + callbacks: InputCallbacks, +): number => dispatchEscapeSequence(decodeEscapeSequence(rest), callbacks); + +const tryConsumeEscapeSequenceAsync = async ( + rest: string, + callbacks: InputCallbacks, +): Promise => dispatchEscapeSequenceAsync(decodeEscapeSequence(rest), callbacks); + /** * Parse raw stdin data into semantic input events. * @@ -222,7 +256,7 @@ const ESC_AMBIGUITY_TIMEOUT_MS = 50 as const; */ export const createEscapeParser = ( callbacks: InputCallbacks, -): { feed: (data: string) => void; flush: () => void } => { +): { feed: (data: string) => Promise; flush: () => void } => { let pendingFragment = ''; let escTimer: ReturnType | null = null; @@ -241,7 +275,7 @@ export const createEscapeParser = ( } }; - const feed = (data: string): void => { + const feed = async (data: string): Promise => { let input = data; if (pendingFragment.length > 0) { @@ -263,7 +297,7 @@ export const createEscapeParser = ( return; } - const consumed = tryConsumeEscapeSequence(rest, callbacks); + const consumed = await tryConsumeEscapeSequenceAsync(rest, callbacks); if (consumed === -1) { pendingFragment = input.slice(i); escTimer = setTimeout(flush, ESC_AMBIGUITY_TIMEOUT_MS); @@ -274,7 +308,7 @@ export const createEscapeParser = ( continue; } - callbacks.onChar(ch); + await callbacks.onChar(ch); i++; } }; @@ -299,6 +333,8 @@ export function readMultilineInput( options?: { completionProvider?: CompletionProvider; onImagePaste?: ImagePasteHandler; + onClipboardImagePaste?: () => Promise; + onClipboardImagePasteError?: (error: unknown) => void; }, ): Promise { if (!process.stdin.isTTY) { @@ -687,6 +723,7 @@ export function readMultilineInput( let pendingInlineImage = ''; let inlineImagePrefixTimer: ReturnType | null = null; let inputQueue = Promise.resolve(); + let pendingEditorOperation = Promise.resolve(); let settled = false; function clearInlineImagePrefixTimer(): void { @@ -743,6 +780,18 @@ export function readMultilineInput( rerenderFromCursor(); } + function enqueueEditorOperation(operation: () => Promise): void { + pendingEditorOperation = pendingEditorOperation.then(async () => { + if (!settled) { + await operation(); + } + }); + } + + async function drainPendingEditorOperation(): Promise { + await pendingEditorOperation; + } + async function handleInlineImage(image: PastedImage): Promise { if (!options?.onImagePaste) { return; @@ -759,6 +808,57 @@ export function readMultilineInput( completion.update(); } + async function handleClipboardImagePaste(): Promise { + const pasteClipboardImage = options?.onClipboardImagePaste; + if (!pasteClipboardImage) { + return; + } + completion.hide(); + const placeholder = await pasteClipboardImage().catch((error: unknown) => { + options?.onClipboardImagePasteError?.(error); + return null; + }); + if (!placeholder) { + return; + } + if (settled) { + return; + } + insertText(placeholder); + completion.update(); + } + + function insertClipboardImagePlaceholder(pasteClipboardImage: () => Promise): void { + completion.hide(); + enqueueEditorOperation(async () => { + const placeholder = await pasteClipboardImage().catch((error: unknown) => { + options?.onClipboardImagePasteError?.(error); + return null; + }); + if (!placeholder) { + return; + } + if (settled) { + return; + } + insertText(placeholder); + completion.update(); + }); + } + + function tryHandleClipboardImageCommand(): boolean { + const pasteClipboardImage = options?.onClipboardImagePaste; + if (buffer.trim() !== SlashCommand.PasteImage || buffer.includes('\n') || !pasteClipboardImage) { + return false; + } + + completion.hide(); + moveCursorToLogicalLineStart(); + deleteToLineEnd(); + insertClipboardImagePlaceholder(pasteClipboardImage); + return true; + } + async function feedInputWithImages(input: string): Promise { clearInlineImagePrefixTimer(); const currentInput = pendingInlineImage + input; @@ -766,11 +866,48 @@ export function readMultilineInput( let offset = 0; while (offset < currentInput.length) { + if (state === 'paste') { + const pasteEnd = currentInput.indexOf(PASTE_END_SEQUENCE, offset); + if (pasteEnd === -1) { + await escParser.feed(currentInput.slice(offset)); + return; + } + if (pasteEnd > offset) { + await escParser.feed(currentInput.slice(offset, pasteEnd)); + } + await escParser.feed(PASTE_END_SEQUENCE); + offset = pasteEnd + PASTE_END_SEQUENCE.length; + continue; + } + + const pasteStart = currentInput.indexOf(PASTE_START_SEQUENCE, offset); const imageStart = currentInput.indexOf(OSC_IMAGE_PREFIX, offset); + const clipboardImageStart = options?.onClipboardImagePaste + ? currentInput.indexOf(CTRL_V, offset) + : -1; + if ( + pasteStart !== -1 + && (imageStart === -1 || pasteStart < imageStart) + && (clipboardImageStart === -1 || pasteStart < clipboardImageStart) + ) { + await escParser.feed(currentInput.slice(offset, pasteStart + PASTE_START_SEQUENCE.length)); + offset = pasteStart + PASTE_START_SEQUENCE.length; + continue; + } + + if (clipboardImageStart !== -1 && (imageStart === -1 || clipboardImageStart < imageStart)) { + if (clipboardImageStart > offset) { + await escParser.feed(currentInput.slice(offset, clipboardImageStart)); + } + await handleClipboardImagePaste(); + offset = clipboardImageStart + CTRL_V.length; + continue; + } + if (imageStart === -1) { const tail = splitTrailingInlineImagePrefix(currentInput.slice(offset)); if (tail.ready.length > 0) { - escParser.feed(tail.ready); + await escParser.feed(tail.ready); } if (tail.pending.length > 0) { holdPendingInlineImage(tail.pending); @@ -779,7 +916,7 @@ export function readMultilineInput( } if (imageStart > offset) { - escParser.feed(currentInput.slice(offset, imageStart)); + await escParser.feed(currentInput.slice(offset, imageStart)); } const sequence = parseInlineImageSequence(currentInput, imageStart); @@ -792,7 +929,7 @@ export function readMultilineInput( if (sequence.status === 'image') { await handleInlineImage(sequence.image); } else { - escParser.feed(currentInput.slice(imageStart, sequence.sequenceEnd)); + await escParser.feed(currentInput.slice(imageStart, sequence.sequenceEnd)); } offset = sequence.sequenceEnd; } @@ -931,7 +1068,7 @@ export function readMultilineInput( onEsc() { completion.hide(); }, - onChar(ch: string) { + onChar(ch: string): InputCallbackResult { if (state === 'paste') { if (ch === '\r' || ch === '\n') { insertAt(cursorPos, '\n'); @@ -951,10 +1088,16 @@ export function readMultilineInput( } return; } + if (ch === CTRL_V && options?.onClipboardImagePaste) { + return handleClipboardImagePaste(); + } // Submit if (ch === '\r' || ch === '\n') { completion.acceptSelection(); + if (tryHandleClipboardImageCommand()) { + return; + } process.stdout.write('\n'); finish(buffer); return; @@ -1001,6 +1144,7 @@ export function readMultilineInput( } return feedInputWithImages(str); }) + .then(drainPendingEditorOperation) .catch(fail); } catch (error) { fail(error); diff --git a/src/features/interactive/passthroughMode.ts b/src/features/interactive/passthroughMode.ts index d5e124d8c..1d64f0bd1 100644 --- a/src/features/interactive/passthroughMode.ts +++ b/src/features/interactive/passthroughMode.ts @@ -12,9 +12,11 @@ import { readMultilineInput } from './lineEditor.js'; import type { InteractiveModeResult } from './interactive.js'; import { buildInteractiveResultWithAttachments, + createClipboardImagePasteHandler, createImagePasteHandler, createSessionImageAttachmentStore, } from './imageAttachments.js'; +import { reportClipboardImagePasteError } from './clipboardImageFeedback.js'; /** * Run passthrough mode: collect user input and return it as-is. @@ -41,6 +43,8 @@ export async function passthroughMode( const input = await readMultilineInput(chalk.green('> '), { onImagePaste: createImagePasteHandler(attachmentStore), + onClipboardImagePaste: createClipboardImagePasteHandler(attachmentStore), + onClipboardImagePasteError: reportClipboardImagePasteError, }); if (input === null) { diff --git a/src/features/interactive/quietMode.ts b/src/features/interactive/quietMode.ts index a543926e8..ee539c0ce 100644 --- a/src/features/interactive/quietMode.ts +++ b/src/features/interactive/quietMode.ts @@ -27,9 +27,12 @@ import { import { initializeSession } from './sessionInitialization.js'; import { buildInteractiveResultWithAttachments, + createClipboardImagePasteHandler, createImagePasteHandler, createSessionImageAttachmentStore, + resolvePromptImageAttachments, } from './imageAttachments.js'; +import { reportClipboardImagePasteError } from './clipboardImageFeedback.js'; const log = createLogger('quiet-mode'); @@ -65,6 +68,8 @@ export async function quietMode( const input = await readMultilineInput(chalk.green('> '), { onImagePaste: createImagePasteHandler(attachmentStore), + onClipboardImagePaste: createClipboardImagePasteHandler(attachmentStore), + onClipboardImagePasteError: reportClipboardImagePasteError, }); if (input === null) { blankLine(); @@ -94,6 +99,7 @@ export async function quietMode( const { result } = await callAIWithRetry( summaryPrompt, summaryPrompt, DEFAULT_INTERACTIVE_TOOLS, cwd, { ...ctx, sessionId: undefined }, + { imageAttachments: resolvePromptImageAttachments(summaryPrompt, attachmentStore.listAttachments()) }, ); if (!result) { diff --git a/src/features/interactive/slashCommandRegistry.ts b/src/features/interactive/slashCommandRegistry.ts index 1d764dd25..b4eb7404a 100644 --- a/src/features/interactive/slashCommandRegistry.ts +++ b/src/features/interactive/slashCommandRegistry.ts @@ -16,6 +16,7 @@ const SLASH_COMMAND_LABEL_KEYS: Readonly> = { '/replay': 'interactive.commands.replay', '/cancel': 'interactive.commands.cancel', '/resume': 'interactive.commands.resume', + '/paste-image': 'interactive.commands.pasteImage', } as const; /** diff --git a/src/features/tasks/attachments.ts b/src/features/tasks/attachments.ts index f15cf3764..8dfa8a6a6 100644 --- a/src/features/tasks/attachments.ts +++ b/src/features/tasks/attachments.ts @@ -29,11 +29,12 @@ export function buildTaskOrderContent( return taskContent; } + const normalizedTaskContent = normalizeTaskAttachmentReferences(taskContent, attachments); const attachmentLines = attachments.map((attachment) => `- ${attachment.placeholder}: \`${getTaskAttachmentRelativePath(attachment)}\``, ); return [ - taskContent.trimEnd(), + normalizedTaskContent.trimEnd(), '', '## 添付画像', '', @@ -45,6 +46,26 @@ function getTaskAttachmentRelativePath(attachment: TaskAttachment): string { return path.posix.join('attachments', attachment.fileName); } +function normalizeTaskAttachmentReferences( + taskContent: string, + attachments: readonly TaskAttachment[], +): string { + return attachments.reduce((content, attachment) => { + const relativePath = getTaskAttachmentRelativePath(attachment); + const pathVariants = new Set([ + attachment.tempPath, + attachment.tempPath.replace(/\\/g, '/'), + ]); + let normalized = content; + for (const tempPath of pathVariants) { + normalized = normalized + .split(`\`${tempPath}\``).join(`\`${relativePath}\``) + .split(tempPath).join(`\`${relativePath}\``); + } + return normalized; + }, taskContent); +} + function validateTaskAttachment(attachment: TaskAttachment): void { if (attachment.fileName.includes('/') || attachment.fileName.includes('\\') || attachment.fileName === '') { throw new Error(`Invalid task attachment file name: ${attachment.fileName}`); diff --git a/src/features/tasks/execute/runAllTasks.ts b/src/features/tasks/execute/runAllTasks.ts index 96d60da51..b2bd015af 100644 --- a/src/features/tasks/execute/runAllTasks.ts +++ b/src/features/tasks/execute/runAllTasks.ts @@ -39,9 +39,9 @@ export async function runAllTasks( && globalConfig.notificationSoundEvents?.runAbort !== false; const concurrency = globalConfig.concurrency; const slackWebhookUrl = getSlackWebhookUrl(); - const recovered = taskRunner.recoverInterruptedRunningTasks(); - if (recovered > 0) { - info(`Recovered ${recovered} interrupted running task(s) to pending.`); + const failedInterrupted = taskRunner.failInterruptedRunningTasks(); + if (failedInterrupted > 0) { + info(`Marked ${failedInterrupted} interrupted running task(s) as failed.`); } const initialTasks = taskRunner.claimNextTasks(concurrency); diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 24593fb1a..0eec25ce6 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -134,7 +134,6 @@ export async function selectAndExecuteTask( } } const preparedSpecTaskDirToCleanup = options?.skipTaskList === true ? preparedSpec?.taskDir : undefined; - const stagedSpecToCleanup = options?.skipTaskList === true ? stagedSpec : undefined; const startedAt = new Date().toISOString(); statusLine.start('Running...'); @@ -160,7 +159,7 @@ export async function selectAndExecuteTask( throw err; } finally { statusLine.stop(); - cleanupTransientTaskSpecs(preparedSpecTaskDirToCleanup, stagedSpecToCleanup); + cleanupTransientTaskSpecs(preparedSpecTaskDirToCleanup, undefined); } const completedAt = new Date().toISOString(); diff --git a/src/features/tasks/execute/taskSpecContext.ts b/src/features/tasks/execute/taskSpecContext.ts index 7521919c8..660c58f2b 100644 --- a/src/features/tasks/execute/taskSpecContext.ts +++ b/src/features/tasks/execute/taskSpecContext.ts @@ -8,6 +8,7 @@ import { readTaskSpecFile } from '../taskSpecFile.js'; export interface StagedTaskSpec { taskPrompt: string; orderContent: string; + stagedOrderContent: string; contextTaskDir: string; contextDir: string; runRootDir: string; @@ -23,6 +24,35 @@ function removeEmptyDirectory(directory: string): void { } } +function rewriteAttachmentPathsForRunContext(orderContent: string, contextTaskRel: string): string { + const contextTaskRelPosix = contextTaskRel.replace(/\\/g, '/'); + const toRunContextPath = (attachmentPath: string): string => { + const segments = attachmentPath.split('/'); + if (segments.some((segment) => segment === '..' || segment.length === 0)) { + throw new Error(`Invalid task attachment path: attachments/${attachmentPath}`); + } + return path.posix.join(contextTaskRelPosix, 'attachments', attachmentPath); + }; + const splitTrailingPunctuation = (attachmentPath: string): { pathPart: string; suffix: string } => { + const match = attachmentPath.match(/^(.+?)([.!?,;:]*)$/); + return { + pathPart: match?.[1] ?? attachmentPath, + suffix: match?.[2] ?? '', + }; + }; + const backticked = orderContent.replace(/`attachments\/([^`\r\n]+)`/g, (_match, attachmentPath: string) => + `\`${toRunContextPath(attachmentPath)}\``, + ); + return backticked.replace(/(^|[\s([:])attachments\/([A-Za-z0-9._/-]+)/g, ( + _match, + prefix: string, + attachmentPath: string, + ) => { + const { pathPart, suffix } = splitTrailingPunctuation(attachmentPath); + return `${prefix}\`${toRunContextPath(pathPart)}\`${suffix}`; + }); +} + export function stageTaskSpecForExecution( projectCwd: string, execCwd: string, @@ -33,10 +63,11 @@ export function stageTaskSpecForExecution( const sourceOrderPath = getTaskSpecPath(projectCwd, taskDir); const orderContent = readTaskSpecFile(sourceOrderPath); const runPaths = buildRunPaths(execCwd, reportDirName); + const stagedOrderContent = rewriteAttachmentPathsForRunContext(orderContent, runPaths.contextTaskRel); try { fs.mkdirSync(runPaths.contextTaskAbs, { recursive: true }); - fs.writeFileSync(runPaths.contextTaskOrderAbs, orderContent, 'utf-8'); + fs.writeFileSync(runPaths.contextTaskOrderAbs, stagedOrderContent, 'utf-8'); copyTaskAttachmentsToRunContext(sourceTaskDir, runPaths.contextTaskAbs); } catch (error) { fs.rmSync(runPaths.contextTaskAbs, { recursive: true, force: true }); @@ -46,6 +77,7 @@ export function stageTaskSpecForExecution( return { taskPrompt: buildTaskInstruction(runPaths.contextTaskRel, runPaths.contextTaskOrderRel), orderContent, + stagedOrderContent, contextTaskDir: runPaths.contextTaskAbs, contextDir: runPaths.contextAbs, runRootDir: runPaths.runRootAbs, diff --git a/src/features/tasks/watch/index.ts b/src/features/tasks/watch/index.ts index 58a3b150b..53f9e62a9 100644 --- a/src/features/tasks/watch/index.ts +++ b/src/features/tasks/watch/index.ts @@ -47,7 +47,7 @@ function resolveWatchExecutionOptions(options?: RunAllTasksOptions): { export async function watchTasks(cwd: string, options?: RunAllTasksOptions): Promise { const taskRunner = new TaskRunner(cwd, { onWarning: warn }); const watcher = new TaskWatcher(cwd); - const recovered = taskRunner.recoverInterruptedRunningTasks(); + const failedInterrupted = taskRunner.failInterruptedRunningTasks(); const { agentOverrides, runContext } = resolveWatchExecutionOptions(options); let taskCount = 0; @@ -56,8 +56,8 @@ export async function watchTasks(cwd: string, options?: RunAllTasksOptions): Pro header('TAKT Watch Mode'); info(`Watching: ${taskRunner.getTasksFilePath()}`); - if (recovered > 0) { - info(`Recovered ${recovered} interrupted running task(s) to pending.`); + if (failedInterrupted > 0) { + info(`Marked ${failedInterrupted} interrupted running task(s) as failed.`); } info('Waiting for tasks... (Ctrl+C to stop)'); blankLine(); diff --git a/src/infra/claude/client.ts b/src/infra/claude/client.ts index 4ebba93a4..a216bafa0 100644 --- a/src/infra/claude/client.ts +++ b/src/infra/claude/client.ts @@ -54,6 +54,7 @@ export class ClaudeClient { outputSchema: options.outputSchema, sandbox: options.sandbox, pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable, + imageAttachments: options.imageAttachments, }; } diff --git a/src/infra/claude/executor.ts b/src/infra/claude/executor.ts index 36e94827f..ce6d68944 100644 --- a/src/infra/claude/executor.ts +++ b/src/infra/claude/executor.ts @@ -36,6 +36,7 @@ import { containsRateLimitMarker, resolveRateLimitErrorMessage, } from '../rate-limit/detection.js'; +import { buildClaudePromptInput } from './image-input.js'; import { extractClaudeProviderUsage } from './usage.js'; const log = createLogger('claude-sdk'); @@ -215,7 +216,7 @@ export class QueryExecutor { }; try { - const q = query({ prompt, options: sdkOptions }); + const q = query({ prompt: buildClaudePromptInput(prompt, options.imageAttachments), options: sdkOptions }); registerQuery(queryId, q); if (options.abortSignal) { const interruptQuery = () => { diff --git a/src/infra/claude/image-input.ts b/src/infra/claude/image-input.ts new file mode 100644 index 000000000..a001a3df0 --- /dev/null +++ b/src/infra/claude/image-input.ts @@ -0,0 +1,81 @@ +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.js'; +import type { ProviderImageAttachment } from '../providers/types.js'; +import { formatImageAttachmentPathReference } from '../providers/imageAttachmentPrompt.js'; + +type ClaudeImageMediaType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; + +function inferMediaType(filePath: string): ClaudeImageMediaType { + switch (path.extname(filePath).toLowerCase()) { + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + case '.png': + default: + return 'image/png'; + } +} + +async function readImageAttachment(attachment: ProviderImageAttachment): Promise { + try { + return await readFile(attachment.path); + } catch (error) { + throw new Error(`Failed to read image attachment at ${attachment.path}`, { cause: error }); + } +} + +async function buildAttachmentContentBlocks(attachment: ProviderImageAttachment): Promise { + const data = await readImageAttachment(attachment); + return [ + { type: 'text', text: formatImageAttachmentPathReference(attachment) }, + { + type: 'image', + source: { + type: 'base64', + media_type: inferMediaType(attachment.path), + data: data.toString('base64'), + }, + }, + ]; +} + +async function buildContentBlocks( + prompt: string, + imageAttachments: readonly ProviderImageAttachment[], +): Promise { + const attachmentBlocks = await Promise.all(imageAttachments.map(buildAttachmentContentBlocks)); + return [ + { type: 'text', text: prompt }, + ...attachmentBlocks.flat(), + ]; +} + +async function* createClaudeUserMessageStream( + prompt: string, + imageAttachments: readonly ProviderImageAttachment[], +): AsyncGenerator { + yield { + type: 'user', + message: { + role: 'user', + content: await buildContentBlocks(prompt, imageAttachments), + }, + parent_tool_use_id: null, + }; +} + +export function buildClaudePromptInput( + prompt: string, + imageAttachments: readonly ProviderImageAttachment[] | undefined, +): string | AsyncIterable { + if (!imageAttachments || imageAttachments.length === 0) { + return prompt; + } + return createClaudeUserMessageStream(prompt, imageAttachments); +} diff --git a/src/infra/claude/types.ts b/src/infra/claude/types.ts index a5133b993..4b4869051 100644 --- a/src/infra/claude/types.ts +++ b/src/infra/claude/types.ts @@ -9,6 +9,7 @@ import type { PermissionUpdate, AgentDefinition, SandboxSettings } from '@anthro import type { PermissionMode, McpServerConfig } from '../../core/models/index.js'; import type { ClaudeEffort } from '../../core/models/workflow-types.js'; import type { AgentErrorKind, ProviderUsageSnapshot, RateLimitInfo } from '../../core/models/response.js'; +import type { ProviderImageAttachment } from '../providers/types.js'; import type { StreamEvent as SharedStreamEvent, StreamCallback as SharedStreamCallback, @@ -129,6 +130,7 @@ export interface ClaudeCallOptions { sandbox?: SandboxSettings; /** Custom path to Claude Code executable */ pathToClaudeCodeExecutable?: string; + imageAttachments?: ProviderImageAttachment[]; } /** Options for spawning a Claude SDK query (low-level, used by executor/process) */ @@ -165,4 +167,5 @@ export interface ClaudeSpawnOptions { sandbox?: SandboxSettings; /** Custom path to Claude Code executable */ pathToClaudeCodeExecutable?: string; + imageAttachments?: ProviderImageAttachment[]; } diff --git a/src/infra/codex/client.ts b/src/infra/codex/client.ts index d11b16d15..03613c300 100644 --- a/src/infra/codex/client.ts +++ b/src/infra/codex/client.ts @@ -4,7 +4,7 @@ * Uses @openai/codex-sdk for native TypeScript integration. */ -import { Codex, type TurnOptions } from '@openai/codex-sdk'; +import { Codex, type Input, type TurnOptions } from '@openai/codex-sdk'; import { USAGE_MISSING_REASONS } from '../../core/logging/contracts.js'; import type { AgentResponse, ProviderUsageSnapshot } from '../../core/models/index.js'; import { createLogger, getErrorMessage, createStreamDiagnostics, parseStructuredOutput, type StreamDiagnostics } from '../../shared/utils/index.js'; @@ -20,6 +20,7 @@ import { } from '../../shared/types/agent-failure.js'; import type { StreamToolUseEventData } from '../../shared/types/provider.js'; import { mapToCodexSandboxMode, type CodexCallOptions } from './types.js'; +import { formatImageAttachmentPathReference } from '../providers/imageAttachmentPrompt.js'; import { type CodexEvent, type CodexItem, @@ -284,6 +285,15 @@ export class CodexClient { const fullPrompt = options.systemPrompt ? `${options.systemPrompt}\n\n${prompt}` : prompt; + const input: Input = options.imageAttachments && options.imageAttachments.length > 0 + ? [ + { type: 'text', text: fullPrompt }, + ...options.imageAttachments.flatMap((attachment) => [ + { type: 'text' as const, text: formatImageAttachmentPathReference(attachment) }, + { type: 'local_image' as const, path: attachment.path }, + ]), + ] + : fullPrompt; let standardRetryCount = 0; let timeoutRetryCount = 0; @@ -345,7 +355,7 @@ export class CodexClient { signal: streamAbortController.signal, ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), }; - const { events } = await thread.runStreamed(fullPrompt, turnOptions); + const { events } = await thread.runStreamed(input, turnOptions); resetIdleTimeout(); diag.onConnected(); diff --git a/src/infra/codex/types.ts b/src/infra/codex/types.ts index 24c9a8be5..1611cfbe6 100644 --- a/src/infra/codex/types.ts +++ b/src/infra/codex/types.ts @@ -4,6 +4,7 @@ import type { PermissionMode } from '../../core/models/index.js'; import type { CodexReasoningEffort } from '../../core/models/workflow-types.js'; +import type { ProviderImageAttachment } from '../providers/types.js'; import type { StreamCallback } from '../../shared/types/provider.js'; /** Codex sandbox mode values */ @@ -39,4 +40,5 @@ export interface CodexCallOptions { codexPathOverride?: string; /** JSON Schema for structured output */ outputSchema?: Record; + imageAttachments?: ProviderImageAttachment[]; } diff --git a/src/infra/providers/claude-headless.ts b/src/infra/providers/claude-headless.ts index 77ad4bc18..69d109dd7 100644 --- a/src/infra/providers/claude-headless.ts +++ b/src/infra/providers/claude-headless.ts @@ -28,6 +28,7 @@ function toHeadlessOptions(options: ProviderCallOptions): ClaudeHeadlessCallOpti export class ClaudeHeadlessProvider implements Provider { readonly supportsStructuredOutput = true; + readonly supportsNativeImageInput = false; setup(config: AgentSetup): ProviderAgent { const { name, systemPrompt } = config; diff --git a/src/infra/providers/claude-terminal.ts b/src/infra/providers/claude-terminal.ts index 0b13cd67c..ee2d20fcd 100644 --- a/src/infra/providers/claude-terminal.ts +++ b/src/infra/providers/claude-terminal.ts @@ -69,6 +69,7 @@ function toTerminalOptions(options: ProviderCallOptions): ClaudeTerminalCallOpti export class ClaudeTerminalProvider implements Provider { readonly supportsStructuredOutput = true; + readonly supportsNativeImageInput = false; setup(config: AgentSetup): ProviderAgent { const { name, systemPrompt } = config; diff --git a/src/infra/providers/claude.ts b/src/infra/providers/claude.ts index f40350ea7..258e1f8b9 100644 --- a/src/infra/providers/claude.ts +++ b/src/infra/providers/claude.ts @@ -25,6 +25,7 @@ function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions { bypassPermissions: options.bypassPermissions, anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(), outputSchema: options.outputSchema, + imageAttachments: options.imageAttachments, sandbox: claudeSandbox ? { allowUnsandboxedCommands: claudeSandbox.allowUnsandboxedCommands, excludedCommands: claudeSandbox.excludedCommands, @@ -35,6 +36,7 @@ function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions { export class ClaudeProvider implements Provider { readonly supportsStructuredOutput = true; + readonly supportsNativeImageInput = true; setup(config: AgentSetup): ProviderAgent { const { name, systemPrompt } = config; diff --git a/src/infra/providers/codex.ts b/src/infra/providers/codex.ts index d9caa2cb5..371e69ccf 100644 --- a/src/infra/providers/codex.ts +++ b/src/infra/providers/codex.ts @@ -20,12 +20,14 @@ function toCodexOptions(options: ProviderCallOptions): CodexCallOptions { openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(), codexPathOverride: resolveCodexCliPath(), outputSchema: options.outputSchema, + imageAttachments: options.imageAttachments, }; } /** Codex provider — delegates to OpenAI Codex SDK */ export class CodexProvider implements Provider { readonly supportsStructuredOutput = true; + readonly supportsNativeImageInput = true; setup(config: AgentSetup): ProviderAgent { const { name, systemPrompt } = config; diff --git a/src/infra/providers/copilot.ts b/src/infra/providers/copilot.ts index be7e52b0c..5f45acd5d 100644 --- a/src/infra/providers/copilot.ts +++ b/src/infra/providers/copilot.ts @@ -37,6 +37,7 @@ function toCopilotOptions(options: ProviderCallOptions): CopilotCallOptions { /** Copilot provider — delegates to GitHub Copilot CLI */ export class CopilotProvider implements Provider { readonly supportsStructuredOutput = false; + readonly supportsNativeImageInput = false; setup(config: AgentSetup): ProviderAgent { const { name, systemPrompt } = config; diff --git a/src/infra/providers/cursor.ts b/src/infra/providers/cursor.ts index 505878c3d..a0beb0a11 100644 --- a/src/infra/providers/cursor.ts +++ b/src/infra/providers/cursor.ts @@ -36,6 +36,7 @@ function toCursorOptions(options: ProviderCallOptions): CursorCallOptions { /** Cursor provider — delegates to Cursor Agent CLI */ export class CursorProvider implements Provider { readonly supportsStructuredOutput = false; + readonly supportsNativeImageInput = false; setup(config: AgentSetup): ProviderAgent { const { name, systemPrompt } = config; diff --git a/src/infra/providers/imageAttachmentPrompt.ts b/src/infra/providers/imageAttachmentPrompt.ts new file mode 100644 index 000000000..ee574c70a --- /dev/null +++ b/src/infra/providers/imageAttachmentPrompt.ts @@ -0,0 +1,18 @@ +import type { ProviderImageAttachment } from './types.js'; + +export function formatImageAttachmentPathReference(attachment: ProviderImageAttachment): string { + return `${attachment.placeholder} path: \`${attachment.path}\``; +} + +export function expandImageAttachmentPlaceholders( + prompt: string, + imageAttachments: readonly ProviderImageAttachment[] | undefined, +): string { + if (!imageAttachments || imageAttachments.length === 0) { + return prompt; + } + + return imageAttachments.reduce((expanded, attachment) => + expanded.split(attachment.placeholder).join(`${attachment.placeholder} (\`${attachment.path}\`)`), + prompt); +} diff --git a/src/infra/providers/kiro.ts b/src/infra/providers/kiro.ts index 6015fa670..aadf6eab7 100644 --- a/src/infra/providers/kiro.ts +++ b/src/infra/providers/kiro.ts @@ -22,6 +22,9 @@ function toKiroOptions(options: ProviderCallOptions, systemPrompt?: string): Kir if (options.model) { log.info('Kiro provider does not support model CLI flag; ignoring'); } + if (options.imageAttachments && options.imageAttachments.length > 0) { + log.info('Kiro provider does not support imageAttachments; ignoring'); + } return { cwd: options.cwd, @@ -37,6 +40,7 @@ function toKiroOptions(options: ProviderCallOptions, systemPrompt?: string): Kir export class KiroProvider implements Provider { readonly supportsStructuredOutput = false; + readonly supportsNativeImageInput = false; setup(config: AgentSetup): ProviderAgent { const { name, systemPrompt } = config; diff --git a/src/infra/providers/mock.ts b/src/infra/providers/mock.ts index d449443b9..e530d17f9 100644 --- a/src/infra/providers/mock.ts +++ b/src/infra/providers/mock.ts @@ -19,6 +19,7 @@ function toMockOptions(options: ProviderCallOptions): MockCallOptions { /** Mock provider — deterministic responses for testing */ export class MockProvider implements Provider { readonly supportsStructuredOutput = true; + readonly supportsNativeImageInput = false; setup(config: AgentSetup): ProviderAgent { const { name, systemPrompt } = config; diff --git a/src/infra/providers/opencode.ts b/src/infra/providers/opencode.ts index 0a4e6c0cd..4f5ae16ef 100644 --- a/src/infra/providers/opencode.ts +++ b/src/infra/providers/opencode.ts @@ -31,6 +31,7 @@ function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions { /** OpenCode provider — delegates to OpenCode SDK */ export class OpenCodeProvider implements Provider { readonly supportsStructuredOutput = true; + readonly supportsNativeImageInput = false; setup(config: AgentSetup): ProviderAgent { const { name, systemPrompt } = config; diff --git a/src/infra/providers/provider-capabilities.ts b/src/infra/providers/provider-capabilities.ts index 9116bff91..bdf7ebb32 100644 --- a/src/infra/providers/provider-capabilities.ts +++ b/src/infra/providers/provider-capabilities.ts @@ -34,6 +34,7 @@ const MAX_TURNS_PROVIDERS = new Set([ interface ProviderCapabilities { supportsStructuredOutput: boolean; + supportsNativeImageInput: boolean; supportsMcpServers: boolean; supportsAllowedTools: boolean; supportsClaudeAllowedTools: boolean; @@ -47,8 +48,11 @@ function resolveProviderCapabilities( return undefined; } + const providerImpl = getProvider(provider); + return { - supportsStructuredOutput: getProvider(provider).supportsStructuredOutput, + supportsStructuredOutput: providerImpl.supportsStructuredOutput, + supportsNativeImageInput: providerImpl.supportsNativeImageInput, supportsMcpServers: MCP_SERVER_PROVIDERS.has(provider), supportsAllowedTools: ALLOWED_TOOLS_PROVIDERS.has(provider), supportsClaudeAllowedTools: CLAUDE_ALLOWED_TOOLS_PROVIDERS.has(provider), @@ -62,6 +66,12 @@ export function providerSupportsStructuredOutput( return resolveProviderCapabilities(provider)?.supportsStructuredOutput; } +export function providerSupportsNativeImageInput( + provider: ProviderType | undefined, +): boolean | undefined { + return resolveProviderCapabilities(provider)?.supportsNativeImageInput; +} + export function providerSupportsMcpServers( provider: ProviderType | undefined, ): boolean | undefined { diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index 1aa9c198e..133f3cbd5 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -8,6 +8,11 @@ export interface AgentSetup { systemPrompt?: string; } +export interface ProviderImageAttachment { + placeholder: string; + path: string; +} + export interface ProviderCallOptions { cwd: string; abortSignal?: AbortSignal; @@ -29,6 +34,7 @@ export interface ProviderCallOptions { copilotGithubToken?: string; kiroApiKey?: string; outputSchema?: Record; + imageAttachments?: ProviderImageAttachment[]; } export interface ProviderAgent { @@ -37,6 +43,7 @@ export interface ProviderAgent { export interface Provider { supportsStructuredOutput: boolean; + supportsNativeImageInput: boolean; setup(config: AgentSetup): ProviderAgent; } diff --git a/src/infra/task/clone-exec.ts b/src/infra/task/clone-exec.ts index 11ba5b21b..8e47ef364 100644 --- a/src/infra/task/clone-exec.ts +++ b/src/infra/task/clone-exec.ts @@ -134,6 +134,40 @@ export async function fetchRemoteBranchIntoIsolatedCloneAbortable( } } +export function fetchBaseBranchIntoIsolatedClone(projectDir: string, clonePath: string, branch: string): void { + try { + runIsolatedGitCommandSync(clonePath, [ + 'fetch', + '--no-write-fetch-head', + projectDir, + `refs/remotes/origin/${branch}:refs/takt/base/${branch}`, + ]); + } catch { + throw new Error(REMOTE_BRANCH_FETCH_FAILED_MESSAGE); + } +} + +export async function fetchBaseBranchIntoIsolatedCloneAbortable( + projectDir: string, + clonePath: string, + branch: string, + abortSignal?: AbortSignal, +): Promise { + try { + await runIsolatedGitCommandAbortable(clonePath, [ + 'fetch', + '--no-write-fetch-head', + projectDir, + `refs/remotes/origin/${branch}:refs/takt/base/${branch}`, + ], abortSignal); + } catch (err) { + if (isTaskAbortError(err)) { + throw err; + } + throw new Error(REMOTE_BRANCH_FETCH_FAILED_MESSAGE); + } +} + export function cloneAndIsolate(projectDir: string, clonePath: string, branch?: string): void { const cloneSubmoduleOptions = resolveCloneSubmoduleOptions(projectDir); const useReferenceClone = !isLinkedWorktree(projectDir); diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index f5e5ad827..61ada605f 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -17,6 +17,8 @@ import { cloneAndIsolateAbortable, fetchRemoteBranchIntoIsolatedClone, fetchRemoteBranchIntoIsolatedCloneAbortable, + fetchBaseBranchIntoIsolatedClone, + fetchBaseBranchIntoIsolatedCloneAbortable, resolveCloneSubmoduleOptions, runGitCommandAbortable, } from './clone-exec.js'; @@ -161,6 +163,7 @@ export class CloneManager { const { branch: baseBranch, fetchedCommit } = CloneManager.resolveBaseBranch(projectDir, options.baseBranch); cloneAndIsolate(projectDir, clonePath, baseBranch); if (fetchedCommit) { + fetchBaseBranchIntoIsolatedClone(projectDir, clonePath, baseBranch); execFileSync('git', ['reset', '--hard', fetchedCommit], { cwd: clonePath, stdio: 'pipe' }); } execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' }); @@ -209,6 +212,7 @@ export class CloneManager { ); await cloneAndIsolateAbortable(projectDir, clonePath, baseBranch, abortSignal); if (fetchedCommit) { + await fetchBaseBranchIntoIsolatedCloneAbortable(projectDir, clonePath, baseBranch, abortSignal); await runGitCommandAbortable(clonePath, ['reset', '--hard', fetchedCommit], abortSignal); } await runGitCommandAbortable(clonePath, ['checkout', '-b', branch], abortSignal); diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index 14733d40c..dbf82e73f 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -66,8 +66,8 @@ export class TaskRunner { return this.lifecycle.claimNextTasks(count); } - recoverInterruptedRunningTasks(): number { - return this.lifecycle.recoverInterruptedRunningTasks(); + failInterruptedRunningTasks(): number { + return this.lifecycle.failInterruptedRunningTasks(); } completeTask(result: TaskResult): string { diff --git a/src/infra/task/taskLifecycleService.ts b/src/infra/task/taskLifecycleService.ts index 84494baa2..af60cf2cd 100644 --- a/src/infra/task/taskLifecycleService.ts +++ b/src/infra/task/taskLifecycleService.ts @@ -8,7 +8,6 @@ import { isStaleRunningTask } from './process.js'; import { readRetryMetadataByRunSlug } from '../../core/workflow/run/retry-metadata.js'; import { buildClaimedTaskRecord, - buildRecoveredTaskRecordWithRetryMetadata, type ResolvedTaskRetryMetadata, buildTerminalTaskRecord, generateTaskName, @@ -82,39 +81,26 @@ export class TaskLifecycleService { return claimed.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task)); } - recoverInterruptedRunningTasks(): number { - let recovered = 0; + failInterruptedRunningTasks(): number { + let failed = 0; this.store.update((current) => { const tasks = current.tasks.map((task) => { if (task.status !== 'running' || !this.isRunningTaskStale(task)) { return task; } - recovered++; - return buildRecoveredTaskRecordWithRetryMetadata( - task, - this.readRecoveryRetryMetadata(task), - ); + failed++; + return buildTerminalTaskRecord(task, { + status: 'failed', + completed_at: nowIso(), + owner_pid: null, + failure: { + error: 'Task was interrupted before this TAKT run started. Requeue it explicitly to run again.', + }, + }, this.readTerminalRetryMetadata(task)); }); return { tasks }; }); - return recovered; - } - - private readRecoveryRetryMetadata(task: TaskRecord): ResolvedTaskRetryMetadata { - if (!task.run_slug) { - return { preserveExisting: true }; - } - - const retryMetadata = this.readTerminalRetryMetadata(task); - if (retryMetadata.preserveExisting) { - return retryMetadata; - } - - if (!retryMetadata.startStep) { - return { preserveExisting: true }; - } - - return { startStep: retryMetadata.startStep }; + return failed; } private readTerminalRetryMetadata(task: TaskRecord): ResolvedTaskRetryMetadata { diff --git a/src/infra/task/taskRecordMutations.ts b/src/infra/task/taskRecordMutations.ts index 9708ef555..a5af226c5 100644 --- a/src/infra/task/taskRecordMutations.ts +++ b/src/infra/task/taskRecordMutations.ts @@ -30,22 +30,6 @@ export function buildClaimedTaskRecord(task: TaskRecord): TaskRecord { }; } -export function buildRecoveredTaskRecordWithRetryMetadata( - task: TaskRecord, - retryMetadata: ResolvedTaskRetryMetadata, -): TaskRecord { - const nextTask = retryMetadata.preserveExisting ? { ...task } : clearRetryMetadata(task); - return { - ...nextTask, - status: 'pending', - started_at: null, - completed_at: null, - owner_pid: null, - run_slug: undefined, - ...(retryMetadata.startStep ? { start_step: retryMetadata.startStep } : {}), - }; -} - export function buildTerminalTaskRecord( task: TaskRecord, updates: TerminalTaskUpdates, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index c6651a32e..e7dbd0475 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -20,5 +20,6 @@ export const SlashCommand = { Replay: '/replay', Cancel: '/cancel', Resume: '/resume', + PasteImage: '/paste-image', } as const; export type SlashCommand = typeof SlashCommand[keyof typeof SlashCommand]; diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 1578498c7..e1004886d 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -11,7 +11,7 @@ interactive: conversationLabel: "Conversation:" noTranscript: "(No local transcript. Summarize the current session context.)" ui: - intro: "Interactive mode - describe your task. Commands: /go (create instruction & run), /play (run now), /accept (use latest assistant response), /resume (load session), /cancel (exit)" + intro: "Interactive mode - describe your task. Commands: /go (create instruction & run), /play (run now), /accept (use latest assistant response), /paste-image (attach clipboard image), /resume (load session), /cancel (exit)" introQuiet: "Quiet mode - describe your task. Instructions will be generated without further questions." introPassthrough: "Passthrough mode - describe your task. Your input will be passed directly as the task." resume: "Resuming previous session" @@ -32,6 +32,7 @@ interactive: playNoTask: "Please specify task content: /play " retryNoOrder: "No previous order (order.md) found. /retry is only available during retry." retryUnavailable: "/retry is only available in Retry mode from `takt list`." + pasteImageUnavailable: "/paste-image is only available for image attachment during interactive input." personaFallback: "No persona available for the first step. Falling back to assistant mode." modeSelection: prompt: "Select interactive mode:" @@ -70,6 +71,7 @@ interactive: replay: "Rerun immediately with previous instructions" cancel: "Exit interactive mode" resume: "Load a previous session" + pasteImage: "Attach clipboard image" # ===== Workflow Execution UI ===== workflow: diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index e302d6988..00aec47bd 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -11,7 +11,7 @@ interactive: conversationLabel: "会話:" noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)" ui: - intro: "対話モード - タスク内容を入力してください。コマンド: /go(指示書作成・実行), /play(即実行), /accept(最新のアシスタント発言を採用), /resume(セッション読込), /cancel(終了)" + intro: "対話モード - タスク内容を入力してください。コマンド: /go(指示書作成・実行), /play(即実行), /accept(最新のアシスタント発言を採用), /paste-image(クリップボード画像を添付), /resume(セッション読込), /cancel(終了)" introQuiet: "クワイエットモード - タスク内容を入力してください。追加質問なしで指示書を生成します。" introPassthrough: "パススルーモード - タスク内容を入力してください。入力内容をそのまま実行します。" resume: "前回のセッションを再開します" @@ -32,6 +32,7 @@ interactive: playNoTask: "タスク内容を指定してください: /play <タスク内容>" retryNoOrder: "前回の指示書(order.md)が見つかりません。/retry はリトライ時のみ使用できます。" retryUnavailable: "/retry は `takt list` の Retry モードでのみ使用できます。" + pasteImageUnavailable: "/paste-image は対話入力中の画像添付にのみ使用できます。" personaFallback: "先頭ステップにペルソナがありません。アシスタントモードにフォールバックします。" modeSelection: prompt: "対話モードを選択してください:" @@ -70,6 +71,7 @@ interactive: replay: "前回の指示書で即再実行" cancel: "対話モードを終了" resume: "セッションを読み込む" + pasteImage: "クリップボード画像を添付" # ===== Workflow Execution UI ===== workflow: diff --git a/vitest.config.e2e.mock.ts b/vitest.config.e2e.mock.ts index dc353cacf..4759fc3c3 100644 --- a/vitest.config.e2e.mock.ts +++ b/vitest.config.e2e.mock.ts @@ -23,6 +23,7 @@ export default defineConfig({ 'e2e/specs/cycle-detection.e2e.ts', 'e2e/specs/run-multiple-tasks.e2e.ts', 'e2e/specs/task-status-persistence.e2e.ts', + 'e2e/specs/run-recovery.e2e.ts', 'e2e/specs/session-log.e2e.ts', 'e2e/specs/model-override.e2e.ts', 'e2e/specs/provider-override.e2e.ts',