mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip manages agents and tasks. > - Plugins contribute views to the task sidebar. > - A ready view can belong to an earlier task. > - Runner records activity under its authenticated run. > - This PR maps that activity to installed task-panel declarations. > - The sidebar checks readiness after current-run use and restores use after reconnect. ## Linked Issues or Issue Description Builds on merged contract #18129 and Runner publisher #18050. This PR targets master and is ready for review. Earlier combined verification is retained in [the verification archive](https://github.com/paperclipai/paperclip/pull/18050#issuecomment-6489369676). **Subsystem affected** Run-event reads, plugin task contributions and the task sidebar. **Problem or motivation** Readiness polling can show a view left ready by another task. Reload and reconnect must recover current-run use without losing concurrent live activity. **Proposed solution** Map recorded activity to installed task-panel declarations. Merge authorized history and live activity, then perform bounded readiness checks for the running agent. Preserve the existing read-only view. ## What Changed - Add optional `{ namespace, activityKey }` activation mappings to task-panel declarations. Legacy declarations remain valid but contribute no task panel. - Add `view=plugin-panels` to the existing run-events route. Preserve run-read and telemetry-read authorization, secret redaction and private-run denial. Map activity to installed contributions and return at most 128 panel identities. - Scope activation caches to current panel versions and activity declarations. Preserve unchanged panels during unrelated plugin updates and history reads. - Merge history and live hints without losing concurrent activations. Filter by company, task, run and agent. Restore history on mount and reconnect. - Replace periodic availability checks with up to three readiness attempts after activity. Fresh activity restarts settled transient failures. Pending checks, successful readiness and HTTP 4xx failures are left alone. - Reuse live run updates with the panel cache key. Recover only panel history after a pending subscription read. Mark expected not-ready query results so the global error reporter skips them; real failures remain reportable. - Preserve current-run dismissal and the read-only plugin view. Update manifest comments and producer/consumer documentation. ## Verification - Current head: `5e04d757c`, based on master `09c05c550`. Range comparison confirmed all six feature commits were unchanged by the rebase. The final follow-up keys readiness by the plugin installation ID and availability path. - `cd ui && node ../node_modules/vitest/vitest.mjs run src/hooks/useTaskPluginPanels.test.tsx` — all 45 cases passed. `cd ui && node ../node_modules/typescript/bin/tsc -b` passed. - The shared query-client and live-update recovery selection passed 96 cases on `49c544cd2`. Token gates passed before the final hook-only changes. - Earlier focused verification passed 57 manifest cases, 69 database/history cases, event-registry cases and route authorization cases, including private-run denial. - Regression coverage includes subscription/history races, task changes during recovery, unfocused run completion, bounded readiness retries, denied reads, expected not-ready responses, changed activity mappings, unchanged panel preservation, old cache scopes, and same-version route or installation changes. - [Current-head CI](https://github.com/paperclipai/paperclip/actions/runs/40942001707) passed: 54 successful checks, two skips, no failures or pending checks. - [Standard and final review](https://github.com/paperclipai/paperclip/pull/18131#issuecomment-6489842168) both scored 5/5 on `5e04d757c`. The final review used the Apex tier, added no comments, and left no unresolved findings. The PR is conflict-free and ready for review. - Local checks stay targeted. GitHub CI provides the broad build, test, typecheck, browser and Runner coverage. No deployment or live-provider acceptance is claimed. <details> <summary>Retained failures and recovery evidence</summary> - `ac9329548` twice failed a browser reload assertion while conversation history was concealed. Master fixed its readiness wait in #17923. The approve journey then passed locally on `fea63a418` with installed Chrome (1 passed, 39 seconds). All eight browser shards passed in later CI. The browser test is outside this diff. - A canary checkout failed with Git HTTP 408; its unchanged-head retry passed. - `de07d09fa` failed the mobile Cases card click after a ten-second stability wait. The same read-only journey passed locally on that exact head (1 passed, 26 seconds). Its unchanged-head CI retry passed 22 cases, with one skip. The original artifact is retained; the cause of that click timeout is not established. - [CI on `49c544cd2`](https://github.com/paperclipai/paperclip/actions/runs/40903679075) passed 54 checks with two skips. CI also passed on `de07d09fa` after the recorded retry. - The `1e59ab441` merge checkout passed all browser and server suites. Runner shard 2 passed 2,252 cases and timed out waiting for a controller-recovery command. The test had landed on master in #18161 after the former branch base. Rebasing aligned the local checkout with that code; Runner shard 2 then passed on `fcf7380d3`. The original timeout cause remains unproven; no unrelated timeout was changed. - [CI on `fcf7380d3`](https://github.com/paperclipai/paperclip/actions/runs/40940235193) recorded runner shutdown signals in typecheck and a workspace shard. A chat recovery test observed a null wake deadline, and one Runner transport fixture failed during close. Those separate assertion failures remain unexplained. All of those jobs passed on the final readiness-key head. Those later passes establish recovery; they do not prove the cause of the earlier assertion failures. - Local browser setup first stopped at pnpm version-download verification, then a sandbox IPC restriction, then a missing bundled browser. Installed pnpm 9.15.4 and Chrome completed the journeys. No dependency files were changed. - Earlier optional-property TypeScript errors were corrected. Embedded PostgreSQL needed its install hook run from its package directory. Older CI also recorded fixture-cleanup lock timeouts, attention-fixture timeouts, and canceled server shards after runner shutdown. The parent fixed its FIFO snapshot-copy failure before merge. The linked verification archive retains earlier combined evidence. </details> ## Risks Activity is a hint and grants no access. Missing plugins, denied reads and unavailable views contribute no panel. Older runs without activity records show no task plugin tab. A used view stays available for the running task; reconnect reconciles readiness. Matching producer and consumer builds are required. No migration, dependency, image publication or deployment is included. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository tools and code execution. The exact serving snapshot is not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
396 lines
23 KiB
TypeScript
396 lines
23 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { Issue, LiveEvent } from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, expect, it, vi, type MockInstance } from "vitest";
|
|
import { api, ApiError } from "@/api/client";
|
|
import { heartbeatsApi, type LiveRunForIssue } from "@/api/heartbeats";
|
|
import { useTaskRunLiveUpdates } from "@/context/LiveUpdatesProvider";
|
|
import { createAppQueryClient } from "@/lib/query-client";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import type { ResolvedPluginSlot } from "@/plugins/slots";
|
|
import { useTaskPluginPanels, type TaskPluginPanel } from "./useTaskPluginPanels";
|
|
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
const plugin = vi.hoisted(() => ({ connected: true, slots: [] as ResolvedPluginSlot[], healthy: true, epoch: 0, handler: null as ((event: LiveEvent) => void) | null, errorMessage: null as string | null }));
|
|
vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => plugin }));
|
|
vi.mock("@/context/LiveUpdatesProvider", () => ({ useTaskRunLiveUpdates: vi.fn(() => plugin.connected), useTranscriptLiveStream: (_ids: string[], _options: unknown, handler: (event: LiveEvent) => void) => { plugin.handler = handler; return { healthy: plugin.healthy, epoch: plugin.epoch }; } }));
|
|
const slot = { pluginId: "example-plugin", pluginKey: "example.live-view", id: "screen", pluginVersion: "1.0.0", taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability", activation: { namespace: "example.runtime", activityKey: "desktop.used" } } } as ResolvedPluginSlot;
|
|
const issue = { id: "task", companyId: "company", executionRunId: "run", assigneeAgentId: "agent" } as Issue;
|
|
const run = { id: "run", agentId: "agent", status: "running" } as LiveRunForIssue;
|
|
let root: Root, container: HTMLDivElement, client: QueryClient;
|
|
let current: TaskPluginPanel[];
|
|
let task: Issue | null;
|
|
let enabled: boolean;
|
|
let get: MockInstance<typeof api.get>;
|
|
let list: MockInstance<typeof heartbeatsApi.liveRunsForIssue>;
|
|
let history: MockInstance<typeof heartbeatsApi.taskPanelActivations>;
|
|
function Harness() { current = useTaskPluginPanels(task, enabled); return null; }
|
|
async function render(next: Issue | null = issue, active = true) {
|
|
task = next; enabled = active;
|
|
await act(async () => root.render(<QueryClientProvider client={client}><Harness /></QueryClientProvider>));
|
|
await act(async () => { if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(30); else await new Promise(resolve => setTimeout(resolve, 30)); });
|
|
}
|
|
async function refresh() {
|
|
await act(async () => { await client.refetchQueries(); });
|
|
await act(async () => { if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(30); else await new Promise(resolve => setTimeout(resolve, 30)); });
|
|
}
|
|
beforeEach(() => {
|
|
plugin.slots = [slot]; plugin.errorMessage = null; plugin.connected = true; plugin.healthy = false; plugin.epoch = 0; plugin.handler = null;
|
|
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
|
|
get = vi.spyOn(api, "get").mockResolvedValue({ available: true });
|
|
list = vi.spyOn(heartbeatsApi, "liveRunsForIssue").mockResolvedValue([run]);
|
|
history = vi.spyOn(heartbeatsApi, "taskPanelActivations").mockImplementation(async () => plugin.slots.map(slot => ({ pluginKey: slot.pluginKey, slotId: slot.id })));
|
|
});
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount()); client.clear(); container.remove(); vi.restoreAllMocks(); vi.useRealTimers();
|
|
});
|
|
it("uses the running agent's authorized status and existing plugin slot", async () => {
|
|
await render();
|
|
expect(list).toHaveBeenCalledWith("task");
|
|
expect(get).toHaveBeenCalledWith("/plugins/example-plugin/api/agents/agent/view/availability?runId=run&issueId=task", { signal: expect.any(AbortSignal), cache: "no-store" });
|
|
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
|
|
});
|
|
it("keeps unavailable panels hidden", async () => {
|
|
get.mockResolvedValue({ available: false }); await render(); expect(current).toEqual([]);
|
|
});
|
|
it.each(["absent", "disabled", "draft", "no-task", "plugin-error"])("does no panel polling when %s", async kind => {
|
|
if (kind === "absent") plugin.slots = [];
|
|
if (kind === "plugin-error") plugin.errorMessage = "Unavailable";
|
|
await render(kind === "no-task" ? null : kind === "draft" ? { ...issue, id: "chat:draft" } : issue, kind !== "disabled");
|
|
expect(current).toEqual([]); expect(get).not.toHaveBeenCalled(); expect(list).not.toHaveBeenCalled();
|
|
});
|
|
it.each(["queued", "succeeded", "another-run"])("ignores a %s run", async kind => {
|
|
list.mockResolvedValue([{ ...run, status: kind === "another-run" ? "running" : kind, id: kind === "another-run" ? "other" : "run" }]);
|
|
await render(); expect(current).toEqual([]); expect(get).not.toHaveBeenCalled();
|
|
});
|
|
it("discovers a panel started after the task is already open, then hides it on stop or access failure", async () => {
|
|
get.mockResolvedValue({ available: false }); await render(); expect(current).toEqual([]);
|
|
get.mockResolvedValue({ available: true }); await refresh(); expect(current[0]?.entityId).toBe("agent");
|
|
get.mockRejectedValue(new Error("Access denied")); await refresh(); expect(current).toEqual([]);
|
|
});
|
|
it("removes the panel when the run ends even if its view is still ready", async () => {
|
|
await render(); expect(current).not.toEqual([]);
|
|
await act(async () => { client.setQueryData(queryKeys.issues.liveRuns("task"), []); });
|
|
await render(); expect(current).toEqual([]);
|
|
});
|
|
it("does not carry an old task's ready panel to a new task or disabled plugin", async () => {
|
|
await render(); expect(current).not.toEqual([]);
|
|
list.mockResolvedValue([]); await render({ ...issue, id: "next-task" }); expect(current).toEqual([]);
|
|
plugin.slots = []; await render(); expect(current).toEqual([]);
|
|
});
|
|
|
|
it("stops idle polling with live updates and resumes fallback polling on disconnect", async () => {
|
|
vi.useFakeTimers(); list.mockResolvedValue([]);
|
|
await render(); expect(list).toHaveBeenCalledTimes(1);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(6000); });
|
|
expect(list).toHaveBeenCalledTimes(1);
|
|
plugin.connected = false; await render();
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(6000); });
|
|
expect(list).toHaveBeenCalledTimes(3);
|
|
});
|
|
it("uses each plugin's own availability path and excludes only the failing contribution", async () => {
|
|
const second = { ...slot, pluginId: "other-plugin", pluginKey: "example.other-view", taskSidePanel: { availabilityPath: "/views/:entityId/ready", activation: { namespace: "example.other-runtime", activityKey: "desktop.used" } } };
|
|
plugin.slots = [slot, second];
|
|
get.mockImplementation(async path => {
|
|
if (path.includes("other-plugin")) throw new Error("Unavailable");
|
|
return { available: true } as never;
|
|
});
|
|
await render();
|
|
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
|
|
expect(get).toHaveBeenCalledWith("/plugins/other-plugin/api/views/agent/ready?runId=run&issueId=task", expect.anything());
|
|
});
|
|
|
|
|
|
function event(overrides: Record<string, unknown> = {}): LiveEvent {
|
|
return { id: 1, companyId: "company", type: "heartbeat.run.event", createdAt: "2099-01-01T00:00:00.000Z",
|
|
payload: { runId: "run", agentId: "agent", issueId: "task", eventType: "runtime.activity.recorded",
|
|
payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { ...slot.taskSidePanel!.activation } } }, ...overrides } } as LiveEvent;
|
|
}
|
|
async function emit(value: LiveEvent) {
|
|
await act(async () => { plugin.handler?.(value); });
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30); });
|
|
}
|
|
it("does no availability polling or Launcher discovery until this run activates the panel", async () => {
|
|
vi.useFakeTimers(); history.mockResolvedValue([]);
|
|
await render(); expect(get).not.toHaveBeenCalled(); expect(history).toHaveBeenCalledTimes(1);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(get).not.toHaveBeenCalled(); expect(history).toHaveBeenCalledTimes(1);
|
|
for (const overrides of [{ runId: "other" }, { agentId: "other" }, { issueId: "other" }, { eventType: "item.completed" }]) await emit(event(overrides));
|
|
await emit({ ...event(), companyId: "other" });
|
|
await emit(event({ payload: { pluginKey: "example.other", slotId: "screen" } }));
|
|
for (const native of [
|
|
{ eventType: "runtime.activity.recorded", sourceKind: "control_plane", payload: { ...slot.taskSidePanel!.activation } },
|
|
{ eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { namespace: "example.other-runtime", activityKey: "desktop.used" } },
|
|
{ eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { namespace: "example.runtime", activityKey: "other.used" } },
|
|
]) await emit(event({ payload: { prpEvent: native } }));
|
|
expect(get).not.toHaveBeenCalled();
|
|
await emit(event());
|
|
expect(get).toHaveBeenCalledTimes(1); expect(current[0]?.runId).toBe("run");
|
|
await emit(event());
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("restores an activation from durable run history after reload", async () => {
|
|
await render(); expect(current).toHaveLength(1);
|
|
expect(history).toHaveBeenCalledWith("run", { signal: expect.any(AbortSignal), cache: "no-store" });
|
|
});
|
|
it("reconciles activation history and availability after reconnect", async () => {
|
|
vi.useFakeTimers(); history.mockResolvedValue([]); await render(); expect(current).toEqual([]);
|
|
plugin.healthy = false; await render();
|
|
history.mockResolvedValue([{ pluginKey: slot.pluginKey, slotId: slot.id }]);
|
|
plugin.healthy = true; plugin.epoch++;
|
|
await render(); expect(current).toHaveLength(1);
|
|
});
|
|
it("does not lose an activation to an older in-flight history read", async () => {
|
|
vi.useFakeTimers(); let resolve!: (value: []) => void;
|
|
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
|
await render(); expect(get).not.toHaveBeenCalled();
|
|
await emit(event()); expect(current).toHaveLength(1);
|
|
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
|
expect(current).toHaveLength(1);
|
|
});
|
|
it("merges earlier panel history with a different panel activated during the read", async () => {
|
|
vi.useFakeTimers();
|
|
const second = { ...slot, id: "second-screen", taskSidePanel: { ...slot.taskSidePanel!, activation: { namespace: "example.runtime", activityKey: "other.used" } } };
|
|
plugin.slots = [slot, second];
|
|
let resolve!: (value: Array<{ pluginKey: string; slotId: string }>) => void;
|
|
history.mockReturnValueOnce(new Promise(done => { resolve = done; }));
|
|
await render();
|
|
expect(get).not.toHaveBeenCalled();
|
|
await emit(event({ payload: { prpEvent: {
|
|
eventType: "runtime.activity.recorded", sourceKind: "runner",
|
|
payload: { ...second.taskSidePanel.activation },
|
|
} } }));
|
|
expect(current.map(panel => panel.slot.id)).toEqual([second.id]);
|
|
await act(async () => {
|
|
resolve([{ pluginKey: slot.pluginKey, slotId: slot.id }]);
|
|
await vi.advanceTimersByTimeAsync(30);
|
|
});
|
|
expect(current.map(panel => panel.slot.id)).toEqual([slot.id, second.id]);
|
|
expect(history).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("fails closed when activation history cannot be read", async () => {
|
|
history.mockRejectedValue(new Error("Access denied")); await render();
|
|
expect(get).not.toHaveBeenCalled(); expect(current).toEqual([]);
|
|
});
|
|
|
|
it("bounds readiness retries to three attempts after an activation", async () => {
|
|
vi.useFakeTimers(); get.mockResolvedValue({ available: false });
|
|
await render(); expect(get).toHaveBeenCalledTimes(1);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(get).toHaveBeenCalledTimes(3); expect(current).toEqual([]);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(get).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it.each(["not-ready", "server-error"])("restarts bounded readiness on fresh activity after %s", async failure => {
|
|
vi.useFakeTimers();
|
|
if (failure === "not-ready") get.mockResolvedValue({ available: false });
|
|
else get.mockRejectedValue(new ApiError("Unavailable", 500, {}));
|
|
await render();
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(get).toHaveBeenCalledTimes(3);
|
|
await emit(event());
|
|
await emit(event());
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(get).toHaveBeenCalledTimes(6);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(get).toHaveBeenCalledTimes(6);
|
|
get.mockResolvedValue({ available: true });
|
|
await emit(event());
|
|
expect(get).toHaveBeenCalledTimes(7); expect(current).toHaveLength(1);
|
|
});
|
|
it("does not retry denied readiness on fresh activity", async () => {
|
|
vi.useFakeTimers(); get.mockRejectedValue(new ApiError("Denied", 403, {}));
|
|
await render();
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
get.mockResolvedValue({ available: true });
|
|
await emit(event());
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(get).toHaveBeenCalledTimes(1); expect(current).toEqual([]);
|
|
});
|
|
|
|
it.each(["runs", "history"])("recovers from a transient %s read without recurring polling", async source => {
|
|
vi.useFakeTimers();
|
|
const error = new ApiError("Unavailable", 500, {});
|
|
if (source === "runs") list.mockRejectedValueOnce(error);
|
|
else history.mockRejectedValueOnce(error);
|
|
await render();
|
|
expect(current).toEqual([]);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
|
|
expect(current).toHaveLength(1);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(source === "runs" ? list : history).toHaveBeenCalledTimes(2);
|
|
});
|
|
it.each([500, 403])("bounds failed discovery reads for HTTP %s", async status => {
|
|
vi.useFakeTimers();
|
|
list.mockRejectedValue(new ApiError("Unavailable", status, {}));
|
|
await render();
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(list).toHaveBeenCalledTimes(status === 500 ? 3 : 1);
|
|
expect(history).not.toHaveBeenCalled();
|
|
expect(current).toEqual([]);
|
|
});
|
|
|
|
it("does not discover a task view without a declared activity mapping", async () => {
|
|
plugin.slots = [{ ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } }];
|
|
await render();
|
|
expect(list).not.toHaveBeenCalled(); expect(history).not.toHaveBeenCalled();
|
|
expect(get).not.toHaveBeenCalled(); expect(current).toEqual([]);
|
|
});
|
|
|
|
|
|
it("observes run lifecycle updates using the same UUID key as panel discovery", async () => {
|
|
await render({ ...issue, identifier: "DEMO-1" });
|
|
expect(useTaskRunLiveUpdates).toHaveBeenLastCalledWith({ companyId: "company", issueId: "task", issueQueryRef: "task", assigneeAgentId: "agent" });
|
|
});
|
|
|
|
it.each(["initial", "reconnect"])("reads again after pending history settles across %s subscription", async phase => {
|
|
vi.useFakeTimers();
|
|
let resolve!: (value: []) => void;
|
|
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
|
if (phase === "initial") plugin.healthy = true;
|
|
await render();
|
|
if (phase === "reconnect") { plugin.healthy = true; plugin.epoch++; await render(); }
|
|
expect(history).toHaveBeenCalledTimes(1);
|
|
// The first snapshot predates activity that arrived before subscription.
|
|
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
|
expect(history).toHaveBeenCalledTimes(2);
|
|
expect(list).toHaveBeenCalledTimes(1);
|
|
expect(current).toHaveLength(1);
|
|
});
|
|
|
|
it("discards pending subscription recovery after the task changes", async () => {
|
|
vi.useFakeTimers(); plugin.healthy = true;
|
|
let resolve!: (value: []) => void;
|
|
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
|
await render();
|
|
await render({ ...issue, id: "next-task" });
|
|
const reads = history.mock.calls.length;
|
|
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
|
expect(history).toHaveBeenCalledTimes(reads);
|
|
});
|
|
|
|
it.each(["not-ready", "server-error"])("reports unexpected failures only after bounded %s readiness checks", async failure => {
|
|
vi.useFakeTimers();
|
|
const reportError = vi.fn();
|
|
client = createAppQueryClient({ connectivity: { reportError: vi.fn(), getSnapshot: () => ({ status: "online" } as never) }, reportError });
|
|
if (failure === "not-ready") get.mockResolvedValue({ available: false });
|
|
else get.mockRejectedValue(new ApiError("Unexpected failure", 500, {}));
|
|
await render();
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(get).toHaveBeenCalledTimes(3);
|
|
expect(current).toEqual([]);
|
|
expect(reportError).toHaveBeenCalledTimes(failure === "not-ready" ? 0 : 1);
|
|
});
|
|
|
|
|
|
it.each(["1.0.0", "2.0.0"])("requires current activity after a mapping change at version %s", async pluginVersion => {
|
|
vi.useFakeTimers();
|
|
await render(); expect(current).toHaveLength(1);
|
|
const changed = { ...slot, pluginVersion, taskSidePanel: { ...slot.taskSidePanel!,
|
|
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } };
|
|
history.mockResolvedValue([]); plugin.slots = [changed];
|
|
await render(); expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
|
|
await emit(event()); expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
|
|
plugin.healthy = true; plugin.epoch++; await render();
|
|
expect(current).toEqual([]);
|
|
await emit(event({ payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner",
|
|
payload: changed.taskSidePanel.activation } } }));
|
|
expect(current).toEqual([{ entityId: "agent", runId: "run", slot: changed }]);
|
|
expect(get).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not carry an old in-flight activation read into a changed declaration", async () => {
|
|
vi.useFakeTimers();
|
|
let resolve!: (value: Array<{ pluginKey: string; slotId: string }>) => void;
|
|
history.mockReturnValueOnce(new Promise(done => { resolve = done; }));
|
|
await render(); await emit(event()); expect(current).toHaveLength(1);
|
|
history.mockResolvedValue([]);
|
|
plugin.slots = [{ ...slot, pluginVersion: "2.0.0", taskSidePanel: { ...slot.taskSidePanel!,
|
|
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } }];
|
|
await render(); expect(current).toEqual([]);
|
|
await act(async () => { resolve([{ pluginKey: slot.pluginKey, slotId: slot.id }]); await vi.advanceTimersByTimeAsync(30); });
|
|
expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
|
|
it("keeps an unchanged panel active while another declaration changes and history is pending", async () => {
|
|
vi.useFakeTimers(); client.setDefaultOptions({ queries: { retry: false, staleTime: 60_000 } });
|
|
const second = { ...slot, id: "other-screen", pluginKey: "example.other", pluginId: "other-plugin" };
|
|
plugin.slots = [slot, second];
|
|
await render(); expect(current).toHaveLength(2);
|
|
let resolve!: (value: []) => void;
|
|
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
|
const changed = { ...second, pluginVersion: "2.0.0", taskSidePanel: { ...second.taskSidePanel!,
|
|
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } };
|
|
plugin.slots = [slot, changed];
|
|
await render();
|
|
expect(history).toHaveBeenCalledTimes(2);
|
|
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
|
|
await emit(event({ payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner",
|
|
payload: changed.taskSidePanel.activation } } }));
|
|
expect(current.map(panel => panel.slot)).toEqual([slot, changed]);
|
|
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
|
expect(current.map(panel => panel.slot)).toEqual([slot, changed]);
|
|
});
|
|
|
|
it("does not seed another task's activation from cached declarations", async () => {
|
|
await render(); expect(current).toHaveLength(1);
|
|
history.mockResolvedValue([]);
|
|
await render({ ...issue, id: "other-task" });
|
|
expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
|
|
it("does not seed activations from a history read that lost access", async () => {
|
|
vi.useFakeTimers();
|
|
await render(); expect(current).toHaveLength(1);
|
|
history.mockRejectedValue(new ApiError("Denied", 403, {}));
|
|
await refresh(); expect(current).toEqual([]);
|
|
let resolve!: (value: []) => void;
|
|
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
|
plugin.slots = [slot, { ...slot, id: "other-screen", pluginKey: "example.other" }];
|
|
await render(); expect(current).toEqual([]);
|
|
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
|
expect(current).toEqual([]);
|
|
});
|
|
|
|
|
|
it("preserves activity when returning to an earlier cached declaration set", async () => {
|
|
vi.useFakeTimers();
|
|
const second = { ...slot, id: "other-screen", pluginKey: "example.other" };
|
|
plugin.slots = [slot, second]; history.mockResolvedValue([]);
|
|
await render(); expect(current).toEqual([]);
|
|
plugin.slots = [slot, { ...second, pluginVersion: "2.0.0" }];
|
|
await render(); await emit(event()); expect(current.map(panel => panel.slot.id)).toEqual([slot.id, second.id]);
|
|
let resolve!: (value: []) => void;
|
|
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
|
|
plugin.slots = [slot, second]; await render();
|
|
expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
|
|
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
|
|
expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
|
|
await act(async () => client.removeQueries({ predicate: query => String(query.queryKey[5]).includes("2.0.0") }));
|
|
await render(); expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
|
|
});
|
|
|
|
|
|
it.each(["route", "installation"])("rechecks readiness after a same-version %s change", async change => {
|
|
vi.useFakeTimers(); await render(); expect(current).toHaveLength(1);
|
|
get.mockResolvedValue({ available: false });
|
|
const changed = change === "route"
|
|
? { ...slot, taskSidePanel: { ...slot.taskSidePanel!, availabilityPath: "/views/:entityId/ready" } }
|
|
: { ...slot, pluginId: "replacement-plugin" };
|
|
plugin.slots = [changed]; await render();
|
|
expect(current).toEqual([]);
|
|
expect(get).toHaveBeenLastCalledWith(`/plugins/${changed.pluginId}/api${changed.taskSidePanel!.availabilityPath.replace(":entityId", "agent")}?runId=run&issueId=task`, expect.anything());
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
|
expect(get).toHaveBeenCalledTimes(4); expect(current).toEqual([]);
|
|
get.mockResolvedValue({ available: true }); await emit(event());
|
|
expect(get).toHaveBeenCalledTimes(5); expect(current).toHaveLength(1);
|
|
});
|