Merge pull request #247 from nrslib/release/v0.12.1

Release v0.12.1
This commit is contained in:
nrs 2026-02-11 22:48:12 +09:00 committed by GitHub
commit f8fe5f69d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 205 additions and 19 deletions

View File

@ -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/). 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 ## [0.12.0] - 2026-02-11
### Added ### Added

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "takt", "name": "takt",
"version": "0.12.0", "version": "0.12.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "takt", "name": "takt",
"version": "0.12.0", "version": "0.12.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.37", "@anthropic-ai/claude-agent-sdk": "^0.2.37",

View File

@ -1,6 +1,6 @@
{ {
"name": "takt", "name": "takt",
"version": "0.12.0", "version": "0.12.1",
"description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration", "description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -80,17 +80,6 @@ describe('copyProjectResourcesToDir', () => {
expect(existsSync(join(testProjectDir, '.gitignore'))).toBe(true); expect(existsSync(join(testProjectDir, '.gitignore'))).toBe(true);
expect(existsSync(join(testProjectDir, 'dotgitignore'))).toBe(false); 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', () => { describe('getLanguageResourcesDir', () => {

View File

@ -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 () => { it('should configure allow permissions for edit mode', async () => {
const { OpenCodeClient } = await import('../infra/opencode/client.js'); const { OpenCodeClient } = await import('../infra/opencode/client.js');
const stream = new MockEventStream([ const stream = new MockEventStream([

View File

@ -54,7 +54,10 @@ describe('mapToOpenCodeTools', () => {
it('should return undefined when tools are not provided', () => { it('should return undefined when tools are not provided', () => {
expect(mapToOpenCodeTools(undefined)).toBeUndefined(); expect(mapToOpenCodeTools(undefined)).toBeUndefined();
expect(mapToOpenCodeTools([])).toBeUndefined(); });
it('should return empty tool map when explicit empty tools are provided', () => {
expect(mapToOpenCodeTools([])).toEqual({});
}); });
}); });

View File

@ -19,10 +19,17 @@ vi.mock('../shared/prompt/index.js', () => ({
selectOption: (...args: [string, unknown[]]) => mockSelectOption(...args), 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', () => ({ vi.mock('../shared/i18n/index.js', () => ({
getLabel: (key: string, _lang: string, params?: Record<string, string>) => { getLabel: (key: string, _lang: string, params?: Record<string, string>) => {
if (key === 'interactive.sessionSelector.newSession') return 'New session'; if (key === 'interactive.sessionSelector.newSession') return 'New session';
if (key === 'interactive.sessionSelector.newSessionDescription') return 'Start a new conversation'; 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.messages') return `${params?.count} messages`;
if (key === 'interactive.sessionSelector.lastResponse') return `Last: ${params?.response}`; if (key === 'interactive.sessionSelector.lastResponse') return `Last: ${params?.response}`;
if (key === 'interactive.sessionSelector.prompt') return 'Select a session'; if (key === 'interactive.sessionSelector.prompt') return 'Select a session';
@ -44,6 +51,7 @@ describe('selectRecentSession', () => {
expect(result).toBeNull(); expect(result).toBeNull();
expect(mockSelectOption).not.toHaveBeenCalled(); expect(mockSelectOption).not.toHaveBeenCalled();
expect(mockInfo).toHaveBeenCalledWith('No sessions found');
}); });
it('should return null when user selects __new__', async () => { it('should return null when user selects __new__', async () => {

View File

@ -8,6 +8,7 @@
import { loadSessionIndex, extractLastAssistantResponse } from '../../infra/claude/session-reader.js'; import { loadSessionIndex, extractLastAssistantResponse } from '../../infra/claude/session-reader.js';
import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js'; import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js';
import { getLabel } from '../../shared/i18n/index.js'; import { getLabel } from '../../shared/i18n/index.js';
import { info } from '../../shared/ui/index.js';
/** Maximum number of sessions to display */ /** Maximum number of sessions to display */
const MAX_DISPLAY_SESSIONS = 10; const MAX_DISPLAY_SESSIONS = 10;
@ -53,6 +54,7 @@ export async function selectRecentSession(
const sessions = loadSessionIndex(cwd); const sessions = loadSessionIndex(cwd);
if (sessions.length === 0) { if (sessions.length === 0) {
info(getLabel('interactive.sessionSelector.noSessions', lang));
return null; return null;
} }

View File

@ -6,7 +6,7 @@
import { Codex } from '@openai/codex-sdk'; import { Codex } from '@openai/codex-sdk';
import type { AgentResponse } from '../../core/models/index.js'; 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 { mapToCodexSandboxMode, type CodexCallOptions } from './types.js';
import { import {
type CodexEvent, type CodexEvent,
@ -113,12 +113,14 @@ export class CodexClient {
const streamAbortController = new AbortController(); const streamAbortController = new AbortController();
const timeoutMessage = `Codex stream timed out after ${Math.floor(CODEX_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; const timeoutMessage = `Codex stream timed out after ${Math.floor(CODEX_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`;
let abortCause: 'timeout' | 'external' | undefined; let abortCause: 'timeout' | 'external' | undefined;
let diagRef: StreamDiagnostics | undefined;
const resetIdleTimeout = (): void => { const resetIdleTimeout = (): void => {
if (idleTimeoutId !== undefined) { if (idleTimeoutId !== undefined) {
clearTimeout(idleTimeoutId); clearTimeout(idleTimeoutId);
} }
idleTimeoutId = setTimeout(() => { idleTimeoutId = setTimeout(() => {
diagRef?.onIdleTimeoutFired();
abortCause = 'timeout'; abortCause = 'timeout';
streamAbortController.abort(); streamAbortController.abort();
}, CODEX_STREAM_IDLE_TIMEOUT_MS); }, CODEX_STREAM_IDLE_TIMEOUT_MS);
@ -145,10 +147,14 @@ export class CodexClient {
attempt, attempt,
}); });
const diag = createStreamDiagnostics('codex-sdk', { agentType, model: options.model, attempt });
diagRef = diag;
const { events } = await thread.runStreamed(fullPrompt, { const { events } = await thread.runStreamed(fullPrompt, {
signal: streamAbortController.signal, signal: streamAbortController.signal,
}); });
resetIdleTimeout(); resetIdleTimeout();
diag.onConnected();
let content = ''; let content = '';
const contentOffsets = new Map<string, number>(); const contentOffsets = new Map<string, number>();
@ -158,6 +164,8 @@ export class CodexClient {
for await (const event of events as AsyncGenerator<CodexEvent>) { for await (const event of events as AsyncGenerator<CodexEvent>) {
resetIdleTimeout(); resetIdleTimeout();
diag.onFirstEvent(event.type);
diag.onEvent(event.type);
if (event.type === 'thread.started') { if (event.type === 'thread.started') {
currentThreadId = typeof event.thread_id === 'string' ? event.thread_id : currentThreadId; 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) { if (event.error && typeof event.error === 'object' && 'message' in event.error) {
failureMessage = String((event.error as { message?: unknown }).message ?? ''); failureMessage = String((event.error as { message?: unknown }).message ?? '');
} }
diag.onStreamError('turn.failed', failureMessage);
break; break;
} }
if (event.type === 'error') { if (event.type === 'error') {
success = false; success = false;
failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error'; failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error';
diag.onStreamError('error', failureMessage);
break; break;
} }
@ -237,6 +247,8 @@ export class CodexClient {
} }
} }
diag.onCompleted(success ? 'normal' : 'error', success ? undefined : failureMessage);
if (!success) { if (!success) {
const message = failureMessage || 'Codex execution failed'; const message = failureMessage || 'Codex execution failed';
const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause); const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause);
@ -275,6 +287,11 @@ export class CodexClient {
: CODEX_STREAM_ABORTED_MESSAGE : CODEX_STREAM_ABORTED_MESSAGE
: message; : message;
diagRef?.onCompleted(
abortCause === 'timeout' ? 'timeout' : streamAbortController.signal.aborted ? 'abort' : 'error',
errorMessage,
);
const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause); const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause);
if (retriable && attempt < CODEX_RETRY_MAX_ATTEMPTS) { if (retriable && attempt < CODEX_RETRY_MAX_ATTEMPTS) {
log.info('Retrying Codex call after transient exception', { agentType, attempt, errorMessage }); log.info('Retrying Codex call after transient exception', { agentType, attempt, errorMessage });

View File

@ -8,7 +8,7 @@
import { createOpencode } from '@opencode-ai/sdk/v2'; import { createOpencode } from '@opencode-ai/sdk/v2';
import { createServer } from 'node:net'; import { createServer } from 'node:net';
import type { AgentResponse } from '../../core/models/index.js'; 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 { parseProviderModel } from '../../shared/utils/providerModel.js';
import { import {
buildOpenCodePermissionConfig, buildOpenCodePermissionConfig,
@ -251,6 +251,7 @@ export class OpenCodeClient {
const streamAbortController = new AbortController(); const streamAbortController = new AbortController();
const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`;
let abortCause: 'timeout' | 'external' | undefined; let abortCause: 'timeout' | 'external' | undefined;
let diagRef: StreamDiagnostics | undefined;
let serverClose: (() => void) | undefined; let serverClose: (() => void) | undefined;
let opencodeApiClient: Awaited<ReturnType<typeof createOpencode>>['client'] | undefined; let opencodeApiClient: Awaited<ReturnType<typeof createOpencode>>['client'] | undefined;
@ -259,6 +260,7 @@ export class OpenCodeClient {
clearTimeout(idleTimeoutId); clearTimeout(idleTimeoutId);
} }
idleTimeoutId = setTimeout(() => { idleTimeoutId = setTimeout(() => {
diagRef?.onIdleTimeoutFired();
abortCause = 'timeout'; abortCause = 'timeout';
streamAbortController.abort(); streamAbortController.abort();
}, OPENCODE_STREAM_IDLE_TIMEOUT_MS); }, OPENCODE_STREAM_IDLE_TIMEOUT_MS);
@ -285,6 +287,9 @@ export class OpenCodeClient {
attempt, attempt,
}); });
const diag = createStreamDiagnostics('opencode-sdk', { agentType, model: options.model, attempt });
diagRef = diag;
const parsedModel = parseProviderModel(options.model, 'OpenCode model'); const parsedModel = parseProviderModel(options.model, 'OpenCode model');
const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`;
const port = await getFreePort(); const port = await getFreePort();
@ -321,6 +326,7 @@ export class OpenCodeClient {
{ signal: streamAbortController.signal }, { signal: streamAbortController.signal },
); );
resetIdleTimeout(); resetIdleTimeout();
diag.onConnected();
const tools = mapToOpenCodeTools(options.allowedTools); const tools = mapToOpenCodeTools(options.allowedTools);
await client.session.promptAsync( await client.session.promptAsync(
@ -349,6 +355,8 @@ export class OpenCodeClient {
resetIdleTimeout(); resetIdleTimeout();
const sseEvent = event as OpenCodeStreamEvent; const sseEvent = event as OpenCodeStreamEvent;
diag.onFirstEvent(sseEvent.type);
diag.onEvent(sseEvent.type);
if (sseEvent.type === 'message.part.updated') { if (sseEvent.type === 'message.part.updated') {
const props = sseEvent.properties as { part: OpenCodePart; delta?: string }; const props = sseEvent.properties as { part: OpenCodePart; delta?: string };
const part = props.part; const part = props.part;
@ -458,6 +466,7 @@ export class OpenCodeClient {
if (streamError) { if (streamError) {
success = false; success = false;
failureMessage = streamError; failureMessage = streamError;
diag.onStreamError('message.updated', streamError);
break; break;
} }
} }
@ -479,6 +488,7 @@ export class OpenCodeClient {
if (streamError) { if (streamError) {
success = false; success = false;
failureMessage = streamError; failureMessage = streamError;
diag.onStreamError('message.completed', streamError);
break; break;
} }
} }
@ -498,6 +508,7 @@ export class OpenCodeClient {
if (isCurrentAssistantMessage) { if (isCurrentAssistantMessage) {
success = false; success = false;
failureMessage = extractOpenCodeErrorMessage(info?.error) ?? 'OpenCode message failed'; failureMessage = extractOpenCodeErrorMessage(info?.error) ?? 'OpenCode message failed';
diag.onStreamError('message.failed', failureMessage);
break; break;
} }
continue; continue;
@ -530,6 +541,7 @@ export class OpenCodeClient {
if (!errorProps.sessionID || errorProps.sessionID === sessionId) { if (!errorProps.sessionID || errorProps.sessionID === sessionId) {
success = false; success = false;
failureMessage = errorProps.error?.data?.message ?? 'OpenCode session error'; failureMessage = errorProps.error?.data?.message ?? 'OpenCode session error';
diag.onStreamError('session.error', failureMessage);
break; break;
} }
continue; continue;
@ -537,6 +549,7 @@ export class OpenCodeClient {
} }
content = [...textContentParts.values()].join('\n'); content = [...textContentParts.values()].join('\n');
diag.onCompleted(success ? 'normal' : 'error', success ? undefined : failureMessage);
if (!success) { if (!success) {
const message = failureMessage || 'OpenCode execution failed'; const message = failureMessage || 'OpenCode execution failed';
@ -575,6 +588,11 @@ export class OpenCodeClient {
: OPENCODE_STREAM_ABORTED_MESSAGE : OPENCODE_STREAM_ABORTED_MESSAGE
: message; : message;
diagRef?.onCompleted(
abortCause === 'timeout' ? 'timeout' : streamAbortController.signal.aborted ? 'abort' : 'error',
errorMessage,
);
const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause); const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause);
if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) { if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) {
log.info('Retrying OpenCode call after transient exception', { agentType, attempt, errorMessage }); log.info('Retrying OpenCode call after transient exception', { agentType, attempt, errorMessage });

View File

@ -127,9 +127,12 @@ const BUILTIN_TOOL_MAP: Record<string, string> = {
}; };
export function mapToOpenCodeTools(allowedTools?: string[]): Record<string, boolean> | undefined { export function mapToOpenCodeTools(allowedTools?: string[]): Record<string, boolean> | undefined {
if (!allowedTools || allowedTools.length === 0) { if (!allowedTools) {
return undefined; return undefined;
} }
if (allowedTools.length === 0) {
return {};
}
const mapped = new Set<string>(); const mapped = new Set<string>();
for (const tool of allowedTools) { for (const tool of allowedTools) {
@ -142,7 +145,7 @@ export function mapToOpenCodeTools(allowedTools?: string[]): Record<string, bool
} }
if (mapped.size === 0) { if (mapped.size === 0) {
return undefined; return {};
} }
const tools: Record<string, boolean> = {}; const tools: Record<string, boolean> = {};

View File

@ -53,6 +53,7 @@ export function copyProjectResourcesToDir(targetDir: string): void {
return; return;
} }
copyDirRecursive(resourcesDir, targetDir, { copyDirRecursive(resourcesDir, targetDir, {
skipDirs: ['tasks'],
renameMap: { dotgitignore: '.gitignore' }, renameMap: { dotgitignore: '.gitignore' },
}); });
} }

View File

@ -40,6 +40,7 @@ interactive:
prompt: "Resume from a recent session?" prompt: "Resume from a recent session?"
newSession: "New session" newSession: "New session"
newSessionDescription: "Start a fresh conversation" newSessionDescription: "Start a fresh conversation"
noSessions: "No resumable sessions were found. Starting a new session."
lastResponse: "Last: {response}" lastResponse: "Last: {response}"
messages: "{count} messages" messages: "{count} messages"
previousTask: previousTask:

View File

@ -40,6 +40,7 @@ interactive:
prompt: "直近のセッションを引き継ぎますか?" prompt: "直近のセッションを引き継ぎますか?"
newSession: "新しいセッション" newSession: "新しいセッション"
newSessionDescription: "新しい会話を始める" newSessionDescription: "新しい会話を始める"
noSessions: "引き継げるセッションが見つかりませんでした。新しいセッションで開始します。"
lastResponse: "最後: {response}" lastResponse: "最後: {response}"
messages: "{count}メッセージ" messages: "{count}メッセージ"
previousTask: previousTask:

View File

@ -10,6 +10,7 @@ export * from './reportDir.js';
export * from './slackWebhook.js'; export * from './slackWebhook.js';
export * from './sleep.js'; export * from './sleep.js';
export * from './slug.js'; export * from './slug.js';
export * from './streamDiagnostics.js';
export * from './taskPaths.js'; export * from './taskPaths.js';
export * from './text.js'; export * from './text.js';
export * from './types.js'; export * from './types.js';

View File

@ -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<string, unknown>,
): 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,
});
},
};
}