Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e12feb633f feat(usage): show when each limit resets, and make usage rows easier to read (#8162)
The reset countdown floored to one unit, so a weekly window read "resets 2d"
from 2d 23h down to 2d 0h and looked stuck for a day; the session window had
the same problem with hours. Countdowns now use two units ("2d 23h", "4h 59m"),
and every row also shows the clock time it resets ("7:20 AM", "Sat 8:00 AM",
"Oct 31, 3:00 PM"), so users can plan work around a reset without hovering.

Rows now put the window name and percent on one line, the bar under it, and the
reset on its own full-width line, so the exact time fits at phone width in
longer languages. The pinned-row background is gone; the pin glyph, drawn in
the foreground colour when pinned, is the only pinned signal. Bars are 6px.
2026-10-29 11:24:34 +07:00
Mohamed Boudra 304cbde651 fix(server): reopening a Codex agent no longer reads and holds its full history (#8132)
* fix(server): read Codex history only when Paseo replays it

Reopening a Codex agent whose timeline Paseo still held read the full
thread on connect and kept it on the session until the next replay, which
never came, so every reopened agent held a second copy of its transcript.
thread/resume also returned every turn, and only the sandbox policy was
used from it.

Resume now sends excludeTurns (older Codex ignores the unknown field) and
the history read moves into streamHistory, whose caller owns the result.
A resumed session still needs the earlier turns for rewind targets and the
sub-agent routes that keep an earlier child's interactions on its original
card, so the first turn, goal or rewind indexes the thread and drops the
timeline it read.

* test(server): run reopened Codex agent tests without the Codex CLI installed
2026-10-29 10:17:11 +07:00
22 changed files with 605 additions and 369 deletions
+4 -4
View File
@@ -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();
+3
View File
@@ -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: "الآن",
+5 -2
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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: "たった今",
+3
View File
@@ -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: "방금",
+3
View File
@@ -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",
+3
View File
@@ -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: "только что",
+3
View File
@@ -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: "刚刚",
+28 -71
View File
@@ -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,
},
}));
+67 -2
View File
@@ -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");
+68 -35
View File
@@ -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(
+2 -2
View File
@@ -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",
},
+48
View File
@@ -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 },
}));
+1 -10
View File
@@ -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,
+38 -86
View File
@@ -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,
},
}));
+6 -1
View File
@@ -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