Files
Mitul Shah 8a766884cf change timing markers to be precise on the timeline (#2775)
* doen

* Fix: Old test file `test/format-duration-precise.test.ts` still asserts two-decimal output ("45.20s", "1m 0.00s") that no longer matches the trimmed-zero output of `formatDurationPrecise`, so `pnpm test`/CI fails.

This commit fixes the issue reported at packages/web-shared/test/format-duration-precise.test.ts:15

## Bug

The PR changed `formatDurationPrecise` (in `packages/web-shared/src/lib/utils.ts`) to trim trailing zeros by wrapping the fractional value in `Number(x.toFixed(fractionDigits))`:

```ts
if (normalizedMs < MS_IN_MINUTE) {
  return `${Number((normalizedMs / MS_IN_SECOND).toFixed(fractionDigits))}s`;
}
...
parts.push(`${Number(seconds.toFixed(fractionDigits))}s`);
```

`Number("45.20")` → `45.2`, `Number("0.00")` → `0`, so whole/half seconds now render without padding.

A new test file `packages/web-shared/src/lib/utils.test.ts` reflects this behavior, but the pre-existing `packages/web-shared/test/format-duration-precise.test.ts` was left untouched and still asserts the **old** padded output.

## Concrete trigger

Reproduced the actual function output (integer decomposition + trimmed zeros):

| Input | Old assertion | New actual output |
|-------|---------------|-------------------|
| `45200` | `45.20s` | `45.2s` |
| `999.6` | `1.00s` | `1s` |
| `999.5` | `1.00s` | `1s` |
| `59999` | `1m 0.00s` | `1m 0s` |
| `59995` | `1m 0.00s` | `1m 0s` |
| `119999` | `2m 0.00s` | `2m 0s` |
| `3659999` | `1h 1m 0.00s` | `1h 1m 0s` |
| `86459999` | `1d 1m 0.00s` | `1d 1m 0s` |

The root `vitest.config.ts` uses default include globs, so `test/*.test.ts` runs and these assertions fail, breaking CI. (I couldn't run vitest directly in the sandbox because dev deps weren't installed / `vitest/config` unresolved, so I reproduced the exact function logic in a standalone Node script to confirm the outputs.)

## Fix

Updated the stale assertions in `test/format-duration-precise.test.ts` to the trimmed-zero outputs, and adjusted the file-level doc comment (which referenced `"1m 0.00s"` / `"60.00s"`) to describe the current behavior.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: mitul-s <mitulxshah@gmail.com>

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2026-07-06 18:27:10 -04:00

213 lines
6.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Step } from '@workflow/world';
import type { ModelMessage } from 'ai';
const MS_IN_SECOND = 1000;
const MS_IN_MINUTE = 60 * MS_IN_SECOND;
const MS_IN_HOUR = 60 * MS_IN_MINUTE;
const MS_IN_DAY = 24 * MS_IN_HOUR;
/**
* Formats a duration in milliseconds to a human-readable string.
*
* @param ms - Duration in milliseconds
* @param compact - If true, returns a compact format (e.g., "380ms", "2m 30s").
* If false (default), returns multi-part format (e.g., "1m 13s", "2d 5h 3m 12s").
*
* Compact format (timeline markers):
* - < 1s: shows milliseconds (e.g., "380ms")
* - < 1m: shows seconds (e.g., "45s")
* - < 1h: shows minutes and seconds (e.g., "2m 30s")
* - >= 1h: shows hours and minutes (e.g., "2h 30m")
*
* Full format:
* - < 1s: shows milliseconds (e.g., "380ms")
* - < 1m: shows seconds (e.g., "45s")
* - >= 1m: shows decomposed format with whole seconds (e.g., "1m 13s", "2m 13s")
*/
export function formatDuration(ms: number, compact = false): string {
if (ms === 0) {
return '0s';
}
if (ms < MS_IN_SECOND) {
const roundedMs = Math.round(ms);
return roundedMs < MS_IN_SECOND ? `${roundedMs}ms` : '1s';
}
const roundedMs = Math.round(ms / MS_IN_SECOND) * MS_IN_SECOND;
if (roundedMs < MS_IN_MINUTE) {
return `${Math.floor(roundedMs / MS_IN_SECOND)}s`;
}
// Compact format: multi-unit without decimals (e.g. "8m 20s", "2h 30m")
if (compact) {
if (roundedMs < MS_IN_HOUR) {
const m = Math.floor(roundedMs / MS_IN_MINUTE);
const s = Math.floor((roundedMs % MS_IN_MINUTE) / MS_IN_SECOND);
return s > 0 ? `${m}m ${s}s` : `${m}m`;
}
const h = Math.floor(roundedMs / MS_IN_HOUR);
const m = Math.floor((roundedMs % MS_IN_HOUR) / MS_IN_MINUTE);
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
// Full format: decompose into larger units + whole seconds.
const days = Math.floor(roundedMs / MS_IN_DAY);
const hours = Math.floor((roundedMs % MS_IN_DAY) / MS_IN_HOUR);
const minutes = Math.floor((roundedMs % MS_IN_HOUR) / MS_IN_MINUTE);
const seconds = Math.floor((roundedMs % MS_IN_MINUTE) / MS_IN_SECOND);
const parts: string[] = [];
if (days > 0) {
parts.push(`${days}d`);
}
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0) {
parts.push(`${minutes}m`);
}
parts.push(`${seconds}s`);
return parts.join(' ');
}
/**
* Formats a duration in milliseconds without snapping to whole seconds.
*
* Unlike {@link formatDuration}, this keeps sub-second detail, so 1626ms
* renders as "1.63s" rather than "2s". `fractionDigits` sets both the number of
* decimals on the seconds component (trailing zeros trimmed, so 2000ms is "2s"
* and 1500ms is "1.5s") and the rounding granularity — the default of 2 rounds
* to 10ms; the timeline ruler passes fewer digits to match its tick step.
*
* - < 1s: shows whole milliseconds (e.g. "626ms")
* - 1s – 1m: shows seconds with trimmed decimals (e.g. "1.63s", "1.5s", "45s")
* - >= 1m: decomposes into d/h/m with trimmed-decimal seconds (e.g. "1m 13.45s")
*/
export function formatDurationPrecise(ms: number, fractionDigits = 2): string {
if (ms === 0) {
return '0s';
}
if (ms < MS_IN_SECOND) {
const roundedMs = Math.round(ms);
if (roundedMs < MS_IN_SECOND) {
return `${roundedMs}ms`;
}
}
const granularityMs = MS_IN_SECOND / 10 ** fractionDigits;
const normalizedMs = Math.round(ms / granularityMs) * granularityMs;
if (normalizedMs < MS_IN_MINUTE) {
return `${Number((normalizedMs / MS_IN_SECOND).toFixed(fractionDigits))}s`;
}
const days = Math.floor(normalizedMs / MS_IN_DAY);
const hours = Math.floor((normalizedMs % MS_IN_DAY) / MS_IN_HOUR);
const minutes = Math.floor((normalizedMs % MS_IN_HOUR) / MS_IN_MINUTE);
const seconds = (normalizedMs % MS_IN_MINUTE) / MS_IN_SECOND;
const parts: string[] = [];
if (days > 0) {
parts.push(`${days}d`);
}
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0) {
parts.push(`${minutes}m`);
}
parts.push(`${Number(seconds.toFixed(fractionDigits))}s`);
return parts.join(' ');
}
/**
* Returns a formatted pagination display string
* @param currentPage - The current page number
* @param totalPages - The total number of pages visited so far
* @param hasMore - Whether there are more pages available
* @returns Formatted string like "Page 1 of 3+" or "Page 2 of 2"
*/
export function getPaginationDisplay(
currentPage: number,
totalPages: number,
hasMore: boolean
): string {
if (hasMore) {
return `Page ${currentPage} of ${totalPages}+`;
}
return `Page ${currentPage} of ${totalPages}`;
}
// ============================================================================
// Durable Agent Utilities
// ============================================================================
/**
* Check if a step is a doStreamStep (LLM call with conversation input)
*/
export function isDoStreamStep(stepName: string): boolean {
return stepName.endsWith('//doStreamStep');
}
/**
* Extract the conversation from a hydrated doStreamStep input.
* doStreamStep signature: (conversationPrompt, model, writable, tools, options)
* So input[0] is the conversation.
*/
export function extractConversation(stepInput: unknown): ModelMessage[] | null {
if (!Array.isArray(stepInput) || stepInput.length === 0) {
return null;
}
const firstArg = stepInput[0];
if (!Array.isArray(firstArg)) {
return null;
}
// Validate it looks like ModelMessage[]
if (
!firstArg.every((msg) => msg && typeof msg === 'object' && 'role' in msg)
) {
return null;
}
return firstArg as ModelMessage[];
}
/**
* A doStreamStep with its conversation input extracted
*/
export interface StreamStep {
stepId: string;
stepName: string;
displayName: string;
conversation: ModelMessage[];
}
/**
* Identifies all stream steps (doStreamStep) in a run and extracts their conversations.
*/
export function identifyStreamSteps(steps: Step[]): StreamStep[] {
return steps
.filter((step) => isDoStreamStep(step.stepName))
.map((step) => {
const functionName = step.stepName.split('//').pop() ?? 'unknown';
const conversation = extractConversation(step.input) ?? [];
return {
stepId: step.stepId,
stepName: step.stepName,
displayName: functionName,
conversation,
};
});
}