feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)

Expose la surface produit des limites de session côté React/TS,
au-dessus du câblage backend (9df5923).

- domain/index.ts : 5 variantes ajoutées au union DomainEvent
  (agentRateLimited / ResumeScheduled / ResumeCancelled / Resumed /
  RateLimitSuspected).
- ports/index.ts : cancelResume(agentId) ajouté à InputGateway.
- adapters/input.ts : TauriInputGateway.cancelResume → invoke("cancel_resume").
- adapters/mock/index.ts : MockInputGateway.cancelResume
  (cancelledResumes / cancelResumeResult).
- features/agents/useAgents.ts : état limitByAgent + action cancelResume.
- features/agents/AgentLimitBadge.tsx (nouveau) : badge + compte à rebours
  + bouton Annuler + helpers purs.
- features/agents/AgentsPanel.tsx : câblage du badge.

Tests : useAgentsLimits.test.tsx (13) + AgentLimitBadge.test.tsx (11),
suite agents 63 tests verts, tsc --noEmit propre.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 08:06:56 +02:00
parent 9df592389c
commit 4fad0423e7
9 changed files with 702 additions and 1 deletions

View File

@ -38,4 +38,10 @@ export class TauriInputGateway implements InputGateway {
request: { agentId, attached },
});
}
async cancelResume(agentId: string): Promise<boolean> {
// `cancel_resume` takes a bare `agent_id` (camelCase `agentId`), not a
// `request` envelope, and returns whether a pending resume was disarmed.
return invoke<boolean>("cancel_resume", { agentId });
}
}

View File

@ -1559,6 +1559,14 @@ export class MockInputGateway implements InputGateway {
readonly delivered: MockInputCall[] = [];
/** All recorded front-attached reports, in order. */
readonly frontAttached: { agentId: string; attached: boolean }[] = [];
/** All recorded cancel-resume calls, in order. */
readonly cancelledResumes: string[] = [];
/**
* What {@link cancelResume} resolves to (mirrors the backend `bool`: whether a
* pending resume was disarmed). Tests can flip it to exercise the "already
* fired / none armed" path.
*/
cancelResumeResult = true;
async interrupt(projectId: string, agentId: string): Promise<void> {
this.interrupts.push({ projectId, agentId });
@ -1575,6 +1583,11 @@ export class MockInputGateway implements InputGateway {
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
this.frontAttached.push({ agentId, attached });
}
async cancelResume(agentId: string): Promise<boolean> {
this.cancelledResumes.push(agentId);
return this.cancelResumeResult;
}
}
/** In-memory permissions gateway. */

View File

@ -36,6 +36,55 @@ export type DomainEvent =
submitSequence?: string;
submitDelayMs?: number;
}
| {
/**
* An agent entered a session/rate limit (ARCHITECTURE §21). Low-frequency,
* model-agnostic. `resetsAtMs` is the reset instant in epoch-milliseconds;
* absent ⇒ unknown (no reliable time → no auto-resume). The frontend badges
* "limité jusqu'à HH:MM" when known, "limité" otherwise.
*/
type: "agentRateLimited";
agentId: string;
resetsAtMs?: number;
}
| {
/**
* An auto-resume was armed for a rate-limited agent (ARCHITECTURE §21).
* `fireAtMs` is the wake-up deadline in epoch-milliseconds. The frontend
* shows the countdown + the "Annuler la reprise" button (cancellable window).
*/
type: "agentResumeScheduled";
agentId: string;
fireAtMs: number;
}
| {
/**
* An agent's auto-resume was cancelled (ARCHITECTURE §21): the user clicked
* "Annuler la reprise". The frontend removes the countdown but keeps the
* "limité" state (no auto-resume will fire).
*/
type: "agentResumeCancelled";
agentId: string;
}
| {
/**
* An agent was effectively resumed after a limit (ARCHITECTURE §21): the
* wake-up fired (or an immediate resume). The frontend clears all limit state.
*/
type: "agentResumed";
agentId: string;
}
| {
/**
* Human net (level 3): a session limit is suspected without any reliable
* reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the
* frontend to surface an "heure inconnue" state and prompt the user.
* `resetsAtMs` carries an estimate when one exists, else absent.
*/
type: "agentRateLimitSuspected";
agentId: string;
resetsAtMs?: number;
}
| { type: "templateUpdated"; templateId: string; version: number }
| { type: "agentDriftDetected"; agentId: string; from: number; to: number }
| { type: "agentSynced"; agentId: string; to: number }

View File

@ -0,0 +1,149 @@
/**
* LS7-front — {@link AgentLimitBadge} presentation + its pure helpers
* (ARCHITECTURE §21).
*
* Two layers:
* - the exported pure helpers `formatCountdown` / `formatResetTime` (edge cases:
* 0, negative, sub-minute, multi-minute, midnight, seconds-stripped);
* - the rendered badge across its three states (limité jusqu'à HH:MM /
* "limité" without a time / "heure inconnue" for suspected without a reset),
* plus the "Annuler la reprise" button wiring when a resume is armed.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import {
AgentLimitBadge,
formatCountdown,
formatResetTime,
} from "./AgentLimitBadge";
describe("formatCountdown", () => {
it("renders 0s for exactly zero", () => {
expect(formatCountdown(0)).toBe("0s");
});
it("clamps a negative (past) deadline to 0s", () => {
expect(formatCountdown(-5_000)).toBe("0s");
});
it("renders sub-minute durations as just seconds", () => {
expect(formatCountdown(5_000)).toBe("5s");
expect(formatCountdown(59_000)).toBe("59s");
});
it("rounds partial seconds up (ceil)", () => {
expect(formatCountdown(4_200)).toBe("5s");
expect(formatCountdown(1)).toBe("1s");
});
it("renders minutes + seconds past a minute", () => {
expect(formatCountdown(60_000)).toBe("1m 0s");
expect(formatCountdown(90_000)).toBe("1m 30s");
expect(formatCountdown(125_000)).toBe("2m 5s");
});
});
describe("formatResetTime", () => {
it("produces an HH:MM wall-clock label (no seconds component)", () => {
// Same minute, +30s apart → identical label (seconds are not shown).
const base = new Date(2026, 0, 15, 9, 5, 0).getTime();
const plus30s = new Date(2026, 0, 15, 9, 5, 30).getTime();
expect(formatResetTime(base)).toBe(formatResetTime(plus30s));
// Matches the documented HH:MM contract (delegates to toLocaleTimeString).
expect(formatResetTime(base)).toBe(
new Date(base).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
);
});
it("renders midnight as a stable two-digit label", () => {
const midnight = new Date(2026, 0, 15, 0, 0, 0).getTime();
const label = formatResetTime(midnight);
expect(label).toBe(
new Date(midnight).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
);
// Minutes are "00" at midnight regardless of 12h/24h locale.
expect(label).toContain("00");
});
});
describe("AgentLimitBadge (render)", () => {
const noop = () => {};
it("shows 'limité jusqu'à HH:MM' when the reset time is known", () => {
const at = new Date(2026, 0, 15, 14, 30, 0).getTime();
render(
<AgentLimitBadge state={{ limitedUntil: at }} onCancelResume={noop} />,
);
const expected = `limité jusqu'à ${formatResetTime(at)}`;
expect(screen.getByText(expected)).toBeTruthy();
// No countdown / cancel button without an armed resume.
expect(screen.queryByLabelText("cancel resume")).toBeNull();
});
it("shows plain 'limité' when no reset time is known", () => {
render(<AgentLimitBadge state={{}} onCancelResume={noop} />);
expect(screen.getByText("limité")).toBeTruthy();
});
it("shows the 'heure inconnue' note for a suspected limit without a time", () => {
render(
<AgentLimitBadge state={{ suspected: true }} onCancelResume={noop} />,
);
expect(screen.getByText("limité")).toBeTruthy();
expect(
screen.getByText(/heure inconnue — reprise à préciser/),
).toBeTruthy();
});
it("does NOT show the 'heure inconnue' note when a suspected limit has a time", () => {
const at = new Date(2026, 0, 15, 14, 30, 0).getTime();
render(
<AgentLimitBadge
state={{ suspected: true, limitedUntil: at }}
onCancelResume={noop}
/>,
);
expect(screen.getByText(`limité jusqu'à ${formatResetTime(at)}`)).toBeTruthy();
expect(screen.queryByText(/heure inconnue/)).toBeNull();
});
it("renders the countdown + calls onCancelResume when a resume is armed", () => {
const onCancelResume = vi.fn();
const fireAt = Date.now() + 90_000;
render(
<AgentLimitBadge
state={{ limitedUntil: Date.now(), resumeFireAt: fireAt }}
onCancelResume={onCancelResume}
/>,
);
// Countdown label present (≈ "reprise dans 1m 30s").
expect(screen.getByLabelText("resume countdown").textContent).toMatch(
/reprise dans/,
);
const btn = screen.getByLabelText("cancel resume");
fireEvent.click(btn);
expect(onCancelResume).toHaveBeenCalledTimes(1);
});
it("disables the cancel button while busy", () => {
render(
<AgentLimitBadge
state={{ resumeFireAt: Date.now() + 10_000 }}
onCancelResume={noop}
busy
/>,
);
expect(
(screen.getByLabelText("cancel resume") as HTMLButtonElement).disabled,
).toBe(true);
});
});

View File

@ -0,0 +1,114 @@
/**
* `AgentLimitBadge` — presentational badge for an agent's session-limit state
* (ARCHITECTURE §21). All limit *state* is computed in {@link useAgents}; this
* component only renders it and owns the 1 s countdown clock (a pure UI concern).
*
* It shows:
* - "limité jusqu'à HH:MM" when the reset time is known, "limité" otherwise;
* - a live countdown + an "Annuler la reprise" button while an auto-resume is
* armed (`resumeFireAt`);
* - a "heure inconnue" human-net note (level 3) when the limit is only
* *suspected* with no reliable time — IdeA never resumes blind.
*/
import { useEffect, useState } from "react";
import { Button, cn } from "@/shared";
import type { AgentLimitState } from "./useAgents";
/** Formats an epoch-ms instant as a local `HH:MM` wall-clock label. */
export function formatResetTime(epochMs: number): string {
return new Date(epochMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Formats a remaining duration (ms) as a compact countdown: `Xm Ys` (or `Ys`
* under a minute). Clamped at zero — a past deadline reads `0s`.
*/
export function formatCountdown(remainingMs: number): string {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
export interface AgentLimitBadgeProps {
/** The agent's limit state (from {@link useAgents.limitByAgent}). */
state: AgentLimitState;
/** Invoked when the user clicks "Annuler la reprise". */
onCancelResume: () => void;
/** Disables the cancel action (e.g. while another request is in flight). */
busy?: boolean;
}
export function AgentLimitBadge({
state,
onCancelResume,
busy = false,
}: AgentLimitBadgeProps) {
const { limitedUntil, resumeFireAt, suspected } = state;
// Tick a local clock once per second only while a resume countdown is armed,
// so the displayed remaining time stays fresh without a global timer.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (resumeFireAt === undefined) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [resumeFireAt]);
const limitLabel =
limitedUntil !== undefined
? `limité jusqu'à ${formatResetTime(limitedUntil)}`
: "limité";
return (
<span className="flex min-w-0 flex-wrap items-center gap-2">
<span
aria-label="session limit"
title={
suspected
? "Limite de session détectée sans heure fiable"
: "Limite de session atteinte"
}
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
"bg-danger/20 text-danger",
)}
>
{limitLabel}
</span>
{suspected && limitedUntil === undefined && (
// Human net (level 3, §21.1): the limit is suspected but no reliable time
// is known. IdeA never resumes blind. NOTE: there is no backend command
// yet to persist a user-entered resume time, so we only surface the state
// here. TODO(LS-front): wire an hour-entry form once the backend exposes a
// command to record the chosen resume instant — do NOT fabricate one.
<span className="text-xs text-muted" role="note">
heure inconnue reprise à préciser
</span>
)}
{resumeFireAt !== undefined && (
<span className="flex items-center gap-2">
<span aria-label="resume countdown" className="text-xs text-muted">
reprise dans {formatCountdown(resumeFireAt - now)}
</span>
<Button
size="sm"
variant="ghost"
aria-label="cancel resume"
disabled={busy}
onClick={onCancelResume}
>
Annuler la reprise
</Button>
</span>
)}
</span>
);
}

View File

@ -21,6 +21,7 @@ import { TerminalView } from "@/features/terminals/TerminalView";
import { useDrift } from "@/features/templates/useDrift";
import { useGateways } from "@/app/di";
import { useAgents } from "./useAgents";
import { AgentLimitBadge } from "./AgentLimitBadge";
export interface AgentsPanelProps {
/** The project whose agents to manage. */
@ -327,6 +328,8 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
// Source of this agent's last orchestration delegation (mcp vs
// file), if any has been observed. Absent ⇒ no badge.
const delegationSource = vm.delegationSourceByRequester[a.id];
// Session-limit state (ARCHITECTURE §21), if the agent is limited.
const limitState = vm.limitByAgent[a.id];
return (
<li
key={a.id}
@ -373,6 +376,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
)}
</button>
{limitState && (
<AgentLimitBadge
state={limitState}
busy={vm.busy}
onCancelResume={() => void vm.cancelResume(a.id)}
/>
)}
<div className="flex flex-wrap items-center gap-1.5">
{/* Profile hot-swap selector (Chantier A). Changing the
engine abandons the conversation history → confirmation. */}

View File

@ -18,6 +18,29 @@ import type {
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
import { useGateways } from "@/app/di";
/**
* Per-agent session-limit state (ARCHITECTURE §21), populated from the limit
* domain events. An agent absent from the map is in its normal (unlimited) state.
*/
export interface AgentLimitState {
/**
* Reset instant in epoch-milliseconds when known (from `agentRateLimited` /
* `agentRateLimitSuspected`). Absent ⇒ "limité" without a reliable time.
*/
limitedUntil?: number;
/**
* Wake-up deadline in epoch-milliseconds of an armed auto-resume (from
* `agentResumeScheduled`). Present ⇒ show the countdown + "Annuler la reprise".
*/
resumeFireAt?: number;
/**
* Human net (level 3): the limit was suspected without a reliable time
* (`agentRateLimitSuspected`). The UI surfaces an "heure inconnue" state and
* asks the user — IdeA never resumes blind.
*/
suspected?: boolean;
}
/** What the agents UI needs from this hook. */
export interface AgentsViewModel {
/** All agents known to the project. */
@ -37,6 +60,13 @@ export interface AgentsViewModel {
* without `source`) yields no badge.
*/
delegationSourceByRequester: Record<string, "mcp" | "file">;
/**
* Per-agent session-limit state (ARCHITECTURE §21), keyed by agent id. Populated
* from the limit domain events (`agentRateLimited`, `agentResumeScheduled`,
* `agentResumeCancelled`, `agentResumed`, `agentRateLimitSuspected`). An agent
* absent from the map is unlimited.
*/
limitByAgent: Record<string, AgentLimitState>;
/** Last error message, or `null`. */
error: string | null;
/** Whether a request is in flight. */
@ -83,6 +113,14 @@ export interface AgentsViewModel {
) => Promise<TerminalHandle>;
/** Stops an agent PTY explicitly by closing its live session id. */
stopAgent: (agentId?: string) => Promise<void>;
/**
* Cancels the auto-resume armed for a rate-limited agent (ARCHITECTURE §21):
* the "Annuler la reprise" action. Optimistically drops the countdown; the
* backend's `agentResumeCancelled` event confirms it. The agent stays "limité"
* (no auto-resume will fire). Resolves to the backend verdict (`false` if the
* resume had already fired / none was armed).
*/
cancelResume: (agentId: string) => Promise<boolean>;
}
function describe(e: unknown): string {
@ -93,7 +131,7 @@ function describe(e: unknown): string {
}
export function useAgents(projectId: string): AgentsViewModel {
const { agent, profile, system, terminal } = useGateways();
const { agent, profile, system, terminal, input } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
@ -106,6 +144,9 @@ export function useAgents(projectId: string): AgentsViewModel {
const [delegationSourceByRequester, setDelegationSourceByRequester] = useState<
Record<string, "mcp" | "file">
>({});
const [limitByAgent, setLimitByAgent] = useState<
Record<string, AgentLimitState>
>({});
const refresh = useCallback(async () => {
setBusy(true);
@ -173,6 +214,57 @@ export function useAgents(projectId: string): AgentsViewModel {
: { ...prev, [requesterId]: source },
);
}
// Session-limit lifecycle (ARCHITECTURE §21): fold each event into the
// per-agent limit state. `agentResumed` clears the entry entirely.
switch (event.type) {
case "agentRateLimited":
setLimitByAgent((prev) => ({
...prev,
[event.agentId]: {
limitedUntil: event.resetsAtMs,
resumeFireAt: prev[event.agentId]?.resumeFireAt,
suspected: false,
},
}));
break;
case "agentResumeScheduled":
setLimitByAgent((prev) => ({
...prev,
[event.agentId]: {
...prev[event.agentId],
resumeFireAt: event.fireAtMs,
},
}));
break;
case "agentResumeCancelled":
setLimitByAgent((prev) => {
const current = prev[event.agentId];
if (!current) return prev;
// Keep the agent "limité" but drop the armed resume countdown.
const { resumeFireAt: _dropped, ...rest } = current;
return { ...prev, [event.agentId]: rest };
});
break;
case "agentResumed":
setLimitByAgent((prev) => {
if (!(event.agentId in prev)) return prev;
const { [event.agentId]: _cleared, ...rest } = prev;
return rest;
});
break;
case "agentRateLimitSuspected":
setLimitByAgent((prev) => ({
...prev,
[event.agentId]: {
...prev[event.agentId],
limitedUntil: event.resetsAtMs,
suspected: true,
},
}));
break;
default:
break;
}
})
.then((un) => {
if (cancelled) un();
@ -334,6 +426,27 @@ export function useAgents(projectId: string): AgentsViewModel {
[liveAgents, refreshLiveAgents, runningAgentId, terminal],
);
const cancelResume = useCallback(
async (agentId: string): Promise<boolean> => {
// Optimistically drop the countdown so the button feels responsive; the
// backend's `agentResumeCancelled` event confirms it (and a `false` verdict
// — already fired — is reconciled by the eventual `agentResumed`).
setLimitByAgent((prev) => {
const current = prev[agentId];
if (!current?.resumeFireAt) return prev;
const { resumeFireAt: _dropped, ...rest } = current;
return { ...prev, [agentId]: rest };
});
try {
return (await input?.cancelResume(agentId)) ?? false;
} catch (e) {
setError(describe(e));
return false;
}
},
[input],
);
return {
agents,
selectedAgentId,
@ -341,6 +454,7 @@ export function useAgents(projectId: string): AgentsViewModel {
profiles,
liveAgents,
delegationSourceByRequester,
limitByAgent,
error,
busy,
runningAgentId,
@ -353,5 +467,6 @@ export function useAgents(projectId: string): AgentsViewModel {
deleteAgent,
launchAgent,
stopAgent,
cancelResume,
};
}

View File

@ -0,0 +1,235 @@
/**
* LS7-front — session-limit state in {@link useAgents} (ARCHITECTURE §21).
*
* Drives the hook behind the real {@link DIProvider} with an in-memory
* {@link MockSystemGateway} (to emit the limit domain events) and a
* {@link MockInputGateway} (to record `cancelResume`). Verifies that each of the
* five limit events folds correctly into `limitByAgent`, plus the optimistic
* `cancelResume` action and the backend verdict it returns.
*
* Cases:
* - `agentRateLimited` → `{ limitedUntil, suspected: false }`
* - `agentResumeScheduled` → arms `resumeFireAt`
* - `agentResumeCancelled` → drops `resumeFireAt`, stays limité
* - `agentResumed` → clears the entry entirely
* - `agentRateLimitSuspected` (with & without `resetsAtMs`) → `suspected: true`
* - realistic sequence rateLimited → scheduled → cancelResume (action)
* - `cancelResume` action: optimistic drop + both backend verdicts
*/
import { describe, it, expect } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { DomainEvent } from "@/domain";
import type { Gateways } from "@/ports";
import { MockInputGateway, MockSystemGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { useAgents } from "./useAgents";
const PROJECT_ID = "proj-limits-001";
const AGENT = "agent-A";
function setup() {
const system = new MockSystemGateway();
const input = new MockInputGateway();
// useAgents calls agent.listAgents / profile.listProfiles on mount; provide
// minimal stubs so the mount effect resolves without a backend.
const agent = {
listAgents: async () => [],
listLiveAgents: async () => [],
};
const profile = { listProfiles: async () => [] };
const gateways = { system, input, agent, profile } as unknown as Gateways;
const wrapper = ({ children }: { children: React.ReactNode }) => (
<DIProvider gateways={gateways}>{children}</DIProvider>
);
const view = renderHook(() => useAgents(PROJECT_ID), { wrapper });
return { system, input, view };
}
/** Emits an event and flushes the async subscription/microtasks. */
async function emit(system: MockSystemGateway, event: DomainEvent) {
await act(async () => {
system.emit(event);
await Promise.resolve();
});
}
describe("useAgents — session-limit state (§21)", () => {
it("agentRateLimited with a reset time → { limitedUntil, suspected:false }", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: AGENT,
resetsAtMs: 1_800_000_000_000,
});
expect(view.result.current.limitByAgent[AGENT]).toEqual({
limitedUntil: 1_800_000_000_000,
resumeFireAt: undefined,
suspected: false,
});
});
it("agentResumeScheduled arms resumeFireAt on top of the limited state", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: AGENT,
resetsAtMs: 1_800_000_000_000,
});
await emit(system, {
type: "agentResumeScheduled",
agentId: AGENT,
fireAtMs: 1_800_000_300_000,
});
expect(view.result.current.limitByAgent[AGENT]).toMatchObject({
limitedUntil: 1_800_000_000_000,
resumeFireAt: 1_800_000_300_000,
suspected: false,
});
});
it("agentResumeCancelled drops resumeFireAt but keeps the agent limité", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: AGENT,
resetsAtMs: 1_800_000_000_000,
});
await emit(system, {
type: "agentResumeScheduled",
agentId: AGENT,
fireAtMs: 1_800_000_300_000,
});
await emit(system, { type: "agentResumeCancelled", agentId: AGENT });
const state = view.result.current.limitByAgent[AGENT];
expect(state).toBeDefined();
expect(state.resumeFireAt).toBeUndefined();
expect(state.limitedUntil).toBe(1_800_000_000_000);
});
it("agentResumeCancelled on an unknown agent is a no-op (no entry created)", async () => {
const { system, view } = setup();
await emit(system, { type: "agentResumeCancelled", agentId: AGENT });
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
});
it("agentResumed clears the limit entry entirely", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: AGENT,
resetsAtMs: 1_800_000_000_000,
});
await emit(system, { type: "agentResumed", agentId: AGENT });
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
expect(AGENT in view.result.current.limitByAgent).toBe(false);
});
it("agentRateLimitSuspected with a time → { limitedUntil, suspected:true }", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimitSuspected",
agentId: AGENT,
resetsAtMs: 1_800_000_500_000,
});
expect(view.result.current.limitByAgent[AGENT]).toMatchObject({
limitedUntil: 1_800_000_500_000,
suspected: true,
});
});
it("agentRateLimitSuspected WITHOUT a time → suspected:true, no limitedUntil", async () => {
const { system, view } = setup();
await emit(system, { type: "agentRateLimitSuspected", agentId: AGENT });
const state = view.result.current.limitByAgent[AGENT];
expect(state.suspected).toBe(true);
expect(state.limitedUntil).toBeUndefined();
});
it("realistic sequence: rateLimited → scheduled → cancelResume (action)", async () => {
const { system, input, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: AGENT,
resetsAtMs: 1_800_000_000_000,
});
await emit(system, {
type: "agentResumeScheduled",
agentId: AGENT,
fireAtMs: 1_800_000_300_000,
});
expect(view.result.current.limitByAgent[AGENT].resumeFireAt).toBe(
1_800_000_300_000,
);
// The user clicks "Annuler la reprise": optimistic drop + port call.
let verdict: boolean | undefined;
await act(async () => {
verdict = await view.result.current.cancelResume(AGENT);
});
// Optimistic: the countdown is gone immediately; the agent stays limité.
expect(view.result.current.limitByAgent[AGENT].resumeFireAt).toBeUndefined();
expect(view.result.current.limitByAgent[AGENT].limitedUntil).toBe(
1_800_000_000_000,
);
// The action routed through the InputGateway and returned the verdict.
expect(input.cancelledResumes).toEqual([AGENT]);
expect(verdict).toBe(true);
});
it("cancelResume relays a `false` backend verdict (already fired / none armed)", async () => {
const { system, input, view } = setup();
input.cancelResumeResult = false;
await emit(system, {
type: "agentResumeScheduled",
agentId: AGENT,
fireAtMs: 1_800_000_300_000,
});
let verdict: boolean | undefined;
await act(async () => {
verdict = await view.result.current.cancelResume(AGENT);
});
expect(verdict).toBe(false);
expect(input.cancelledResumes).toEqual([AGENT]);
// Still optimistically dropped locally.
expect(view.result.current.limitByAgent[AGENT]?.resumeFireAt).toBeUndefined();
});
it("cancelResume with no armed resume still calls the port (no-op locally)", async () => {
const { input, view } = setup();
// No event emitted: the agent has no entry at all.
await waitFor(() => expect(view.result.current).toBeTruthy());
let verdict: boolean | undefined;
await act(async () => {
verdict = await view.result.current.cancelResume(AGENT);
});
expect(verdict).toBe(true);
expect(input.cancelledResumes).toEqual([AGENT]);
expect(view.result.current.limitByAgent[AGENT]).toBeUndefined();
});
it("folds limits for two agents independently", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentRateLimited",
agentId: "agent-A",
resetsAtMs: 111,
});
await emit(system, {
type: "agentRateLimitSuspected",
agentId: "agent-B",
});
expect(view.result.current.limitByAgent["agent-A"]).toMatchObject({
limitedUntil: 111,
suspected: false,
});
expect(view.result.current.limitByAgent["agent-B"]).toMatchObject({
suspected: true,
});
expect(view.result.current.limitByAgent["agent-B"].limitedUntil).toBeUndefined();
});
});

View File

@ -552,6 +552,15 @@ export interface InputGateway {
* mounted cell keeps the write-portal path. Best-effort; never throws into the UI.
*/
setFrontAttached(agentId: string, attached: boolean): Promise<void>;
/**
* Cancels the **auto-resume** armed for a rate-limited agent (ARCHITECTURE §21):
* the user clicked "Annuler la reprise" during the cancellable window. Disarms
* the scheduled wake-up. Resolves to `true` iff a resume was effectively
* cancelled (a wake-up was still pending); `false` if none was armed or it had
* already fired (the resume then runs its course). The backend also emits
* `agentResumeCancelled` on success, which clears the countdown in the UI.
*/
cancelResume(agentId: string): Promise<boolean>;
}
/**