mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
8a766884cf
* 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>
213 lines
6.3 KiB
TypeScript
213 lines
6.3 KiB
TypeScript
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,
|
||
};
|
||
});
|
||
}
|