takt: implement-usage-event-logging (#470)
This commit is contained in:
parent
2f268f6d43
commit
76cfd771f8
@ -161,4 +161,49 @@ describe('QueryExecutor — structuredOutput 抽出', () => {
|
|||||||
expect(result.content).toBe('final text');
|
expect(result.content).toBe('final text');
|
||||||
expect(result.structuredOutput).toEqual({ step: 1, reason: 'approved' });
|
expect(result.structuredOutput).toEqual({ step: 1, reason: 'approved' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('result メッセージの usage を providerUsage として抽出する', async () => {
|
||||||
|
mockQuery.mockReturnValue(createMockQuery([
|
||||||
|
{
|
||||||
|
type: 'result',
|
||||||
|
subtype: 'success',
|
||||||
|
result: 'done',
|
||||||
|
usage: {
|
||||||
|
input_tokens: 12,
|
||||||
|
output_tokens: 34,
|
||||||
|
cache_creation_input_tokens: 5,
|
||||||
|
cache_read_input_tokens: 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
|
||||||
|
const executor = new QueryExecutor();
|
||||||
|
const result = await executor.execute('test', { cwd: '/tmp' });
|
||||||
|
const providerUsage = result.providerUsage;
|
||||||
|
|
||||||
|
expect(providerUsage).toEqual({
|
||||||
|
inputTokens: 12,
|
||||||
|
outputTokens: 34,
|
||||||
|
totalTokens: 46,
|
||||||
|
cachedInputTokens: 12,
|
||||||
|
cacheCreationInputTokens: 5,
|
||||||
|
cacheReadInputTokens: 7,
|
||||||
|
usageMissing: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('usage が存在しない場合は usageMissing=true と reason を返す', async () => {
|
||||||
|
mockQuery.mockReturnValue(createMockQuery([
|
||||||
|
{ type: 'result', subtype: 'success', result: 'done' },
|
||||||
|
]));
|
||||||
|
|
||||||
|
const executor = new QueryExecutor();
|
||||||
|
const result = await executor.execute('test', { cwd: '/tmp' });
|
||||||
|
const providerUsage = result.providerUsage;
|
||||||
|
|
||||||
|
expect(providerUsage).toMatchObject({
|
||||||
|
usageMissing: true,
|
||||||
|
reason: 'usage_not_available',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -191,4 +191,42 @@ describe('CodexClient — structuredOutput 抽出', () => {
|
|||||||
codexPathOverride: '/opt/codex/bin/codex',
|
codexPathOverride: '/opt/codex/bin/codex',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('turn.completed の usage を providerUsage として返す', async () => {
|
||||||
|
mockEvents = [
|
||||||
|
{ type: 'thread.started', thread_id: 'thread-1' },
|
||||||
|
{
|
||||||
|
type: 'turn.completed',
|
||||||
|
usage: { input_tokens: 11, output_tokens: 22, cached_input_tokens: 3 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const client = new CodexClient();
|
||||||
|
const result = await client.call('coder', 'prompt', { cwd: '/tmp' });
|
||||||
|
const providerUsage = result.providerUsage;
|
||||||
|
|
||||||
|
expect(providerUsage).toEqual({
|
||||||
|
inputTokens: 11,
|
||||||
|
outputTokens: 22,
|
||||||
|
totalTokens: 33,
|
||||||
|
cachedInputTokens: 3,
|
||||||
|
usageMissing: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turn.completed に usage がない場合は usageMissing=true と reason を返す', async () => {
|
||||||
|
mockEvents = [
|
||||||
|
{ type: 'thread.started', thread_id: 'thread-1' },
|
||||||
|
{ type: 'turn.completed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const client = new CodexClient();
|
||||||
|
const result = await client.call('coder', 'prompt', { cwd: '/tmp' });
|
||||||
|
const providerUsage = result.providerUsage;
|
||||||
|
|
||||||
|
expect(providerUsage).toMatchObject({
|
||||||
|
usageMissing: true,
|
||||||
|
reason: 'usage_not_available',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -503,6 +503,7 @@ describe('loadGlobalConfig', () => {
|
|||||||
'language: en',
|
'language: en',
|
||||||
'logging:',
|
'logging:',
|
||||||
' provider_events: false',
|
' provider_events: false',
|
||||||
|
' usage_events: true',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
@ -510,6 +511,7 @@ describe('loadGlobalConfig', () => {
|
|||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
expect(config.logging).toEqual({
|
expect(config.logging).toEqual({
|
||||||
providerEvents: false,
|
providerEvents: false,
|
||||||
|
usageEvents: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -525,6 +527,7 @@ describe('loadGlobalConfig', () => {
|
|||||||
' trace: true',
|
' trace: true',
|
||||||
' debug: true',
|
' debug: true',
|
||||||
' provider_events: true',
|
' provider_events: true',
|
||||||
|
' usage_events: false',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
@ -535,6 +538,7 @@ describe('loadGlobalConfig', () => {
|
|||||||
trace: true,
|
trace: true,
|
||||||
debug: true,
|
debug: true,
|
||||||
providerEvents: true,
|
providerEvents: true,
|
||||||
|
usageEvents: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -549,6 +553,7 @@ describe('loadGlobalConfig', () => {
|
|||||||
trace: false,
|
trace: false,
|
||||||
debug: true,
|
debug: true,
|
||||||
providerEvents: false,
|
providerEvents: false,
|
||||||
|
usageEvents: true,
|
||||||
};
|
};
|
||||||
saveGlobalConfig(config);
|
saveGlobalConfig(config);
|
||||||
invalidateGlobalConfigCache();
|
invalidateGlobalConfigCache();
|
||||||
@ -559,6 +564,7 @@ describe('loadGlobalConfig', () => {
|
|||||||
trace: false,
|
trace: false,
|
||||||
debug: true,
|
debug: true,
|
||||||
providerEvents: false,
|
providerEvents: false,
|
||||||
|
usageEvents: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -580,6 +586,24 @@ describe('loadGlobalConfig', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should save partial logging config (only usage_events)', () => {
|
||||||
|
const taktDir = join(testHomeDir, '.takt');
|
||||||
|
mkdirSync(taktDir, { recursive: true });
|
||||||
|
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||||
|
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
config.logging = {
|
||||||
|
usageEvents: true,
|
||||||
|
};
|
||||||
|
saveGlobalConfig(config);
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
|
||||||
|
const reloaded = loadGlobalConfig();
|
||||||
|
expect(reloaded.logging).toEqual({
|
||||||
|
usageEvents: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
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');
|
||||||
|
|||||||
118
src/__tests__/logging-contracts.test.ts
Normal file
118
src/__tests__/logging-contracts.test.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdirSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import {
|
||||||
|
PROVIDER_EVENTS_LOG_FILE_SUFFIX,
|
||||||
|
USAGE_EVENTS_LOG_FILE_SUFFIX,
|
||||||
|
USAGE_MISSING_REASONS,
|
||||||
|
} from '../core/logging/contracts.js';
|
||||||
|
import { buildUsageEventRecord } from '../core/logging/providerEvent.js';
|
||||||
|
import { createProviderEventLogger } from '../core/logging/providerEventLogger.js';
|
||||||
|
import { createUsageEventLogger } from '../core/logging/usageEventLogger.js';
|
||||||
|
import type { ProviderUsageSnapshot } from '../core/models/response.js';
|
||||||
|
|
||||||
|
describe('logging contracts', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = join(tmpdir(), `takt-logging-contracts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use shared file suffix contracts for provider and usage loggers', () => {
|
||||||
|
const providerLogger = createProviderEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-a',
|
||||||
|
runId: 'run-a',
|
||||||
|
provider: 'claude',
|
||||||
|
movement: 'plan',
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
const usageLogger = createUsageEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-b',
|
||||||
|
runId: 'run-b',
|
||||||
|
provider: 'codex',
|
||||||
|
providerModel: 'gpt-5-codex',
|
||||||
|
movement: 'implement',
|
||||||
|
movementType: 'normal',
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(providerLogger.filepath.endsWith(PROVIDER_EVENTS_LOG_FILE_SUFFIX)).toBe(true);
|
||||||
|
expect(usageLogger.filepath.endsWith(USAGE_EVENTS_LOG_FILE_SUFFIX)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept shared ProviderUsageSnapshot contract in usage record builder', () => {
|
||||||
|
const usage: ProviderUsageSnapshot = {
|
||||||
|
inputTokens: 12,
|
||||||
|
outputTokens: 8,
|
||||||
|
totalTokens: 20,
|
||||||
|
cachedInputTokens: 4,
|
||||||
|
cacheCreationInputTokens: 2,
|
||||||
|
cacheReadInputTokens: 2,
|
||||||
|
usageMissing: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const record = buildUsageEventRecord(
|
||||||
|
{
|
||||||
|
runId: 'run-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
provider: 'claude',
|
||||||
|
providerModel: 'sonnet',
|
||||||
|
movement: 'implement',
|
||||||
|
movementType: 'normal',
|
||||||
|
},
|
||||||
|
{ success: true, usage, timestamp: new Date('2026-03-04T12:00:00.000Z') },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(record.usage.cached_input_tokens).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject usage_missing records with unknown reason values', () => {
|
||||||
|
expect(() =>
|
||||||
|
buildUsageEventRecord(
|
||||||
|
{
|
||||||
|
runId: 'run-2',
|
||||||
|
sessionId: 'session-2',
|
||||||
|
provider: 'opencode',
|
||||||
|
providerModel: 'openai/gpt-4.1',
|
||||||
|
movement: 'implement',
|
||||||
|
movementType: 'normal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
usage: {
|
||||||
|
usageMissing: true,
|
||||||
|
reason: 'invalid_reason',
|
||||||
|
} as ProviderUsageSnapshot,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toThrow('[usage-events] reason is invalid');
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
buildUsageEventRecord(
|
||||||
|
{
|
||||||
|
runId: 'run-3',
|
||||||
|
sessionId: 'session-3',
|
||||||
|
provider: 'opencode',
|
||||||
|
providerModel: 'openai/gpt-4.1',
|
||||||
|
movement: 'implement',
|
||||||
|
movementType: 'normal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
usage: {
|
||||||
|
usageMissing: true,
|
||||||
|
reason: USAGE_MISSING_REASONS.NOT_AVAILABLE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -614,11 +614,13 @@ describe('GlobalConfigSchema', () => {
|
|||||||
const config = {
|
const config = {
|
||||||
logging: {
|
logging: {
|
||||||
provider_events: false,
|
provider_events: false,
|
||||||
|
usage_events: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = GlobalConfigSchema.parse(config);
|
const result = GlobalConfigSchema.parse(config);
|
||||||
expect(result.logging?.provider_events).toBe(false);
|
expect(result.logging?.provider_events).toBe(false);
|
||||||
|
expect(result.logging?.usage_events).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept full logging config with all fields', () => {
|
it('should accept full logging config with all fields', () => {
|
||||||
@ -628,6 +630,7 @@ describe('GlobalConfigSchema', () => {
|
|||||||
trace: true,
|
trace: true,
|
||||||
debug: true,
|
debug: true,
|
||||||
provider_events: true,
|
provider_events: true,
|
||||||
|
usage_events: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -636,6 +639,7 @@ describe('GlobalConfigSchema', () => {
|
|||||||
expect(result.logging?.trace).toBe(true);
|
expect(result.logging?.trace).toBe(true);
|
||||||
expect(result.logging?.debug).toBe(true);
|
expect(result.logging?.debug).toBe(true);
|
||||||
expect(result.logging?.provider_events).toBe(true);
|
expect(result.logging?.provider_events).toBe(true);
|
||||||
|
expect(result.logging?.usage_events).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept partial logging config', () => {
|
it('should accept partial logging config', () => {
|
||||||
@ -650,6 +654,7 @@ describe('GlobalConfigSchema', () => {
|
|||||||
expect(result.logging?.trace).toBeUndefined();
|
expect(result.logging?.trace).toBeUndefined();
|
||||||
expect(result.logging?.debug).toBeUndefined();
|
expect(result.logging?.debug).toBeUndefined();
|
||||||
expect(result.logging?.provider_events).toBeUndefined();
|
expect(result.logging?.provider_events).toBeUndefined();
|
||||||
|
expect(result.logging?.usage_events).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject invalid logging level', () => {
|
it('should reject invalid logging level', () => {
|
||||||
|
|||||||
@ -6,14 +6,45 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { USAGE_MISSING_REASONS } from '../core/logging/contracts.js';
|
||||||
import type { PieceConfig } from '../core/models/index.js';
|
import type { PieceConfig } from '../core/models/index.js';
|
||||||
|
|
||||||
const { MockPieceEngine, mockLoadPersonaSessions, mockLoadWorktreeSessions } = vi.hoisted(() => {
|
const {
|
||||||
|
MockPieceEngine,
|
||||||
|
mockLoadPersonaSessions,
|
||||||
|
mockLoadWorktreeSessions,
|
||||||
|
mockCreateUsageEventLogger,
|
||||||
|
mockUsageLogger,
|
||||||
|
mockMovementResponse,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { EventEmitter: EE } = require('node:events') as typeof import('node:events');
|
const { EventEmitter: EE } = require('node:events') as typeof import('node:events');
|
||||||
|
|
||||||
const mockLoadPersonaSessions = vi.fn().mockReturnValue({ coder: 'saved-session-id' });
|
const mockLoadPersonaSessions = vi.fn().mockReturnValue({ coder: 'saved-session-id' });
|
||||||
const mockLoadWorktreeSessions = vi.fn().mockReturnValue({ coder: 'worktree-session-id' });
|
const mockLoadWorktreeSessions = vi.fn().mockReturnValue({ coder: 'worktree-session-id' });
|
||||||
|
const mockUsageLogger = {
|
||||||
|
filepath: '/tmp/test-usage-events.jsonl',
|
||||||
|
setMovement: vi.fn(),
|
||||||
|
setProvider: vi.fn(),
|
||||||
|
logUsage: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockCreateUsageEventLogger = vi.fn().mockReturnValue(mockUsageLogger);
|
||||||
|
const mockMovementResponse: {
|
||||||
|
providerUsage: {
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
usageMissing: boolean;
|
||||||
|
reason?: string;
|
||||||
|
} | undefined;
|
||||||
|
} = {
|
||||||
|
providerUsage: {
|
||||||
|
inputTokens: 3,
|
||||||
|
outputTokens: 2,
|
||||||
|
totalTokens: 5,
|
||||||
|
usageMissing: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
type PersonaProviderMap = Record<string, { provider?: string; model?: string }>;
|
type PersonaProviderMap = Record<string, { provider?: string; model?: string }>;
|
||||||
|
|
||||||
@ -47,13 +78,28 @@ const { MockPieceEngine, mockLoadPersonaSessions, mockLoadWorktreeSessions } = v
|
|||||||
if (firstStep) {
|
if (firstStep) {
|
||||||
const providerInfo = resolveProviderInfo(firstStep, this.receivedOptions);
|
const providerInfo = resolveProviderInfo(firstStep, this.receivedOptions);
|
||||||
this.emit('movement:start', firstStep, 1, firstStep.instructionTemplate, providerInfo);
|
this.emit('movement:start', firstStep, 1, firstStep.instructionTemplate, providerInfo);
|
||||||
|
this.emit('movement:complete', firstStep, {
|
||||||
|
persona: firstStep.personaDisplayName,
|
||||||
|
status: 'done',
|
||||||
|
content: 'ok',
|
||||||
|
timestamp: new Date('2026-03-04T00:00:00.000Z'),
|
||||||
|
sessionId: 'movement-session',
|
||||||
|
providerUsage: mockMovementResponse.providerUsage,
|
||||||
|
}, firstStep.instructionTemplate);
|
||||||
}
|
}
|
||||||
this.emit('piece:complete', { status: 'completed', iteration: 1 });
|
this.emit('piece:complete', { status: 'completed', iteration: 1 });
|
||||||
return { status: 'completed', iteration: 1 };
|
return { status: 'completed', iteration: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { MockPieceEngine, mockLoadPersonaSessions, mockLoadWorktreeSessions };
|
return {
|
||||||
|
MockPieceEngine,
|
||||||
|
mockLoadPersonaSessions,
|
||||||
|
mockLoadWorktreeSessions,
|
||||||
|
mockCreateUsageEventLogger,
|
||||||
|
mockUsageLogger,
|
||||||
|
mockMovementResponse,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('../core/piece/index.js', async () => {
|
vi.mock('../core/piece/index.js', async () => {
|
||||||
@ -146,6 +192,10 @@ vi.mock('../shared/prompt/index.js', () => ({
|
|||||||
selectOption: vi.fn(),
|
selectOption: vi.fn(),
|
||||||
promptInput: vi.fn(),
|
promptInput: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock('../shared/utils/usageEventLogger.js', () => ({
|
||||||
|
createUsageEventLogger: mockCreateUsageEventLogger,
|
||||||
|
isUsageEventsEnabled: vi.fn().mockReturnValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../shared/i18n/index.js', () => ({
|
vi.mock('../shared/i18n/index.js', () => ({
|
||||||
getLabel: vi.fn().mockImplementation((key: string) => key),
|
getLabel: vi.fn().mockImplementation((key: string) => key),
|
||||||
@ -188,12 +238,30 @@ function makeConfig(): PieceConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeConfigWithMovement(overrides: Record<string, unknown>): PieceConfig {
|
||||||
|
const baseMovement = makeConfig().movements[0];
|
||||||
|
if (!baseMovement) {
|
||||||
|
throw new Error('Base movement is required');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...makeConfig(),
|
||||||
|
movements: [{ ...baseMovement, ...overrides }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('executePiece session loading', () => {
|
describe('executePiece session loading', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockCreateUsageEventLogger.mockReturnValue(mockUsageLogger);
|
||||||
vi.mocked(resolvePieceConfigValues).mockReturnValue({ ...defaultResolvedConfigValues });
|
vi.mocked(resolvePieceConfigValues).mockReturnValue({ ...defaultResolvedConfigValues });
|
||||||
mockLoadPersonaSessions.mockReturnValue({ coder: 'saved-session-id' });
|
mockLoadPersonaSessions.mockReturnValue({ coder: 'saved-session-id' });
|
||||||
mockLoadWorktreeSessions.mockReturnValue({ coder: 'worktree-session-id' });
|
mockLoadWorktreeSessions.mockReturnValue({ coder: 'worktree-session-id' });
|
||||||
|
mockMovementResponse.providerUsage = {
|
||||||
|
inputTokens: 3,
|
||||||
|
outputTokens: 2,
|
||||||
|
totalTokens: 5,
|
||||||
|
usageMissing: false,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass empty initialSessions on normal run', async () => {
|
it('should pass empty initialSessions on normal run', async () => {
|
||||||
@ -208,6 +276,41 @@ describe('executePiece session loading', () => {
|
|||||||
expect(MockPieceEngine.lastInstance.receivedOptions.initialSessions).toEqual({});
|
expect(MockPieceEngine.lastInstance.receivedOptions.initialSessions).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should log usage events on movement completion when usage logging is enabled', async () => {
|
||||||
|
await executePiece(makeConfig(), 'task', '/tmp/project', {
|
||||||
|
projectCwd: '/tmp/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCreateUsageEventLogger).toHaveBeenCalledOnce();
|
||||||
|
expect(mockUsageLogger.setMovement).toHaveBeenCalledWith('implement', 'normal');
|
||||||
|
expect(mockUsageLogger.setProvider).toHaveBeenCalledWith('claude', '(default)');
|
||||||
|
expect(mockUsageLogger.logUsage).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
usage: {
|
||||||
|
inputTokens: 3,
|
||||||
|
outputTokens: 2,
|
||||||
|
totalTokens: 5,
|
||||||
|
usageMissing: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log usage_missing reason when provider usage is unavailable', async () => {
|
||||||
|
mockMovementResponse.providerUsage = undefined;
|
||||||
|
|
||||||
|
await executePiece(makeConfig(), 'task', '/tmp/project', {
|
||||||
|
projectCwd: '/tmp/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUsageLogger.logUsage).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
usage: {
|
||||||
|
usageMissing: true,
|
||||||
|
reason: USAGE_MISSING_REASONS.NOT_AVAILABLE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should load persisted sessions when startMovement is set (retry)', async () => {
|
it('should load persisted sessions when startMovement is set (retry)', async () => {
|
||||||
// Given: retry execution with startMovement
|
// Given: retry execution with startMovement
|
||||||
await executePiece(makeConfig(), 'task', '/tmp/project', {
|
await executePiece(makeConfig(), 'task', '/tmp/project', {
|
||||||
@ -314,4 +417,33 @@ describe('executePiece session loading', () => {
|
|||||||
expect(mockInfo).toHaveBeenCalledWith('Provider: opencode');
|
expect(mockInfo).toHaveBeenCalledWith('Provider: opencode');
|
||||||
expect(mockInfo).toHaveBeenCalledWith('Model: gpt-5');
|
expect(mockInfo).toHaveBeenCalledWith('Model: gpt-5');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should pass movement type to usage logger for parallel movement', async () => {
|
||||||
|
await executePiece(makeConfigWithMovement({ parallel: { branches: [] } }), 'task', '/tmp/project', {
|
||||||
|
projectCwd: '/tmp/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUsageLogger.setMovement).toHaveBeenCalledWith('implement', 'parallel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass movement type to usage logger for arpeggio movement', async () => {
|
||||||
|
await executePiece(makeConfigWithMovement({ arpeggio: { source: './items.csv' } }), 'task', '/tmp/project', {
|
||||||
|
projectCwd: '/tmp/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUsageLogger.setMovement).toHaveBeenCalledWith('implement', 'arpeggio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass movement type to usage logger for team leader movement', async () => {
|
||||||
|
await executePiece(
|
||||||
|
makeConfigWithMovement({ teamLeader: { output: { mode: 'summary' } } }),
|
||||||
|
'task',
|
||||||
|
'/tmp/project',
|
||||||
|
{
|
||||||
|
projectCwd: '/tmp/project',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockUsageLogger.setMovement).toHaveBeenCalledWith('implement', 'team_leader');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -94,6 +94,20 @@ describe('providerEventLogger', () => {
|
|||||||
expect(parsed.data['tool']).toBe('Read');
|
expect(parsed.data['tool']).toBe('Read');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should keep provider-events filename suffix for backward compatibility', () => {
|
||||||
|
const logger = createProviderEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-compat',
|
||||||
|
runId: 'run-compat',
|
||||||
|
provider: 'claude',
|
||||||
|
movement: 'plan',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logger.filepath.endsWith('-provider-events.jsonl')).toBe(true);
|
||||||
|
expect(logger.filepath.endsWith('-usage-events.jsonl')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('should update movement and provider for subsequent events', () => {
|
it('should update movement and provider for subsequent events', () => {
|
||||||
const logger = createProviderEventLogger({
|
const logger = createProviderEventLogger({
|
||||||
logsDir: tempDir,
|
logsDir: tempDir,
|
||||||
|
|||||||
@ -318,6 +318,26 @@ describe('loadRunSessionContext', () => {
|
|||||||
expect(context.movementLogs).toEqual([]);
|
expect(context.movementLogs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should exclude usage-events log files', () => {
|
||||||
|
const slug = 'usage-events-run';
|
||||||
|
const runDir = createRunDir(tmpDir, slug, {
|
||||||
|
task: 'Usage events test',
|
||||||
|
piece: 'default',
|
||||||
|
status: 'completed',
|
||||||
|
startTime: '2026-02-01T00:00:00.000Z',
|
||||||
|
logsDirectory: `.takt/runs/${slug}/logs`,
|
||||||
|
reportDirectory: `.takt/runs/${slug}/reports`,
|
||||||
|
runSlug: slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only usage-events log file
|
||||||
|
writeFileSync(join(runDir, 'logs', 'session-001-usage-events.jsonl'), '{}', 'utf-8');
|
||||||
|
|
||||||
|
const context = loadRunSessionContext(tmpDir, slug);
|
||||||
|
expect(mockLoadNdjsonLog).not.toHaveBeenCalled();
|
||||||
|
expect(context.movementLogs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|||||||
273
src/__tests__/usageEventLogger.test.ts
Normal file
273
src/__tests__/usageEventLogger.test.ts
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
type ProviderType = 'claude' | 'codex' | 'opencode';
|
||||||
|
type MovementType = 'normal' | 'parallel' | 'arpeggio' | 'team_leader';
|
||||||
|
|
||||||
|
interface ProviderUsageSnapshot {
|
||||||
|
readonly inputTokens?: number;
|
||||||
|
readonly outputTokens?: number;
|
||||||
|
readonly totalTokens?: number;
|
||||||
|
readonly cachedInputTokens?: number;
|
||||||
|
readonly usageMissing: boolean;
|
||||||
|
readonly reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageEventLoggerConfig {
|
||||||
|
readonly logsDir: string;
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly runId: string;
|
||||||
|
readonly provider: ProviderType;
|
||||||
|
readonly providerModel: string;
|
||||||
|
readonly movement: string;
|
||||||
|
readonly movementType: MovementType;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageEventLogger {
|
||||||
|
readonly filepath: string;
|
||||||
|
setMovement(movement: string, movementType: MovementType): void;
|
||||||
|
setProvider(provider: ProviderType, providerModel: string): void;
|
||||||
|
logUsage(params: {
|
||||||
|
readonly success: boolean;
|
||||||
|
readonly usage: ProviderUsageSnapshot;
|
||||||
|
readonly timestamp?: Date;
|
||||||
|
}): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageEventLoggerModule {
|
||||||
|
createUsageEventLogger(config: UsageEventLoggerConfig): UsageEventLogger;
|
||||||
|
isUsageEventsEnabled(config?: { logging?: { usageEvents?: boolean } }): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE_EVENT_LOGGER_MODULE_PATH = ['..', 'shared', 'utils', 'usageEventLogger.js'].join('/');
|
||||||
|
|
||||||
|
async function loadUsageEventLoggerModule(): Promise<UsageEventLoggerModule> {
|
||||||
|
return (await import(USAGE_EVENT_LOGGER_MODULE_PATH)) as UsageEventLoggerModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('usageEventLogger', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = join(tmpdir(), `takt-usage-events-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable usage events by default', async () => {
|
||||||
|
const { isUsageEventsEnabled } = await loadUsageEventLoggerModule();
|
||||||
|
|
||||||
|
expect(isUsageEventsEnabled()).toBe(false);
|
||||||
|
expect(isUsageEventsEnabled({})).toBe(false);
|
||||||
|
expect(isUsageEventsEnabled({ logging: {} })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable usage events only when explicitly true', async () => {
|
||||||
|
const { isUsageEventsEnabled } = await loadUsageEventLoggerModule();
|
||||||
|
|
||||||
|
expect(isUsageEventsEnabled({ logging: { usageEvents: true } })).toBe(true);
|
||||||
|
expect(isUsageEventsEnabled({ logging: { usageEvents: false } })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write usage event records with required fields', async () => {
|
||||||
|
const { createUsageEventLogger } = await loadUsageEventLoggerModule();
|
||||||
|
const logger = createUsageEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-1',
|
||||||
|
runId: 'run-1',
|
||||||
|
provider: 'codex',
|
||||||
|
providerModel: 'gpt-5-codex',
|
||||||
|
movement: 'implement',
|
||||||
|
movementType: 'normal',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.logUsage({
|
||||||
|
success: true,
|
||||||
|
usage: {
|
||||||
|
inputTokens: 12,
|
||||||
|
outputTokens: 8,
|
||||||
|
totalTokens: 20,
|
||||||
|
cachedInputTokens: 4,
|
||||||
|
usageMissing: false,
|
||||||
|
},
|
||||||
|
timestamp: new Date('2026-03-04T12:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(existsSync(logger.filepath)).toBe(true);
|
||||||
|
|
||||||
|
const line = readFileSync(logger.filepath, 'utf-8').trim();
|
||||||
|
const parsed = JSON.parse(line) as {
|
||||||
|
run_id: string;
|
||||||
|
session_id: string;
|
||||||
|
provider: ProviderType;
|
||||||
|
provider_model: string;
|
||||||
|
movement: string;
|
||||||
|
movement_type: MovementType;
|
||||||
|
timestamp: string;
|
||||||
|
success: boolean;
|
||||||
|
usage_missing: boolean;
|
||||||
|
reason?: string;
|
||||||
|
usage: {
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
cached_input_tokens?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.run_id).toBe('run-1');
|
||||||
|
expect(parsed.session_id).toBe('session-1');
|
||||||
|
expect(parsed.provider).toBe('codex');
|
||||||
|
expect(parsed.provider_model).toBe('gpt-5-codex');
|
||||||
|
expect(parsed.movement).toBe('implement');
|
||||||
|
expect(parsed.movement_type).toBe('normal');
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
expect(parsed.usage_missing).toBe(false);
|
||||||
|
expect(parsed.timestamp).toBe('2026-03-04T12:00:00.000Z');
|
||||||
|
expect(parsed.usage.input_tokens).toBe(12);
|
||||||
|
expect(parsed.usage.output_tokens).toBe(8);
|
||||||
|
expect(parsed.usage.total_tokens).toBe(20);
|
||||||
|
expect(parsed.usage.cached_input_tokens).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write usage_missing and reason when provider usage is unavailable', async () => {
|
||||||
|
const { createUsageEventLogger } = await loadUsageEventLoggerModule();
|
||||||
|
const logger = createUsageEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-2',
|
||||||
|
runId: 'run-2',
|
||||||
|
provider: 'opencode',
|
||||||
|
providerModel: 'openai/gpt-4.1',
|
||||||
|
movement: 'implement',
|
||||||
|
movementType: 'normal',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.logUsage({
|
||||||
|
success: true,
|
||||||
|
usage: {
|
||||||
|
usageMissing: true,
|
||||||
|
reason: 'usage_not_supported_by_provider',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const line = readFileSync(logger.filepath, 'utf-8').trim();
|
||||||
|
const parsed = JSON.parse(line) as {
|
||||||
|
provider: ProviderType;
|
||||||
|
usage_missing: boolean;
|
||||||
|
reason?: string;
|
||||||
|
usage: {
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
cached_input_tokens?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.provider).toBe('opencode');
|
||||||
|
expect(parsed.usage_missing).toBe(true);
|
||||||
|
expect(parsed.reason).toBe('usage_not_supported_by_provider');
|
||||||
|
expect(parsed.usage).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update movement and provider metadata for subsequent records', async () => {
|
||||||
|
const { createUsageEventLogger } = await loadUsageEventLoggerModule();
|
||||||
|
const logger = createUsageEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-3',
|
||||||
|
runId: 'run-3',
|
||||||
|
provider: 'claude',
|
||||||
|
providerModel: 'sonnet',
|
||||||
|
movement: 'plan',
|
||||||
|
movementType: 'normal',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.logUsage({
|
||||||
|
success: true,
|
||||||
|
usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3, usageMissing: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.setMovement('implement', 'parallel');
|
||||||
|
logger.setProvider('codex', 'gpt-5-codex');
|
||||||
|
logger.logUsage({
|
||||||
|
success: true,
|
||||||
|
usage: { inputTokens: 4, outputTokens: 5, totalTokens: 9, usageMissing: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = readFileSync(logger.filepath, 'utf-8').trim().split('\n');
|
||||||
|
expect(lines).toHaveLength(2);
|
||||||
|
|
||||||
|
const first = JSON.parse(lines[0] ?? '{}') as { provider: ProviderType; provider_model: string; movement: string; movement_type: MovementType };
|
||||||
|
const second = JSON.parse(lines[1] ?? '{}') as { provider: ProviderType; provider_model: string; movement: string; movement_type: MovementType };
|
||||||
|
|
||||||
|
expect(first.provider).toBe('claude');
|
||||||
|
expect(first.provider_model).toBe('sonnet');
|
||||||
|
expect(first.movement).toBe('plan');
|
||||||
|
expect(first.movement_type).toBe('normal');
|
||||||
|
|
||||||
|
expect(second.provider).toBe('codex');
|
||||||
|
expect(second.provider_model).toBe('gpt-5-codex');
|
||||||
|
expect(second.movement).toBe('implement');
|
||||||
|
expect(second.movement_type).toBe('parallel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not write records when disabled', async () => {
|
||||||
|
const { createUsageEventLogger } = await loadUsageEventLoggerModule();
|
||||||
|
const logger = createUsageEventLogger({
|
||||||
|
logsDir: tempDir,
|
||||||
|
sessionId: 'session-disabled',
|
||||||
|
runId: 'run-disabled',
|
||||||
|
provider: 'claude',
|
||||||
|
providerModel: 'sonnet',
|
||||||
|
movement: 'plan',
|
||||||
|
movementType: 'normal',
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.logUsage({
|
||||||
|
success: true,
|
||||||
|
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2, usageMissing: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(existsSync(logger.filepath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report file write failures to stderr only once', async () => {
|
||||||
|
const { createUsageEventLogger } = await loadUsageEventLoggerModule();
|
||||||
|
const logger = createUsageEventLogger({
|
||||||
|
logsDir: join(tempDir, 'missing', 'nested'),
|
||||||
|
sessionId: 'session-err',
|
||||||
|
runId: 'run-err',
|
||||||
|
provider: 'claude',
|
||||||
|
providerModel: 'sonnet',
|
||||||
|
movement: 'plan',
|
||||||
|
movementType: 'normal',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
try {
|
||||||
|
logger.logUsage({
|
||||||
|
success: true,
|
||||||
|
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2, usageMissing: false },
|
||||||
|
});
|
||||||
|
logger.logUsage({
|
||||||
|
success: true,
|
||||||
|
usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4, usageMissing: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stderrSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stderrSpy.mock.calls[0]?.[0]).toContain('Failed to write usage event log');
|
||||||
|
} finally {
|
||||||
|
stderrSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
11
src/core/logging/contracts.ts
Normal file
11
src/core/logging/contracts.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export const PROVIDER_EVENTS_LOG_FILE_SUFFIX = '-provider-events.jsonl';
|
||||||
|
export const USAGE_EVENTS_LOG_FILE_SUFFIX = '-usage-events.jsonl';
|
||||||
|
|
||||||
|
export const USAGE_MISSING_REASONS = {
|
||||||
|
NOT_AVAILABLE: 'usage_not_available',
|
||||||
|
TOKENS_MISSING: 'usage_tokens_missing',
|
||||||
|
NOT_SUPPORTED_BY_PROVIDER: 'usage_not_supported_by_provider',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UsageMissingReason =
|
||||||
|
(typeof USAGE_MISSING_REASONS)[keyof typeof USAGE_MISSING_REASONS];
|
||||||
176
src/core/logging/providerEvent.ts
Normal file
176
src/core/logging/providerEvent.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import type { ProviderType, StreamEvent } from '../../shared/types/provider.js';
|
||||||
|
import type { ProviderUsageSnapshot } from '../models/response.js';
|
||||||
|
import { USAGE_MISSING_REASONS, type UsageMissingReason } from './contracts.js';
|
||||||
|
|
||||||
|
export type MovementType = 'normal' | 'parallel' | 'arpeggio' | 'team_leader';
|
||||||
|
|
||||||
|
export 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageEventLogRecord {
|
||||||
|
run_id: string;
|
||||||
|
session_id: string;
|
||||||
|
provider: ProviderType;
|
||||||
|
provider_model: string;
|
||||||
|
movement: string;
|
||||||
|
movement_type: MovementType;
|
||||||
|
timestamp: string;
|
||||||
|
success: boolean;
|
||||||
|
usage_missing: boolean;
|
||||||
|
reason?: UsageMissingReason;
|
||||||
|
usage: {
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
cached_input_tokens?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageEventMeta {
|
||||||
|
runId: string;
|
||||||
|
sessionId: string;
|
||||||
|
provider: ProviderType;
|
||||||
|
providerModel: string;
|
||||||
|
movement: string;
|
||||||
|
movementType: MovementType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildUsageRecordParams {
|
||||||
|
success: boolean;
|
||||||
|
usage: ProviderUsageSnapshot;
|
||||||
|
timestamp?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 assertFiniteNumber(value: number | undefined, field: string): void {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new Error(`[usage-events] ${field} is required`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertUsageMissingReason(value: string): UsageMissingReason {
|
||||||
|
for (const reason of Object.values(USAGE_MISSING_REASONS)) {
|
||||||
|
if (value === reason) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('[usage-events] reason is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProviderEvent(
|
||||||
|
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 buildUsageEventRecord(
|
||||||
|
meta: UsageEventMeta,
|
||||||
|
params: BuildUsageRecordParams
|
||||||
|
): UsageEventLogRecord {
|
||||||
|
if (params.usage.usageMissing) {
|
||||||
|
if (typeof params.usage.reason !== 'string' || params.usage.reason.length === 0) {
|
||||||
|
throw new Error('[usage-events] reason is required when usageMissing=true');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
run_id: meta.runId,
|
||||||
|
session_id: meta.sessionId,
|
||||||
|
provider: meta.provider,
|
||||||
|
provider_model: meta.providerModel,
|
||||||
|
movement: meta.movement,
|
||||||
|
movement_type: meta.movementType,
|
||||||
|
timestamp: (params.timestamp ?? new Date()).toISOString(),
|
||||||
|
success: params.success,
|
||||||
|
usage_missing: true,
|
||||||
|
reason: assertUsageMissingReason(params.usage.reason),
|
||||||
|
usage: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFiniteNumber(params.usage.inputTokens, 'usage.inputTokens');
|
||||||
|
assertFiniteNumber(params.usage.outputTokens, 'usage.outputTokens');
|
||||||
|
assertFiniteNumber(params.usage.totalTokens, 'usage.totalTokens');
|
||||||
|
|
||||||
|
const usage = {
|
||||||
|
input_tokens: params.usage.inputTokens,
|
||||||
|
output_tokens: params.usage.outputTokens,
|
||||||
|
total_tokens: params.usage.totalTokens,
|
||||||
|
...(Number.isFinite(params.usage.cachedInputTokens)
|
||||||
|
? { cached_input_tokens: params.usage.cachedInputTokens }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
run_id: meta.runId,
|
||||||
|
session_id: meta.sessionId,
|
||||||
|
provider: meta.provider,
|
||||||
|
provider_model: meta.providerModel,
|
||||||
|
movement: meta.movement,
|
||||||
|
movement_type: meta.movementType,
|
||||||
|
timestamp: (params.timestamp ?? new Date()).toISOString(),
|
||||||
|
success: params.success,
|
||||||
|
usage_missing: false,
|
||||||
|
usage,
|
||||||
|
};
|
||||||
|
}
|
||||||
87
src/core/logging/providerEventLogger.ts
Normal file
87
src/core/logging/providerEventLogger.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { appendFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { ProviderType, StreamCallback, StreamEvent } from '../../shared/types/provider.js';
|
||||||
|
import { PROVIDER_EVENTS_LOG_FILE_SUFFIX } from './contracts.js';
|
||||||
|
import { normalizeProviderEvent } from './providerEvent.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmpty(value: string, field: string): void {
|
||||||
|
if (value.length === 0) {
|
||||||
|
throw new Error(`[provider-events] ${field} is required`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProviderEventLogger(config: ProviderEventLoggerConfig): ProviderEventLogger {
|
||||||
|
if (config.enabled) {
|
||||||
|
assertNonEmpty(config.logsDir, 'logsDir');
|
||||||
|
assertNonEmpty(config.sessionId, 'sessionId');
|
||||||
|
assertNonEmpty(config.runId, 'runId');
|
||||||
|
assertNonEmpty(config.movement, 'movement');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filepath = join(config.logsDir, `${config.sessionId}${PROVIDER_EVENTS_LOG_FILE_SUFFIX}`);
|
||||||
|
let movement = config.movement;
|
||||||
|
let provider = config.provider;
|
||||||
|
let hasReportedWriteFailure = false;
|
||||||
|
|
||||||
|
const write = (event: StreamEvent): void => {
|
||||||
|
const record = normalizeProviderEvent(event, provider, movement, config.runId);
|
||||||
|
try {
|
||||||
|
appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8');
|
||||||
|
} catch (error) {
|
||||||
|
if (hasReportedWriteFailure) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasReportedWriteFailure = true;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
process.stderr.write(`[takt] Failed to write provider event log: ${message}\n`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
filepath,
|
||||||
|
setMovement(nextMovement: string): void {
|
||||||
|
assertNonEmpty(nextMovement, 'movement');
|
||||||
|
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?: {
|
||||||
|
logging?: {
|
||||||
|
providerEvents?: boolean;
|
||||||
|
};
|
||||||
|
}): boolean {
|
||||||
|
return config?.logging?.providerEvents === true;
|
||||||
|
}
|
||||||
108
src/core/logging/usageEventLogger.ts
Normal file
108
src/core/logging/usageEventLogger.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { appendFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { ProviderType } from '../../shared/types/provider.js';
|
||||||
|
import type { ProviderUsageSnapshot } from '../models/response.js';
|
||||||
|
import { USAGE_EVENTS_LOG_FILE_SUFFIX } from './contracts.js';
|
||||||
|
import {
|
||||||
|
buildUsageEventRecord,
|
||||||
|
type MovementType,
|
||||||
|
} from './providerEvent.js';
|
||||||
|
|
||||||
|
export interface UsageEventLoggerConfig {
|
||||||
|
readonly logsDir: string;
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly runId: string;
|
||||||
|
readonly provider: ProviderType;
|
||||||
|
readonly providerModel: string;
|
||||||
|
readonly movement: string;
|
||||||
|
readonly movementType: MovementType;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageEventLogger {
|
||||||
|
readonly filepath: string;
|
||||||
|
setMovement(movement: string, movementType: MovementType): void;
|
||||||
|
setProvider(provider: ProviderType, providerModel: string): void;
|
||||||
|
logUsage(params: {
|
||||||
|
readonly success: boolean;
|
||||||
|
readonly usage: ProviderUsageSnapshot;
|
||||||
|
readonly timestamp?: Date;
|
||||||
|
}): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmpty(value: string, field: string): void {
|
||||||
|
if (value.length === 0) {
|
||||||
|
throw new Error(`[usage-events] ${field} is required`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUsageEventLogger(config: UsageEventLoggerConfig): UsageEventLogger {
|
||||||
|
if (config.enabled) {
|
||||||
|
assertNonEmpty(config.logsDir, 'logsDir');
|
||||||
|
assertNonEmpty(config.sessionId, 'sessionId');
|
||||||
|
assertNonEmpty(config.runId, 'runId');
|
||||||
|
assertNonEmpty(config.providerModel, 'providerModel');
|
||||||
|
assertNonEmpty(config.movement, 'movement');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filepath = join(config.logsDir, `${config.sessionId}${USAGE_EVENTS_LOG_FILE_SUFFIX}`);
|
||||||
|
let movement = config.movement;
|
||||||
|
let movementType = config.movementType;
|
||||||
|
let provider = config.provider;
|
||||||
|
let providerModel = config.providerModel;
|
||||||
|
let hasReportedWriteFailure = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
filepath,
|
||||||
|
setMovement(nextMovement: string, nextMovementType: MovementType): void {
|
||||||
|
assertNonEmpty(nextMovement, 'movement');
|
||||||
|
movement = nextMovement;
|
||||||
|
movementType = nextMovementType;
|
||||||
|
},
|
||||||
|
setProvider(nextProvider: ProviderType, nextProviderModel: string): void {
|
||||||
|
assertNonEmpty(nextProviderModel, 'providerModel');
|
||||||
|
provider = nextProvider;
|
||||||
|
providerModel = nextProviderModel;
|
||||||
|
},
|
||||||
|
logUsage(params: {
|
||||||
|
readonly success: boolean;
|
||||||
|
readonly usage: ProviderUsageSnapshot;
|
||||||
|
readonly timestamp?: Date;
|
||||||
|
}): void {
|
||||||
|
if (!config.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = buildUsageEventRecord(
|
||||||
|
{
|
||||||
|
runId: config.runId,
|
||||||
|
sessionId: config.sessionId,
|
||||||
|
provider,
|
||||||
|
providerModel,
|
||||||
|
movement,
|
||||||
|
movementType,
|
||||||
|
},
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8');
|
||||||
|
} catch (error) {
|
||||||
|
if (hasReportedWriteFailure) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasReportedWriteFailure = true;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
process.stderr.write(`[takt] Failed to write usage event log: ${message}\n`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUsageEventsEnabled(config?: {
|
||||||
|
logging?: {
|
||||||
|
usageEvents?: boolean;
|
||||||
|
};
|
||||||
|
}): boolean {
|
||||||
|
return config?.logging?.usageEvents === true;
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ export type {
|
|||||||
RuntimePrepareEntry,
|
RuntimePrepareEntry,
|
||||||
PieceRuntimeConfig,
|
PieceRuntimeConfig,
|
||||||
AgentResponse,
|
AgentResponse,
|
||||||
|
ProviderUsageSnapshot,
|
||||||
SessionState,
|
SessionState,
|
||||||
PartDefinition,
|
PartDefinition,
|
||||||
PartResult,
|
PartResult,
|
||||||
|
|||||||
@ -45,6 +45,8 @@ export interface LoggingConfig {
|
|||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
/** Enable provider stream event logging (default: false when undefined) */
|
/** Enable provider stream event logging (default: false when undefined) */
|
||||||
providerEvents?: boolean;
|
providerEvents?: boolean;
|
||||||
|
/** Enable usage event logging (default: false when undefined) */
|
||||||
|
usageEvents?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Analytics configuration for local metrics collection */
|
/** Analytics configuration for local metrics collection */
|
||||||
|
|||||||
@ -4,6 +4,17 @@
|
|||||||
|
|
||||||
import type { Status, RuleMatchMethod } from './status.js';
|
import type { Status, RuleMatchMethod } from './status.js';
|
||||||
|
|
||||||
|
export interface ProviderUsageSnapshot {
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
cachedInputTokens?: number;
|
||||||
|
cacheCreationInputTokens?: number;
|
||||||
|
cacheReadInputTokens?: number;
|
||||||
|
usageMissing: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Response from an agent execution */
|
/** Response from an agent execution */
|
||||||
export interface AgentResponse {
|
export interface AgentResponse {
|
||||||
persona: string;
|
persona: string;
|
||||||
@ -19,4 +30,6 @@ export interface AgentResponse {
|
|||||||
matchedRuleMethod?: RuleMatchMethod;
|
matchedRuleMethod?: RuleMatchMethod;
|
||||||
/** Structured output returned by provider SDK (JSON Schema mode) */
|
/** Structured output returned by provider SDK (JSON Schema mode) */
|
||||||
structuredOutput?: Record<string, unknown>;
|
structuredOutput?: Record<string, unknown>;
|
||||||
|
/** Provider-native usage payload normalized for TAKT observability */
|
||||||
|
providerUsage?: ProviderUsageSnapshot;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -466,6 +466,7 @@ export const LoggingConfigSchema = z.object({
|
|||||||
trace: z.boolean().optional(),
|
trace: z.boolean().optional(),
|
||||||
debug: z.boolean().optional(),
|
debug: z.boolean().optional(),
|
||||||
provider_events: z.boolean().optional(),
|
provider_events: z.boolean().optional(),
|
||||||
|
usage_events: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Analytics config schema */
|
/** Analytics config schema */
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export type {
|
|||||||
// Agent response
|
// Agent response
|
||||||
export type {
|
export type {
|
||||||
AgentResponse,
|
AgentResponse,
|
||||||
|
ProviderUsageSnapshot,
|
||||||
} from './response.js';
|
} from './response.js';
|
||||||
|
|
||||||
// Session state (authoritative definition with createSessionState)
|
// Session state (authoritative definition with createSessionState)
|
||||||
|
|||||||
@ -7,6 +7,10 @@
|
|||||||
|
|
||||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
PROVIDER_EVENTS_LOG_FILE_SUFFIX,
|
||||||
|
USAGE_EVENTS_LOG_FILE_SUFFIX,
|
||||||
|
} from '../../core/logging/contracts.js';
|
||||||
import { loadNdjsonLog } from '../../infra/fs/index.js';
|
import { loadNdjsonLog } from '../../infra/fs/index.js';
|
||||||
import type { SessionLog } from '../../shared/utils/index.js';
|
import type { SessionLog } from '../../shared/utils/index.js';
|
||||||
|
|
||||||
@ -113,7 +117,11 @@ function findSessionLogFile(logsDir: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const files = readdirSync(logsDir).filter(
|
const files = readdirSync(logsDir).filter(
|
||||||
(f) => f.endsWith('.jsonl') && !f.includes('-provider-events'),
|
(f) => (
|
||||||
|
f.endsWith('.jsonl')
|
||||||
|
&& !f.endsWith(PROVIDER_EVENTS_LOG_FILE_SUFFIX)
|
||||||
|
&& !f.endsWith(USAGE_EVENTS_LOG_FILE_SUFFIX)
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const first = files[0];
|
const first = files[0];
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import { TaskPrefixWriter } from '../../../shared/ui/TaskPrefixWriter.js';
|
|||||||
import { generateSessionId, createSessionLog, finalizeSessionLog, initNdjsonLog } from '../../../infra/fs/index.js';
|
import { generateSessionId, createSessionLog, finalizeSessionLog, initNdjsonLog } from '../../../infra/fs/index.js';
|
||||||
import { createLogger, notifySuccess, notifyError, preventSleep, generateReportDir, isValidReportDirName, getDebugPromptsLogFile } from '../../../shared/utils/index.js';
|
import { createLogger, notifySuccess, notifyError, preventSleep, generateReportDir, isValidReportDirName, getDebugPromptsLogFile } from '../../../shared/utils/index.js';
|
||||||
import { createProviderEventLogger, isProviderEventsEnabled } from '../../../shared/utils/providerEventLogger.js';
|
import { createProviderEventLogger, isProviderEventsEnabled } from '../../../shared/utils/providerEventLogger.js';
|
||||||
|
import { createUsageEventLogger, isUsageEventsEnabled } from '../../../shared/utils/usageEventLogger.js';
|
||||||
|
import { USAGE_MISSING_REASONS } from '../../../core/logging/contracts.js';
|
||||||
import { getLabel } from '../../../shared/i18n/index.js';
|
import { getLabel } from '../../../shared/i18n/index.js';
|
||||||
import { buildRunPaths } from '../../../core/piece/run/run-paths.js';
|
import { buildRunPaths } from '../../../core/piece/run/run-paths.js';
|
||||||
import { resolveRuntimeConfig } from '../../../core/runtime/runtime-environment.js';
|
import { resolveRuntimeConfig } from '../../../core/runtime/runtime-environment.js';
|
||||||
@ -24,7 +26,7 @@ import { AnalyticsEmitter } from './analyticsEmitter.js';
|
|||||||
import { createOutputFns, createPrefixedStreamHandler } from './outputFns.js';
|
import { createOutputFns, createPrefixedStreamHandler } from './outputFns.js';
|
||||||
import { RunMetaManager } from './runMeta.js';
|
import { RunMetaManager } from './runMeta.js';
|
||||||
import { createIterationLimitHandler, createUserInputHandler } from './iterationLimitHandler.js';
|
import { createIterationLimitHandler, createUserInputHandler } from './iterationLimitHandler.js';
|
||||||
import { assertTaskPrefixPair, truncate, formatElapsedTime } from './pieceExecutionUtils.js';
|
import { assertTaskPrefixPair, truncate, formatElapsedTime, detectMovementType } from './pieceExecutionUtils.js';
|
||||||
import { createTraceReportWriter } from './traceReportWriter.js';
|
import { createTraceReportWriter } from './traceReportWriter.js';
|
||||||
import { sanitizeTextForStorage } from './traceReportRedaction.js';
|
import { sanitizeTextForStorage } from './traceReportRedaction.js';
|
||||||
export type { PieceExecutionResult, PieceExecutionOptions };
|
export type { PieceExecutionResult, PieceExecutionOptions };
|
||||||
@ -46,7 +48,6 @@ export async function executePiece(
|
|||||||
const isRetry = Boolean(options.startMovement || options.retryNote);
|
const isRetry = Boolean(options.startMovement || options.retryNote);
|
||||||
log.debug('Session mode', { isRetry, isWorktree: cwd !== projectCwd });
|
log.debug('Session mode', { isRetry, isWorktree: cwd !== projectCwd });
|
||||||
out.header(`${headerPrefix} ${pieceConfig.name}`);
|
out.header(`${headerPrefix} ${pieceConfig.name}`);
|
||||||
|
|
||||||
const pieceSessionId = generateSessionId();
|
const pieceSessionId = generateSessionId();
|
||||||
const runSlug = options.reportDirName ?? generateReportDir(task);
|
const runSlug = options.reportDirName ?? generateReportDir(task);
|
||||||
if (!isValidReportDirName(runSlug)) throw new Error(`Invalid reportDirName: ${runSlug}`);
|
if (!isValidReportDirName(runSlug)) throw new Error(`Invalid reportDirName: ${runSlug}`);
|
||||||
@ -61,10 +62,7 @@ export async function executePiece(
|
|||||||
displayRef.current.createHandler()(event);
|
displayRef.current.createHandler()(event);
|
||||||
};
|
};
|
||||||
const isWorktree = cwd !== projectCwd;
|
const isWorktree = cwd !== projectCwd;
|
||||||
const globalConfig = resolvePieceConfigValues(
|
const globalConfig = resolvePieceConfigValues(projectCwd, ['notificationSound', 'notificationSoundEvents', 'provider', 'runtime', 'preventSleep', 'model', 'logging', 'analytics']);
|
||||||
projectCwd,
|
|
||||||
['notificationSound', 'notificationSoundEvents', 'provider', 'runtime', 'preventSleep', 'model', 'logging', 'analytics'],
|
|
||||||
);
|
|
||||||
const traceReportMode = globalConfig.logging?.trace === true ? 'full' : 'redacted';
|
const traceReportMode = globalConfig.logging?.trace === true ? 'full' : 'redacted';
|
||||||
const allowSensitiveData = traceReportMode === 'full';
|
const allowSensitiveData = traceReportMode === 'full';
|
||||||
const ndjsonLogPath = initNdjsonLog(
|
const ndjsonLogPath = initNdjsonLog(
|
||||||
@ -74,9 +72,7 @@ export async function executePiece(
|
|||||||
{ logsDir: runPaths.logsAbs },
|
{ logsDir: runPaths.logsAbs },
|
||||||
);
|
);
|
||||||
const sessionLogger = new SessionLogger(ndjsonLogPath, allowSensitiveData);
|
const sessionLogger = new SessionLogger(ndjsonLogPath, allowSensitiveData);
|
||||||
if (options.interactiveMetadata) {
|
if (options.interactiveMetadata) sessionLogger.writeInteractiveMetadata(options.interactiveMetadata);
|
||||||
sessionLogger.writeInteractiveMetadata(options.interactiveMetadata);
|
|
||||||
}
|
|
||||||
const shouldNotify = globalConfig.notificationSound !== false;
|
const shouldNotify = globalConfig.notificationSound !== false;
|
||||||
const nse = globalConfig.notificationSoundEvents;
|
const nse = globalConfig.notificationSoundEvents;
|
||||||
const shouldNotifyIterationLimit = shouldNotify && nse?.iterationLimit !== false;
|
const shouldNotifyIterationLimit = shouldNotify && nse?.iterationLimit !== false;
|
||||||
@ -98,6 +94,16 @@ export async function executePiece(
|
|||||||
movement: options.startMovement ?? pieceConfig.initialMovement,
|
movement: options.startMovement ?? pieceConfig.initialMovement,
|
||||||
enabled: isProviderEventsEnabled(globalConfig),
|
enabled: isProviderEventsEnabled(globalConfig),
|
||||||
});
|
});
|
||||||
|
const usageEventLogger = createUsageEventLogger({
|
||||||
|
logsDir: runPaths.logsAbs,
|
||||||
|
sessionId: pieceSessionId,
|
||||||
|
runId: runSlug,
|
||||||
|
provider: currentProvider,
|
||||||
|
providerModel: configuredModel ?? '(default)',
|
||||||
|
movement: options.startMovement ?? pieceConfig.initialMovement,
|
||||||
|
movementType: 'normal',
|
||||||
|
enabled: isUsageEventsEnabled(globalConfig),
|
||||||
|
});
|
||||||
initAnalyticsWriter(globalConfig.analytics?.enabled === true, globalConfig.analytics?.eventsPath ?? join(getGlobalConfigDir(), 'analytics', 'events'));
|
initAnalyticsWriter(globalConfig.analytics?.enabled === true, globalConfig.analytics?.eventsPath ?? join(getGlobalConfigDir(), 'analytics', 'events'));
|
||||||
if (globalConfig.preventSleep) preventSleep();
|
if (globalConfig.preventSleep) preventSleep();
|
||||||
const analyticsEmitter = new AnalyticsEmitter(runSlug, currentProvider, configuredModel ?? '(default)');
|
const analyticsEmitter = new AnalyticsEmitter(runSlug, currentProvider, configuredModel ?? '(default)');
|
||||||
@ -107,7 +113,6 @@ export async function executePiece(
|
|||||||
const sessionUpdateHandler = isWorktree
|
const sessionUpdateHandler = isWorktree
|
||||||
? (personaName: string, personaSessionId: string) => updateWorktreeSession(projectCwd, cwd, personaName, personaSessionId, currentProvider)
|
? (personaName: string, personaSessionId: string) => updateWorktreeSession(projectCwd, cwd, personaName, personaSessionId, currentProvider)
|
||||||
: (persona: string, personaSessionId: string) => updatePersonaSession(projectCwd, persona, personaSessionId, currentProvider);
|
: (persona: string, personaSessionId: string) => updatePersonaSession(projectCwd, persona, personaSessionId, currentProvider);
|
||||||
|
|
||||||
const iterationLimitHandler = createIterationLimitHandler(
|
const iterationLimitHandler = createIterationLimitHandler(
|
||||||
out,
|
out,
|
||||||
displayRef,
|
displayRef,
|
||||||
@ -121,7 +126,6 @@ export async function executePiece(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const onUserInput = interactiveUserInput ? createUserInputHandler(out, displayRef) : undefined;
|
const onUserInput = interactiveUserInput ? createUserInputHandler(out, displayRef) : undefined;
|
||||||
|
|
||||||
let abortReason: string | undefined;
|
let abortReason: string | undefined;
|
||||||
let exceededInfo: ExceededInfo | undefined;
|
let exceededInfo: ExceededInfo | undefined;
|
||||||
let lastMovementContent: string | undefined;
|
let lastMovementContent: string | undefined;
|
||||||
@ -174,13 +178,11 @@ export async function executePiece(
|
|||||||
log.debug('Phase starting', { step: step.name, phase, phaseName });
|
log.debug('Phase starting', { step: step.name, phase, phaseName });
|
||||||
sessionLogger.onPhaseStart(step, phase, phaseName, instruction, promptParts, phaseExecutionId, iteration);
|
sessionLogger.onPhaseStart(step, phase, phaseName, instruction, promptParts, phaseExecutionId, iteration);
|
||||||
});
|
});
|
||||||
|
|
||||||
engine.on('phase:complete', (step, phase, phaseName, content, phaseStatus, phaseError, phaseExecutionId, iteration) => {
|
engine.on('phase:complete', (step, phase, phaseName, content, phaseStatus, phaseError, phaseExecutionId, iteration) => {
|
||||||
log.debug('Phase completed', { step: step.name, phase, phaseName, status: phaseStatus });
|
log.debug('Phase completed', { step: step.name, phase, phaseName, status: phaseStatus });
|
||||||
sessionLogger.setIteration(currentIteration);
|
sessionLogger.setIteration(currentIteration);
|
||||||
sessionLogger.onPhaseComplete(step, phase, phaseName, content, phaseStatus, phaseError, phaseExecutionId, iteration);
|
sessionLogger.onPhaseComplete(step, phase, phaseName, content, phaseStatus, phaseError, phaseExecutionId, iteration);
|
||||||
});
|
});
|
||||||
|
|
||||||
engine.on('phase:judge_stage', (step, phase, phaseName, entry, phaseExecutionId, iteration) => {
|
engine.on('phase:judge_stage', (step, phase, phaseName, entry, phaseExecutionId, iteration) => {
|
||||||
sessionLogger.onJudgeStage(step, phase, phaseName, entry, phaseExecutionId, iteration);
|
sessionLogger.onJudgeStage(step, phase, phaseName, entry, phaseExecutionId, iteration);
|
||||||
});
|
});
|
||||||
@ -195,6 +197,8 @@ export async function executePiece(
|
|||||||
const movementModel = providerInfo.model ?? (movementProvider === currentProvider ? configuredModel : undefined) ?? '(default)';
|
const movementModel = providerInfo.model ?? (movementProvider === currentProvider ? configuredModel : undefined) ?? '(default)';
|
||||||
providerEventLogger.setMovement(step.name);
|
providerEventLogger.setMovement(step.name);
|
||||||
providerEventLogger.setProvider(movementProvider);
|
providerEventLogger.setProvider(movementProvider);
|
||||||
|
usageEventLogger.setMovement(step.name, detectMovementType(step));
|
||||||
|
usageEventLogger.setProvider(movementProvider, movementModel);
|
||||||
out.info(`Provider: ${movementProvider}`);
|
out.info(`Provider: ${movementProvider}`);
|
||||||
out.info(`Model: ${movementModel}`);
|
out.info(`Model: ${movementModel}`);
|
||||||
if (instruction) log.debug('Step instruction', instruction);
|
if (instruction) log.debug('Step instruction', instruction);
|
||||||
@ -210,7 +214,6 @@ export async function executePiece(
|
|||||||
}
|
}
|
||||||
sessionLogger.onMovementStart(step, iteration, instruction);
|
sessionLogger.onMovementStart(step, iteration, instruction);
|
||||||
});
|
});
|
||||||
|
|
||||||
engine.on('movement:complete', (step, response, instruction) => {
|
engine.on('movement:complete', (step, response, instruction) => {
|
||||||
log.debug('Movement completed', { step: step.name, status: response.status, matchedRuleIndex: response.matchedRuleIndex, matchedRuleMethod: response.matchedRuleMethod, contentLength: response.content.length, sessionId: response.sessionId, error: response.error });
|
log.debug('Movement completed', { step: step.name, status: response.status, matchedRuleIndex: response.matchedRuleIndex, matchedRuleMethod: response.matchedRuleMethod, contentLength: response.content.length, sessionId: response.sessionId, error: response.error });
|
||||||
lastMovementContent = response.content;
|
lastMovementContent = response.content;
|
||||||
@ -227,17 +230,16 @@ export async function executePiece(
|
|||||||
}
|
}
|
||||||
if (response.error) out.error(`Error: ${response.error}`);
|
if (response.error) out.error(`Error: ${response.error}`);
|
||||||
if (response.sessionId) out.status('Session', response.sessionId);
|
if (response.sessionId) out.status('Session', response.sessionId);
|
||||||
|
usageEventLogger.logUsage({ success: response.status === 'done', usage: response.providerUsage ?? { usageMissing: true, reason: USAGE_MISSING_REASONS.NOT_AVAILABLE } });
|
||||||
sessionLogger.onMovementComplete(step, response, instruction);
|
sessionLogger.onMovementComplete(step, response, instruction);
|
||||||
analyticsEmitter.onMovementComplete(step, response);
|
analyticsEmitter.onMovementComplete(step, response);
|
||||||
sessionLog = { ...sessionLog, iterations: sessionLog.iterations + 1 };
|
sessionLog = { ...sessionLog, iterations: sessionLog.iterations + 1 };
|
||||||
});
|
});
|
||||||
|
|
||||||
engine.on('movement:report', (step, filePath, fileName) => {
|
engine.on('movement:report', (step, filePath, fileName) => {
|
||||||
out.logLine(`\n📄 Report: ${fileName}\n`);
|
out.logLine(`\n📄 Report: ${fileName}\n`);
|
||||||
out.logLine(readFileSync(filePath, 'utf-8'));
|
out.logLine(readFileSync(filePath, 'utf-8'));
|
||||||
analyticsEmitter.onMovementReport(step, filePath);
|
analyticsEmitter.onMovementReport(step, filePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
engine.on('piece:complete', (state) => {
|
engine.on('piece:complete', (state) => {
|
||||||
log.info('Piece completed successfully', { iterations: state.iteration });
|
log.info('Piece completed successfully', { iterations: state.iteration });
|
||||||
sessionLog = finalizeSessionLog(sessionLog, 'completed');
|
sessionLog = finalizeSessionLog(sessionLog, 'completed');
|
||||||
@ -256,7 +258,6 @@ export async function executePiece(
|
|||||||
out.info(`Session log: ${ndjsonLogPath}`);
|
out.info(`Session log: ${ndjsonLogPath}`);
|
||||||
if (shouldNotifyPieceComplete) notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) }));
|
if (shouldNotifyPieceComplete) notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) }));
|
||||||
});
|
});
|
||||||
|
|
||||||
engine.on('piece:abort', (state, reason) => {
|
engine.on('piece:abort', (state, reason) => {
|
||||||
interruptAllQueries();
|
interruptAllQueries();
|
||||||
log.error('Piece aborted', { reason, iterations: state.iteration });
|
log.error('Piece aborted', { reason, iterations: state.iteration });
|
||||||
@ -280,7 +281,6 @@ export async function executePiece(
|
|||||||
out.info(`Session log: ${ndjsonLogPath}`);
|
out.info(`Session log: ${ndjsonLogPath}`);
|
||||||
if (shouldNotifyPieceAbort) notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason }));
|
if (shouldNotifyPieceAbort) notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason }));
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalState = await engine.run();
|
const finalState = await engine.run();
|
||||||
return {
|
return {
|
||||||
success: finalState.status === 'completed',
|
success: finalState.status === 'completed',
|
||||||
|
|||||||
@ -18,3 +18,10 @@ export function formatElapsedTime(startTime: string, endTime: string): string {
|
|||||||
}
|
}
|
||||||
return `${Math.floor(elapsedSec / 60)}m ${Math.floor(elapsedSec % 60)}s`;
|
return `${Math.floor(elapsedSec / 60)}m ${Math.floor(elapsedSec % 60)}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function detectMovementType(step: { parallel?: unknown; arpeggio?: unknown; teamLeader?: unknown }): 'normal' | 'parallel' | 'arpeggio' | 'team_leader' {
|
||||||
|
if (step.parallel) return 'parallel';
|
||||||
|
if (step.arpeggio) return 'arpeggio';
|
||||||
|
if (step.teamLeader) return 'team_leader';
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
|||||||
@ -79,6 +79,7 @@ export class ClaudeClient {
|
|||||||
sessionId: result.sessionId,
|
sessionId: result.sessionId,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
structuredOutput: result.structuredOutput,
|
structuredOutput: result.structuredOutput,
|
||||||
|
providerUsage: result.providerUsage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +109,7 @@ export class ClaudeClient {
|
|||||||
sessionId: result.sessionId,
|
sessionId: result.sessionId,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
structuredOutput: result.structuredOutput,
|
structuredOutput: result.structuredOutput,
|
||||||
|
providerUsage: result.providerUsage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,6 +160,7 @@ export class ClaudeClient {
|
|||||||
sessionId: result.sessionId,
|
sessionId: result.sessionId,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
structuredOutput: result.structuredOutput,
|
structuredOutput: result.structuredOutput,
|
||||||
|
providerUsage: result.providerUsage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import {
|
|||||||
type SDKResultMessage,
|
type SDKResultMessage,
|
||||||
type SDKAssistantMessage,
|
type SDKAssistantMessage,
|
||||||
} from '@anthropic-ai/claude-agent-sdk';
|
} from '@anthropic-ai/claude-agent-sdk';
|
||||||
|
import { USAGE_MISSING_REASONS } from '../../core/logging/contracts.js';
|
||||||
|
import type { ProviderUsageSnapshot } from '../../core/models/response.js';
|
||||||
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
||||||
import {
|
import {
|
||||||
generateQueryId,
|
generateQueryId,
|
||||||
@ -26,6 +28,55 @@ import type {
|
|||||||
|
|
||||||
const log = createLogger('claude-sdk');
|
const log = createLogger('claude-sdk');
|
||||||
|
|
||||||
|
function toNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractProviderUsage(resultMsg: SDKResultMessage): ProviderUsageSnapshot {
|
||||||
|
const rawUsage = (resultMsg as unknown as { usage?: unknown }).usage;
|
||||||
|
if (!rawUsage || typeof rawUsage !== 'object') {
|
||||||
|
return {
|
||||||
|
usageMissing: true,
|
||||||
|
reason: USAGE_MISSING_REASONS.NOT_AVAILABLE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = rawUsage as Record<string, unknown>;
|
||||||
|
const inputTokens = toNumber(usage.input_tokens);
|
||||||
|
const outputTokens = toNumber(usage.output_tokens);
|
||||||
|
const cacheCreationInputTokens = toNumber(usage.cache_creation_input_tokens);
|
||||||
|
const cacheReadInputTokens = toNumber(usage.cache_read_input_tokens);
|
||||||
|
if (inputTokens === undefined || outputTokens === undefined) {
|
||||||
|
return {
|
||||||
|
usageMissing: true,
|
||||||
|
reason: USAGE_MISSING_REASONS.TOKENS_MISSING,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const totalTokens = inputTokens + outputTokens;
|
||||||
|
const cachedInputTokens = (
|
||||||
|
cacheCreationInputTokens !== undefined && cacheReadInputTokens !== undefined
|
||||||
|
? cacheCreationInputTokens + cacheReadInputTokens
|
||||||
|
: cacheReadInputTokens ?? cacheCreationInputTokens
|
||||||
|
);
|
||||||
|
|
||||||
|
const providerUsage: ProviderUsageSnapshot = {
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
totalTokens,
|
||||||
|
usageMissing: false,
|
||||||
|
};
|
||||||
|
if (cachedInputTokens !== undefined) {
|
||||||
|
providerUsage.cachedInputTokens = cachedInputTokens;
|
||||||
|
}
|
||||||
|
if (cacheCreationInputTokens !== undefined) {
|
||||||
|
providerUsage.cacheCreationInputTokens = cacheCreationInputTokens;
|
||||||
|
}
|
||||||
|
if (cacheReadInputTokens !== undefined) {
|
||||||
|
providerUsage.cacheReadInputTokens = cacheReadInputTokens;
|
||||||
|
}
|
||||||
|
return providerUsage;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes Claude queries using the Agent SDK.
|
* Executes Claude queries using the Agent SDK.
|
||||||
*
|
*
|
||||||
@ -95,6 +146,7 @@ export class QueryExecutor {
|
|||||||
let hasResultMessage = false;
|
let hasResultMessage = false;
|
||||||
let accumulatedAssistantText = '';
|
let accumulatedAssistantText = '';
|
||||||
let structuredOutput: Record<string, unknown> | undefined;
|
let structuredOutput: Record<string, unknown> | undefined;
|
||||||
|
let providerUsage: ProviderUsageSnapshot | undefined;
|
||||||
let onExternalAbort: (() => void) | undefined;
|
let onExternalAbort: (() => void) | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -138,6 +190,7 @@ export class QueryExecutor {
|
|||||||
if (message.type === 'result') {
|
if (message.type === 'result') {
|
||||||
hasResultMessage = true;
|
hasResultMessage = true;
|
||||||
const resultMsg = message as SDKResultMessage;
|
const resultMsg = message as SDKResultMessage;
|
||||||
|
providerUsage = extractProviderUsage(resultMsg);
|
||||||
if (resultMsg.subtype === 'success') {
|
if (resultMsg.subtype === 'success') {
|
||||||
resultContent = resultMsg.result;
|
resultContent = resultMsg.result;
|
||||||
const rawStructuredOutput = (resultMsg as unknown as {
|
const rawStructuredOutput = (resultMsg as unknown as {
|
||||||
@ -176,13 +229,18 @@ export class QueryExecutor {
|
|||||||
hasResultMessage,
|
hasResultMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const response: ClaudeResult = {
|
||||||
success,
|
success,
|
||||||
content: finalContent.trim(),
|
content: finalContent.trim(),
|
||||||
sessionId,
|
sessionId,
|
||||||
fullContent: accumulatedAssistantText.trim(),
|
fullContent: accumulatedAssistantText.trim(),
|
||||||
structuredOutput,
|
structuredOutput,
|
||||||
|
providerUsage: providerUsage ?? {
|
||||||
|
usageMissing: true,
|
||||||
|
reason: USAGE_MISSING_REASONS.NOT_AVAILABLE,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (onExternalAbort && options.abortSignal) {
|
if (onExternalAbort && options.abortSignal) {
|
||||||
options.abortSignal.removeEventListener('abort', onExternalAbort);
|
options.abortSignal.removeEventListener('abort', onExternalAbort);
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import type { PermissionUpdate, AgentDefinition, SandboxSettings } from '@anthropic-ai/claude-agent-sdk';
|
import type { PermissionUpdate, AgentDefinition, SandboxSettings } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import type { PermissionMode, McpServerConfig } from '../../core/models/index.js';
|
import type { PermissionMode, McpServerConfig } from '../../core/models/index.js';
|
||||||
|
import type { ProviderUsageSnapshot } from '../../core/models/response.js';
|
||||||
|
|
||||||
export type { SandboxSettings };
|
export type { SandboxSettings };
|
||||||
import type { PermissionResult } from '../../core/piece/index.js';
|
import type { PermissionResult } from '../../core/piece/index.js';
|
||||||
@ -113,6 +114,8 @@ export interface ClaudeResult {
|
|||||||
fullContent?: string;
|
fullContent?: string;
|
||||||
/** Structured output returned by Claude SDK */
|
/** Structured output returned by Claude SDK */
|
||||||
structuredOutput?: Record<string, unknown>;
|
structuredOutput?: Record<string, unknown>;
|
||||||
|
/** Provider-native usage payload normalized for TAKT observability */
|
||||||
|
providerUsage?: ProviderUsageSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extended result with query ID for concurrent execution */
|
/** Extended result with query ID for concurrent execution */
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Codex, type TurnOptions } from '@openai/codex-sdk';
|
import { Codex, type TurnOptions } from '@openai/codex-sdk';
|
||||||
import type { AgentResponse } from '../../core/models/index.js';
|
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';
|
import { createLogger, getErrorMessage, createStreamDiagnostics, parseStructuredOutput, type StreamDiagnostics } from '../../shared/utils/index.js';
|
||||||
import { mapToCodexSandboxMode, type CodexCallOptions } from './types.js';
|
import { mapToCodexSandboxMode, type CodexCallOptions } from './types.js';
|
||||||
import {
|
import {
|
||||||
@ -38,6 +39,49 @@ const CODEX_RETRYABLE_ERROR_PATTERNS = [
|
|||||||
'fetch failed',
|
'fetch failed',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function toNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractProviderUsageFromTurnCompleted(event: CodexEvent): ProviderUsageSnapshot {
|
||||||
|
const usageRaw = event.usage;
|
||||||
|
if (!usageRaw || typeof usageRaw !== 'object') {
|
||||||
|
return {
|
||||||
|
usageMissing: true,
|
||||||
|
reason: USAGE_MISSING_REASONS.NOT_AVAILABLE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = usageRaw as Record<string, unknown>;
|
||||||
|
const inputTokens = toNumber(usage.input_tokens);
|
||||||
|
const outputTokens = toNumber(usage.output_tokens);
|
||||||
|
const explicitTotal = toNumber(usage.total_tokens);
|
||||||
|
const totalTokens = explicitTotal ?? (
|
||||||
|
inputTokens !== undefined && outputTokens !== undefined
|
||||||
|
? inputTokens + outputTokens
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
const cachedInputTokens = toNumber(usage.cached_input_tokens);
|
||||||
|
if (inputTokens === undefined || outputTokens === undefined || totalTokens === undefined) {
|
||||||
|
return {
|
||||||
|
usageMissing: true,
|
||||||
|
reason: USAGE_MISSING_REASONS.TOKENS_MISSING,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerUsage: ProviderUsageSnapshot = {
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
totalTokens,
|
||||||
|
usageMissing: false,
|
||||||
|
};
|
||||||
|
if (cachedInputTokens !== undefined) {
|
||||||
|
providerUsage.cachedInputTokens = cachedInputTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
return providerUsage;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client for Codex SDK agent interactions.
|
* Client for Codex SDK agent interactions.
|
||||||
*
|
*
|
||||||
@ -167,6 +211,7 @@ export class CodexClient {
|
|||||||
const contentOffsets = new Map<string, number>();
|
const contentOffsets = new Map<string, number>();
|
||||||
let success = true;
|
let success = true;
|
||||||
let failureMessage = '';
|
let failureMessage = '';
|
||||||
|
let providerUsage: ProviderUsageSnapshot | undefined;
|
||||||
const state = createStreamTrackingState();
|
const state = createStreamTrackingState();
|
||||||
|
|
||||||
for await (const event of events as AsyncGenerator<CodexEvent>) {
|
for await (const event of events as AsyncGenerator<CodexEvent>) {
|
||||||
@ -180,6 +225,11 @@ export class CodexClient {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.type === 'turn.completed') {
|
||||||
|
providerUsage = extractProviderUsageFromTurnCompleted(event);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === 'turn.failed') {
|
if (event.type === 'turn.failed') {
|
||||||
success = false;
|
success = false;
|
||||||
if (event.error && typeof event.error === 'object' && 'message' in event.error) {
|
if (event.error && typeof event.error === 'object' && 'message' in event.error) {
|
||||||
@ -280,14 +330,19 @@ export class CodexClient {
|
|||||||
const structuredOutput = parseStructuredOutput(trimmed, !!options.outputSchema);
|
const structuredOutput = parseStructuredOutput(trimmed, !!options.outputSchema);
|
||||||
emitResult(options.onStream, true, trimmed, currentThreadId);
|
emitResult(options.onStream, true, trimmed, currentThreadId);
|
||||||
|
|
||||||
return {
|
const response: AgentResponse = {
|
||||||
persona: agentType,
|
persona: agentType,
|
||||||
status: 'done',
|
status: 'done',
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
sessionId: currentThreadId,
|
sessionId: currentThreadId,
|
||||||
structuredOutput,
|
structuredOutput,
|
||||||
|
providerUsage: providerUsage ?? {
|
||||||
|
usageMissing: true,
|
||||||
|
reason: USAGE_MISSING_REASONS.NOT_AVAILABLE,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
const errorMessage = streamAbortController.signal.aborted
|
const errorMessage = streamAbortController.signal.aborted
|
||||||
|
|||||||
@ -97,6 +97,7 @@ export class GlobalConfigManager {
|
|||||||
trace: parsed.logging.trace,
|
trace: parsed.logging.trace,
|
||||||
debug: parsed.logging.debug,
|
debug: parsed.logging.debug,
|
||||||
providerEvents: parsed.logging.provider_events,
|
providerEvents: parsed.logging.provider_events,
|
||||||
|
usageEvents: parsed.logging.usage_events,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
analytics: parsed.analytics ? {
|
analytics: parsed.analytics ? {
|
||||||
enabled: parsed.analytics.enabled,
|
enabled: parsed.analytics.enabled,
|
||||||
|
|||||||
@ -18,12 +18,14 @@ export function serializeGlobalConfig(config: PersistedGlobalConfig): Record<str
|
|||||||
|| config.logging.trace !== undefined
|
|| config.logging.trace !== undefined
|
||||||
|| config.logging.debug !== undefined
|
|| config.logging.debug !== undefined
|
||||||
|| config.logging.providerEvents !== undefined
|
|| config.logging.providerEvents !== undefined
|
||||||
|
|| config.logging.usageEvents !== undefined
|
||||||
)) {
|
)) {
|
||||||
raw.logging = {
|
raw.logging = {
|
||||||
...(config.logging.level !== undefined ? { level: config.logging.level } : {}),
|
...(config.logging.level !== undefined ? { level: config.logging.level } : {}),
|
||||||
...(config.logging.trace !== undefined ? { trace: config.logging.trace } : {}),
|
...(config.logging.trace !== undefined ? { trace: config.logging.trace } : {}),
|
||||||
...(config.logging.debug !== undefined ? { debug: config.logging.debug } : {}),
|
...(config.logging.debug !== undefined ? { debug: config.logging.debug } : {}),
|
||||||
...(config.logging.providerEvents !== undefined ? { provider_events: config.logging.providerEvents } : {}),
|
...(config.logging.providerEvents !== undefined ? { provider_events: config.logging.providerEvents } : {}),
|
||||||
|
...(config.logging.usageEvents !== undefined ? { usage_events: config.logging.usageEvents } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (config.analytics) {
|
if (config.analytics) {
|
||||||
|
|||||||
@ -1,143 +1,8 @@
|
|||||||
import { appendFileSync } from 'node:fs';
|
export {
|
||||||
import { join } from 'node:path';
|
createProviderEventLogger,
|
||||||
import type { ProviderType, StreamCallback, StreamEvent } from '../types/provider.js';
|
isProviderEventsEnabled,
|
||||||
|
} from '../../core/logging/providerEventLogger.js';
|
||||||
export interface ProviderEventLoggerConfig {
|
export type {
|
||||||
logsDir: string;
|
ProviderEventLogger,
|
||||||
sessionId: string;
|
ProviderEventLoggerConfig,
|
||||||
runId: string;
|
} from '../../core/logging/providerEventLogger.js';
|
||||||
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;
|
|
||||||
let hasReportedWriteFailure = false;
|
|
||||||
|
|
||||||
const write = (event: StreamEvent): void => {
|
|
||||||
try {
|
|
||||||
const record = buildLogRecord(event, provider, movement, config.runId);
|
|
||||||
appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8');
|
|
||||||
} catch (error) {
|
|
||||||
if (hasReportedWriteFailure) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hasReportedWriteFailure = true;
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
process.stderr.write(`[takt] Failed to write provider event log: ${message}\n`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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?: {
|
|
||||||
logging?: {
|
|
||||||
providerEvents?: boolean;
|
|
||||||
};
|
|
||||||
}): boolean {
|
|
||||||
return config?.logging?.providerEvents === true;
|
|
||||||
}
|
|
||||||
|
|||||||
8
src/shared/utils/usageEventLogger.ts
Normal file
8
src/shared/utils/usageEventLogger.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
createUsageEventLogger,
|
||||||
|
isUsageEventsEnabled,
|
||||||
|
} from '../../core/logging/usageEventLogger.js';
|
||||||
|
export type {
|
||||||
|
UsageEventLogger,
|
||||||
|
UsageEventLoggerConfig,
|
||||||
|
} from '../../core/logging/usageEventLogger.js';
|
||||||
Loading…
x
Reference in New Issue
Block a user