diff --git a/packages/core/src/agents/agent-transcript.test.ts b/packages/core/src/agents/agent-transcript.test.ts index e80ce6636a8..0e970249cb1 100644 --- a/packages/core/src/agents/agent-transcript.test.ts +++ b/packages/core/src/agents/agent-transcript.test.ts @@ -409,7 +409,43 @@ describe('agent-transcript', () => { cleanup(); }); - it('drops usage-only ROUND_TEXT to keep the canonical view valid', () => { + it('writes usage-only ROUND_TEXT with a model message for exporters', () => { + const jsonlPath = path.join(tempDir, 's', 'agent-x.jsonl'); + const { emitter, cleanup } = makeWriter(jsonlPath); + + emitter.emit(AgentEventType.ROUND_TEXT, { + subagentId: 'agent-x', + runId: 'run-1', + round: 1, + text: '', + thoughtText: '', + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 20, + cachedContentTokenCount: 40, + totalTokenCount: 120, + }, + timestamp: Date.now(), + }); + cleanup(); + + const records = readJsonl(jsonlPath); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + type: 'assistant', + message: { role: 'model', parts: [] }, + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 20, + cachedContentTokenCount: 40, + totalTokenCount: 120, + }, + agentRunId: 'run-1', + agentRound: 1, + }); + }); + + it('drops ROUND_TEXT with no text, thought, or usage', () => { const jsonlPath = path.join(tempDir, 's', 'agent-x.jsonl'); const { emitter, cleanup } = makeWriter(jsonlPath); @@ -418,7 +454,6 @@ describe('agent-transcript', () => { round: 1, text: '', thoughtText: '', - usageMetadata: { totalTokenCount: 42 }, timestamp: Date.now(), }); cleanup(); diff --git a/packages/core/src/agents/agent-transcript.ts b/packages/core/src/agents/agent-transcript.ts index 77a7a5d2c44..ccd2e205eb6 100644 --- a/packages/core/src/agents/agent-transcript.ts +++ b/packages/core/src/agents/agent-transcript.ts @@ -458,18 +458,16 @@ export function attachJsonlTranscriptWriter( }; const onRoundText = (event: AgentRoundTextEvent) => { - if (!event.text && !event.thoughtText) return; + const parts = [ + ...(event.thoughtText + ? [{ text: event.thoughtText, thought: true }] + : []), + ...(event.text ? [{ text: event.text }] : []), + ]; + if (parts.length === 0 && !event.usageMetadata) return; append({ ...baseFields('assistant'), - message: { - role: 'model', - parts: [ - ...(event.thoughtText - ? [{ text: event.thoughtText, thought: true }] - : []), - ...(event.text ? [{ text: event.text }] : []), - ], - }, + message: { role: 'model', parts }, usageMetadata: event.usageMetadata, agentRunId: event.runId ?? streamRunId, agentRound: event.round, diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index c8574ba3052..f2947846a4d 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -1002,7 +1002,7 @@ export class AgentCore { break; } - if (roundText || roundThoughtText) { + if (roundText || roundThoughtText || lastUsage) { this.eventEmitter?.emit(AgentEventType.ROUND_TEXT, { subagentId: this.subagentId, runId, diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 36440f33104..270432120fd 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -43,6 +43,7 @@ import { import { AgentEventEmitter, AgentEventType, + type AgentRoundTextEvent, type AgentStreamTextEvent, type AgentToolCallEvent, type AgentToolResultEvent, @@ -2090,6 +2091,59 @@ describe('subagent.ts', () => { expect(events[1]!.thought).toBe(false); }); + it('should emit usage for a tool-call-only model round', async () => { + const { config } = await createMockConfig(); + const usageMetadata = { + promptTokenCount: 100, + candidatesTokenCount: 10, + cachedContentTokenCount: 5, + totalTokenCount: 110, + }; + mockSendMessageStream.mockImplementation(async () => + (async function* () { + yield { + type: 'chunk', + value: { + functionCalls: [ + { + id: 'call-1', + name: 'missing_tool', + args: {}, + }, + ], + usageMetadata, + }, + }; + })(), + ); + + const eventEmitter = new AgentEventEmitter(); + const events: AgentRoundTextEvent[] = []; + eventEmitter.on(AgentEventType.ROUND_TEXT, (event) => { + events.push(event); + }); + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + { ...defaultRunConfig, max_turns: 1 }, + undefined, + eventEmitter, + ); + + await scope.execute(new ContextState()); + + expect(events).toEqual([ + expect.objectContaining({ + round: 1, + text: '', + thoughtText: '', + usageMetadata, + }), + ]); + }); + it('should exclude thought text from finalText', async () => { const { config } = await createMockConfig();