takt: github-issue-236-feat-claude-codex-opencode (#239)
This commit is contained in:
parent
3ffae2ffc2
commit
a3555ebeb4
@ -314,6 +314,43 @@ describe('loadGlobalConfig', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should load observability.provider_events config from config.yaml', () => {
|
||||||
|
const taktDir = join(testHomeDir, '.takt');
|
||||||
|
mkdirSync(taktDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
getGlobalConfigPath(),
|
||||||
|
[
|
||||||
|
'language: en',
|
||||||
|
'observability:',
|
||||||
|
' provider_events: false',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
expect(config.observability).toEqual({
|
||||||
|
providerEvents: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save and reload observability.provider_events config', () => {
|
||||||
|
const taktDir = join(testHomeDir, '.takt');
|
||||||
|
mkdirSync(taktDir, { recursive: true });
|
||||||
|
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||||
|
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
config.observability = {
|
||||||
|
providerEvents: false,
|
||||||
|
};
|
||||||
|
saveGlobalConfig(config);
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
|
||||||
|
const reloaded = loadGlobalConfig();
|
||||||
|
expect(reloaded.observability).toEqual({
|
||||||
|
providerEvents: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should save and reload notification_sound_events config', () => {
|
it('should save and reload notification_sound_events config', () => {
|
||||||
const taktDir = join(testHomeDir, '.takt');
|
const taktDir = join(testHomeDir, '.takt');
|
||||||
mkdirSync(taktDir, { recursive: true });
|
mkdirSync(taktDir, { recursive: true });
|
||||||
|
|||||||
@ -410,15 +410,20 @@ describe('GlobalConfigSchema', () => {
|
|||||||
expect(result.default_piece).toBe('default');
|
expect(result.default_piece).toBe('default');
|
||||||
expect(result.log_level).toBe('info');
|
expect(result.log_level).toBe('info');
|
||||||
expect(result.provider).toBe('claude');
|
expect(result.provider).toBe('claude');
|
||||||
|
expect(result.observability).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept valid config', () => {
|
it('should accept valid config', () => {
|
||||||
const config = {
|
const config = {
|
||||||
default_piece: 'custom',
|
default_piece: 'custom',
|
||||||
log_level: 'debug' as const,
|
log_level: 'debug' as const,
|
||||||
|
observability: {
|
||||||
|
provider_events: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = GlobalConfigSchema.parse(config);
|
const result = GlobalConfigSchema.parse(config);
|
||||||
expect(result.log_level).toBe('debug');
|
expect(result.log_level).toBe('debug');
|
||||||
|
expect(result.observability?.provider_events).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
188
src/__tests__/providerEventLogger.test.ts
Normal file
188
src/__tests__/providerEventLogger.test.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import {
|
||||||
|
createProviderEventLogger,
|
||||||
|
isProviderEventsEnabled,
|
||||||
|
} from '../shared/utils/providerEventLogger.js';
|
||||||
|
import type { ProviderType } from '../core/piece/index.js';
|
||||||
|
|
||||||
|
describe('providerEventLogger', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = join(tmpdir(), `takt-provider-events-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable provider events by default', () => {
|
||||||
|
expect(isProviderEventsEnabled()).toBe(true);
|
||||||
|
expect(isProviderEventsEnabled({})).toBe(true);
|
||||||
|
expect(isProviderEventsEnabled({ observability: {} })).toBe(true);
|
||||||
|
expect(isProviderEventsEnabled({ observability: { providerEvents: true } })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable provider events only when explicitly false', () => {
|
||||||
|
expect(isProviderEventsEnabled({ observability: { providerEvents: false } })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write normalized JSONL records when enabled', () => {
|
||||||
|
const logger = createProviderEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-1',
|
||||||
|
runId: 'run-1',
|
||||||
|
provider: 'opencode',
|
||||||
|
movement: 'implement',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const original = vi.fn();
|
||||||
|
const wrapped = logger.wrapCallback(original);
|
||||||
|
|
||||||
|
wrapped({
|
||||||
|
type: 'tool_use',
|
||||||
|
data: {
|
||||||
|
tool: 'Read',
|
||||||
|
id: 'call-123',
|
||||||
|
messageId: 'msg-123',
|
||||||
|
requestId: 'req-123',
|
||||||
|
sessionID: 'session-abc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(original).toHaveBeenCalledTimes(1);
|
||||||
|
expect(existsSync(logger.filepath)).toBe(true);
|
||||||
|
|
||||||
|
const lines = readFileSync(logger.filepath, 'utf-8').trim().split('\n');
|
||||||
|
expect(lines).toHaveLength(1);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(lines[0]!) as {
|
||||||
|
provider: ProviderType;
|
||||||
|
event_type: string;
|
||||||
|
run_id: string;
|
||||||
|
movement: string;
|
||||||
|
session_id?: string;
|
||||||
|
call_id?: string;
|
||||||
|
message_id?: string;
|
||||||
|
request_id?: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.provider).toBe('opencode');
|
||||||
|
expect(parsed.event_type).toBe('tool_use');
|
||||||
|
expect(parsed.run_id).toBe('run-1');
|
||||||
|
expect(parsed.movement).toBe('implement');
|
||||||
|
expect(parsed.session_id).toBe('session-abc');
|
||||||
|
expect(parsed.call_id).toBe('call-123');
|
||||||
|
expect(parsed.message_id).toBe('msg-123');
|
||||||
|
expect(parsed.request_id).toBe('req-123');
|
||||||
|
expect(parsed.data['tool']).toBe('Read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update movement and provider for subsequent events', () => {
|
||||||
|
const logger = createProviderEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-2',
|
||||||
|
runId: 'run-2',
|
||||||
|
provider: 'claude',
|
||||||
|
movement: 'plan',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapped = logger.wrapCallback();
|
||||||
|
|
||||||
|
wrapped({ type: 'init', data: { model: 'sonnet', sessionId: 's-1' } });
|
||||||
|
logger.setMovement('implement');
|
||||||
|
logger.setProvider('codex');
|
||||||
|
wrapped({ type: 'result', data: { result: 'ok', sessionId: 's-1', success: true } });
|
||||||
|
|
||||||
|
const lines = readFileSync(logger.filepath, 'utf-8').trim().split('\n');
|
||||||
|
expect(lines).toHaveLength(2);
|
||||||
|
|
||||||
|
const first = JSON.parse(lines[0]!) as { provider: ProviderType; movement: string };
|
||||||
|
const second = JSON.parse(lines[1]!) as { provider: ProviderType; movement: string };
|
||||||
|
|
||||||
|
expect(first.provider).toBe('claude');
|
||||||
|
expect(first.movement).toBe('plan');
|
||||||
|
expect(second.provider).toBe('codex');
|
||||||
|
expect(second.movement).toBe('implement');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not write records when disabled', () => {
|
||||||
|
const logger = createProviderEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-3',
|
||||||
|
runId: 'run-3',
|
||||||
|
provider: 'claude',
|
||||||
|
movement: 'plan',
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const original = vi.fn();
|
||||||
|
const wrapped = logger.wrapCallback(original);
|
||||||
|
wrapped({ type: 'text', data: { text: 'hello' } });
|
||||||
|
|
||||||
|
expect(original).toHaveBeenCalledTimes(1);
|
||||||
|
expect(existsSync(logger.filepath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate long text fields', () => {
|
||||||
|
const logger = createProviderEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-4',
|
||||||
|
runId: 'run-4',
|
||||||
|
provider: 'claude',
|
||||||
|
movement: 'plan',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapped = logger.wrapCallback();
|
||||||
|
const longText = 'a'.repeat(11_000);
|
||||||
|
wrapped({ type: 'text', data: { text: longText } });
|
||||||
|
|
||||||
|
const line = readFileSync(logger.filepath, 'utf-8').trim();
|
||||||
|
const parsed = JSON.parse(line) as { data: { text: string } };
|
||||||
|
|
||||||
|
expect(parsed.data.text.length).toBeLessThan(longText.length);
|
||||||
|
expect(parsed.data.text).toContain('...[truncated]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write init event records with typed data objects', () => {
|
||||||
|
const logger = createProviderEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-5',
|
||||||
|
runId: 'run-5',
|
||||||
|
provider: 'codex',
|
||||||
|
movement: 'implement',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapped = logger.wrapCallback();
|
||||||
|
wrapped({
|
||||||
|
type: 'init',
|
||||||
|
data: {
|
||||||
|
model: 'gpt-5-codex',
|
||||||
|
sessionId: 'thread-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const line = readFileSync(logger.filepath, 'utf-8').trim();
|
||||||
|
const parsed = JSON.parse(line) as {
|
||||||
|
provider: ProviderType;
|
||||||
|
event_type: string;
|
||||||
|
session_id?: string;
|
||||||
|
data: { model: string; sessionId: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.provider).toBe('codex');
|
||||||
|
expect(parsed.event_type).toBe('init');
|
||||||
|
expect(parsed.session_id).toBe('thread-1');
|
||||||
|
expect(parsed.data.model).toBe('gpt-5-codex');
|
||||||
|
expect(parsed.data.sessionId).toBe('thread-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -20,6 +20,12 @@ export interface DebugConfig {
|
|||||||
logFile?: string;
|
logFile?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Observability configuration for runtime event logs */
|
||||||
|
export interface ObservabilityConfig {
|
||||||
|
/** Enable provider stream event logging (default: true when undefined) */
|
||||||
|
providerEvents?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Language setting for takt */
|
/** Language setting for takt */
|
||||||
export type Language = 'en' | 'ja';
|
export type Language = 'en' | 'ja';
|
||||||
|
|
||||||
@ -55,6 +61,7 @@ export interface GlobalConfig {
|
|||||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
debug?: DebugConfig;
|
debug?: DebugConfig;
|
||||||
|
observability?: ObservabilityConfig;
|
||||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||||
worktreeDir?: string;
|
worktreeDir?: string;
|
||||||
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
|
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export type {
|
|||||||
PieceState,
|
PieceState,
|
||||||
CustomAgentConfig,
|
CustomAgentConfig,
|
||||||
DebugConfig,
|
DebugConfig,
|
||||||
|
ObservabilityConfig,
|
||||||
Language,
|
Language,
|
||||||
PipelineConfig,
|
PipelineConfig,
|
||||||
GlobalConfig,
|
GlobalConfig,
|
||||||
|
|||||||
@ -309,6 +309,10 @@ export const DebugConfigSchema = z.object({
|
|||||||
log_file: z.string().optional(),
|
log_file: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ObservabilityConfigSchema = z.object({
|
||||||
|
provider_events: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
/** Language setting schema */
|
/** Language setting schema */
|
||||||
export const LanguageSchema = z.enum(['en', 'ja']);
|
export const LanguageSchema = z.enum(['en', 'ja']);
|
||||||
|
|
||||||
@ -341,6 +345,7 @@ export const GlobalConfigSchema = z.object({
|
|||||||
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'),
|
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
debug: DebugConfigSchema.optional(),
|
debug: DebugConfigSchema.optional(),
|
||||||
|
observability: ObservabilityConfigSchema.optional(),
|
||||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||||
worktree_dir: z.string().optional(),
|
worktree_dir: z.string().optional(),
|
||||||
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
|
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export type {
|
|||||||
export type {
|
export type {
|
||||||
CustomAgentConfig,
|
CustomAgentConfig,
|
||||||
DebugConfig,
|
DebugConfig,
|
||||||
|
ObservabilityConfig,
|
||||||
Language,
|
Language,
|
||||||
PipelineConfig,
|
PipelineConfig,
|
||||||
GlobalConfig,
|
GlobalConfig,
|
||||||
|
|||||||
@ -59,6 +59,10 @@ import {
|
|||||||
isValidReportDirName,
|
isValidReportDirName,
|
||||||
} from '../../../shared/utils/index.js';
|
} from '../../../shared/utils/index.js';
|
||||||
import type { PromptLogRecord } from '../../../shared/utils/index.js';
|
import type { PromptLogRecord } from '../../../shared/utils/index.js';
|
||||||
|
import {
|
||||||
|
createProviderEventLogger,
|
||||||
|
isProviderEventsEnabled,
|
||||||
|
} from '../../../shared/utils/providerEventLogger.js';
|
||||||
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
|
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
|
||||||
import { getLabel } from '../../../shared/i18n/index.js';
|
import { getLabel } from '../../../shared/i18n/index.js';
|
||||||
import { installSigIntHandler } from './sigintHandler.js';
|
import { installSigIntHandler } from './sigintHandler.js';
|
||||||
@ -303,6 +307,14 @@ export async function executePiece(
|
|||||||
const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false;
|
const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false;
|
||||||
const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false;
|
const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false;
|
||||||
const currentProvider = globalConfig.provider ?? 'claude';
|
const currentProvider = globalConfig.provider ?? 'claude';
|
||||||
|
const providerEventLogger = createProviderEventLogger({
|
||||||
|
logsDir: runPaths.logsAbs,
|
||||||
|
sessionId: pieceSessionId,
|
||||||
|
runId: runSlug,
|
||||||
|
provider: currentProvider,
|
||||||
|
movement: options.startMovement ?? pieceConfig.initialMovement,
|
||||||
|
enabled: isProviderEventsEnabled(globalConfig),
|
||||||
|
});
|
||||||
|
|
||||||
// Prevent macOS idle sleep if configured
|
// Prevent macOS idle sleep if configured
|
||||||
if (globalConfig.preventSleep) {
|
if (globalConfig.preventSleep) {
|
||||||
@ -402,7 +414,7 @@ export async function executePiece(
|
|||||||
try {
|
try {
|
||||||
engine = new PieceEngine(pieceConfig, cwd, task, {
|
engine = new PieceEngine(pieceConfig, cwd, task, {
|
||||||
abortSignal: runAbortController.signal,
|
abortSignal: runAbortController.signal,
|
||||||
onStream: streamHandler,
|
onStream: providerEventLogger.wrapCallback(streamHandler),
|
||||||
onUserInput,
|
onUserInput,
|
||||||
initialSessions: savedSessions,
|
initialSessions: savedSessions,
|
||||||
onSessionUpdate: sessionUpdateHandler,
|
onSessionUpdate: sessionUpdateHandler,
|
||||||
@ -492,6 +504,8 @@ export async function executePiece(
|
|||||||
});
|
});
|
||||||
const movementProvider = resolved.provider ?? currentProvider;
|
const movementProvider = resolved.provider ?? currentProvider;
|
||||||
const movementModel = resolved.model ?? globalConfig.model ?? '(default)';
|
const movementModel = resolved.model ?? globalConfig.model ?? '(default)';
|
||||||
|
providerEventLogger.setMovement(step.name);
|
||||||
|
providerEventLogger.setProvider(movementProvider);
|
||||||
out.info(`Provider: ${movementProvider}`);
|
out.info(`Provider: ${movementProvider}`);
|
||||||
out.info(`Model: ${movementModel}`);
|
out.info(`Model: ${movementModel}`);
|
||||||
|
|
||||||
|
|||||||
@ -105,6 +105,9 @@ export class GlobalConfigManager {
|
|||||||
enabled: parsed.debug.enabled,
|
enabled: parsed.debug.enabled,
|
||||||
logFile: parsed.debug.log_file,
|
logFile: parsed.debug.log_file,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
|
observability: parsed.observability ? {
|
||||||
|
providerEvents: parsed.observability.provider_events,
|
||||||
|
} : undefined,
|
||||||
worktreeDir: parsed.worktree_dir,
|
worktreeDir: parsed.worktree_dir,
|
||||||
autoPr: parsed.auto_pr,
|
autoPr: parsed.auto_pr,
|
||||||
disabledBuiltins: parsed.disabled_builtins,
|
disabledBuiltins: parsed.disabled_builtins,
|
||||||
@ -158,6 +161,11 @@ export class GlobalConfigManager {
|
|||||||
log_file: config.debug.logFile,
|
log_file: config.debug.logFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (config.observability && config.observability.providerEvents !== undefined) {
|
||||||
|
raw.observability = {
|
||||||
|
provider_events: config.observability.providerEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (config.worktreeDir) {
|
if (config.worktreeDir) {
|
||||||
raw.worktree_dir = config.worktreeDir;
|
raw.worktree_dir = config.worktreeDir;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
export * from './debug.js';
|
export * from './debug.js';
|
||||||
export * from './error.js';
|
export * from './error.js';
|
||||||
export * from './notification.js';
|
export * from './notification.js';
|
||||||
|
export * from './providerEventLogger.js';
|
||||||
export * from './reportDir.js';
|
export * from './reportDir.js';
|
||||||
export * from './sleep.js';
|
export * from './sleep.js';
|
||||||
export * from './slug.js';
|
export * from './slug.js';
|
||||||
|
|||||||
137
src/shared/utils/providerEventLogger.ts
Normal file
137
src/shared/utils/providerEventLogger.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { appendFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { ProviderType, StreamCallback, StreamEvent } from '../../core/piece/index.js';
|
||||||
|
|
||||||
|
export interface ProviderEventLoggerConfig {
|
||||||
|
logsDir: string;
|
||||||
|
sessionId: string;
|
||||||
|
runId: string;
|
||||||
|
provider: ProviderType;
|
||||||
|
movement: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderEventLogger {
|
||||||
|
readonly filepath: string;
|
||||||
|
setMovement(movement: string): void;
|
||||||
|
setProvider(provider: ProviderType): void;
|
||||||
|
wrapCallback(original?: StreamCallback): StreamCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderEventLogRecord {
|
||||||
|
timestamp: string;
|
||||||
|
provider: ProviderType;
|
||||||
|
event_type: string;
|
||||||
|
run_id: string;
|
||||||
|
movement: string;
|
||||||
|
session_id?: string;
|
||||||
|
message_id?: string;
|
||||||
|
call_id?: string;
|
||||||
|
request_id?: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TEXT_LENGTH = 10_000;
|
||||||
|
const HEAD_LENGTH = 5_000;
|
||||||
|
const TAIL_LENGTH = 2_000;
|
||||||
|
const TRUNCATED_MARKER = '...[truncated]';
|
||||||
|
|
||||||
|
function truncateString(value: string): string {
|
||||||
|
if (value.length <= MAX_TEXT_LENGTH) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value.slice(0, HEAD_LENGTH) + TRUNCATED_MARKER + value.slice(-TAIL_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeData(data: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(data).map(([key, value]) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return [key, truncateString(value)];
|
||||||
|
}
|
||||||
|
return [key, value];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickString(source: Record<string, unknown>, keys: string[]): string | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key];
|
||||||
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogRecord(
|
||||||
|
event: StreamEvent,
|
||||||
|
provider: ProviderType,
|
||||||
|
movement: string,
|
||||||
|
runId: string,
|
||||||
|
): ProviderEventLogRecord {
|
||||||
|
const data = sanitizeData(event.data as unknown as Record<string, unknown>);
|
||||||
|
const sessionId = pickString(data, ['session_id', 'sessionId', 'sessionID', 'thread_id', 'threadId']);
|
||||||
|
const messageId = pickString(data, ['message_id', 'messageId', 'item_id', 'itemId']);
|
||||||
|
const callId = pickString(data, ['call_id', 'callId', 'id']);
|
||||||
|
const requestId = pickString(data, ['request_id', 'requestId']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider,
|
||||||
|
event_type: event.type,
|
||||||
|
run_id: runId,
|
||||||
|
movement,
|
||||||
|
...(sessionId ? { session_id: sessionId } : {}),
|
||||||
|
...(messageId ? { message_id: messageId } : {}),
|
||||||
|
...(callId ? { call_id: callId } : {}),
|
||||||
|
...(requestId ? { request_id: requestId } : {}),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProviderEventLogger(config: ProviderEventLoggerConfig): ProviderEventLogger {
|
||||||
|
const filepath = join(config.logsDir, `${config.sessionId}-provider-events.jsonl`);
|
||||||
|
let movement = config.movement;
|
||||||
|
let provider = config.provider;
|
||||||
|
|
||||||
|
const write = (event: StreamEvent): void => {
|
||||||
|
try {
|
||||||
|
const record = buildLogRecord(event, provider, movement, config.runId);
|
||||||
|
appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// Silently fail - observability logging should not interrupt main flow.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
filepath,
|
||||||
|
setMovement(nextMovement: string): void {
|
||||||
|
movement = nextMovement;
|
||||||
|
},
|
||||||
|
setProvider(nextProvider: ProviderType): void {
|
||||||
|
provider = nextProvider;
|
||||||
|
},
|
||||||
|
wrapCallback(original?: StreamCallback): StreamCallback {
|
||||||
|
if (!config.enabled && original) {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
if (!config.enabled) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (event: StreamEvent): void => {
|
||||||
|
write(event);
|
||||||
|
original?.(event);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProviderEventsEnabled(config?: {
|
||||||
|
observability?: {
|
||||||
|
providerEvents?: boolean;
|
||||||
|
};
|
||||||
|
}): boolean {
|
||||||
|
return config?.observability?.providerEvents !== false;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user