Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 0 additions & 11 deletions src/__tests__/initialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/opencode-client-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
5 changes: 4 additions & 1 deletion src/__tests__/opencode-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});
});

Expand Down
8 changes: 8 additions & 0 deletions src/__tests__/sessionSelector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) => {
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';
Expand All @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions src/features/interactive/sessionSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,6 +54,7 @@ export async function selectRecentSession(
const sessions = loadSessionIndex(cwd);

if (sessions.length === 0) {
info(getLabel('interactive.sessionSelector.noSessions', lang));
return null;
}

Expand Down
19 changes: 18 additions & 1 deletion src/infra/codex/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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<string, number>();
Expand All @@ -158,6 +164,8 @@ export class CodexClient {

for await (const event of events as AsyncGenerator<CodexEvent>) {
resetIdleTimeout();
diag.onFirstEvent(event.type);
diag.onEvent(event.type);

if (event.type === 'thread.started') {
currentThreadId = typeof event.thread_id === 'string' ? event.thread_id : currentThreadId;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down
20 changes: 19 additions & 1 deletion src/infra/opencode/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ReturnType<typeof createOpencode>>['client'] | undefined;

Expand All @@ -259,6 +260,7 @@ export class OpenCodeClient {
clearTimeout(idleTimeoutId);
}
idleTimeoutId = setTimeout(() => {
diagRef?.onIdleTimeoutFired();
abortCause = 'timeout';
streamAbortController.abort();
}, OPENCODE_STREAM_IDLE_TIMEOUT_MS);
Expand All @@ -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();
Expand Down Expand Up @@ -321,6 +326,7 @@ export class OpenCodeClient {
{ signal: streamAbortController.signal },
);
resetIdleTimeout();
diag.onConnected();

const tools = mapToOpenCodeTools(options.allowedTools);
await client.session.promptAsync(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -458,6 +466,7 @@ export class OpenCodeClient {
if (streamError) {
success = false;
failureMessage = streamError;
diag.onStreamError('message.updated', streamError);
break;
}
}
Expand All @@ -479,6 +488,7 @@ export class OpenCodeClient {
if (streamError) {
success = false;
failureMessage = streamError;
diag.onStreamError('message.completed', streamError);
break;
}
}
Expand All @@ -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;
Expand Down Expand Up @@ -530,13 +541,15 @@ 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;
}
}

content = [...textContentParts.values()].join('\n');
diag.onCompleted(success ? 'normal' : 'error', success ? undefined : failureMessage);

if (!success) {
const message = failureMessage || 'OpenCode execution failed';
Expand Down Expand Up @@ -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 });
Expand Down
7 changes: 5 additions & 2 deletions src/infra/opencode/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,12 @@ const BUILTIN_TOOL_MAP: Record<string, string> = {
};

export function mapToOpenCodeTools(allowedTools?: string[]): Record<string, boolean> | undefined {
if (!allowedTools || allowedTools.length === 0) {
if (!allowedTools) {
return undefined;
}
if (allowedTools.length === 0) {
return {};
}

const mapped = new Set<string>();
for (const tool of allowedTools) {
Expand All @@ -142,7 +145,7 @@ export function mapToOpenCodeTools(allowedTools?: string[]): Record<string, bool
}

if (mapped.size === 0) {
return undefined;
return {};
}

const tools: Record<string, boolean> = {};
Expand Down
1 change: 1 addition & 0 deletions src/infra/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function copyProjectResourcesToDir(targetDir: string): void {
return;
}
copyDirRecursive(resourcesDir, targetDir, {
skipDirs: ['tasks'],
renameMap: { dotgitignore: '.gitignore' },
});
}
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/labels_en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading