mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e12feb633f | ||
|
|
304cbde651 |
@@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page
|
||||
await openCompactUsageWithClock(page, now);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
||||
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
|
||||
await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible();
|
||||
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
|
||||
// After two browser minutes the latest displayed tick is one or two minutes old.
|
||||
await advanceUsageClock(page, 120_000);
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
|
||||
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
|
||||
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
|
||||
await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible();
|
||||
await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible();
|
||||
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
|
||||
/^resets (28|29)m$/,
|
||||
/^resets in (28|29)m\s·\s/,
|
||||
);
|
||||
} finally {
|
||||
await login.cleanup();
|
||||
|
||||
@@ -2157,6 +2157,7 @@ export const ar: TranslationResources = {
|
||||
minutes_few: "{{value}} دقائق",
|
||||
minutes_many: "{{value}} دقيقة",
|
||||
minutes_other: "{{value}} دقيقة",
|
||||
pair: "{{first}} و{{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "آخر تحديث {{relativeTime}}",
|
||||
@@ -2164,6 +2165,8 @@ export const ar: TranslationResources = {
|
||||
resetTimePassed: "مضى وقت إعادة التعيين",
|
||||
resettingNow: "تتم إعادة التعيين الآن",
|
||||
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}، {{time}}",
|
||||
runsOutNow: "ينفد الآن",
|
||||
runsOutIn: "ينفد خلال {{duration}}",
|
||||
justNow: "الآن",
|
||||
|
||||
@@ -2164,15 +2164,18 @@ export const en = {
|
||||
days: "{{value}}d",
|
||||
hours: "{{value}}h",
|
||||
minutes: "{{value}}m",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Last updated {{relativeTime}}",
|
||||
noSuccessfulUpdate: "No successful update",
|
||||
resetTimePassed: "Reset time passed",
|
||||
resettingNow: "resetting now",
|
||||
resetsIn: "resets {{duration}}",
|
||||
resetsIn: "resets in {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}, {{time}}",
|
||||
runsOutNow: "runs out now",
|
||||
runsOutIn: "runs out {{duration}}",
|
||||
runsOutIn: "runs out in {{duration}}",
|
||||
justNow: "just now",
|
||||
daysAgo: "{{value}}d ago",
|
||||
hoursAgo: "{{value}}h ago",
|
||||
|
||||
@@ -2187,6 +2187,7 @@ export const es: TranslationResources = {
|
||||
days: "{{value}} d",
|
||||
hours: "{{value}} h",
|
||||
minutes: "{{value}} min",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Última actualización {{relativeTime}}",
|
||||
@@ -2194,6 +2195,8 @@ export const es: TranslationResources = {
|
||||
resetTimePassed: "Pasó la hora de restablecimiento",
|
||||
resettingNow: "se restablece ahora",
|
||||
resetsIn: "se restablece en {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}, {{time}}",
|
||||
runsOutNow: "se agota ahora",
|
||||
runsOutIn: "se agota en {{duration}}",
|
||||
justNow: "ahora mismo",
|
||||
|
||||
@@ -2187,6 +2187,7 @@ export const fr: TranslationResources = {
|
||||
days: "{{value}} j",
|
||||
hours: "{{value}} h",
|
||||
minutes: "{{value}} min",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Dernière mise à jour {{relativeTime}}",
|
||||
@@ -2194,6 +2195,8 @@ export const fr: TranslationResources = {
|
||||
resetTimePassed: "Heure de réinitialisation dépassée",
|
||||
resettingNow: "réinitialisation en cours",
|
||||
resetsIn: "se réinitialise dans {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}, {{time}}",
|
||||
runsOutNow: "s’épuise maintenant",
|
||||
runsOutIn: "s’épuise dans {{duration}}",
|
||||
justNow: "à l’instant",
|
||||
|
||||
@@ -2160,6 +2160,7 @@ export const ja: TranslationResources = {
|
||||
days: "{{value}}日",
|
||||
hours: "{{value}}時間",
|
||||
minutes: "{{value}}分",
|
||||
pair: "{{first}}{{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "最終更新: {{relativeTime}}",
|
||||
@@ -2167,6 +2168,8 @@ export const ja: TranslationResources = {
|
||||
resetTimePassed: "リセット時刻を過ぎています",
|
||||
resettingNow: "まもなくリセット",
|
||||
resetsIn: "{{duration}}後にリセット",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}} {{time}}",
|
||||
runsOutNow: "まもなく上限に到達",
|
||||
runsOutIn: "{{duration}}後に上限に到達",
|
||||
justNow: "たった今",
|
||||
|
||||
@@ -2149,6 +2149,7 @@ export const ko: TranslationResources = {
|
||||
days: "{{value}}일",
|
||||
hours: "{{value}}시간",
|
||||
minutes: "{{value}}분",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "마지막 업데이트: {{relativeTime}}",
|
||||
@@ -2156,6 +2157,8 @@ export const ko: TranslationResources = {
|
||||
resetTimePassed: "초기화 시간이 지났습니다",
|
||||
resettingNow: "지금 재설정 중",
|
||||
resetsIn: "{{duration}} 후 재설정",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}} {{time}}",
|
||||
runsOutNow: "지금 한도에 도달",
|
||||
runsOutIn: "{{duration}} 후 한도에 도달",
|
||||
justNow: "방금",
|
||||
|
||||
@@ -2172,6 +2172,7 @@ export const ptBR: TranslationResources = {
|
||||
days: "{{value}} d",
|
||||
hours: "{{value}} h",
|
||||
minutes: "{{value}} min",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Última atualização {{relativeTime}}",
|
||||
@@ -2179,6 +2180,8 @@ export const ptBR: TranslationResources = {
|
||||
resetTimePassed: "O horário de redefinição passou",
|
||||
resettingNow: "redefinindo agora",
|
||||
resetsIn: "redefine em {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}, {{time}}",
|
||||
runsOutNow: "esgota agora",
|
||||
runsOutIn: "esgota em {{duration}}",
|
||||
justNow: "agora mesmo",
|
||||
|
||||
@@ -2172,6 +2172,7 @@ export const ru: TranslationResources = {
|
||||
days: "{{value}} дн.",
|
||||
hours: "{{value}} ч",
|
||||
minutes: "{{value}} мин",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Последнее обновление {{relativeTime}}",
|
||||
@@ -2179,6 +2180,8 @@ export const ru: TranslationResources = {
|
||||
resetTimePassed: "Время сброса прошло",
|
||||
resettingNow: "сброс сейчас",
|
||||
resetsIn: "сброс через {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}, {{time}}",
|
||||
runsOutNow: "закончится сейчас",
|
||||
runsOutIn: "закончится через {{duration}}",
|
||||
justNow: "только что",
|
||||
|
||||
@@ -2117,6 +2117,7 @@ export const zhCN: TranslationResources = {
|
||||
days: "{{value}}天",
|
||||
hours: "{{value}}小时",
|
||||
minutes: "{{value}}分钟",
|
||||
pair: "{{first}}{{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "上次更新于 {{relativeTime}}",
|
||||
@@ -2124,6 +2125,8 @@ export const zhCN: TranslationResources = {
|
||||
resetTimePassed: "重置时间已过",
|
||||
resettingNow: "正在重置",
|
||||
resetsIn: "将在 {{duration}}后重置",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}} {{time}}",
|
||||
runsOutNow: "即将用尽",
|
||||
runsOutIn: "将在 {{duration}}后用尽",
|
||||
justNow: "刚刚",
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format";
|
||||
import type { UsageBalance, UsageTone } from "./types";
|
||||
import {
|
||||
formatAmount,
|
||||
formatProviderUsageLabel,
|
||||
formatResetLabel,
|
||||
formatResetTime,
|
||||
} from "./format";
|
||||
import { UsageMeter } from "./meter";
|
||||
import { UsageResetLine } from "./reset-line";
|
||||
import type { UsageBalance } from "./types";
|
||||
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
||||
|
||||
interface ResolvedBalance {
|
||||
@@ -41,19 +47,6 @@ function resolveBalance({
|
||||
return { amountText: "—", usedPct: null };
|
||||
}
|
||||
|
||||
function fillToneStyle(tone: UsageTone) {
|
||||
switch (tone) {
|
||||
case "ok":
|
||||
return styles.fillOk;
|
||||
case "warning":
|
||||
return styles.fillWarning;
|
||||
case "danger":
|
||||
return styles.fillDanger;
|
||||
default:
|
||||
return styles.fillDefault;
|
||||
}
|
||||
}
|
||||
|
||||
export function UsageBalanceBar({
|
||||
balance,
|
||||
retained = false,
|
||||
@@ -66,11 +59,7 @@ export function UsageBalanceBar({
|
||||
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
|
||||
const tone = balance.tone ?? "default";
|
||||
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
|
||||
|
||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
|
||||
[usedPct, tone],
|
||||
);
|
||||
const resetTime = formatResetTime(balance.resetsAt, now, t);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -78,22 +67,21 @@ export function UsageBalanceBar({
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{formatProviderUsageLabel(balance.id, balance.label, t)}
|
||||
</Text>
|
||||
<View style={styles.value} testID={`provider-usage-balance-${balance.id}-value`}>
|
||||
<Text style={styles.amount} numberOfLines={1} ellipsizeMode="tail">
|
||||
{amountText}
|
||||
</Text>
|
||||
{resetLabel ? (
|
||||
<Text style={styles.reset} testID={`provider-usage-balance-${balance.id}-reset`}>
|
||||
{resetLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text
|
||||
style={styles.amount}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
testID={`provider-usage-balance-${balance.id}-value`}
|
||||
>
|
||||
{amountText}
|
||||
</Text>
|
||||
</View>
|
||||
{usedPct != null ? (
|
||||
<View style={styles.track}>
|
||||
<View style={fillStyle} />
|
||||
</View>
|
||||
) : null}
|
||||
{usedPct != null ? <UsageMeter percent={usedPct} tone={tone} /> : null}
|
||||
<UsageResetLine
|
||||
countdown={resetLabel}
|
||||
exactTime={resetTime}
|
||||
testID={`provider-usage-balance-${balance.id}-reset`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -110,44 +98,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
value: {
|
||||
flexShrink: 1,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
amount: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
reset: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
amount: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
textAlign: "right",
|
||||
},
|
||||
track: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
fill: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
fillDefault: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
fillOk: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
fillWarning: {
|
||||
backgroundColor: theme.colors.statusWarning,
|
||||
},
|
||||
fillDanger: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formatPct,
|
||||
formatProviderUsageLabel,
|
||||
formatResetLabel,
|
||||
formatResetTime,
|
||||
formatRunsOutLabel,
|
||||
} from "./format";
|
||||
|
||||
@@ -36,8 +37,8 @@ describe("formatAmount", () => {
|
||||
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
expect(formatPct(7, "en")).toBe("7%");
|
||||
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h");
|
||||
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h");
|
||||
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets in 2h");
|
||||
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h");
|
||||
|
||||
await i18n.changeLanguage("ja");
|
||||
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
|
||||
@@ -94,6 +95,70 @@ describe("formatAmount", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset countdown", () => {
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const countdown = (ms: number) => formatResetLabel(new Date(NOW + ms).toISOString(), false, NOW);
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
it("keeps counting down inside a day instead of sitting on the rounded-down day", () => {
|
||||
expect(countdown(2 * DAY + 23 * HOUR + 10 * MINUTE)).toBe("resets in 2d 23h");
|
||||
expect(countdown(2 * DAY + 22 * HOUR + 10 * MINUTE)).toBe("resets in 2d 22h");
|
||||
});
|
||||
|
||||
it("says two units, dropping a unit that is zero", () => {
|
||||
expect(countdown(DAY + 30 * MINUTE)).toBe("resets in 1d");
|
||||
expect(countdown(4 * HOUR + 59 * MINUTE)).toBe("resets in 4h 59m");
|
||||
expect(countdown(HOUR)).toBe("resets in 1h");
|
||||
expect(countdown(37 * MINUTE)).toBe("resets in 37m");
|
||||
expect(countdown(59 * 1000)).toBe("resets in 0m");
|
||||
});
|
||||
|
||||
it("joins the at-risk countdown the same way", () => {
|
||||
const iso = new Date(NOW + 3 * HOUR + 5 * MINUTE).toISOString();
|
||||
expect(formatRunsOutLabel(iso, NOW)).toBe("runs out in 3h 5m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exact reset time", () => {
|
||||
// Local-time dates, so the thresholds hold in any timezone the tests run in.
|
||||
const now = new Date(2026, 9, 29, 12, 0).getTime();
|
||||
const at = (...args: [number, number, number, number, number]) =>
|
||||
formatResetTime(new Date(...args).toISOString(), now)?.replace(/\s/g, " ");
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
it("shows only the time within 24 hours", () => {
|
||||
expect(at(2026, 9, 29, 15, 0)).toMatch(/^(3:00 PM|15:00)$/);
|
||||
expect(at(2026, 9, 30, 11, 59)).toMatch(/^(11:59 AM|11:59)$/);
|
||||
});
|
||||
|
||||
it("adds the weekday within 7 days", () => {
|
||||
expect(at(2026, 9, 30, 12, 0)).toMatch(/^Fri (12:00 PM|12:00)$/);
|
||||
expect(at(2026, 10, 5, 11, 59)).toMatch(/^Thu (11:59 AM|11:59)$/);
|
||||
});
|
||||
|
||||
it("adds the date after 7 days", () => {
|
||||
expect(at(2026, 10, 5, 12, 0)).toMatch(/^Nov 5, (12:00 PM|12:00)$/);
|
||||
});
|
||||
|
||||
it("names the weekday in the app language", async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
expect(at(2026, 9, 31, 15, 0)).toMatch(/^sam\.? /);
|
||||
});
|
||||
|
||||
it("says nothing for a time that has passed", () => {
|
||||
expect(formatResetTime(new Date(now - 1).toISOString(), now)).toBeNull();
|
||||
expect(formatResetTime(null, now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("retained reset times that have passed do not claim the quota is resetting", () => {
|
||||
const past = "2000-01-01T00:00:00.000Z";
|
||||
expect(formatResetLabel(past, true)).toBe("Reset time passed");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { formatTimeOfDay } from "@/utils/time";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import type { UsageBalanceUnit } from "./types";
|
||||
|
||||
@@ -50,7 +51,17 @@ export function formatDisplayPct(
|
||||
: t("providerUsage.values.remaining", { amount: percentage });
|
||||
}
|
||||
|
||||
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
|
||||
type DurationUnit = "minutes" | "hours" | "days";
|
||||
interface DurationPart {
|
||||
unit: DurationUnit;
|
||||
count: number;
|
||||
}
|
||||
/** Two units, floored, so a countdown keeps moving: "2d 23h", "4h 59m", "37m". */
|
||||
type RelativeDuration = { kind: "now" } | { kind: "remaining"; parts: DurationPart[] };
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
const HOUR_MS = 60 * MINUTE_MS;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
|
||||
@@ -59,35 +70,34 @@ function formatCount(value: number): string {
|
||||
function relativeDuration(iso: string, now: number): RelativeDuration | null {
|
||||
const diffMs = new Date(iso).getTime() - now;
|
||||
if (!Number.isFinite(diffMs)) return null;
|
||||
if (diffMs <= 0) return { unit: "now" };
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays > 0) return { unit: "days", count: diffDays };
|
||||
if (diffHours > 0) return { unit: "hours", count: diffHours };
|
||||
return { unit: "minutes", count: diffMinutes };
|
||||
if (diffMs <= 0) return { kind: "now" };
|
||||
const days = Math.floor(diffMs / DAY_MS);
|
||||
const hours = Math.floor((diffMs % DAY_MS) / HOUR_MS);
|
||||
const minutes = Math.floor((diffMs % HOUR_MS) / MINUTE_MS);
|
||||
if (days > 0) return remaining({ unit: "days", count: days }, { unit: "hours", count: hours });
|
||||
if (hours > 0)
|
||||
return remaining({ unit: "hours", count: hours }, { unit: "minutes", count: minutes });
|
||||
return remaining({ unit: "minutes", count: minutes });
|
||||
}
|
||||
|
||||
function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null {
|
||||
switch (duration.unit) {
|
||||
case "now":
|
||||
return null;
|
||||
case "days":
|
||||
return t("providerUsage.duration.days", {
|
||||
count: duration.count,
|
||||
value: formatCount(duration.count),
|
||||
});
|
||||
case "hours":
|
||||
return t("providerUsage.duration.hours", {
|
||||
count: duration.count,
|
||||
value: formatCount(duration.count),
|
||||
});
|
||||
case "minutes":
|
||||
return t("providerUsage.duration.minutes", {
|
||||
count: duration.count,
|
||||
value: formatCount(duration.count),
|
||||
});
|
||||
}
|
||||
/** The major unit, plus the minor one unless it is zero. */
|
||||
function remaining(major: DurationPart, minor?: DurationPart): RelativeDuration {
|
||||
return { kind: "remaining", parts: minor && minor.count > 0 ? [major, minor] : [major] };
|
||||
}
|
||||
|
||||
function formatDurationPart(part: DurationPart, t: TFunction): string {
|
||||
return t(`providerUsage.duration.${part.unit}`, {
|
||||
count: part.count,
|
||||
value: formatCount(part.count),
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelativeDuration(
|
||||
duration: Extract<RelativeDuration, { kind: "remaining" }>,
|
||||
t: TFunction,
|
||||
): string {
|
||||
const [first, second] = duration.parts.map((part) => formatDurationPart(part, t));
|
||||
return second ? t("providerUsage.duration.pair", { first, second }) : first;
|
||||
}
|
||||
|
||||
export function formatResetLabel(
|
||||
@@ -99,10 +109,34 @@ export function formatResetLabel(
|
||||
if (!iso) return null;
|
||||
const rel = relativeDuration(iso, now);
|
||||
if (!rel) return null;
|
||||
if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed");
|
||||
if (rel.unit === "now") return t("providerUsage.timing.resettingNow");
|
||||
const duration = formatRelativeDuration(rel, t);
|
||||
return duration ? t("providerUsage.timing.resetsIn", { duration }) : null;
|
||||
if (rel.kind === "now" && retained) return t("providerUsage.timing.resetTimePassed");
|
||||
if (rel.kind === "now") return t("providerUsage.timing.resettingNow");
|
||||
return t("providerUsage.timing.resetsIn", { duration: formatRelativeDuration(rel, t) });
|
||||
}
|
||||
|
||||
/**
|
||||
* When a window resets on the clock: "3:00 PM" within a day, "Fri 3:00 PM" within a week,
|
||||
* "Oct 31, 3:00 PM" after that. Names follow the app language; 12/24h follows the device.
|
||||
* Nothing for a time that has passed, where the countdown already says so.
|
||||
*/
|
||||
export function formatResetTime(
|
||||
iso: string | null | undefined,
|
||||
now = Date.now(),
|
||||
t: TFunction = i18n.t,
|
||||
): string | null {
|
||||
if (!iso) return null;
|
||||
const at = new Date(iso);
|
||||
const diffMs = at.getTime() - now;
|
||||
if (!Number.isFinite(diffMs) || diffMs <= 0) return null;
|
||||
const time = formatTimeOfDay(at);
|
||||
const locale = i18n.resolvedLanguage;
|
||||
if (diffMs < DAY_MS) return time;
|
||||
if (diffMs < 7 * DAY_MS) {
|
||||
const weekday = at.toLocaleDateString(locale, { weekday: "short" });
|
||||
return t("providerUsage.timing.resetsAtWeekday", { weekday, time });
|
||||
}
|
||||
const date = at.toLocaleDateString(locale, { month: "short", day: "numeric" });
|
||||
return t("providerUsage.timing.resetsAtDate", { date, time });
|
||||
}
|
||||
|
||||
export function formatRunsOutLabel(
|
||||
@@ -113,9 +147,8 @@ export function formatRunsOutLabel(
|
||||
if (!iso) return null;
|
||||
const rel = relativeDuration(iso, now);
|
||||
if (!rel) return null;
|
||||
if (rel.unit === "now") return t("providerUsage.timing.runsOutNow");
|
||||
const duration = formatRelativeDuration(rel, t);
|
||||
return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
|
||||
if (rel.kind === "now") return t("providerUsage.timing.runsOutNow");
|
||||
return t("providerUsage.timing.runsOutIn", { duration: formatRelativeDuration(rel, t) });
|
||||
}
|
||||
|
||||
export function formatAgo(
|
||||
|
||||
@@ -46,8 +46,8 @@ export function UsageMeter({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
track: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
/**
|
||||
* The reset data of a window or balance: the countdown first, then the exact time. When the line
|
||||
* is too narrow the exact time gives way (truncates) and the countdown stays whole.
|
||||
*/
|
||||
export function UsageResetLine({
|
||||
countdown,
|
||||
exactTime,
|
||||
atRisk = false,
|
||||
testID,
|
||||
}: {
|
||||
countdown: string | null;
|
||||
exactTime: string | null;
|
||||
/** The countdown is a runs-out warning rather than a reset. */
|
||||
atRisk?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
if (!countdown) return null;
|
||||
return (
|
||||
<View style={styles.line} testID={testID}>
|
||||
<Text style={atRisk ? styles.atRisk : styles.text}>{countdown}</Text>
|
||||
{exactTime ? (
|
||||
<Text style={[styles.text, styles.exactTime]} numberOfLines={1} ellipsizeMode="tail">
|
||||
{`\u00a0·\u00a0${exactTime}`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
line: { flexDirection: "row", alignItems: "center" },
|
||||
text: {
|
||||
flexShrink: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
atRisk: {
|
||||
flexShrink: 0,
|
||||
color: theme.colors.statusDanger,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
exactTime: { flexShrink: 1 },
|
||||
}));
|
||||
@@ -192,12 +192,7 @@ function PinnedUsageLine({
|
||||
<Fragment key={window.key}>
|
||||
{layout === "meters" ? (
|
||||
<View style={[styles.meterSlot, leadStyle]}>
|
||||
<UsageMeter
|
||||
percent={window.percent}
|
||||
tone={window.tone}
|
||||
quiet
|
||||
style={styles.meter}
|
||||
/>
|
||||
<UsageMeter percent={window.percent} tone={window.tone} quiet />
|
||||
</View>
|
||||
) : null}
|
||||
<Text
|
||||
@@ -269,10 +264,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: MIN_METER_WIDTH,
|
||||
marginRight: METER_GAP,
|
||||
},
|
||||
meter: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
percent: {
|
||||
flexShrink: 0,
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { Pin } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
formatDisplayPct,
|
||||
formatProviderUsageLabel,
|
||||
formatResetLabel,
|
||||
formatResetTime,
|
||||
formatRunsOutLabel,
|
||||
} from "./format";
|
||||
import { UsageMeter } from "./meter";
|
||||
import { displayPercent, usageWindowRowLabel } from "./model";
|
||||
import { UsageResetLine } from "./reset-line";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import { windowTone } from "./tone";
|
||||
import type { UsageTone, UsageWindow } from "./types";
|
||||
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
||||
|
||||
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
|
||||
// so a hover never reads as the selection. Pinned rows do not react to hover.
|
||||
function highlightStyle(pinned: boolean, hovered: boolean) {
|
||||
if (pinned) return styles.highlightPinned;
|
||||
return hovered ? styles.highlightHovered : styles.highlightNone;
|
||||
}
|
||||
|
||||
export function UsageWindowBar({
|
||||
window,
|
||||
displayAs,
|
||||
@@ -54,9 +49,12 @@ export function UsageWindowBar({
|
||||
window.runsOutAt != null &&
|
||||
window.shortfallPct != null &&
|
||||
(!retained || Date.parse(window.runsOutAt) > now);
|
||||
const trailing = isAtRisk
|
||||
const countdown = isAtRisk
|
||||
? formatRunsOutLabel(window.runsOutAt, now, t)
|
||||
: formatResetLabel(window.resetsAt, retained, now, t);
|
||||
// The exact time belongs to the reset; beside a runs-out warning it would read as the moment
|
||||
// the quota runs out.
|
||||
const exactTime = isAtRisk ? null : formatResetTime(window.resetsAt, now, t);
|
||||
|
||||
const value =
|
||||
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
|
||||
@@ -64,60 +62,57 @@ export function UsageWindowBar({
|
||||
const content = {
|
||||
label: formatProviderUsageLabel(window.id, window.label, t),
|
||||
value,
|
||||
trailing,
|
||||
countdown,
|
||||
exactTime,
|
||||
isAtRisk,
|
||||
percent: shownPct ?? 0,
|
||||
tone,
|
||||
pinned,
|
||||
};
|
||||
|
||||
// Same padding as the pinnable row, so bars line up in both modes.
|
||||
if (!pinnable) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<WindowRowContent {...content} highlight={styles.highlightNone} pinnable={false} />
|
||||
<WindowRowContent {...content} pinnable={false} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same
|
||||
// padding so toggling only changes the background.
|
||||
// The whole row pins the window to the sidebar Usage item; the pin glyph is the only sign of it.
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onTogglePin}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={usageWindowRowLabel({ pinLabel, value, trailing })}
|
||||
accessibilityLabel={usageWindowRowLabel({
|
||||
pinLabel,
|
||||
value,
|
||||
trailing: [countdown, exactTime].filter(Boolean).join(" · "),
|
||||
})}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-checked={pinned}
|
||||
style={styles.row}
|
||||
testID={pinTestID}
|
||||
>
|
||||
{({ hovered }: { hovered?: boolean }) => (
|
||||
<WindowRowContent
|
||||
{...content}
|
||||
highlight={highlightStyle(pinned, Boolean(hovered))}
|
||||
pinnable
|
||||
/>
|
||||
)}
|
||||
<WindowRowContent {...content} pinnable />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowRowContent({
|
||||
highlight,
|
||||
label,
|
||||
value,
|
||||
trailing,
|
||||
countdown,
|
||||
exactTime,
|
||||
isAtRisk,
|
||||
percent,
|
||||
tone,
|
||||
pinnable,
|
||||
pinned,
|
||||
}: {
|
||||
highlight: StyleProp<ViewStyle>;
|
||||
label: string;
|
||||
value: string;
|
||||
trailing: string | null | undefined;
|
||||
countdown: string | null;
|
||||
exactTime: string | null;
|
||||
isAtRisk: boolean;
|
||||
percent: number;
|
||||
tone: UsageTone;
|
||||
@@ -126,24 +121,17 @@ function WindowRowContent({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View style={highlight} pointerEvents="none" />
|
||||
<View style={styles.contentRow}>
|
||||
<View style={styles.windowContent}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={styles.value} numberOfLines={1} ellipsizeMode="tail">
|
||||
{value}
|
||||
{trailing ? (
|
||||
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
<UsageMeter percent={percent} tone={tone} />
|
||||
</View>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={styles.value} numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
{pinnable ? <UsagePinGlyph pinned={pinned} /> : null}
|
||||
</View>
|
||||
<UsageMeter percent={percent} tone={tone} />
|
||||
<UsageResetLine countdown={countdown} exactTime={exactTime} atRisk={isAtRisk} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -153,9 +141,9 @@ const ThemedPin = withUnistyles(Pin);
|
||||
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const iconMapping = useMemo(
|
||||
() => (theme: { colors: { foregroundMuted: string } }) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fill: pinned ? theme.colors.foregroundMuted : "none",
|
||||
() => (theme: { colors: { foreground: string; foregroundMuted: string } }) => ({
|
||||
color: pinned ? theme.colors.foreground : theme.colors.foregroundMuted,
|
||||
fill: pinned ? theme.colors.foreground : "none",
|
||||
}),
|
||||
[pinned],
|
||||
);
|
||||
@@ -179,59 +167,23 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
windowContent: { flex: 1, gap: 3 },
|
||||
row: { gap: 3, paddingVertical: theme.spacing[1.5] },
|
||||
pin: { width: 12, alignItems: "center" },
|
||||
tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm },
|
||||
row: {
|
||||
gap: 3,
|
||||
// The highlight bleeds into the card padding so the label and bar stay on the card's rail.
|
||||
marginHorizontal: -theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
// Its own stacking context, so the highlight layer paints above the card and below the text.
|
||||
zIndex: 0,
|
||||
},
|
||||
highlightNone: {
|
||||
display: "none",
|
||||
},
|
||||
highlightPinned: {
|
||||
...StyleSheet.absoluteFill,
|
||||
zIndex: -1,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
// The pinned surface at half strength: a separate layer, so the text keeps full opacity.
|
||||
highlightHovered: {
|
||||
...StyleSheet.absoluteFill,
|
||||
zIndex: -1,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
flex: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
value: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
reset: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
atRisk: {
|
||||
color: theme.colors.statusDanger,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
value: {
|
||||
flexShrink: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -125,6 +125,11 @@ function getTimeFormatter(): Intl.DateTimeFormat {
|
||||
return cachedTimeFormatter;
|
||||
}
|
||||
|
||||
/** A clock time in the device's 12h/24h preference: "3:00 PM" or "15:00". */
|
||||
export function formatTimeOfDay(date: Date): string {
|
||||
return getTimeFormatter().format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a chat-message timestamp for hover-revealed UI.
|
||||
* - Same day: "10:11 PM" or "22:11" depending on user preference
|
||||
@@ -132,7 +137,7 @@ function getTimeFormatter(): Intl.DateTimeFormat {
|
||||
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
|
||||
*/
|
||||
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
|
||||
const time = getTimeFormatter().format(date);
|
||||
const time = formatTimeOfDay(date);
|
||||
const daysAgo = localCalendarDaysBetween(date, now);
|
||||
|
||||
if (daysAgo === 0) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
buildCodexAppServerEnv,
|
||||
@@ -114,7 +115,6 @@ interface CodexSessionTestAccess {
|
||||
ensureThreadLoaded(): Promise<void>;
|
||||
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
||||
handleNotification(method: string, params: unknown): Promise<void>;
|
||||
loadPersistedHistory(client: CodexClientLike | null): Promise<void>;
|
||||
refreshResolvedCollaborationMode(): void;
|
||||
serviceTier: "fast" | null;
|
||||
planModeEnabled: boolean;
|
||||
@@ -503,6 +503,81 @@ function archivedThreadErrorMessage(threadId: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function codexUserItem(id: string, text: string) {
|
||||
return { type: "userMessage", id, content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function codexAgentItem(text: string) {
|
||||
return { type: "agentMessage", id: `agent-${text}`, text };
|
||||
}
|
||||
|
||||
// An AgentManager over a real Codex provider whose every spawn gets a fresh fake
|
||||
// app-server serving `threads` from thread/read.
|
||||
function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
|
||||
const appServers: FakeCodexAppServer[] = [];
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
const internals = castInternals<{
|
||||
goalsEnabledPromise: Promise<boolean> | null;
|
||||
autoReviewEnabledPromise: Promise<boolean> | null;
|
||||
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
|
||||
}>(provider);
|
||||
internals.goalsEnabledPromise = Promise.resolve(false);
|
||||
internals.autoReviewEnabledPromise = Promise.resolve(false);
|
||||
internals.spawnAppServer = async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/read": (params) => {
|
||||
const { threadId } = params as { threadId: string };
|
||||
return { thread: { id: threadId, turns: threads[threadId] ?? [] } };
|
||||
},
|
||||
});
|
||||
appServers.push(appServer);
|
||||
return appServer.child;
|
||||
};
|
||||
// The fake app-server stands in for the Codex CLI, which test machines may not have.
|
||||
const client: AgentClient = {
|
||||
provider: "codex",
|
||||
capabilities: provider.capabilities,
|
||||
isAvailable: async () => true,
|
||||
fetchCatalog: async () => ({ models: [], modes: [] }),
|
||||
createSession: async () => {
|
||||
throw new Error("Unexpected create");
|
||||
},
|
||||
resumeSession: (...args) => provider.resumeSession(...args),
|
||||
};
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
|
||||
const agentId = "00000000-0000-4000-8000-000000000001";
|
||||
const handle = { provider: "codex", sessionId: "saved-thread" };
|
||||
const config = createConfig({ cwd: process.cwd() });
|
||||
|
||||
async function open(): Promise<void> {
|
||||
await manager.resumeAgentFromPersistence(handle, config, agentId);
|
||||
await manager.hydrateTimelineFromProvider(agentId);
|
||||
}
|
||||
|
||||
return {
|
||||
manager,
|
||||
agentId,
|
||||
latestAppServer: () => appServers[appServers.length - 1],
|
||||
threadMethods: (index: number) =>
|
||||
appServers[index]
|
||||
.requests()
|
||||
.map((request) => request.method)
|
||||
.filter((method) => typeof method === "string" && method.startsWith("thread/")),
|
||||
// Cold-loads the agent, closes its runtime, and reopens it while Paseo keeps its timeline.
|
||||
async loadCloseAndReopen(): Promise<AgentTimelineItem[]> {
|
||||
await open();
|
||||
const projected = manager.getTimeline(agentId);
|
||||
await manager.closeAgent(agentId);
|
||||
await open();
|
||||
return projected;
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
await manager.closeAgent(agentId);
|
||||
for (const appServer of appServers) appServer.assertNoErrors();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function asInternals(session: CodexTestSession): CodexSessionTestAccess {
|
||||
return castInternals<CodexSessionTestAccess>(session);
|
||||
}
|
||||
@@ -1897,12 +1972,147 @@ describe("Codex app-server provider", () => {
|
||||
"thread/resume",
|
||||
"thread/unarchive",
|
||||
"thread/resume",
|
||||
"thread/read",
|
||||
]);
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("resumes a Codex thread without asking for its turns", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const provider = createProviderWithFakeAppServer(appServer);
|
||||
|
||||
const session = await provider.resumeSession({ sessionId: "saved-thread" });
|
||||
|
||||
expect(
|
||||
appServer
|
||||
.requests()
|
||||
.filter((request) => request.method === "thread/resume")
|
||||
.map((request) => request.params),
|
||||
).toEqual([expect.objectContaining({ threadId: "saved-thread", excludeTurns: true })]);
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("reopening an agent whose timeline Paseo still holds does not read Codex history", async () => {
|
||||
const codex = createReopenableCodexAgent({
|
||||
"saved-thread": [
|
||||
{ id: "turn-1", items: [codexUserItem("user-1", "Hello"), codexAgentItem("Hi there.")] },
|
||||
],
|
||||
});
|
||||
try {
|
||||
const projected = await codex.loadCloseAndReopen();
|
||||
|
||||
expect(projected).toEqual([
|
||||
expect.objectContaining({ type: "user_message", messageId: "user-1", text: "Hello" }),
|
||||
expect.objectContaining({ type: "assistant_message", text: "Hi there." }),
|
||||
]);
|
||||
expect(codex.threadMethods(0)).toEqual([
|
||||
"thread/loaded/list",
|
||||
"thread/resume",
|
||||
"thread/read",
|
||||
]);
|
||||
expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]);
|
||||
expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected);
|
||||
} finally {
|
||||
await codex.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rewinds an agent reopened without reading its Codex history", async () => {
|
||||
const firstTurn = {
|
||||
id: "turn-1",
|
||||
items: [codexUserItem("user-1", "First"), codexAgentItem("First answer.")],
|
||||
};
|
||||
const codex = createReopenableCodexAgent({
|
||||
"saved-thread": [
|
||||
firstTurn,
|
||||
{ id: "turn-2", items: [codexUserItem("user-2", "Second"), codexAgentItem("Second.")] },
|
||||
],
|
||||
"forked-thread": [firstTurn],
|
||||
});
|
||||
try {
|
||||
await codex.loadCloseAndReopen();
|
||||
|
||||
await codex.manager.rewind(codex.agentId, "user-2", "conversation");
|
||||
|
||||
expect(codex.latestAppServer().recordedRollbacks).toEqual([
|
||||
{ threadId: "forked-thread", numTurns: 1 },
|
||||
]);
|
||||
expect(codex.manager.getTimeline(codex.agentId)).toEqual([
|
||||
expect.objectContaining({ type: "user_message", messageId: "user-1", text: "First" }),
|
||||
expect.objectContaining({ type: "assistant_message", text: "First answer." }),
|
||||
]);
|
||||
} finally {
|
||||
await codex.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a reopened agent's interaction with an earlier sub-agent on its original card", async () => {
|
||||
const codex = createReopenableCodexAgent({
|
||||
"saved-thread": [
|
||||
{
|
||||
id: "turn-1",
|
||||
items: [
|
||||
codexUserItem("user-1", "Delegate"),
|
||||
{
|
||||
type: "subAgentActivity",
|
||||
id: "spawn-child",
|
||||
kind: "started",
|
||||
agentThreadId: "child-thread",
|
||||
agentPath: "/root/child",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"child-thread": [{ id: "child-turn-1", items: [codexAgentItem("Child started.")] }],
|
||||
});
|
||||
try {
|
||||
await codex.loadCloseAndReopen();
|
||||
const appServer = codex.latestAppServer();
|
||||
|
||||
const run = codex.manager.runAgent(codex.agentId, "Ask the child again");
|
||||
await appServer.waitForTurnStart();
|
||||
appServer.startsTurn({ threadId: "saved-thread", turnId: "turn-2" });
|
||||
appServer.beginsSubAgentActivity({
|
||||
callId: "interact-child",
|
||||
threadId: "child-thread",
|
||||
parentThreadId: "saved-thread",
|
||||
kind: "interacted",
|
||||
agentPath: "/root/child",
|
||||
});
|
||||
appServer.says({ threadId: "child-thread", itemId: "child-reply", text: "Child replied." });
|
||||
appServer.completesSubAgentActivity({
|
||||
callId: "interact-child",
|
||||
threadId: "child-thread",
|
||||
parentThreadId: "saved-thread",
|
||||
kind: "interacted",
|
||||
agentPath: "/root/child",
|
||||
});
|
||||
appServer.completeTurn({ threadId: "saved-thread" });
|
||||
await run;
|
||||
|
||||
const toolCalls = codex.manager
|
||||
.getTimeline(codex.agentId)
|
||||
.filter((item) => item.type === "tool_call");
|
||||
expect(new Set(toolCalls.map((item) => item.callId))).toEqual(new Set(["spawn-child"]));
|
||||
expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child", status: "completed" });
|
||||
expect(codex.manager.getProviderSubagent(codex.agentId, "child-thread")).toMatchObject({
|
||||
toolCallId: "spawn-child",
|
||||
status: "completed",
|
||||
});
|
||||
expect(
|
||||
codex.manager
|
||||
.fetchProviderSubagentTimeline(codex.agentId, "child-thread")
|
||||
.rows.map((row) => row.item),
|
||||
).toEqual([
|
||||
expect.objectContaining({ type: "assistant_message", text: "Child started." }),
|
||||
expect.objectContaining({ type: "assistant_message", text: "Child replied." }),
|
||||
]);
|
||||
} finally {
|
||||
await codex.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("closes Codex app-server when an interactive resume fails", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/resume": () => Promise.reject(new Error("thread resume failed")),
|
||||
@@ -4673,8 +4883,6 @@ describe("Codex app-server provider", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await asInternals(session).loadPersistedHistory(session.client);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
history.push(event);
|
||||
@@ -4727,7 +4935,7 @@ describe("Codex app-server provider", () => {
|
||||
})),
|
||||
};
|
||||
|
||||
await asInternals(session).loadPersistedHistory(session.client);
|
||||
await Array.fromAsync(session.streamHistory());
|
||||
|
||||
expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({
|
||||
index: 0,
|
||||
@@ -4796,8 +5004,6 @@ describe("Codex app-server provider", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await asInternals(session).loadPersistedHistory(session.client);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
history.push(event);
|
||||
@@ -4958,8 +5164,6 @@ describe("Codex app-server provider", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await asInternals(session).loadPersistedHistory(session.client);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
history.push(event);
|
||||
@@ -5023,8 +5227,6 @@ describe("Codex app-server provider", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await asInternals(session).loadPersistedHistory(session.client);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
history.push(event);
|
||||
@@ -5200,8 +5402,6 @@ describe("Codex app-server provider", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await asInternals(session).loadPersistedHistory(session.client);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
history.push(event);
|
||||
@@ -5261,8 +5461,6 @@ describe("Codex app-server provider", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
await asInternals(session).loadPersistedHistory(session.client);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
history.push(event);
|
||||
@@ -5564,7 +5762,10 @@ describe("Codex app-server provider", () => {
|
||||
expect(session.currentThreadId).toBe("archived-thread-id");
|
||||
expect(requests).toEqual([
|
||||
{ method: "thread/loaded/list", params: {} },
|
||||
{ method: "thread/resume", params: { threadId: "archived-thread-id" } },
|
||||
{
|
||||
method: "thread/resume",
|
||||
params: { threadId: "archived-thread-id", excludeTurns: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -6399,8 +6600,6 @@ describe("Codex app-server provider", () => {
|
||||
})),
|
||||
};
|
||||
|
||||
await asInternals(session).loadPersistedHistory(session.client);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
history.push(event);
|
||||
|
||||
@@ -496,6 +496,11 @@ interface PersistedTimelineEntry {
|
||||
providerTurnId?: string;
|
||||
}
|
||||
|
||||
interface CodexThreadHistory {
|
||||
timeline: PersistedTimelineEntry[];
|
||||
providerSubagentEvents: AgentStreamEvent[];
|
||||
}
|
||||
|
||||
interface PersistedSubAgentRoute {
|
||||
childThreadId: string;
|
||||
toolCall: ToolCallTimelineItem;
|
||||
@@ -3464,10 +3469,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private serviceTier: string | null = null;
|
||||
private speedModels: CodexModel[] = [];
|
||||
private planModeEnabled = false;
|
||||
private historyPending = false;
|
||||
private persistedHistory: PersistedTimelineEntry[] = [];
|
||||
private loadingPersistedHistory = false;
|
||||
private persistedProviderSubagentEvents: AgentStreamEvent[] = [];
|
||||
// History read by rewind or an archived resume, held until streamHistory consumes it.
|
||||
private stagedHistory: CodexThreadHistory | null = null;
|
||||
// Collects provider subagent events emitted while a thread history read rebuilds routes.
|
||||
private historySubagentEvents: AgentStreamEvent[] | null = null;
|
||||
// A resumed thread's earlier turns and sub-agents are known only after reading its history.
|
||||
private historyIndexed = true;
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private mcpElicitationPermissionIds = new Map<number, string>();
|
||||
private pendingPermissionHandlers = new Map<string, CodexPendingPermissionHandler>();
|
||||
@@ -3574,7 +3581,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
if (this.resumeHandle?.sessionId) {
|
||||
this.currentThreadId = this.resumeHandle.sessionId;
|
||||
this.historyPending = true;
|
||||
this.historyIndexed = false;
|
||||
// Resuming can start a goal before the caller receives the session.
|
||||
if (this.initialResumePurpose === "interactive") this.startupEvents = [];
|
||||
}
|
||||
@@ -3622,7 +3629,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private async establishConnection(): Promise<void> {
|
||||
if (this.initialResumePurpose === "history") {
|
||||
await this.readArchivedHistory();
|
||||
this.stagedHistory = await this.readArchivedHistory();
|
||||
this.connectionState = "history-ready";
|
||||
return;
|
||||
}
|
||||
@@ -3655,7 +3662,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
await this.loadPersistedHistory(this.client);
|
||||
await this.applyDefaultModelAndThinking();
|
||||
}
|
||||
|
||||
@@ -3970,39 +3976,40 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private async readArchivedHistory(): Promise<void> {
|
||||
private async readArchivedHistory(): Promise<CodexThreadHistory> {
|
||||
const child = await this.spawnAppServer();
|
||||
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
|
||||
try {
|
||||
await client.request("initialize", buildCodexAppServerInitializeParams());
|
||||
client.notify("initialized", {});
|
||||
await this.loadPersistedHistory(client);
|
||||
return await this.readThreadHistory(client);
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPersistedHistory(client: CodexAppServerClientLike | null): Promise<void> {
|
||||
if (!client || !this.currentThreadId) return;
|
||||
const threadId = this.currentThreadId;
|
||||
|
||||
const history = await loadCodexThreadHistoryTimeline({
|
||||
threadId,
|
||||
// Reads the thread and its sub-agent threads, rebuilding the turn index and
|
||||
// sub-agent routes. The caller owns the returned history; nothing retains it.
|
||||
private async readThreadHistory(
|
||||
client: CodexAppServerClientLike | null,
|
||||
): Promise<CodexThreadHistory> {
|
||||
if (!client || !this.currentThreadId) {
|
||||
return { timeline: [], providerSubagentEvents: [] };
|
||||
}
|
||||
const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({
|
||||
threadId: this.currentThreadId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
requestThread: (threadIdToRead) => {
|
||||
return readCodexThread(client, threadIdToRead);
|
||||
},
|
||||
requestThread: (threadIdToRead) => readCodexThread(client, threadIdToRead),
|
||||
});
|
||||
const { timeline, subAgentRoutes } = history;
|
||||
this.subAgentCallsByCallId.clear();
|
||||
this.subAgentCallIdByChildThreadId.clear();
|
||||
this.pendingSubAgentNotificationsByThreadId.clear();
|
||||
this.persistedProviderSubagentEvents = [];
|
||||
this.loadingPersistedHistory = true;
|
||||
const providerSubagentEvents: AgentStreamEvent[] = [];
|
||||
this.historySubagentEvents = providerSubagentEvents;
|
||||
try {
|
||||
await this.loadPersistedSubAgentHistories(client, subAgentRoutes);
|
||||
} finally {
|
||||
this.loadingPersistedHistory = false;
|
||||
this.historySubagentEvents = null;
|
||||
}
|
||||
this.resetCodexUserMessageTurns();
|
||||
for (const entry of timeline) {
|
||||
@@ -4013,8 +4020,15 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId);
|
||||
}
|
||||
}
|
||||
this.persistedHistory = timeline;
|
||||
this.historyPending = timeline.length > 0;
|
||||
this.historyIndexed = true;
|
||||
return { timeline, providerSubagentEvents };
|
||||
}
|
||||
|
||||
// Turns and rewind route by the thread's earlier turns and sub-agents. A resumed
|
||||
// session whose projection Paseo already holds reads them here and drops the rest.
|
||||
private async ensureHistoryIndexed(): Promise<void> {
|
||||
if (this.historyIndexed) return;
|
||||
await this.readThreadHistory(this.client);
|
||||
}
|
||||
|
||||
private async loadPersistedSubAgentHistories(
|
||||
@@ -4066,7 +4080,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private async ensureThreadLoaded(): Promise<void> {
|
||||
if (!this.client || !this.currentThreadId) return;
|
||||
const params: Record<string, unknown> = { threadId: this.currentThreadId };
|
||||
// Paseo reads history only when it replays it; the resume response needs no turns.
|
||||
const params: Record<string, unknown> = { threadId: this.currentThreadId, excludeTurns: true };
|
||||
const developerInstructions = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
@@ -4386,6 +4401,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
await this.ensureHistoryIndexed();
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
@@ -4559,21 +4575,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
if (
|
||||
(!this.historyPending || this.persistedHistory.length === 0) &&
|
||||
this.persistedProviderSubagentEvents.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const history = this.persistedHistory;
|
||||
const providerSubagents = this.persistedProviderSubagentEvents;
|
||||
this.persistedHistory = [];
|
||||
this.persistedProviderSubagentEvents = [];
|
||||
this.historyPending = false;
|
||||
for (const event of providerSubagents) {
|
||||
const history = this.stagedHistory ?? (await this.readThreadHistory(this.client));
|
||||
this.stagedHistory = null;
|
||||
for (const event of history.providerSubagentEvents) {
|
||||
yield event;
|
||||
}
|
||||
for (const entry of history) {
|
||||
for (const entry of history.timeline) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
@@ -4956,6 +4963,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
await this.ensureHistoryIndexed();
|
||||
|
||||
await revertCodexConversation({
|
||||
client: this.client,
|
||||
@@ -4970,10 +4978,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
setThreadId: async (threadId) => {
|
||||
this.currentThreadId = threadId;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.persistedHistory = [];
|
||||
this.historyPending = false;
|
||||
await this.loadPersistedHistory(this.client);
|
||||
this.reconcileAsyncQuestionsAfterRewind();
|
||||
this.stagedHistory = await this.readThreadHistory(this.client);
|
||||
this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5211,6 +5217,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
await this.connect();
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
// A goal runs turns of its own.
|
||||
await this.ensureHistoryIndexed();
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
@@ -5404,8 +5412,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private emitEvent(event: AgentStreamEvent): void {
|
||||
if (this.loadingPersistedHistory && event.type === "provider_subagent") {
|
||||
this.persistedProviderSubagentEvents.push(event);
|
||||
if (this.historySubagentEvents && event.type === "provider_subagent") {
|
||||
this.historySubagentEvents.push(event);
|
||||
return;
|
||||
}
|
||||
this.notifySubscribers(event);
|
||||
@@ -6583,9 +6591,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private reconcileAsyncQuestionsAfterRewind(): void {
|
||||
private reconcileAsyncQuestionsAfterRewind(timeline: readonly PersistedTimelineEntry[]): void {
|
||||
const retainedIds = new Set(
|
||||
this.persistedHistory.flatMap(({ item }) =>
|
||||
timeline.flatMap(({ item }) =>
|
||||
item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -223,13 +223,7 @@ function runWorker(argv: string[]): void {
|
||||
...record,
|
||||
terminals: record.terminals.filter((entry) => sessionsById.has(entry.id)),
|
||||
};
|
||||
console.error("CI terminal record write attempted");
|
||||
try {
|
||||
writeTerminalWorkerRecord(args.paseoHome, record);
|
||||
} catch (error) {
|
||||
console.error("CI terminal record write failed:", (error as NodeJS.ErrnoException).code);
|
||||
throw error;
|
||||
}
|
||||
writeTerminalWorkerRecord(args.paseoHome, record);
|
||||
}
|
||||
|
||||
function removeOwnFiles(): void {
|
||||
|
||||
@@ -2,9 +2,7 @@ import { afterEach, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
@@ -45,8 +43,6 @@ import {
|
||||
listTerminalWorkerRecords,
|
||||
readTerminalWorkerRecord,
|
||||
terminalWorkerDirectory,
|
||||
terminalWorkerLogPath,
|
||||
terminalWorkerRecordPath,
|
||||
type TerminalWorkerRecord,
|
||||
} from "./terminal-worker-record.js";
|
||||
import { TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV } from "./terminal-worker-protocol.js";
|
||||
@@ -769,91 +765,52 @@ async function recordedTerminalPids(paseoHome: string): Promise<Map<string, numb
|
||||
return pids;
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
"never adopts a live worker's terminals after a kill cleanup it could not finish, even once the worker rewrites its record (held reader: %s)",
|
||||
async (holdReader) => {
|
||||
const paseoHome = createTemporaryHome();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-unfinished-kill-"));
|
||||
temporaryDirs.push(cwd);
|
||||
const first = await startRealWorkerManager({ paseoHome });
|
||||
manager = first;
|
||||
const exitRequestPath = join(cwd, "exit-requested");
|
||||
const exitObservedPath = join(cwd, "exit-observed");
|
||||
const ending = await first.createTerminal({
|
||||
workspaceId: "ws-unfinished-kill",
|
||||
cwd,
|
||||
...nodeTerminalCommand(`
|
||||
const { existsSync, writeFileSync } = require("node:fs");
|
||||
setInterval(() => {
|
||||
if (existsSync(${JSON.stringify(exitRequestPath)})) {
|
||||
writeFileSync(${JSON.stringify(exitObservedPath)}, "");
|
||||
process.exit(0);
|
||||
}
|
||||
}, 25);
|
||||
`),
|
||||
});
|
||||
const remaining = await first.createTerminal({
|
||||
workspaceId: "ws-unfinished-kill",
|
||||
cwd,
|
||||
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
|
||||
});
|
||||
const pids = await recordedTerminalPids(paseoHome);
|
||||
const endingPid = pids.get(ending.id)!;
|
||||
const remainingPid = pids.get(remaining.id)!;
|
||||
const [record] = await workerRecords(paseoHome);
|
||||
it("never adopts a live worker's terminals after a kill cleanup it could not finish, even once the worker rewrites its record", async () => {
|
||||
const paseoHome = createTemporaryHome();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-unfinished-kill-"));
|
||||
temporaryDirs.push(cwd);
|
||||
const first = await startRealWorkerManager({ paseoHome });
|
||||
manager = first;
|
||||
const ending = await first.createTerminal({
|
||||
workspaceId: "ws-unfinished-kill",
|
||||
cwd,
|
||||
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
|
||||
});
|
||||
const remaining = await first.createTerminal({
|
||||
workspaceId: "ws-unfinished-kill",
|
||||
cwd,
|
||||
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
|
||||
});
|
||||
const pids = await recordedTerminalPids(paseoHome);
|
||||
const endingPid = pids.get(ending.id)!;
|
||||
const remainingPid = pids.get(remaining.id)!;
|
||||
|
||||
// A kill cleanup whose process table read fails signals nothing and leaves the worker live.
|
||||
const inspector = createTerminalWorkerProcessInspector();
|
||||
await expect(
|
||||
reapTerminalWorkers({
|
||||
paseoHome,
|
||||
inspector: { ...inspector, listProcesses: () => Promise.reject(new Error("ps timed out")) },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError);
|
||||
await first.detach();
|
||||
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
|
||||
// A kill cleanup whose process table read fails signals nothing and leaves the worker live.
|
||||
const inspector = createTerminalWorkerProcessInspector();
|
||||
await expect(
|
||||
reapTerminalWorkers({
|
||||
paseoHome,
|
||||
inspector: { ...inspector, listProcesses: () => Promise.reject(new Error("ps timed out")) },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError);
|
||||
await first.detach();
|
||||
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
|
||||
|
||||
// Diagnostic: keep the same read handle the poller uses open across the exit write.
|
||||
const recordFd = holdReader
|
||||
? openSync(terminalWorkerRecordPath(paseoHome, record!.pid), "r")
|
||||
: null;
|
||||
const attemptsBeforeExit =
|
||||
readFileSync(terminalWorkerLogPath(paseoHome), "utf8").match(
|
||||
/CI terminal record write attempted/g,
|
||||
)?.length ?? 0;
|
||||
try {
|
||||
writeFileSync(exitRequestPath, "");
|
||||
await waitForCondition(
|
||||
async () => !(await recordedTerminalPids(paseoHome)).has(ending.id),
|
||||
10000,
|
||||
);
|
||||
} catch (error) {
|
||||
const workerLog = readFileSync(terminalWorkerLogPath(paseoHome), "utf8");
|
||||
console.error("CI terminal exit diagnostics", {
|
||||
holdReader,
|
||||
attemptsAfterExit:
|
||||
(workerLog.match(/CI terminal record write attempted/g)?.length ?? 0) -
|
||||
attemptsBeforeExit,
|
||||
uncaughtExceptionLogged: workerLog.includes("Terminal worker uncaught exception"),
|
||||
exitObserved: existsSync(exitObservedPath),
|
||||
endingAlive: isProcessAlive(endingPid),
|
||||
remainingAlive: isProcessAlive(remainingPid),
|
||||
recordWriteErrors: workerLog.match(/CI terminal record write failed: \w+/g) ?? [],
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
if (recordFd !== null) closeSync(recordFd);
|
||||
}
|
||||
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
|
||||
// One terminal ends on its own: the live worker rewrites its record from memory.
|
||||
process.kill(endingPid, "SIGKILL");
|
||||
await waitForCondition(
|
||||
async () => !(await recordedTerminalPids(paseoHome)).has(ending.id),
|
||||
10000,
|
||||
);
|
||||
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
|
||||
|
||||
manager = await startRealWorkerManager({ paseoHome });
|
||||
manager = await startRealWorkerManager({ paseoHome });
|
||||
|
||||
expect(manager.getTerminal(remaining.id)).toBeUndefined();
|
||||
expect(await manager.getTerminals(cwd)).toEqual([]);
|
||||
await waitForCondition(() => !isProcessAlive(remainingPid), 10000);
|
||||
expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]);
|
||||
},
|
||||
);
|
||||
expect(manager.getTerminal(remaining.id)).toBeUndefined();
|
||||
expect(await manager.getTerminals(cwd)).toEqual([]);
|
||||
await waitForCondition(() => !isProcessAlive(remainingPid), 10000);
|
||||
expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]);
|
||||
});
|
||||
|
||||
// The worker's own `ps`, delayed: a program that exits while the worker reads its birth time
|
||||
// lands its exit in the middle of creation. Only the worker launched while this PATH is set sees
|
||||
|
||||
Reference in New Issue
Block a user