diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts index 30df8157a8..f4e47ebc62 100644 --- a/packages/llm/llm/src/message.ts +++ b/packages/llm/llm/src/message.ts @@ -3,7 +3,7 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import { MessageId, type CallId } from './brand.ts' import { deepFreeze } from './call-config.ts' -import type { ContentBlock, StreamChunk, ToolResultBlock } from './types.ts' +import type { ContentBlock, ToolResultBlock } from './types.ts' /** Provider/model identity and adapter-private replay data for an assistant message. */ export interface AssistantProvenance { @@ -240,23 +240,3 @@ export function createToolResultMessage(input: ToolResultMessageInput): ToolResu }], }) } - -/** - * Whether a stream chunk carries visible model output (the first-token - * boundary shared by client step timing and the whole-log sessionStats - * projection). Empty deltas (heartbeats, empty tool-call frames) do not count - * as a first token. - * @param chunk - the stream chunk to test. - * @returns true when the chunk contains a non-empty text/reasoning/tool delta. - */ -export function isTokenDelta(chunk: StreamChunk): boolean { - switch (chunk.type) { - case 'text-delta': - case 'reasoning-delta': - return chunk.text !== '' - case 'tool-call-delta': - return chunk.argumentsDelta !== '' || chunk.name !== undefined - default: - return false - } -} diff --git a/packages/session/session-stats/src/projection.ts b/packages/session/session-stats/src/projection.ts index 4ab43036c4..dc408fe45a 100644 --- a/packages/session/session-stats/src/projection.ts +++ b/packages/session/session-stats/src/projection.ts @@ -24,9 +24,26 @@ */ import { z } from 'zod' -import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' +import type { StreamChunk } from '@deepseek-ai/dsh-llm/types' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +/* jscpd:ignore-start -- Session Stats owns its whole-log timing projection independently. */ + +/** Whether a stream chunk carries a non-empty first-token delta. */ +function isTokenDelta(chunk: StreamChunk): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} + +/* jscpd:ignore-end */ + /** Accumulated whole-log figures (the view is exactly these totals). */ interface SessionStatsTotals { /** Distinct turns with at least one closed step so far. */ diff --git a/packages/session/session-stats/tests/projection.spec.ts b/packages/session/session-stats/tests/projection.spec.ts index 63688ce436..839d7aa7d4 100644 --- a/packages/session/session-stats/tests/projection.spec.ts +++ b/packages/session/session-stats/tests/projection.spec.ts @@ -204,6 +204,35 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => { ])).toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 400, ttftSteps: 1 })) }) + it('uses non-empty Tool-call names or arguments as the first token', () => { + expect(fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_100, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', argumentsDelta: '' }, + }), + at(1_200, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' }, + }), + at(2_000, 'assistant/message', { turn: 1, step: 1, message }), + at(2_100, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 200, ttftSteps: 1 })) + + expect(fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_300, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', argumentsDelta: '{' }, + }), + at(2_000, 'assistant/message', { turn: 1, step: 1, message }), + at(2_100, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 300, ttftSteps: 1 })) + }) + it('leaves a cancelled step untimed: counted by step/end, no assembled message to accrue from', () => { expect(fold([ at(1_000, 'step/start', { turn: 1, step: 1 }),