From ef0eeb057f0395b5c8cdecbf1493a2f6f4f65c4a Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:05:21 +0900 Subject: [PATCH 1/4] Skip copying tasks/ dir during project init (TASK-FORMAT is no longer needed) --- src/__tests__/initialization.test.ts | 11 ----------- src/infra/resources/index.ts | 1 + 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/__tests__/initialization.test.ts b/src/__tests__/initialization.test.ts index 97181653..e20f9347 100644 --- a/src/__tests__/initialization.test.ts +++ b/src/__tests__/initialization.test.ts @@ -80,17 +80,6 @@ describe('copyProjectResourcesToDir', () => { expect(existsSync(join(testProjectDir, '.gitignore'))).toBe(true); expect(existsSync(join(testProjectDir, 'dotgitignore'))).toBe(false); }); - - it('should copy tasks/TASK-FORMAT to target directory', () => { - const resourcesDir = getProjectResourcesDir(); - if (!existsSync(join(resourcesDir, 'tasks', 'TASK-FORMAT'))) { - return; // Skip if resource file doesn't exist - } - - copyProjectResourcesToDir(testProjectDir); - - expect(existsSync(join(testProjectDir, 'tasks', 'TASK-FORMAT'))).toBe(true); - }); }); describe('getLanguageResourcesDir', () => { diff --git a/src/infra/resources/index.ts b/src/infra/resources/index.ts index e2fdcf82..e9cd2fdb 100644 --- a/src/infra/resources/index.ts +++ b/src/infra/resources/index.ts @@ -53,6 +53,7 @@ export function copyProjectResourcesToDir(targetDir: string): void { return; } copyDirRecursive(resourcesDir, targetDir, { + skipDirs: ['tasks'], renameMap: { dotgitignore: '.gitignore' }, }); } From b4a224c0f0fdbc2ac224da2c6e46bc6340545a11 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:35:19 +0900 Subject: [PATCH 2/4] =?UTF-8?q?opencode=20=E3=81=AB=E5=AF=BE=E3=81=97?= =?UTF-8?q?=E3=81=A6=20report=20fase=20=E3=81=AF=20deny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/opencode-client-cleanup.test.ts | 46 ++++++++++ src/__tests__/opencode-types.test.ts | 5 +- src/infra/codex/client.ts | 19 ++++- src/infra/opencode/client.ts | 20 ++++- src/infra/opencode/types.ts | 7 +- src/shared/utils/index.ts | 1 + src/shared/utils/streamDiagnostics.ts | 84 +++++++++++++++++++ 7 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 src/shared/utils/streamDiagnostics.ts diff --git a/src/__tests__/opencode-client-cleanup.test.ts b/src/__tests__/opencode-client-cleanup.test.ts index af74d2cf..b4ebd592 100644 --- a/src/__tests__/opencode-client-cleanup.test.ts +++ b/src/__tests__/opencode-client-cleanup.test.ts @@ -399,6 +399,52 @@ describe('OpenCodeClient stream cleanup', () => { ); }); + it('should pass empty tools object to promptAsync when allowedTools is an explicit empty array', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-empty-tools', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-empty-tools' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const subscribe = vi.fn().mockResolvedValue({ stream }); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('coder', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + allowedTools: [], + }); + + expect(result.status).toBe('done'); + expect(promptAsync).toHaveBeenCalledWith( + expect.objectContaining({ + tools: {}, + }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + it('should configure allow permissions for edit mode', async () => { const { OpenCodeClient } = await import('../infra/opencode/client.js'); const stream = new MockEventStream([ diff --git a/src/__tests__/opencode-types.test.ts b/src/__tests__/opencode-types.test.ts index e8ad9b3f..f661940b 100644 --- a/src/__tests__/opencode-types.test.ts +++ b/src/__tests__/opencode-types.test.ts @@ -54,7 +54,10 @@ describe('mapToOpenCodeTools', () => { it('should return undefined when tools are not provided', () => { expect(mapToOpenCodeTools(undefined)).toBeUndefined(); - expect(mapToOpenCodeTools([])).toBeUndefined(); + }); + + it('should return empty tool map when explicit empty tools are provided', () => { + expect(mapToOpenCodeTools([])).toEqual({}); }); }); diff --git a/src/infra/codex/client.ts b/src/infra/codex/client.ts index e0d43ec4..1fb5da4d 100644 --- a/src/infra/codex/client.ts +++ b/src/infra/codex/client.ts @@ -6,7 +6,7 @@ import { Codex } from '@openai/codex-sdk'; import type { AgentResponse } from '../../core/models/index.js'; -import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; +import { createLogger, getErrorMessage, createStreamDiagnostics, type StreamDiagnostics } from '../../shared/utils/index.js'; import { mapToCodexSandboxMode, type CodexCallOptions } from './types.js'; import { type CodexEvent, @@ -113,12 +113,14 @@ export class CodexClient { const streamAbortController = new AbortController(); const timeoutMessage = `Codex stream timed out after ${Math.floor(CODEX_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; let abortCause: 'timeout' | 'external' | undefined; + let diagRef: StreamDiagnostics | undefined; const resetIdleTimeout = (): void => { if (idleTimeoutId !== undefined) { clearTimeout(idleTimeoutId); } idleTimeoutId = setTimeout(() => { + diagRef?.onIdleTimeoutFired(); abortCause = 'timeout'; streamAbortController.abort(); }, CODEX_STREAM_IDLE_TIMEOUT_MS); @@ -145,10 +147,14 @@ export class CodexClient { attempt, }); + const diag = createStreamDiagnostics('codex-sdk', { agentType, model: options.model, attempt }); + diagRef = diag; + const { events } = await thread.runStreamed(fullPrompt, { signal: streamAbortController.signal, }); resetIdleTimeout(); + diag.onConnected(); let content = ''; const contentOffsets = new Map(); @@ -158,6 +164,8 @@ export class CodexClient { for await (const event of events as AsyncGenerator) { resetIdleTimeout(); + diag.onFirstEvent(event.type); + diag.onEvent(event.type); if (event.type === 'thread.started') { currentThreadId = typeof event.thread_id === 'string' ? event.thread_id : currentThreadId; @@ -170,12 +178,14 @@ export class CodexClient { if (event.error && typeof event.error === 'object' && 'message' in event.error) { failureMessage = String((event.error as { message?: unknown }).message ?? ''); } + diag.onStreamError('turn.failed', failureMessage); break; } if (event.type === 'error') { success = false; failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error'; + diag.onStreamError('error', failureMessage); break; } @@ -237,6 +247,8 @@ export class CodexClient { } } + diag.onCompleted(success ? 'normal' : 'error', success ? undefined : failureMessage); + if (!success) { const message = failureMessage || 'Codex execution failed'; const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause); @@ -275,6 +287,11 @@ export class CodexClient { : CODEX_STREAM_ABORTED_MESSAGE : message; + diagRef?.onCompleted( + abortCause === 'timeout' ? 'timeout' : streamAbortController.signal.aborted ? 'abort' : 'error', + errorMessage, + ); + const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause); if (retriable && attempt < CODEX_RETRY_MAX_ATTEMPTS) { log.info('Retrying Codex call after transient exception', { agentType, attempt, errorMessage }); diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index d8537979..18c70b66 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -8,7 +8,7 @@ import { createOpencode } from '@opencode-ai/sdk/v2'; import { createServer } from 'node:net'; import type { AgentResponse } from '../../core/models/index.js'; -import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; +import { createLogger, getErrorMessage, createStreamDiagnostics, type StreamDiagnostics } from '../../shared/utils/index.js'; import { parseProviderModel } from '../../shared/utils/providerModel.js'; import { buildOpenCodePermissionConfig, @@ -251,6 +251,7 @@ export class OpenCodeClient { const streamAbortController = new AbortController(); const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; let abortCause: 'timeout' | 'external' | undefined; + let diagRef: StreamDiagnostics | undefined; let serverClose: (() => void) | undefined; let opencodeApiClient: Awaited>['client'] | undefined; @@ -259,6 +260,7 @@ export class OpenCodeClient { clearTimeout(idleTimeoutId); } idleTimeoutId = setTimeout(() => { + diagRef?.onIdleTimeoutFired(); abortCause = 'timeout'; streamAbortController.abort(); }, OPENCODE_STREAM_IDLE_TIMEOUT_MS); @@ -285,6 +287,9 @@ export class OpenCodeClient { attempt, }); + const diag = createStreamDiagnostics('opencode-sdk', { agentType, model: options.model, attempt }); + diagRef = diag; + const parsedModel = parseProviderModel(options.model, 'OpenCode model'); const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; const port = await getFreePort(); @@ -321,6 +326,7 @@ export class OpenCodeClient { { signal: streamAbortController.signal }, ); resetIdleTimeout(); + diag.onConnected(); const tools = mapToOpenCodeTools(options.allowedTools); await client.session.promptAsync( @@ -349,6 +355,8 @@ export class OpenCodeClient { resetIdleTimeout(); const sseEvent = event as OpenCodeStreamEvent; + diag.onFirstEvent(sseEvent.type); + diag.onEvent(sseEvent.type); if (sseEvent.type === 'message.part.updated') { const props = sseEvent.properties as { part: OpenCodePart; delta?: string }; const part = props.part; @@ -458,6 +466,7 @@ export class OpenCodeClient { if (streamError) { success = false; failureMessage = streamError; + diag.onStreamError('message.updated', streamError); break; } } @@ -479,6 +488,7 @@ export class OpenCodeClient { if (streamError) { success = false; failureMessage = streamError; + diag.onStreamError('message.completed', streamError); break; } } @@ -498,6 +508,7 @@ export class OpenCodeClient { if (isCurrentAssistantMessage) { success = false; failureMessage = extractOpenCodeErrorMessage(info?.error) ?? 'OpenCode message failed'; + diag.onStreamError('message.failed', failureMessage); break; } continue; @@ -530,6 +541,7 @@ export class OpenCodeClient { if (!errorProps.sessionID || errorProps.sessionID === sessionId) { success = false; failureMessage = errorProps.error?.data?.message ?? 'OpenCode session error'; + diag.onStreamError('session.error', failureMessage); break; } continue; @@ -537,6 +549,7 @@ export class OpenCodeClient { } content = [...textContentParts.values()].join('\n'); + diag.onCompleted(success ? 'normal' : 'error', success ? undefined : failureMessage); if (!success) { const message = failureMessage || 'OpenCode execution failed'; @@ -575,6 +588,11 @@ export class OpenCodeClient { : OPENCODE_STREAM_ABORTED_MESSAGE : message; + diagRef?.onCompleted( + abortCause === 'timeout' ? 'timeout' : streamAbortController.signal.aborted ? 'abort' : 'error', + errorMessage, + ); + const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause); if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) { log.info('Retrying OpenCode call after transient exception', { agentType, attempt, errorMessage }); diff --git a/src/infra/opencode/types.ts b/src/infra/opencode/types.ts index 490561d7..fb5fa5a5 100644 --- a/src/infra/opencode/types.ts +++ b/src/infra/opencode/types.ts @@ -127,9 +127,12 @@ const BUILTIN_TOOL_MAP: Record = { }; export function mapToOpenCodeTools(allowedTools?: string[]): Record | undefined { - if (!allowedTools || allowedTools.length === 0) { + if (!allowedTools) { return undefined; } + if (allowedTools.length === 0) { + return {}; + } const mapped = new Set(); for (const tool of allowedTools) { @@ -142,7 +145,7 @@ export function mapToOpenCodeTools(allowedTools?: string[]): Record = {}; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 340d55ca..69050cb8 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -10,6 +10,7 @@ export * from './reportDir.js'; export * from './slackWebhook.js'; export * from './sleep.js'; export * from './slug.js'; +export * from './streamDiagnostics.js'; export * from './taskPaths.js'; export * from './text.js'; export * from './types.js'; diff --git a/src/shared/utils/streamDiagnostics.ts b/src/shared/utils/streamDiagnostics.ts new file mode 100644 index 00000000..5a8b15b3 --- /dev/null +++ b/src/shared/utils/streamDiagnostics.ts @@ -0,0 +1,84 @@ +/** + * Stream lifecycle diagnostics for provider clients. + * + * Tracks connection, iteration, event counts, and completion + * to fill the observability gap between stream start and timeout/error. + * All output is debug-level only. + */ + +import { createLogger } from './debug.js'; + +export interface StreamDiagnostics { + /** Call when the stream connection resolves (runStreamed / subscribe) */ + onConnected(): void; + /** Call at the top of each for-await iteration (logs only on first call) */ + onFirstEvent(eventType: string): void; + /** Call for each event to track count and last type (no log output) */ + onEvent(eventType: string): void; + /** Call when the idle timeout callback fires (before abort) */ + onIdleTimeoutFired(): void; + /** Call when error events are received (turn.failed, session.error, etc.) */ + onStreamError(eventType: string, message: string): void; + /** Call on stream completion with reason */ + onCompleted(reason: 'normal' | 'timeout' | 'abort' | 'error', detail?: string): void; +} + +export function createStreamDiagnostics( + component: string, + context: Record, +): StreamDiagnostics { + const log = createLogger(component); + const startTime = Date.now(); + let eventCount = 0; + let lastEventType = ''; + let lastEventTime = 0; + let connected = false; + let firstEventLogged = false; + + return { + onConnected() { + connected = true; + log.debug('Stream connected', { ...context, elapsedMs: Date.now() - startTime }); + }, + + onFirstEvent(eventType: string) { + if (firstEventLogged) return; + firstEventLogged = true; + log.debug('Stream first event', { ...context, firstEventType: eventType, elapsedMs: Date.now() - startTime }); + }, + + onEvent(eventType: string) { + eventCount++; + lastEventType = eventType; + lastEventTime = Date.now(); + }, + + onIdleTimeoutFired() { + log.debug('Idle timeout fired', { + ...context, + eventCount, + lastEventType, + msSinceLastEvent: lastEventTime > 0 ? Date.now() - lastEventTime : undefined, + connected, + iterationStarted: firstEventLogged, + }); + }, + + onStreamError(eventType: string, message: string) { + log.debug('Stream error event', { ...context, eventType, message, eventCount }); + }, + + onCompleted(reason: 'normal' | 'timeout' | 'abort' | 'error', detail?: string) { + log.debug('Stream completed', { + ...context, + reason, + detail, + eventCount, + lastEventType, + durationMs: Date.now() - startTime, + connected, + iterationStarted: firstEventLogged, + }); + }, + }; +} From 0d73007c3f1c7fd37f4610c76fd95ea54f5f4f39 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:46:22 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E3=82=BB=E3=83=83=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=81=8C=E8=A6=8B=E3=81=A4=E3=81=8B=E3=82=89=E3=81=AA?= =?UTF-8?q?=E3=81=84=E5=A0=B4=E5=90=88=E3=81=AB=20info=20=E3=83=A1?= =?UTF-8?q?=E3=83=83=E3=82=BB=E3=83=BC=E3=82=B8=E3=82=92=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/sessionSelector.test.ts | 8 ++++++++ src/features/interactive/sessionSelector.ts | 2 ++ src/shared/i18n/labels_en.yaml | 1 + src/shared/i18n/labels_ja.yaml | 1 + 4 files changed, 12 insertions(+) diff --git a/src/__tests__/sessionSelector.test.ts b/src/__tests__/sessionSelector.test.ts index 9bb7941d..87c3a437 100644 --- a/src/__tests__/sessionSelector.test.ts +++ b/src/__tests__/sessionSelector.test.ts @@ -19,10 +19,17 @@ vi.mock('../shared/prompt/index.js', () => ({ selectOption: (...args: [string, unknown[]]) => mockSelectOption(...args), })); +const mockInfo = vi.fn<(message: string) => void>(); + +vi.mock('../shared/ui/index.js', () => ({ + info: (...args: [string]) => mockInfo(...args), +})); + vi.mock('../shared/i18n/index.js', () => ({ getLabel: (key: string, _lang: string, params?: Record) => { if (key === 'interactive.sessionSelector.newSession') return 'New session'; if (key === 'interactive.sessionSelector.newSessionDescription') return 'Start a new conversation'; + if (key === 'interactive.sessionSelector.noSessions') return 'No sessions found'; if (key === 'interactive.sessionSelector.messages') return `${params?.count} messages`; if (key === 'interactive.sessionSelector.lastResponse') return `Last: ${params?.response}`; if (key === 'interactive.sessionSelector.prompt') return 'Select a session'; @@ -44,6 +51,7 @@ describe('selectRecentSession', () => { expect(result).toBeNull(); expect(mockSelectOption).not.toHaveBeenCalled(); + expect(mockInfo).toHaveBeenCalledWith('No sessions found'); }); it('should return null when user selects __new__', async () => { diff --git a/src/features/interactive/sessionSelector.ts b/src/features/interactive/sessionSelector.ts index c7fd6116..f353a70a 100644 --- a/src/features/interactive/sessionSelector.ts +++ b/src/features/interactive/sessionSelector.ts @@ -8,6 +8,7 @@ import { loadSessionIndex, extractLastAssistantResponse } from '../../infra/claude/session-reader.js'; import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js'; import { getLabel } from '../../shared/i18n/index.js'; +import { info } from '../../shared/ui/index.js'; /** Maximum number of sessions to display */ const MAX_DISPLAY_SESSIONS = 10; @@ -53,6 +54,7 @@ export async function selectRecentSession( const sessions = loadSessionIndex(cwd); if (sessions.length === 0) { + info(getLabel('interactive.sessionSelector.noSessions', lang)); return null; } diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 5826eb6c..c5fea741 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -40,6 +40,7 @@ interactive: prompt: "Resume from a recent session?" newSession: "New session" newSessionDescription: "Start a fresh conversation" + noSessions: "No resumable sessions were found. Starting a new session." lastResponse: "Last: {response}" messages: "{count} messages" previousTask: diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 8239e7d1..bbd4e383 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -40,6 +40,7 @@ interactive: prompt: "直近のセッションを引き継ぎますか?" newSession: "新しいセッション" newSessionDescription: "新しい会話を始める" + noSessions: "引き継げるセッションが見つかりませんでした。新しいセッションで開始します。" lastResponse: "最後: {response}" messages: "{count}メッセージ" previousTask: From 3e8db0e05066077f8401c00d08df1677937eebf1 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:47:32 +0900 Subject: [PATCH 4/4] Release v0.12.1 --- CHANGELOG.md | 12 ++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26fbb64e..c170daf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.12.1] - 2026-02-11 + +### Fixed + +- セッションが見つからない場合に無言で新規セッションに進む問題を修正 — セッション未検出時に info メッセージを表示するように改善 + +### Internal + +- OpenCode プロバイダーの report フェーズを deny に設定(Phase 2 での不要な書き込みを防止) +- プロジェクト初期化時の `tasks/` ディレクトリコピーをスキップ(TASK-FORMAT が不要になったため) +- ストリーム診断ユーティリティ (`streamDiagnostics.ts`) を追加 + ## [0.12.0] - 2026-02-11 ### Added diff --git a/package-lock.json b/package-lock.json index 8d8d0c96..0f1b8a42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "takt", - "version": "0.12.0", + "version": "0.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.12.0", + "version": "0.12.1", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.37", diff --git a/package.json b/package.json index 24913259..92b5859d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "takt", - "version": "0.12.0", + "version": "0.12.1", "description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration", "main": "dist/index.js", "types": "dist/index.d.ts",