diff --git a/frontend/src/adapters/input.ts b/frontend/src/adapters/input.ts index b25406b..b2c78af 100644 --- a/frontend/src/adapters/input.ts +++ b/frontend/src/adapters/input.ts @@ -38,4 +38,10 @@ export class TauriInputGateway implements InputGateway { request: { agentId, attached }, }); } + + async cancelResume(agentId: string): Promise { + // `cancel_resume` takes a bare `agent_id` (camelCase `agentId`), not a + // `request` envelope, and returns whether a pending resume was disarmed. + return invoke("cancel_resume", { agentId }); + } } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index a4c0f70..9ef45d3 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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 { this.interrupts.push({ projectId, agentId }); @@ -1575,6 +1583,11 @@ export class MockInputGateway implements InputGateway { async setFrontAttached(agentId: string, attached: boolean): Promise { this.frontAttached.push({ agentId, attached }); } + + async cancelResume(agentId: string): Promise { + this.cancelledResumes.push(agentId); + return this.cancelResumeResult; + } } /** In-memory permissions gateway. */ diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 3b9078a..a3f06b6 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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 } diff --git a/frontend/src/features/agents/AgentLimitBadge.test.tsx b/frontend/src/features/agents/AgentLimitBadge.test.tsx new file mode 100644 index 0000000..44822b0 --- /dev/null +++ b/frontend/src/features/agents/AgentLimitBadge.test.tsx @@ -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( + , + ); + 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(); + expect(screen.getByText("limité")).toBeTruthy(); + }); + + it("shows the 'heure inconnue' note for a suspected limit without a time", () => { + render( + , + ); + 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( + , + ); + 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( + , + ); + // 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( + , + ); + expect( + (screen.getByLabelText("cancel resume") as HTMLButtonElement).disabled, + ).toBe(true); + }); +}); diff --git a/frontend/src/features/agents/AgentLimitBadge.tsx b/frontend/src/features/agents/AgentLimitBadge.tsx new file mode 100644 index 0000000..f2d8889 --- /dev/null +++ b/frontend/src/features/agents/AgentLimitBadge.tsx @@ -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 ( + + + {limitLabel} + + + {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. + + heure inconnue — reprise à préciser + + )} + + {resumeFireAt !== undefined && ( + + + reprise dans {formatCountdown(resumeFireAt - now)} + + + + )} + + ); +} diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 585e9e4..ce3d7da 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -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 (
  • + {limitState && ( + void vm.cancelResume(a.id)} + /> + )} +
    {/* Profile hot-swap selector (Chantier A). Changing the engine abandons the conversation history → confirmation. */} diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index 256ecf7..d60410e 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -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; + /** + * 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; /** Last error message, or `null`. */ error: string | null; /** Whether a request is in flight. */ @@ -83,6 +113,14 @@ export interface AgentsViewModel { ) => Promise; /** Stops an agent PTY explicitly by closing its live session id. */ stopAgent: (agentId?: string) => Promise; + /** + * 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; } 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([]); const [selectedAgentId, setSelectedAgentId] = useState(null); @@ -106,6 +144,9 @@ export function useAgents(projectId: string): AgentsViewModel { const [delegationSourceByRequester, setDelegationSourceByRequester] = useState< Record >({}); + const [limitByAgent, setLimitByAgent] = useState< + Record + >({}); 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 => { + // 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, }; } diff --git a/frontend/src/features/agents/useAgentsLimits.test.tsx b/frontend/src/features/agents/useAgentsLimits.test.tsx new file mode 100644 index 0000000..9353a82 --- /dev/null +++ b/frontend/src/features/agents/useAgentsLimits.test.tsx @@ -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 }) => ( + {children} + ); + 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(); + }); +}); diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 613d418..030d189 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -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; + /** + * 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; } /**