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:
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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 }
|
||||
|
||||
149
frontend/src/features/agents/AgentLimitBadge.test.tsx
Normal file
149
frontend/src/features/agents/AgentLimitBadge.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
114
frontend/src/features/agents/AgentLimitBadge.tsx
Normal file
114
frontend/src/features/agents/AgentLimitBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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. */}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
235
frontend/src/features/agents/useAgentsLimits.test.tsx
Normal file
235
frontend/src/features/agents/useAgentsLimits.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user