From e8437387562cf85068bb317515b0b3e3fb917b4b Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Wed, 5 Aug 2026 23:15:15 +0100 Subject: [PATCH 1/5] feat(eve): cover the full eve 0.30 event surface --- .changeset/eve-030-event-surface.md | 19 ++ packages/evlog/package.json | 4 +- packages/evlog/src/ai/index.ts | 5 + packages/evlog/src/eve/index.ts | 420 ++++++++++++++++++++++++++-- packages/evlog/test/eve.test.ts | 360 +++++++++++++++++++++++- pnpm-lock.yaml | 84 +++++- 6 files changed, 855 insertions(+), 37 deletions(-) create mode 100644 .changeset/eve-030-event-surface.md diff --git a/.changeset/eve-030-event-surface.md b/.changeset/eve-030-event-surface.md new file mode 100644 index 00000000..ba020b70 --- /dev/null +++ b/.changeset/eve-030-event-surface.md @@ -0,0 +1,19 @@ +--- +"evlog": minor +--- + +Cover the eve 0.30 event surface. **Requires eve >= 0.30** — the peer range moves from `>=0.24.3`. + +The wide event now carries what eve started reporting since 0.24: + +- `eve.runtime` — eve version, agent id, model, and the deployed git sha, branch and date, from `session.started` +- `eve.parent` — parent and root session ids for a subagent run, so a drain can rebuild the delegation tree +- `eve.authorizations` — connection sign-ins with their outcome, reason and duration; a turn parked on one ends as `eve.phase: 'awaiting-authorization'` +- `eve.compaction` — how many compactions ran, on which model, and how full the context was when the first one triggered +- `eve.contextCleared`, `eve.stepFailures` and `eve.failedSteps` — a model call that failed and was retried no longer disappears from a turn that ends up succeeding +- `ai.costUsd` — the cost eve reports, used in place of the `cost` pricing map when available. `ai.model` falls back to the model reported at session start, so `model` is only needed for dynamic-model agents +- subagents record `durationMs` and a `started` status + +`message` replaces `redactMessage` with three modes: `'omit'` (default), `'preview'` (text truncated to `messagePreviewLength`, attachments reduced to their type) and `'full'`. Attachment parts were previously not redacted at all. `redactMessage` still works and is deprecated. + +`sessionEvent: true` adds one wide event per session on top of the per-turn ones, rolling up turns, tokens, cost, tools used, compactions and authorizations — one row per conversation, which is what makes tail sampling useful on an agent. diff --git a/packages/evlog/package.json b/packages/evlog/package.json index e587e6ea..a03aa24e 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -438,7 +438,7 @@ "changelogen": "^0.6.2", "consola": "^3.4.2", "elysia": "^1.4.29", - "eve": "^0.24.3", + "eve": "^0.30.8", "express": "^5.2.1", "fastify": "^5.10.0", "h3": "^1.15.11", @@ -463,7 +463,7 @@ "@nestjs/common": ">=11.1.28", "@nuxt/kit": "^4.4.2", "@orpc/server": ">=1.14.8", - "eve": ">=0.24.3", + "eve": ">=0.30.0", "@tanstack/start-client-core": "^1.170.14", "ai": ">=6.0.168 <8.0.0", "elysia": ">=1.4.29", diff --git a/packages/evlog/src/ai/index.ts b/packages/evlog/src/ai/index.ts index a2f78f26..7bde1386 100644 --- a/packages/evlog/src/ai/index.ts +++ b/packages/evlog/src/ai/index.ts @@ -172,6 +172,11 @@ export interface AIEventData { totalDurationMs?: number embedding?: AIEmbeddingData estimatedCost?: number + /** + * Cost in dollars as reported by the runtime or provider, as opposed to + * {@link AIEventData.estimatedCost}, which is derived from a pricing map. + */ + costUsd?: number } /** diff --git a/packages/evlog/src/eve/index.ts b/packages/evlog/src/eve/index.ts index 44571e3f..0c4afc7b 100644 --- a/packages/evlog/src/eve/index.ts +++ b/packages/evlog/src/eve/index.ts @@ -17,23 +17,48 @@ const DEFAULT_MAX_SESSIONS = 256 /** Client-closed-request status used for turns eve cancelled before a terminal outcome. */ const CANCELLED_STATUS = 499 +/** + * How much of the user message from `message.received` reaches the wide event. + * + * - `omit` — no message content at all (default) + * - `preview` — text truncated to `messagePreviewLength`, attachments reduced + * to their type and size + * - `full` — text and attachment parts verbatim + */ +export type EveMessageMode = 'omit' | 'preview' | 'full' + /** Options for {@link defineEvlogHook}. */ export interface EvlogEveOptions extends BaseEvlogOptions { /** Passed to {@link initLogger} on the first hook invocation. */ init?: LoggerConfig /** - * When `true` (default), user message content from `message.received` is - * omitted from the wide event. Set to `false` to include a truncated preview. + * How much of the user message to record. Default `'omit'`. + * + * `'full'` records message text and attachment parts as sent — review your + * PII policy before enabling it. + */ + message?: EveMessageMode + /** Max characters kept in `'preview'` mode. Default `500`. */ + messagePreviewLength?: number + /** + * @deprecated Use {@link EvlogEveOptions.message}. `true` maps to `'omit'`, + * `false` to `'preview'`. */ redactMessage?: boolean /** * Pricing map for {@link AIEventData.estimatedCost}. Keys are model IDs, * values are dollars per 1M tokens — same shape as `evlog/ai`. + * + * Only used as a fallback: when eve reports `usage.costUsd`, that value is + * recorded as `ai.costUsd` instead. */ cost?: Record /** * Model ID used with `cost` when eve stream events do not expose the model * name. When `cost` has exactly one entry, that key is used automatically. + * + * Only used as a fallback: `session.started` reports the configured model, + * which is used when this is unset. */ model?: string /** @@ -41,6 +66,11 @@ export interface EvlogEveOptions extends BaseEvlogOptions { * Oldest sessions are evicted when exceeded. Default `256`. */ maxSessions?: number + /** + * Emit one extra wide event per session on `session.completed` / + * `session.failed`, rolling up every turn of that session. Default `false`. + */ + sessionEvent?: boolean } /** Minimal session shape accepted by {@link useLogger} as a fallback lookup key. */ @@ -65,6 +95,27 @@ interface EveApprovalPending { interface SessionRollup { turnCount: number lastAccess: number + startedAt: number + calls: number + inputTokens: number + outputTokens: number + costUsd: number + tools: Set + compactions: number + authorizations: number + failedTurns: number + cancelledTurns: number +} + +/** Identity of the eve instance serving a session, from `session.started`. */ +interface EveRuntimeInfo { + version: string + agentId: string + model: string + gitSha?: string + gitBranch?: string + deployedAt?: string + subagent?: string } interface EveSubagentRecord { @@ -72,8 +123,23 @@ interface EveSubagentRecord { name: string toolName?: string childSessionId?: string - status: 'called' | 'completed' + status: 'called' | 'started' | 'completed' output?: string + startedAt?: number + durationMs?: number +} + +interface EveAuthorizationRecord { + name: string + outcome?: string + reason?: string + durationMs?: number +} + +interface EveStepFailure { + code: string + message: string + stepIndex: number } interface TurnAccumulator { @@ -83,9 +149,16 @@ interface TurnAccumulator { outputTokens: number cacheReadTokens: number cacheWriteTokens: number + costUsd: number finishReason?: string toolExecutions: AIToolExecution[] subagents: EveSubagentRecord[] + authorizations: EveAuthorizationRecord[] + stepFailures: EveStepFailure[] + compactions: number + compactionModel?: string + compactionInputTokens?: number + contextCleared: boolean pausedForInput: boolean stepStartedAt?: number costMap?: Record @@ -136,8 +209,13 @@ function freshAccumulator(options: EvlogEveOptions): TurnAccumulator { outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + costUsd: 0, toolExecutions: [], subagents: [], + authorizations: [], + stepFailures: [], + compactions: 0, + contextCleared: false, pausedForInput: false, costMap: options.cost, costModel: resolveCostModel(options), @@ -151,6 +229,10 @@ function resolveCostModel(options: EvlogEveOptions): string | undefined { return undefined } +function roundCost(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000 +} + function computeEstimatedCost(state: TurnAccumulator): number | undefined { if (!state.costMap || !state.costModel) return undefined const pricing = state.costMap[state.costModel] @@ -174,8 +256,12 @@ function buildAiField(state: TurnAccumulator): AIEventData { if (state.cacheReadTokens > 0) data.cacheReadTokens = state.cacheReadTokens if (state.cacheWriteTokens > 0) data.cacheWriteTokens = state.cacheWriteTokens if (state.finishReason) data.finishReason = state.finishReason - const estimatedCost = computeEstimatedCost(state) - if (estimatedCost !== undefined) data.estimatedCost = estimatedCost + if (state.costUsd > 0) { + data.costUsd = roundCost(state.costUsd) + } else { + const estimatedCost = computeEstimatedCost(state) + if (estimatedCost !== undefined) data.estimatedCost = estimatedCost + } if (state.toolExecutions.length > 0) { data.tools = state.toolExecutions.map(t => ({ ...t })) } @@ -200,11 +286,29 @@ function extractCallId(result: unknown): string | undefined { return undefined } -function truncateMessage(message: string, maxLength = 500): string { +const DEFAULT_MESSAGE_PREVIEW_LENGTH = 500 + +function truncateMessage(message: string, maxLength = DEFAULT_MESSAGE_PREVIEW_LENGTH): string { if (message.length <= maxLength) return message return `${message.slice(0, maxLength)}…` } +function resolveMessageMode(options: EvlogEveOptions): EveMessageMode { + if (options.message) return options.message + if (options.redactMessage === false) return 'preview' + return 'omit' +} + +/** Attachment parts stripped of everything but their kind — filenames carry PII. */ +function summarizeMessageParts(parts: readonly unknown[]): Array> { + return parts.map((part) => { + const record = part as Record + const summary: Record = { type: record.type } + if (typeof record.mediaType === 'string') summary.mediaType = record.mediaType + return summary + }) +} + function ensureInit(options: EvlogEveOptions): void { const state = getEveGlobalState() if (options.maxSessions !== undefined) { @@ -313,6 +417,8 @@ interface EveGlobalState { sessionPendingActions: Map> sessionApprovals: Map sessionRollups: Map + sessionRuntimes: Map + sessionAuthorizationStarts: Map> maxSessions: number initialized: boolean } @@ -332,6 +438,8 @@ function getEveGlobalState(): EveGlobalState { sessionPendingActions: new Map(), sessionApprovals: new Map(), sessionRollups: new Map(), + sessionRuntimes: new Map(), + sessionAuthorizationStarts: new Map(), maxSessions: DEFAULT_MAX_SESSIONS, initialized: false, } @@ -372,9 +480,34 @@ function sessionRollups(): Map { return getEveGlobalState().sessionRollups } +function sessionRuntimes(): Map { + return getEveGlobalState().sessionRuntimes +} + +function sessionAuthorizationStarts(): Map> { + return getEveGlobalState().sessionAuthorizationStarts +} + +function freshRollup(): SessionRollup { + return { + turnCount: 0, + lastAccess: 0, + startedAt: Date.now(), + calls: 0, + inputTokens: 0, + outputTokens: 0, + costUsd: 0, + tools: new Set(), + compactions: 0, + authorizations: 0, + failedTurns: 0, + cancelledTurns: 0, + } +} + function touchSession(sessionId: string): void { const rollups = sessionRollups() - const rollup = rollups.get(sessionId) ?? { turnCount: 0, lastAccess: 0 } + const rollup = rollups.get(sessionId) ?? freshRollup() rollup.lastAccess = Date.now() rollups.set(sessionId, rollup) evictStaleSessions() @@ -386,6 +519,8 @@ function clearSessionState(sessionId: string): void { sessionPendingActions().delete(sessionId) sessionApprovals().delete(sessionId) sessionTurnIds().delete(sessionId) + sessionRuntimes().delete(sessionId) + sessionAuthorizationStarts().delete(sessionId) } function evictStaleSessions(): void { @@ -429,6 +564,7 @@ function derivePhase( const approval = ctx.approval as { status?: string } | undefined if (approval?.status === 'rejected') return 'rejected' if (approval?.status === 'pending' || accumulator.pausedForInput) return 'awaiting-approval' + if (accumulator.authorizations.some(a => !a.outcome)) return 'awaiting-authorization' if (httpStatus >= 400 && httpStatus !== CANCELLED_STATUS) return 'failed' return undefined } @@ -522,12 +658,58 @@ function persistSessionContext(sessionId: string, logger: AuditableLogger): void } function flushEveMetadata(state: TurnState): void { - if (state.accumulator.subagents.length === 0) return - state.logger.set({ - eve: { - subagents: state.accumulator.subagents.map(s => ({ ...s })), - }, - }) + const acc = state.accumulator + const eve: Record = {} + + if (acc.subagents.length > 0) { + eve.subagents = acc.subagents.map(({ startedAt, ...record }) => ({ ...record })) + } + if (acc.authorizations.length > 0) { + eve.authorizations = acc.authorizations.map(a => ({ ...a })) + } + if (acc.stepFailures.length > 0) { + eve.stepFailures = acc.stepFailures.map(f => ({ ...f })) + eve.failedSteps = acc.stepFailures.length + } + if (acc.compactions > 0) { + eve.compaction = { + count: acc.compactions, + ...(acc.compactionModel ? { model: acc.compactionModel } : {}), + ...(acc.compactionInputTokens !== undefined + ? { inputTokensAtTrigger: acc.compactionInputTokens } + : {}), + } + } + if (acc.contextCleared) eve.contextCleared = true + + if (Object.keys(eve).length > 0) state.logger.set({ eve }) +} + +/** Wide-event view of the eve instance and the parent session, when there is one. */ +function buildLineage(sessionId: string, ctx: HookContext): Record { + const eve: Record = {} + const runtime = sessionRuntimes().get(sessionId) + if (runtime) { + const { subagent, ...identity } = runtime + eve.runtime = identity + } + + const { parent } = (ctx.session as { parent?: { + callId: string + rootSessionId: string + sessionId: string + turn?: { id?: string } + } }) + if (parent) { + eve.parent = { + sessionId: parent.sessionId, + rootSessionId: parent.rootSessionId, + callId: parent.callId, + ...(parent.turn?.id ? { turnId: parent.turn.id } : {}), + ...(runtime?.subagent ? { subagent: runtime.subagent } : {}), + } + } + return eve } function getOrCreateTurnState( @@ -568,6 +750,7 @@ function getOrCreateTurnState( eve: { sessionId, turnId, + ...buildLineage(sessionId, ctx), }, agent: { name: ctx.agent.name, @@ -588,7 +771,12 @@ function getOrCreateTurnState( } function flushAi(state: TurnState): void { - state.logger.set({ ai: buildAiField(state.accumulator) }) + const ai = buildAiField(state.accumulator) + if (!ai.model) { + const model = sessionRuntimes().get(state.sessionId)?.model + if (model) ai.model = model + } + state.logger.set({ ai }) } async function finishTurn( @@ -607,6 +795,7 @@ async function finishTurn( const ctx = state.logger.getContext() as Record const phase = derivePhase(ctx, state.accumulator, httpStatus) const sessionTurns = bumpSessionTurnCount(sessionId) + accumulateSessionTotals(sessionId, state.accumulator, phase) state.logger.set({ eve: { ...(phase ? { phase } : {}), @@ -628,6 +817,78 @@ async function finishTurn( } } +/** Fold one finished turn into its session rollup, for the session wide event. */ +function accumulateSessionTotals( + sessionId: string, + acc: TurnAccumulator, + phase: string | undefined, +): void { + const rollup = sessionRollups().get(sessionId) + if (!rollup) return + + rollup.calls += acc.calls + rollup.inputTokens += acc.inputTokens + rollup.outputTokens += acc.outputTokens + rollup.costUsd += acc.costUsd + rollup.compactions += acc.compactions + rollup.authorizations += acc.authorizations.length + for (const tool of acc.toolExecutions) rollup.tools.add(tool.name) + if (phase === 'failed') rollup.failedTurns += 1 + if (phase === 'cancelled') rollup.cancelledTurns += 1 +} + +/** + * Emit one wide event summarizing a whole session. Opt-in through + * {@link EvlogEveOptions.sessionEvent}: it is the "one row per conversation" + * view, complementing the per-turn events. + */ +async function emitSessionEvent( + sessionId: string, + options: EvlogEveOptions, + ctx: HookContext, + outcome: { status?: number; error?: Error }, +): Promise { + const rollup = sessionRollups().get(sessionId) + if (!rollup) return + + const { logger, finish, skipped } = createMiddlewareLogger({ + method: 'EVE', + path: `/sessions/${sessionId}`, + requestId: sessionId, + ...pickBaseEvlogOptions(options), + }) + if (skipped) return + + applySessionContext(sessionId, logger) + logger.set({ + eve: { + sessionId, + scope: 'session', + turns: rollup.turnCount, + ...(rollup.failedTurns > 0 ? { failedTurns: rollup.failedTurns } : {}), + ...(rollup.cancelledTurns > 0 ? { cancelledTurns: rollup.cancelledTurns } : {}), + ...(rollup.compactions > 0 ? { compactions: rollup.compactions } : {}), + ...(rollup.authorizations > 0 ? { authorizations: rollup.authorizations } : {}), + ...buildLineage(sessionId, ctx), + }, + agent: { + name: ctx.agent.name, + ...(ctx.agent.nodeId ? { nodeId: ctx.agent.nodeId } : {}), + }, + channel: { kind: ctx.channel.kind ?? 'unknown' }, + ai: { + calls: rollup.calls, + inputTokens: rollup.inputTokens, + outputTokens: rollup.outputTokens, + totalTokens: rollup.inputTokens + rollup.outputTokens, + ...(rollup.costUsd > 0 ? { costUsd: roundCost(rollup.costUsd) } : {}), + ...(rollup.tools.size > 0 ? { toolCalls: [...rollup.tools] } : {}), + }, + }) + + await finish(outcome) +} + /** * Emit every turn still open for a session. eve ends a session with * `session.completed` / `session.failed`; a turn left open at that point never @@ -691,10 +952,29 @@ function getTurnState(sessionId: string, turnId: string): TurnState | undefined * ``` */ export function defineEvlogHook(options: EvlogEveOptions = {}): HookDefinition { - const redactMessage = options.redactMessage ?? true + const messageMode = resolveMessageMode(options) + const previewLength = options.messagePreviewLength ?? DEFAULT_MESSAGE_PREVIEW_LENGTH return defineHook({ events: { + 'session.started'(event, ctx) { + runSafe(() => { + ensureInit(options) + const { runtime, invocation } = event.data + if (!runtime && !invocation) return + touchSession(ctx.session.id) + sessionRuntimes().set(ctx.session.id, { + version: runtime?.eveVersion ?? '', + agentId: runtime?.agentId ?? '', + model: runtime?.modelId ?? '', + ...(runtime?.build?.gitSha ? { gitSha: runtime.build.gitSha } : {}), + ...(runtime?.build?.gitBranch ? { gitBranch: runtime.build.gitBranch } : {}), + ...(runtime?.build?.deployedAt ? { deployedAt: runtime.build.deployedAt } : {}), + ...(invocation?.name ? { subagent: invocation.name } : {}), + }) + }) + }, + 'turn.started'(event, ctx) { try { ensureInit(options) @@ -710,11 +990,18 @@ export function defineEvlogHook(options: EvlogEveOptions = {}): HookDefinition { 'message.received'(event, ctx) { runSafe(() => { - if (redactMessage) return + if (messageMode === 'omit') return const state = getTurnState(ctx.session.id, event.data.turnId) if (!state) return + const { message, parts } = event.data + const full = messageMode === 'full' state.logger.set({ - message: { received: truncateMessage(event.data.message) }, + message: { + received: full ? message : truncateMessage(message, previewLength), + ...(parts?.length + ? { parts: full ? parts.map(p => ({ ...p })) : summarizeMessageParts(parts) } + : {}), + }, }) }) }, @@ -741,10 +1028,87 @@ export function defineEvlogHook(options: EvlogEveOptions = {}): HookDefinition { acc.outputTokens += usage.outputTokens ?? 0 acc.cacheReadTokens += usage.cacheReadTokens ?? 0 acc.cacheWriteTokens += usage.cacheWriteTokens ?? 0 + acc.costUsd += usage.costUsd ?? 0 } }) }, + 'step.failed'(event, ctx) { + runSafe(() => { + const state = getTurnState(ctx.session.id, event.data.turnId) + if (!state) return + state.accumulator.stepFailures.push({ + code: event.data.code, + message: event.data.message, + stepIndex: event.data.stepIndex, + }) + }) + }, + + 'authorization.required'(event, ctx) { + runSafe(() => { + const state = getTurnState(ctx.session.id, event.data.turnId) + if (!state) return + const starts = sessionAuthorizationStarts().get(ctx.session.id) ?? new Map() + starts.set(event.data.name, Date.now()) + sessionAuthorizationStarts().set(ctx.session.id, starts) + state.accumulator.authorizations.push({ name: event.data.name }) + }) + }, + + 'authorization.completed'(event, ctx) { + runSafe(() => { + const state = getTurnState(ctx.session.id, event.data.turnId) + if (!state) return + const starts = sessionAuthorizationStarts().get(ctx.session.id) + const startedAt = starts?.get(event.data.name) + starts?.delete(event.data.name) + if (starts?.size === 0) sessionAuthorizationStarts().delete(ctx.session.id) + + const record: EveAuthorizationRecord = { + name: event.data.name, + outcome: event.data.outcome, + ...(event.data.reason ? { reason: event.data.reason } : {}), + ...(startedAt !== undefined ? { durationMs: Math.max(0, Date.now() - startedAt) } : {}), + } + // The `required` event may belong to an earlier turn: eve parks the + // session across the sign-in, so only same-turn records are updated. + const pending = state.accumulator.authorizations.find( + a => a.name === event.data.name && !a.outcome, + ) + if (pending) Object.assign(pending, record) + else state.accumulator.authorizations.push(record) + }) + }, + + 'compaction.requested'(event, ctx) { + runSafe(() => { + const state = getTurnState(ctx.session.id, event.data.turnId) + if (!state) return + const acc = state.accumulator + acc.compactionModel = event.data.modelId + if (event.data.usageInputTokens !== null) { + acc.compactionInputTokens = event.data.usageInputTokens + } + }) + }, + + 'compaction.completed'(event, ctx) { + runSafe(() => { + const state = getTurnState(ctx.session.id, event.data.turnId) + if (!state) return + state.accumulator.compactions += 1 + }) + }, + + 'context.cleared'(event, ctx) { + runSafe(() => { + const state = getTurnState(ctx.session.id, event.data.turnId) + if (!state) return + state.accumulator.contextCleared = true + }) + }, + 'actions.requested'(event, ctx) { runSafe(() => { const state = getTurnState(ctx.session.id, event.data.turnId) @@ -835,10 +1199,21 @@ export function defineEvlogHook(options: EvlogEveOptions = {}): HookDefinition { toolName: event.data.toolName, childSessionId: event.data.childSessionId, status: 'called', + startedAt: Date.now(), }) }) }, + 'subagent.started'(event, ctx) { + runSafe(() => { + const turnId = activeTurnBySession().get(ctx.session.id) + if (!turnId) return + const state = getTurnState(ctx.session.id, turnId) + const existing = state?.accumulator.subagents.find(s => s.callId === event.data.callId) + if (existing) existing.status = 'started' + }) + }, + 'subagent.completed'(event, ctx) { runSafe(() => { const sessionId = ctx.session.id @@ -850,6 +1225,9 @@ export function defineEvlogHook(options: EvlogEveOptions = {}): HookDefinition { if (existing) { existing.status = 'completed' existing.output = event.data.output + if (existing.startedAt !== undefined) { + existing.durationMs = Math.max(0, Date.now() - existing.startedAt) + } } else { state.accumulator.subagents.push({ callId: event.data.callId, @@ -882,6 +1260,9 @@ export function defineEvlogHook(options: EvlogEveOptions = {}): HookDefinition { async 'session.completed'(_event, ctx) { try { await finishOpenTurns(ctx.session.id, { status: 200 }) + if (options.sessionEvent) { + await emitSessionEvent(ctx.session.id, options, ctx, { status: 200 }) + } } catch (err) { console.error('[evlog] eve hook handler failed:', err) } finally { @@ -904,6 +1285,9 @@ export function defineEvlogHook(options: EvlogEveOptions = {}): HookDefinition { }, }) }) + if (options.sessionEvent) { + await emitSessionEvent(ctx.session.id, options, ctx, { error, status: 500 }) + } } catch (err) { console.error('[evlog] eve hook handler failed:', err) } finally { @@ -950,6 +1334,8 @@ export function resetEvlogEveForTests(): void { sessionPendingActions().clear() sessionApprovals().clear() sessionRollups().clear() + sessionRuntimes().clear() + sessionAuthorizationStarts().clear() setEveInitialized(false) delete (globalThis as typeof globalThis & { [EVE_GLOBAL_STATE]?: EveGlobalState })[EVE_GLOBAL_STATE] } diff --git a/packages/evlog/test/eve.test.ts b/packages/evlog/test/eve.test.ts index 776c0f3a..ec5b8d96 100644 --- a/packages/evlog/test/eve.test.ts +++ b/packages/evlog/test/eve.test.ts @@ -53,12 +53,19 @@ async function runTurn( }> toolRequests?: Array<{ toolName: string, callId: string }> message?: string + messageParts?: Array> inputRequests?: Array<{ requestId: string, toolName: string, prompt: string }> subagents?: Array<{ phase: 'called' | 'completed', callId: string, name: string }> + costUsd?: number + stepFailures?: Array<{ code: string, message: string, stepIndex: number }> + authorizations?: Array<{ name: string, outcome?: string, reason?: string }> + compactions?: Array<{ modelId: string, usageInputTokens: number | null, complete?: boolean }> + clearContext?: boolean + ctx?: HookContext } = {}, ) { const turnId = options.turnId ?? TURN_ID - const ctx = hookContext() + const ctx = options.ctx ?? hookContext() const events = hook.events! events['turn.started']!({ @@ -69,8 +76,13 @@ async function runTurn( if (options.message !== undefined) { events['message.received']!({ type: 'message.received', - data: { message: options.message, sequence: 1, turnId }, - }, ctx) + data: { + message: options.message, + ...(options.messageParts ? { parts: options.messageParts } : {}), + sequence: 1, + turnId, + }, + } as never, ctx) } const stepCount = options.steps ?? 1 @@ -86,11 +98,71 @@ async function runTurn( inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, + ...(options.costUsd !== undefined ? { costUsd: options.costUsd } : {}), }, }, }, ctx) } + for (const failure of options.stepFailures ?? []) { + events['step.failed']!({ + type: 'step.failed', + data: { ...failure, sequence: 4, turnId }, + }, ctx) + } + + for (const [index, auth] of (options.authorizations ?? []).entries()) { + events['authorization.required']!({ + type: 'authorization.required', + data: { + description: `sign in to ${auth.name}`, + name: auth.name, + sequence: 30 + index, + stepIndex: 0, + turnId, + }, + }, ctx) + if (auth.outcome) { + events['authorization.completed']!({ + type: 'authorization.completed', + data: { + name: auth.name, + outcome: auth.outcome, + ...(auth.reason ? { reason: auth.reason } : {}), + sequence: 31 + index, + stepIndex: 0, + turnId, + }, + } as never, ctx) + } + } + + for (const [index, compaction] of (options.compactions ?? []).entries()) { + events['compaction.requested']!({ + type: 'compaction.requested', + data: { + modelId: compaction.modelId, + sequence: 40 + index, + sessionId: SESSION_ID, + turnId, + usageInputTokens: compaction.usageInputTokens, + }, + }, ctx) + if (compaction.complete !== false) { + events['compaction.completed']!({ + type: 'compaction.completed', + data: { modelId: compaction.modelId, sequence: 41 + index, sessionId: SESSION_ID, turnId }, + }, ctx) + } + } + + if (options.clearContext) { + events['context.cleared']!({ + type: 'context.cleared', + data: { sequence: 45, sessionId: SESSION_ID, turnId }, + }, ctx) + } + for (const [index, req] of (options.toolRequests ?? []).entries()) { events['actions.requested']!({ type: 'actions.requested', @@ -430,6 +502,287 @@ describe('evlog/eve', () => { expect(thirdTurn?.customer).toBeUndefined() }) + it('records runtime identity from session.started on every turn', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + const ctx = hookContext() + + hook.events!['session.started']!({ + type: 'session.started', + data: { + runtime: { + agentId: 'agent_1', + agentName: 'support', + eveVersion: '0.30.8', + modelId: 'anthropic/claude-opus-5', + build: { gitSha: 'abc123', gitBranch: 'main', deployedAt: '2026-08-05T00:00:00Z' }, + }, + }, + }, ctx) + + await runTurn(hook, { ctx }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.eve).toMatchObject({ + runtime: { + version: '0.30.8', + agentId: 'agent_1', + model: 'anthropic/claude-opus-5', + gitSha: 'abc123', + gitBranch: 'main', + deployedAt: '2026-08-05T00:00:00Z', + }, + }) + expect(event?.ai).toMatchObject({ model: 'anthropic/claude-opus-5' }) + }) + + it('records parent lineage for a subagent session', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + const ctx = { + ...hookContext(), + session: { + id: SESSION_ID, + parent: { + callId: 'call_delegate', + rootSessionId: 'sess_root', + sessionId: 'sess_parent', + turn: { id: 'turn_parent' }, + }, + }, + } as HookContext + + hook.events!['session.started']!({ + type: 'session.started', + data: { + invocation: { + kind: 'subagent', + name: 'researcher', + parentCallId: 'call_delegate', + parentSessionId: 'sess_parent', + parentTurnId: 'turn_parent', + }, + }, + }, ctx) + + await runTurn(hook, { ctx }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.eve).toMatchObject({ + parent: { + sessionId: 'sess_parent', + rootSessionId: 'sess_root', + callId: 'call_delegate', + turnId: 'turn_parent', + subagent: 'researcher', + }, + }) + }) + + it('prefers the cost reported by eve over the configured pricing map', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ + drain: spies.drain, + cost: { 'gpt-5': { input: 1000, output: 2000 } }, + }) + + await runTurn(hook, { steps: 2, costUsd: 0.0125 }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.ai).toMatchObject({ costUsd: 0.025 }) + expect((event?.ai as Record).estimatedCost).toBeUndefined() + }) + + it('records failed model steps on a turn that still completes', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook, { + stepFailures: [{ code: 'RATE_LIMIT', message: 'slow down', stepIndex: 0 }], + }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.status).toBe(200) + expect(event?.eve).toMatchObject({ + failedSteps: 1, + stepFailures: [{ code: 'RATE_LIMIT', message: 'slow down', stepIndex: 0 }], + }) + }) + + it('records connection authorization outcomes', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook, { + authorizations: [{ name: 'linear', outcome: 'declined', reason: 'user said no' }], + }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + const { authorizations } = (event?.eve as { authorizations?: Array> }) + expect(authorizations).toHaveLength(1) + expect(authorizations?.[0]).toMatchObject({ + name: 'linear', + outcome: 'declined', + reason: 'user said no', + }) + expect(authorizations?.[0]?.durationMs).toBeTypeOf('number') + }) + + it('marks a turn awaiting an authorization that never completed', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook, { authorizations: [{ name: 'github' }] }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.eve).toMatchObject({ phase: 'awaiting-authorization' }) + }) + + it('records compaction and context clearing', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook, { + compactions: [{ modelId: 'gpt-5-mini', usageInputTokens: 180_000 }], + clearContext: true, + }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.eve).toMatchObject({ + compaction: { count: 1, model: 'gpt-5-mini', inputTokensAtTrigger: 180_000 }, + contextCleared: true, + }) + }) + + it('marks a subagent started and times it to completion', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + const ctx = hookContext() + const events = hook.events! + + events['turn.started']!({ type: 'turn.started', data: { sequence: 0, turnId: TURN_ID } }, ctx) + events['subagent.called']!({ + type: 'subagent.called', + data: { + callId: 'call_1', + childSessionId: 'child_1', + sessionId: SESSION_ID, + sequence: 20, + name: 'researcher', + toolName: 'delegate', + turnId: TURN_ID, + workflowId: 'wf_1', + }, + }, ctx) + events['subagent.started']!({ + type: 'subagent.started', + data: { callId: 'call_1', childSessionId: 'child_1', sequence: 21, subagentName: 'researcher' }, + } as never, ctx) + events['subagent.completed']!({ + type: 'subagent.completed', + data: { callId: 'call_1', output: 'done', subagentName: 'researcher' }, + }, ctx) + await events['turn.completed']!({ + type: 'turn.completed', + data: { sequence: 99, turnId: TURN_ID }, + }, ctx) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + const { subagents } = (event?.eve as { subagents?: Array> }) + expect(subagents?.[0]).toMatchObject({ callId: 'call_1', status: 'completed' }) + expect(subagents?.[0]?.durationMs).toBeTypeOf('number') + }) + + it('summarizes attachment parts without their content in preview mode', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain, message: 'preview' }) + + await runTurn(hook, { + message: 'here is my passport', + messageParts: [{ type: 'file', mediaType: 'application/pdf', filename: 'john-doe-passport.pdf', data: 'JVBER' },], + }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.message).toEqual({ + received: 'here is my passport', + parts: [{ type: 'file', mediaType: 'application/pdf' }], + }) + }) + + it('keeps attachment parts verbatim in full mode', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain, message: 'full' }) + + await runTurn(hook, { + message: 'x'.repeat(600), + messageParts: [{ type: 'text', text: 'hello' }], + }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + const message = event?.message as { received: string, parts: unknown[] } + expect(message.received).toHaveLength(600) + expect(message.parts).toEqual([{ type: 'text', text: 'hello' }]) + }) + + it('truncates the preview to messagePreviewLength', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain, message: 'preview', messagePreviewLength: 10 }) + + await runTurn(hook, { message: 'x'.repeat(50) }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect((event?.message as { received: string }).received).toBe(`${'x'.repeat(10)}…`) + }) + + it('emits a session wide event rolling up every turn when sessionEvent is on', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain, sessionEvent: true }) + const ctx = hookContext() + + await runTurn(hook, { ctx, costUsd: 0.01, toolResults: [{ toolName: 'search', status: 'completed' }] }) + await runTurn(hook, { ctx, turnId: TURN_ID_1, cancel: true, costUsd: 0.02 }) + await hook.events!['session.completed']!({ type: 'session.completed' } as never, ctx) + + await waitForDrainCalls(spies.drain, 3) + const sessionEvent = findEventViaDrain(spies.drain, e => e.path === `/sessions/${SESSION_ID}`) + expect(sessionEvent?.eve).toMatchObject({ + scope: 'session', + sessionId: SESSION_ID, + turns: 2, + cancelledTurns: 1, + }) + expect(sessionEvent?.ai).toMatchObject({ + calls: 2, + inputTokens: 200, + outputTokens: 100, + costUsd: 0.03, + toolCalls: ['search'], + }) + }) + + it('emits no session wide event by default', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + const ctx = hookContext() + + await runTurn(hook, { ctx }) + await hook.events!['session.completed']!({ type: 'session.completed' } as never, ctx) + + await waitForDrainCalls(spies.drain) + expect(findEventViaDrain(spies.drain, e => e.path === `/sessions/${SESSION_ID}`)).toBeUndefined() + }) + it('does not throw when an internal handler fails', async () => { const hook = defineEvlogHook({ enrich: () => { @@ -899,6 +1252,7 @@ describe('evlog/eve', () => { childSessionId: 'child_sub_1', status: 'completed', output: 'done', + durationMs: expect.any(Number), }, ]) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ed4c3eb..3ed8ce20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,7 +252,7 @@ importers: devDependencies: nitro: specifier: latest - version: 3.0.260610-beta(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.3.5)(rollup@4.60.2)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + version: 3.0.260610-beta(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.3.5)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) rolldown: specifier: latest version: 1.2.3 @@ -993,8 +993,8 @@ importers: specifier: ^1.4.29 version: 1.4.29(@sinclair/typebox@0.34.49)(@types/bun@1.3.14)(exact-mirror@1.0.0)(file-type@21.3.4)(openapi-types@12.1.3)(typescript@6.0.3) eve: - specifier: ^0.24.3 - version: 0.24.3(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(ai@7.0.29(zod@4.4.3))(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.3.5)(rollup@4.60.2)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + specifier: ^0.30.8 + version: 0.30.8(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(ai@7.0.29(zod@4.4.3))(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.3.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.3.5)(rollup@4.60.2)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) express: specifier: ^5.2.1 version: 5.2.1 @@ -11959,8 +11959,8 @@ packages: vue: optional: true - eve@0.24.3: - resolution: {integrity: sha512-qdNUjF1tlEJHVEsmnuSBHkJToDo0VjXgiHX5KaWhbHxBfsHahPGVI+/ecW+DHvsLlzmw58/rk2ypQXdAkul4uA==} + eve@0.30.6: + resolution: {integrity: sha512-sSO04d1vcPtI/Nf3nQfUAfIG7BrZNLih1Kr70Fab87llaIjGQOwXs0C5LYSfTnrdcZwD+wEWjZ8EDhFIQcp6Ig==} engines: {node: '>=24'} hasBin: true peerDependencies: @@ -11979,8 +11979,8 @@ packages: microsandbox: optional: true - eve@0.30.6: - resolution: {integrity: sha512-sSO04d1vcPtI/Nf3nQfUAfIG7BrZNLih1Kr70Fab87llaIjGQOwXs0C5LYSfTnrdcZwD+wEWjZ8EDhFIQcp6Ig==} + eve@0.30.8: + resolution: {integrity: sha512-ATF9CHJQBNce7+3leWH87w6Cc7TlsNlfpGAENzmkD4d7oBpSI+P7KB3cvtGM75t+9gT+nJ0R+u7u8hF8BJdBiw==} engines: {node: '>=24'} hasBin: true peerDependencies: @@ -34051,12 +34051,14 @@ snapshots: - xml2js - zephyr-agent - eve@0.24.3(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(ai@7.0.29(zod@4.4.3))(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.3.5)(rollup@4.60.2)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + eve@0.30.6(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(ai@7.0.51(zod@4.4.3))(better-sqlite3@12.11.1)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.3.0)(jiti@2.7.0)(microsandbox@0.6.8)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): dependencies: - ai: 7.0.29(zod@4.4.3) - nitro: 3.0.260610-beta(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.3.5)(rollup@4.60.2)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + ai: 7.0.51(zod@4.4.3) + nitro: 3.0.260610-beta(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.3.0)(jiti@2.7.0)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + undici: 8.9.0 optionalDependencies: '@opentelemetry/api': 1.9.1 + microsandbox: 0.6.8 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -34097,14 +34099,13 @@ snapshots: - xml2js - zephyr-agent - eve@0.30.6(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(ai@7.0.51(zod@4.4.3))(better-sqlite3@12.11.1)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.3.0)(jiti@2.7.0)(microsandbox@0.6.8)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + eve@0.30.8(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(ai@7.0.29(zod@4.4.3))(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.3.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.3.5)(rollup@4.60.2)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): dependencies: - ai: 7.0.51(zod@4.4.3) - nitro: 3.0.260610-beta(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.3.0)(jiti@2.7.0)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + ai: 7.0.29(zod@4.4.3) + nitro: 3.0.260610-beta(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.3.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.3.5)(rollup@4.60.2)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) undici: 8.9.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - microsandbox: 0.6.8 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -37039,7 +37040,7 @@ snapshots: - sqlite3 - uploadthing - nitro@3.0.260610-beta(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.3.5)(rollup@4.60.2)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + nitro@3.0.260610-beta(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.2.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.3.5)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): dependencies: consola: 3.4.2 crossws: 0.4.6(srvx@0.11.16) @@ -37059,6 +37060,59 @@ snapshots: dotenv: 17.4.2 giget: 3.2.0 jiti: 2.7.0 + vite: 8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@netlify/runtime' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - better-sqlite3 + - chokidar + - drizzle-orm + - idb-keyval + - ioredis + - lru-cache + - miniflare + - mongodb + - mysql2 + - sqlite3 + - uploadthing + - wrangler + + nitro@3.0.260610-beta(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9))(giget@3.3.0)(ioredis@5.10.1)(jiti@2.7.0)(lru-cache@11.3.5)(rollup@4.60.2)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + dependencies: + consola: 3.4.2 + crossws: 0.4.6(srvx@0.11.16) + db0: 0.3.4(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9)) + env-runner: 0.1.14 + h3: 2.0.1-rc.22(crossws@0.4.6(srvx@0.11.16)) + hookable: 6.1.1 + nf3: 0.3.17 + ocache: 0.1.5 + ofetch: 2.0.0-alpha.3 + ohash: 2.0.11 + rolldown: 1.2.3 + srvx: 0.11.16 + unenv: 2.0.0-rc.24 + unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260503.1)(@electric-sql/pglite@0.5.4)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(bun-types@1.3.14)(kysely@0.28.17)(postgres@3.4.9)))(ioredis@5.10.1)(lru-cache@11.3.5)(ofetch@2.0.0-alpha.3) + optionalDependencies: + dotenv: 17.4.2 + giget: 3.3.0 + jiti: 2.7.0 rollup: 4.60.2 vite: 8.1.4(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) transitivePeerDependencies: From f4a258749a36e363047ca12eeb77676aec8629a2 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 6 Aug 2026 08:30:40 +0100 Subject: [PATCH 2/5] fix(eve): tighten runtime identity, turn classification and compaction reporting --- .changeset/eve-030-event-surface.md | 4 +- packages/evlog/src/eve/index.ts | 45 ++++++++++--------- packages/evlog/test/eve.test.ts | 68 +++++++++++++++++++++++------ 3 files changed, 80 insertions(+), 37 deletions(-) diff --git a/.changeset/eve-030-event-surface.md b/.changeset/eve-030-event-surface.md index ba020b70..d30b8af9 100644 --- a/.changeset/eve-030-event-surface.md +++ b/.changeset/eve-030-event-surface.md @@ -1,8 +1,8 @@ --- -"evlog": minor +"evlog": major --- -Cover the eve 0.30 event surface. **Requires eve >= 0.30** — the peer range moves from `>=0.24.3`. +Cover the eve 0.30 event surface. **Requires eve >= 0.30** — the peer range moves from `>=0.24.3`, which is why this is a major release. Agents on an older eve keep working on the previous evlog; upgrade eve first. The wide event now carries what eve started reporting since 0.24: diff --git a/packages/evlog/src/eve/index.ts b/packages/evlog/src/eve/index.ts index 0c4afc7b..7a83dd45 100644 --- a/packages/evlog/src/eve/index.ts +++ b/packages/evlog/src/eve/index.ts @@ -22,7 +22,7 @@ const CANCELLED_STATUS = 499 * * - `omit` — no message content at all (default) * - `preview` — text truncated to `messagePreviewLength`, attachments reduced - * to their type and size + * to their type and media type * - `full` — text and attachment parts verbatim */ export type EveMessageMode = 'omit' | 'preview' | 'full' @@ -95,7 +95,6 @@ interface EveApprovalPending { interface SessionRollup { turnCount: number lastAccess: number - startedAt: number calls: number inputTokens: number outputTokens: number @@ -109,9 +108,9 @@ interface SessionRollup { /** Identity of the eve instance serving a session, from `session.started`. */ interface EveRuntimeInfo { - version: string - agentId: string - model: string + version?: string + agentId?: string + model?: string gitSha?: string gitBranch?: string deployedAt?: string @@ -156,6 +155,7 @@ interface TurnAccumulator { authorizations: EveAuthorizationRecord[] stepFailures: EveStepFailure[] compactions: number + compactionsRequested: number compactionModel?: string compactionInputTokens?: number contextCleared: boolean @@ -215,6 +215,7 @@ function freshAccumulator(options: EvlogEveOptions): TurnAccumulator { authorizations: [], stepFailures: [], compactions: 0, + compactionsRequested: 0, contextCleared: false, pausedForInput: false, costMap: options.cost, @@ -492,7 +493,6 @@ function freshRollup(): SessionRollup { return { turnCount: 0, lastAccess: 0, - startedAt: Date.now(), calls: 0, inputTokens: 0, outputTokens: 0, @@ -671,9 +671,14 @@ function flushEveMetadata(state: TurnState): void { eve.stepFailures = acc.stepFailures.map(f => ({ ...f })) eve.failedSteps = acc.stepFailures.length } - if (acc.compactions > 0) { + // A compaction requested but not yet completed still carries the most + // actionable signal — the context was full enough to trigger one. + if (acc.compactions > 0 || acc.compactionsRequested > 0) { eve.compaction = { count: acc.compactions, + ...(acc.compactionsRequested > acc.compactions + ? { requested: acc.compactionsRequested } + : {}), ...(acc.compactionModel ? { model: acc.compactionModel } : {}), ...(acc.compactionInputTokens !== undefined ? { inputTokensAtTrigger: acc.compactionInputTokens } @@ -691,15 +696,10 @@ function buildLineage(sessionId: string, ctx: HookContext): Record 0) eve.runtime = identity } - const { parent } = (ctx.session as { parent?: { - callId: string - rootSessionId: string - sessionId: string - turn?: { id?: string } - } }) + const { parent } = ctx.session if (parent) { eve.parent = { sessionId: parent.sessionId, @@ -795,7 +795,7 @@ async function finishTurn( const ctx = state.logger.getContext() as Record const phase = derivePhase(ctx, state.accumulator, httpStatus) const sessionTurns = bumpSessionTurnCount(sessionId) - accumulateSessionTotals(sessionId, state.accumulator, phase) + accumulateSessionTotals(sessionId, state.accumulator, httpStatus) state.logger.set({ eve: { ...(phase ? { phase } : {}), @@ -821,7 +821,7 @@ async function finishTurn( function accumulateSessionTotals( sessionId: string, acc: TurnAccumulator, - phase: string | undefined, + httpStatus: number, ): void { const rollup = sessionRollups().get(sessionId) if (!rollup) return @@ -833,8 +833,10 @@ function accumulateSessionTotals( rollup.compactions += acc.compactions rollup.authorizations += acc.authorizations.length for (const tool of acc.toolExecutions) rollup.tools.add(tool.name) - if (phase === 'failed') rollup.failedTurns += 1 - if (phase === 'cancelled') rollup.cancelledTurns += 1 + // Classified from the terminal status, not the phase: a turn that fails while + // parked on an approval or an authorization reports that phase, not 'failed'. + if (httpStatus === CANCELLED_STATUS) rollup.cancelledTurns += 1 + else if (httpStatus >= 400) rollup.failedTurns += 1 } /** @@ -964,9 +966,9 @@ export function defineEvlogHook(options: EvlogEveOptions = {}): HookDefinition { if (!runtime && !invocation) return touchSession(ctx.session.id) sessionRuntimes().set(ctx.session.id, { - version: runtime?.eveVersion ?? '', - agentId: runtime?.agentId ?? '', - model: runtime?.modelId ?? '', + ...(runtime?.eveVersion ? { version: runtime.eveVersion } : {}), + ...(runtime?.agentId ? { agentId: runtime.agentId } : {}), + ...(runtime?.modelId ? { model: runtime.modelId } : {}), ...(runtime?.build?.gitSha ? { gitSha: runtime.build.gitSha } : {}), ...(runtime?.build?.gitBranch ? { gitBranch: runtime.build.gitBranch } : {}), ...(runtime?.build?.deployedAt ? { deployedAt: runtime.build.deployedAt } : {}), @@ -1086,6 +1088,7 @@ export function defineEvlogHook(options: EvlogEveOptions = {}): HookDefinition { const state = getTurnState(ctx.session.id, event.data.turnId) if (!state) return const acc = state.accumulator + acc.compactionsRequested += 1 acc.compactionModel = event.data.modelId if (event.data.usageInputTokens !== null) { acc.compactionInputTokens = event.data.usageInputTokens diff --git a/packages/evlog/test/eve.test.ts b/packages/evlog/test/eve.test.ts index ec5b8d96..44f01108 100644 --- a/packages/evlog/test/eve.test.ts +++ b/packages/evlog/test/eve.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { HookContext } from 'eve/hooks' +import type { HookContext, HookEventMap } from 'eve/hooks' import { initLogger } from '../src/logger' import { resetEvlogEveForTests, @@ -53,13 +53,17 @@ async function runTurn( }> toolRequests?: Array<{ toolName: string, callId: string }> message?: string - messageParts?: Array> + messageParts?: HookEventMap['message.received']['data']['parts'] inputRequests?: Array<{ requestId: string, toolName: string, prompt: string }> subagents?: Array<{ phase: 'called' | 'completed', callId: string, name: string }> costUsd?: number stepFailures?: Array<{ code: string, message: string, stepIndex: number }> - authorizations?: Array<{ name: string, outcome?: string, reason?: string }> - compactions?: Array<{ modelId: string, usageInputTokens: number | null, complete?: boolean }> + authorizations?: Array<{ + name: string + outcome?: HookEventMap['authorization.completed']['data']['outcome'] + reason?: string + }> + compactions?: Array<{ modelId: string, usageInputTokens: number | null }> clearContext?: boolean ctx?: HookContext } = {}, @@ -82,7 +86,7 @@ async function runTurn( sequence: 1, turnId, }, - } as never, ctx) + }, ctx) } const stepCount = options.steps ?? 1 @@ -133,7 +137,7 @@ async function runTurn( stepIndex: 0, turnId, }, - } as never, ctx) + }, ctx) } } @@ -148,12 +152,10 @@ async function runTurn( usageInputTokens: compaction.usageInputTokens, }, }, ctx) - if (compaction.complete !== false) { - events['compaction.completed']!({ - type: 'compaction.completed', - data: { modelId: compaction.modelId, sequence: 41 + index, sessionId: SESSION_ID, turnId }, - }, ctx) - } + events['compaction.completed']!({ + type: 'compaction.completed', + data: { modelId: compaction.modelId, sequence: 41 + index, sessionId: SESSION_ID, turnId }, + }, ctx) } if (options.clearContext) { @@ -683,8 +685,8 @@ describe('evlog/eve', () => { }, ctx) events['subagent.started']!({ type: 'subagent.started', - data: { callId: 'call_1', childSessionId: 'child_1', sequence: 21, subagentName: 'researcher' }, - } as never, ctx) + data: { callId: 'call_1', subagentName: 'researcher' }, + }, ctx) events['subagent.completed']!({ type: 'subagent.completed', data: { callId: 'call_1', output: 'done', subagentName: 'researcher' }, @@ -771,6 +773,44 @@ describe('evlog/eve', () => { }) }) + it('counts a turn parked on an authorization as failed in the session rollup', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain, sessionEvent: true }) + const ctx = hookContext() + + hook.events!['turn.started']!({ + type: 'turn.started', + data: { sequence: 0, turnId: TURN_ID }, + }, ctx) + hook.events!['authorization.required']!({ + type: 'authorization.required', + data: { + description: 'sign in to linear', + name: 'linear', + sequence: 1, + stepIndex: 0, + turnId: TURN_ID, + }, + }, ctx) + + await hook.events!['session.failed']!({ + type: 'session.failed', + data: { code: 'SESSION_ERROR', message: 'session exploded', sessionId: SESSION_ID }, + }, ctx) + + await waitForDrainCalls(spies.drain, 2) + const turnEvent = findEventViaDrain(spies.drain, e => e.path?.endsWith(TURN_ID)) + expect(turnEvent?.eve).toMatchObject({ phase: 'awaiting-authorization' }) + + const sessionEvent = findEventViaDrain(spies.drain, e => e.path === `/sessions/${SESSION_ID}`) + expect(sessionEvent?.eve).toMatchObject({ + scope: 'session', + sessionId: SESSION_ID, + turns: 1, + failedTurns: 1, + }) + }) + it('emits no session wide event by default', async () => { const spies = createPipelineSpies() const hook = defineEvlogHook({ drain: spies.drain }) From e5d917a900f92d690b15cb9db793c612e0957f43 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 6 Aug 2026 08:43:46 +0100 Subject: [PATCH 3/5] fix(eve): keep the first compaction trigger and roll up estimated cost --- .changeset/eve-030-event-surface.md | 2 +- packages/evlog/src/eve/index.ts | 19 ++++++-- packages/evlog/test/eve.test.ts | 76 +++++++++++++++++++++++++---- 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/.changeset/eve-030-event-surface.md b/.changeset/eve-030-event-surface.md index d30b8af9..b7e3d03b 100644 --- a/.changeset/eve-030-event-surface.md +++ b/.changeset/eve-030-event-surface.md @@ -14,6 +14,6 @@ The wide event now carries what eve started reporting since 0.24: - `ai.costUsd` — the cost eve reports, used in place of the `cost` pricing map when available. `ai.model` falls back to the model reported at session start, so `model` is only needed for dynamic-model agents - subagents record `durationMs` and a `started` status -`message` replaces `redactMessage` with three modes: `'omit'` (default), `'preview'` (text truncated to `messagePreviewLength`, attachments reduced to their type) and `'full'`. Attachment parts were previously not redacted at all. `redactMessage` still works and is deprecated. +`message` replaces `redactMessage` with three modes: `'omit'` (default), `'preview'` (text truncated to `messagePreviewLength`, attachments reduced to their type and media type) and `'full'`. Attachment parts were previously not redacted at all. `redactMessage` still works and is deprecated. `sessionEvent: true` adds one wide event per session on top of the per-turn ones, rolling up turns, tokens, cost, tools used, compactions and authorizations — one row per conversation, which is what makes tail sampling useful on an agent. diff --git a/packages/evlog/src/eve/index.ts b/packages/evlog/src/eve/index.ts index 7a83dd45..ffbc9dd4 100644 --- a/packages/evlog/src/eve/index.ts +++ b/packages/evlog/src/eve/index.ts @@ -99,6 +99,7 @@ interface SessionRollup { inputTokens: number outputTokens: number costUsd: number + estimatedCost: number tools: Set compactions: number authorizations: number @@ -497,6 +498,7 @@ function freshRollup(): SessionRollup { inputTokens: 0, outputTokens: 0, costUsd: 0, + estimatedCost: 0, tools: new Set(), compactions: 0, authorizations: 0, @@ -830,6 +832,7 @@ function accumulateSessionTotals( rollup.inputTokens += acc.inputTokens rollup.outputTokens += acc.outputTokens rollup.costUsd += acc.costUsd + rollup.estimatedCost += computeEstimatedCost(acc) ?? 0 rollup.compactions += acc.compactions rollup.authorizations += acc.authorizations.length for (const tool of acc.toolExecutions) rollup.tools.add(tool.name) @@ -883,7 +886,11 @@ async function emitSessionEvent( inputTokens: rollup.inputTokens, outputTokens: rollup.outputTokens, totalTokens: rollup.inputTokens + rollup.outputTokens, - ...(rollup.costUsd > 0 ? { costUsd: roundCost(rollup.costUsd) } : {}), + ...(rollup.costUsd > 0 + ? { costUsd: roundCost(rollup.costUsd) } + : rollup.estimatedCost > 0 + ? { estimatedCost: roundCost(rollup.estimatedCost) } + : {}), ...(rollup.tools.size > 0 ? { toolCalls: [...rollup.tools] } : {}), }, }) @@ -1089,9 +1096,13 @@ export function defineEvlogHook(options: EvlogEveOptions = {}): HookDefinition { if (!state) return const acc = state.accumulator acc.compactionsRequested += 1 - acc.compactionModel = event.data.modelId - if (event.data.usageInputTokens !== null) { - acc.compactionInputTokens = event.data.usageInputTokens + // Keep the first trigger of the turn: `inputTokensAtTrigger` reports + // how full the context was when compaction first kicked in. + if (acc.compactionModel === undefined) { + acc.compactionModel = event.data.modelId + if (event.data.usageInputTokens !== null) { + acc.compactionInputTokens = event.data.usageInputTokens + } } }) }, diff --git a/packages/evlog/test/eve.test.ts b/packages/evlog/test/eve.test.ts index 44f01108..6d6abffb 100644 --- a/packages/evlog/test/eve.test.ts +++ b/packages/evlog/test/eve.test.ts @@ -63,7 +63,7 @@ async function runTurn( outcome?: HookEventMap['authorization.completed']['data']['outcome'] reason?: string }> - compactions?: Array<{ modelId: string, usageInputTokens: number | null }> + compactions?: Array<{ modelId: string, usageInputTokens: number | null, complete?: boolean }> clearContext?: boolean ctx?: HookContext } = {}, @@ -152,10 +152,12 @@ async function runTurn( usageInputTokens: compaction.usageInputTokens, }, }, ctx) - events['compaction.completed']!({ - type: 'compaction.completed', - data: { modelId: compaction.modelId, sequence: 41 + index, sessionId: SESSION_ID, turnId }, - }, ctx) + if (compaction.complete !== false) { + events['compaction.completed']!({ + type: 'compaction.completed', + data: { modelId: compaction.modelId, sequence: 41 + index, sessionId: SESSION_ID, turnId }, + }, ctx) + } } if (options.clearContext) { @@ -443,7 +445,7 @@ describe('evlog/eve', () => { }, ctx) useLogger(toolContext()).set({ customer: { slug: 'acme' } }) - await hook.events!['session.completed']!({ type: 'session.completed' } as never, ctx) + await hook.events!['session.completed']!({ type: 'session.completed' }, ctx) await waitForDrainCalls(spies.drain) const openTurn = findEventViaDrain(spies.drain, e => e.path?.includes(TURN_ID)) @@ -484,7 +486,7 @@ describe('evlog/eve', () => { data: { sequence: 1, turnId: TURN_ID_1 }, }, ctx) - await hook.events!['session.completed']!({ type: 'session.completed' } as never, ctx) + await hook.events!['session.completed']!({ type: 'session.completed' }, ctx) await waitForDrainCalls(spies.drain) expect(findEventViaDrain(spies.drain, e => e.path?.endsWith(TURN_ID))).toBeUndefined() @@ -663,6 +665,44 @@ describe('evlog/eve', () => { }) }) + it('reports a compaction that was requested but never completed', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook, { + compactions: [{ modelId: 'gpt-5-mini', usageInputTokens: 180_000, complete: false }], + }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.eve).toMatchObject({ + compaction: { + count: 0, + requested: 1, + model: 'gpt-5-mini', + inputTokensAtTrigger: 180_000, + }, + }) + }) + + it('keeps the first trigger when a turn compacts more than once', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook, { + compactions: [ + { modelId: 'gpt-5-mini', usageInputTokens: 180_000 }, + { modelId: 'gpt-5-nano', usageInputTokens: 90_000 }, + ], + }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.eve).toMatchObject({ + compaction: { count: 2, model: 'gpt-5-mini', inputTokensAtTrigger: 180_000 }, + }) + }) + it('marks a subagent started and times it to completion', async () => { const spies = createPipelineSpies() const hook = defineEvlogHook({ drain: spies.drain }) @@ -754,7 +794,7 @@ describe('evlog/eve', () => { await runTurn(hook, { ctx, costUsd: 0.01, toolResults: [{ toolName: 'search', status: 'completed' }] }) await runTurn(hook, { ctx, turnId: TURN_ID_1, cancel: true, costUsd: 0.02 }) - await hook.events!['session.completed']!({ type: 'session.completed' } as never, ctx) + await hook.events!['session.completed']!({ type: 'session.completed' }, ctx) await waitForDrainCalls(spies.drain, 3) const sessionEvent = findEventViaDrain(spies.drain, e => e.path === `/sessions/${SESSION_ID}`) @@ -811,13 +851,31 @@ describe('evlog/eve', () => { }) }) + it('rolls up the estimated cost when eve reports no cost', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ + drain: spies.drain, + sessionEvent: true, + cost: { 'gpt-5': { input: 1000, output: 2000 } }, + }) + const ctx = hookContext() + + await runTurn(hook, { ctx }) + await hook.events!['session.completed']!({ type: 'session.completed' }, ctx) + + await waitForDrainCalls(spies.drain, 2) + const sessionEvent = findEventViaDrain(spies.drain, e => e.path === `/sessions/${SESSION_ID}`) + expect(sessionEvent?.ai).toMatchObject({ estimatedCost: 0.2 }) + expect((sessionEvent?.ai as Record).costUsd).toBeUndefined() + }) + it('emits no session wide event by default', async () => { const spies = createPipelineSpies() const hook = defineEvlogHook({ drain: spies.drain }) const ctx = hookContext() await runTurn(hook, { ctx }) - await hook.events!['session.completed']!({ type: 'session.completed' } as never, ctx) + await hook.events!['session.completed']!({ type: 'session.completed' }, ctx) await waitForDrainCalls(spies.drain) expect(findEventViaDrain(spies.drain, e => e.path === `/sessions/${SESSION_ID}`)).toBeUndefined() From 3f28ab49670f6db8716a27d7d26c7ee5427b8b74 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 6 Aug 2026 09:01:15 +0100 Subject: [PATCH 4/5] fix(eve): keep reported and estimated session costs separate --- packages/evlog/src/eve/index.ts | 15 ++++++++------- packages/evlog/test/eve.test.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/evlog/src/eve/index.ts b/packages/evlog/src/eve/index.ts index ffbc9dd4..8f5754aa 100644 --- a/packages/evlog/src/eve/index.ts +++ b/packages/evlog/src/eve/index.ts @@ -831,8 +831,10 @@ function accumulateSessionTotals( rollup.calls += acc.calls rollup.inputTokens += acc.inputTokens rollup.outputTokens += acc.outputTokens - rollup.costUsd += acc.costUsd - rollup.estimatedCost += computeEstimatedCost(acc) ?? 0 + // Mirrors the per-turn rule in `buildAiField`: a turn contributes to one cost + // bucket or the other, never both, so the two session totals cannot overlap. + if (acc.costUsd > 0) rollup.costUsd += acc.costUsd + else rollup.estimatedCost += computeEstimatedCost(acc) ?? 0 rollup.compactions += acc.compactions rollup.authorizations += acc.authorizations.length for (const tool of acc.toolExecutions) rollup.tools.add(tool.name) @@ -886,11 +888,10 @@ async function emitSessionEvent( inputTokens: rollup.inputTokens, outputTokens: rollup.outputTokens, totalTokens: rollup.inputTokens + rollup.outputTokens, - ...(rollup.costUsd > 0 - ? { costUsd: roundCost(rollup.costUsd) } - : rollup.estimatedCost > 0 - ? { estimatedCost: roundCost(rollup.estimatedCost) } - : {}), + ...(rollup.costUsd > 0 ? { costUsd: roundCost(rollup.costUsd) } : {}), + ...(rollup.estimatedCost > 0 + ? { estimatedCost: roundCost(rollup.estimatedCost) } + : {}), ...(rollup.tools.size > 0 ? { toolCalls: [...rollup.tools] } : {}), }, }) diff --git a/packages/evlog/test/eve.test.ts b/packages/evlog/test/eve.test.ts index 6d6abffb..da7c4114 100644 --- a/packages/evlog/test/eve.test.ts +++ b/packages/evlog/test/eve.test.ts @@ -869,6 +869,24 @@ describe('evlog/eve', () => { expect((sessionEvent?.ai as Record).costUsd).toBeUndefined() }) + it('reports both cost sources when a session mixes them', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ + drain: spies.drain, + sessionEvent: true, + cost: { 'gpt-5': { input: 1000, output: 2000 } }, + }) + const ctx = hookContext() + + await runTurn(hook, { ctx, costUsd: 0.01 }) + await runTurn(hook, { ctx, turnId: TURN_ID_1 }) + await hook.events!['session.completed']!({ type: 'session.completed' }, ctx) + + await waitForDrainCalls(spies.drain, 3) + const sessionEvent = findEventViaDrain(spies.drain, e => e.path === `/sessions/${SESSION_ID}`) + expect(sessionEvent?.ai).toMatchObject({ costUsd: 0.01, estimatedCost: 0.2 }) + }) + it('emits no session wide event by default', async () => { const spies = createPipelineSpies() const hook = defineEvlogHook({ drain: spies.drain }) From 351f6f00d6804e88bc590fdbf725feab7af6dd8d Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 6 Aug 2026 09:14:12 +0100 Subject: [PATCH 5/5] chore(eve): ship the eve 0.30 peer bump as a minor --- .changeset/eve-030-event-surface.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/eve-030-event-surface.md b/.changeset/eve-030-event-surface.md index b7e3d03b..82e53a41 100644 --- a/.changeset/eve-030-event-surface.md +++ b/.changeset/eve-030-event-surface.md @@ -1,8 +1,8 @@ --- -"evlog": major +"evlog": minor --- -Cover the eve 0.30 event surface. **Requires eve >= 0.30** — the peer range moves from `>=0.24.3`, which is why this is a major release. Agents on an older eve keep working on the previous evlog; upgrade eve first. +Cover the eve 0.30 event surface. **`evlog/eve` now requires eve >= 0.30** — the peer range moves from `>=0.24.3`. The eve integration is still beta and its peer floor moves with it, so this ships as a minor; nothing outside `evlog/eve` is affected. Agents on an older eve keep working on the previous evlog — upgrade eve first. The wide event now carries what eve started reporting since 0.24: