Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/cli/src/__tests__/pi-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2927,6 +2927,56 @@ describe('transcript entry render memoization', () => {
assert.match(rendered, /\(2s · 2 lines\)/);
});

test('provider retry activity strip counts down from the event timestamp', (t) => {
// #3393: a subscription quota window can hand the runtime an hours-long
// Retry-After; the strip must count down from the event's `ts` instead of
// pinning the original delay for the whole sleep.
const start = 1_700_000_000_000;
t.mock.timers.enable({ apis: ['Date'], now: start });
const scheduled = {
type: 'provider_retry',
id: 'retry-1',
turnId: 'turn-1',
ts: start,
phase: 'scheduled',
attempt: 2,
maxAttempts: 10,
delayMs: 16_083_000,
reason: 'rate_limit',
} as const;

// Hours-long waits render as a humanized duration, not a raw second count.
assert.match(
stripAnsi(renderMakaPiActivityStrip({ ...meta(), providerRetry: scheduled }, 120)),
/Retrying in 4h 28m 3s \(2\/10\)/,
);

// Elapsed time ticks the countdown down; zero-value units are omitted.
t.mock.timers.setTime(start + 63_000);
assert.match(
stripAnsi(renderMakaPiActivityStrip({ ...meta(), providerRetry: scheduled }, 120)),
/Retrying in 4h 27m \(2\/10\)/,
);

// An elapsed wait floors at 0s; the countdown never goes negative.
t.mock.timers.setTime(start + 17_000_000);
assert.match(
stripAnsi(renderMakaPiActivityStrip({ ...meta(), providerRetry: scheduled }, 120)),
/Retrying in 0s \(2\/10\)/,
);

// The started phase carries no countdown at all.
assert.match(
stripAnsi(
renderMakaPiActivityStrip(
{ ...meta(), providerRetry: { ...scheduled, phase: 'started' } },
120,
),
),
/^Retrying \(2\/10\)$/,
);
});

test('re-renders equal-length ShellRun output only when revision advances', () => {
const state = createMakaPiTranscriptState();
applyMakaSessionEventToTranscript(
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/pi-transcript.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Markdown } from '@earendil-works/pi-tui';
import type {
ProviderRetryEvent,
ProviderRetryScheduledEvent,
SandboxBoundaryRequestEvent,
UserQuestionRequestEvent,
SessionEvent,
Expand Down Expand Up @@ -1404,14 +1405,26 @@ export function renderMakaPiActivityStrip(
const retry = metadata.providerRetry;
const text =
retry.phase === 'scheduled'
? `Retrying in ${Math.max(1, Math.ceil(retry.delayMs / 1_000))}s (${retry.attempt}/${retry.maxAttempts})`
? `Retrying in ${formatRetryCountdown(retry)} (${retry.attempt}/${retry.maxAttempts})`
: `Retrying (${retry.attempt}/${retry.maxAttempts})`;
return fitLine(ansi.dim(text), safeWidth);
}
if (metadata.turnElapsedMs === undefined) return '';
return fitLine(ansi.dim(`Working… ${formatElapsedDuration(metadata.turnElapsedMs)}`), safeWidth);
}

/**
* Remaining wait for a scheduled provider retry, measured from the event's own
* timestamp so the strip counts down on the 1s ticker instead of pinning the
* original delay for the whole sleep. Long provider-mandated waits (a
* subscription quota window can be hours) render as `4h 28m 3s` via the shared
* duration formatter rather than a raw five-digit second count.
*/
function formatRetryCountdown(retry: ProviderRetryScheduledEvent): string {
const remainingMs = Math.max(0, retry.delayMs - (Date.now() - retry.ts));
return formatElapsedDuration(remainingMs);
}

function formatElapsedDuration(elapsedMs: number): string {
let remainingSeconds = Math.max(0, Math.floor(elapsedMs / 1_000));
const units = [
Expand Down
98 changes: 98 additions & 0 deletions packages/ui/src/__tests__/provider-retry-countdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { parseHTML } from 'linkedom';
import type { ProviderRetryScheduledEvent } from '@maka/core/events';
import { ModelProviderRetryIndicator } from '../chat-turn.js';
import { LocaleProvider } from '../locale-context.js';

const originalGlobals = {
document: globalThis.document,
matchMedia: globalThis.matchMedia,
requestAnimationFrame: globalThis.requestAnimationFrame,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
window: globalThis.window,
};
const originalActEnvironment = (globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}).IS_REACT_ACT_ENVIRONMENT;

const mountedRoots: ReturnType<typeof createRoot>[] = [];

afterEach(async () => {
// Unmount before restoring globals: React's cleanup reads `document`.
for (const root of mountedRoots.splice(0)) await act(() => root.unmount());
Object.assign(globalThis, {
...originalGlobals,
IS_REACT_ACT_ENVIRONMENT: originalActEnvironment,
});
});

function domRoot() {
const { document, window } = parseHTML('<div id="root"></div>');
Object.assign(globalThis, {
document,
window,
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
IS_REACT_ACT_ENVIRONMENT: true,
});
const container = document.querySelector('#root');
assert.ok(container);
const root = createRoot(container);
mountedRoots.push(root);
return { container, root };
}

function scheduledRetry(ts: number): ProviderRetryScheduledEvent {
return {
type: 'provider_retry',
id: 'retry-1',
turnId: 'turn-1',
ts,
phase: 'scheduled',
attempt: 2,
maxAttempts: 10,
delayMs: 10_000,
reason: 'rate_limit',
};
}

async function renderRetry(root: ReturnType<typeof createRoot>, retry: ProviderRetryScheduledEvent) {
await act(() =>
root.render(
<LocaleProvider locale="en">
<ModelProviderRetryIndicator retry={retry} />
</LocaleProvider>,
),
);
}

/**
* #3393: a subscription quota window can hand the runtime an hours-long
* Retry-After. The banner must count down from the event's timestamp — a
* frozen number reads as a hung process.
*/
test('provider retry banner subtracts the time already waited', async (t) => {
const now = 1_700_000_000_000;
t.mock.timers.enable({ apis: ['Date'], now });
const { container, root } = domRoot();

await renderRetry(root, scheduledRetry(now));
assert.match(container.textContent ?? '', /Retrying in 10s \(2\/10\)/);

// Four seconds into the wait the same event renders the remaining six.
await renderRetry(root, scheduledRetry(now - 4_000));
assert.match(container.textContent ?? '', /Retrying in 6s \(2\/10\)/);
});

test('provider retry banner never shows a negative countdown', async (t) => {
const now = 1_700_000_000_000;
t.mock.timers.enable({ apis: ['Date'], now });
const { container, root } = domRoot();

await renderRetry(root, scheduledRetry(now - 60_000));
assert.match(container.textContent ?? '', /Retrying in 1s \(2\/10\)/);
});
31 changes: 26 additions & 5 deletions packages/ui/src/chat-turn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -975,16 +975,37 @@ export function TurnRunningStatus(props: { startedAt?: number }) {

export function ModelProviderRetryIndicator(props: { retry: ProviderRetryEvent }) {
const copy = getConversationCopy(useUiLocale()).messages;
const { retry } = props;
const rootRef = useRef<HTMLDivElement>(null);
// Undefined until an effect measures it — the same determinism contract as
// the elapsed clock above: frozen fixtures keep the provider's original
// delay; live, the banner counts down from the event's timestamp so a long
// provider-mandated wait (a subscription quota window can be hours) shows
// progress instead of a frozen number.
const [nowMs, setNowMs] = useState<number | undefined>(undefined);
useEffect(() => {
if (retry.phase !== 'scheduled' || !isTimeDrivenMotionEnabled(rootRef.current)) return;
setNowMs(Date.now());
const tick = window.setInterval(() => setNowMs(Date.now()), ELAPSED_TICK_MS);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for keeping the countdown visually live. [P2] Because this banner is a role="status" live region, changing its title every second can cause screen readers to announce every tick—potentially for hours during a quota wait. Could the changing visual countdown be hidden from the live region while exposing a stable accessible status, following the pattern already used by the running-turn indicator? A focused accessibility regression would help keep the timer from reintroducing this.

return () => window.clearInterval(tick);
}, [retry.phase, retry.ts]);
const remainingMs =
retry.phase !== 'scheduled'
? 0
: nowMs === undefined
? retry.delayMs
: Math.max(0, retry.delayMs - (nowMs - retry.ts));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for deriving the remaining delay from the event rather than storing another countdown state. [P2] retry.ts is produced by the Runtime Host clock, while nowMs comes from the Client clock. On the supported remote-Host path, ordinary clock skew can therefore make a multi-hour retry display 0s immediately or show substantially more time than the Host will actually wait. Could the Host projection provide an authoritative remaining value, or otherwise establish a single clock domain before the Client starts ticking it down? The same calculation in the TUI has the same boundary.

const title =
props.retry.phase === 'scheduled'
retry.phase === 'scheduled'
? copy.providerRetryScheduled(
Math.max(1, Math.ceil(props.retry.delayMs / 1_000)),
props.retry.attempt,
props.retry.maxAttempts,
Math.max(1, Math.ceil(remainingMs / 1_000)),
retry.attempt,
retry.maxAttempts,
)
: copy.providerRetryStarted(props.retry.attempt, props.retry.maxAttempts);
: copy.providerRetryStarted(retry.attempt, retry.maxAttempts);
return (
<Banner
ref={rootRef}
status="warning"
container="section"
role="status"
Expand Down