mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path > - Paperclip helps people manage agents and their tasks. > - The task side panel can display a plugin's agent view. > - Users need that view even while a task is idle. > - Activity currently controls both manual discovery and automatic opening. > - This pull request keeps manual access available and uses activity to request automatic opening. > - The same authorized agent viewer serves both entry points. ## Linked Issues or Issue Description **What happened?** A task view disappeared when its run ended. Users could not select it until the current run reported matching activity and its readiness check passed. This also excluded declared manual views without an activity mapping. **Expected behavior** The task launcher offers declared views for its running or assigned agent. Selecting one uses the same viewer as the agent detail page. Matching activity and readiness open the pane and select the view by default, once per run. **Steps to reproduce** Open an idle task assigned to an agent with an installed task-panel contribution. Open the panel launcher. The view is currently missing. Related work: #18131 and #18239. ## What Changed - Return manual panel declarations independently of run activity and readiness. - Mark panels that should open automatically after matching activity and readiness. Keep existing run attribution, retry bounds, and dismissal behavior. - Keep an open viewer mounted when a run ends. Focus an already open manual view when activity starts. - Preserve saved manual tabs while plugin and initial run discovery load; remove contributions after discovery confirms they are absent. - Share the run-scoped dismissal key between the task page and panel controller so dismissed views do not reveal an empty pane. - Use the current running agent for declared manual views that have no activity mapping. - Update the plugin authoring contract and feature recipe. ## Verification - Two new regressions failed before the fix: idle discovery returned no view, and a manual contribution opened automatically. - 105 focused hook and panel tests pass. They cover idle/manual access, declarations without mappings, completion, saved-tab restoration, automatic focus, activity attribution, retry bounds, dismissal, and missing/disabled plugins. - Thirteen task-page panel integration tests pass. New regressions reproduce and fix a dismissed manual view revealing the outer pane. They cover desktop/mobile and both workspace modes, and verify that a later run can still open the view automatically. - `node scripts/check-token-gates.mjs`, `node scripts/check-maps.mjs`, and `git diff --check` pass. - Local UI typecheck failed with the reused dependency cache: workspace modules/exports and generated Storybook catalog files are missing or stale. It reported no errors in the changed files. The final head passed the clean GitHub typecheck. No broad local build or repository test run was attempted. - The preceding head passed [GitHub CI](https://github.com/paperclipai/paperclip/actions/runs/41116028688), with 48 jobs succeeding and one skipped. Its final review scored 5/5 and identified a narrow saved-view restoration race. This revision adds the missing initial-run loading guard. Two delayed-response regressions failed before the correction and now pass; they cover a different assignee and an unassigned task. - The final head `bbde3a28f7ac0390da8f3fe81eb9abd2f3020695` passed [GitHub CI](https://github.com/paperclipai/paperclip/actions/runs/41119772438) and the additional [full CI run](https://github.com/paperclipai/paperclip/actions/runs/41121827023), each with 48 successful jobs and one skipped. Standard Greptile scored 5/5 on this head; both review threads are resolved. The small final loading correction used the permitted exception to another final-review cycle. - The additional full CI run initially failed the canary bundle install because npm advertised a newly published `tslib@2.9.1` archive before its tarball was available (HTTP 404). After the archive became available, one retry of the failed canary and dependent aggregate passed. No dependency or lockfile change was needed. - Hosted acceptance of this follow-up requires merge and deployment; the earlier hosted activation checks cover the previously merged behavior. ## Risks Manual views can now open while idle or before readiness. Each viewer retains its normal authorization and status handling. Declarations without an activity mapping now appear in the launcher. Automatic opening still requires authenticated activity and the authorized readiness result. Saved tabs wait for plugin discovery before removal. ## Model Used OpenAI GPT-6 through Codex, with repository inspection, terminal tools, browser verification, and code execution. The exact serving variant and context window size are not exposed in this session. ## 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 #` 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>
467 lines
26 KiB
TypeScript
467 lines
26 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, isLoading: false }));
|
|
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 panels: TaskPluginPanel[];
|
|
let loading: boolean;
|
|
let current: Omit<TaskPluginPanel, "autoOpen">[];
|
|
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() {
|
|
const discovery = useTaskPluginPanels(task, enabled);
|
|
panels = discovery.panels; loading = discovery.isLoading;
|
|
current = panels.filter(panel => panel.autoOpen).map(({ autoOpen: _autoOpen, ...panel }) => panel);
|
|
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.isLoading = false; 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("keeps restored views pending until plugin discovery settles", async () => {
|
|
plugin.slots = []; plugin.isLoading = true;
|
|
await render(); expect(loading).toBe(true); expect(panels).toEqual([]);
|
|
plugin.slots = [slot]; plugin.isLoading = false; list.mockResolvedValue([]);
|
|
await render(); expect(loading).toBe(false);
|
|
expect(panels).toEqual([{ entityId: "agent", autoOpen: false, slot }]);
|
|
});
|
|
|
|
it.each(["assigned-agent", null])("waits for the running agent before settling saved views (assignee=%s)", async assigneeAgentId => {
|
|
const manual = { ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } };
|
|
plugin.slots = [manual];
|
|
let resolve!: (value: LiveRunForIssue[]) => void;
|
|
list.mockReturnValueOnce(new Promise(done => { resolve = done; }));
|
|
await render({ ...issue, assigneeAgentId });
|
|
expect(loading).toBe(true);
|
|
await act(async () => { resolve([{ ...run, agentId: "running-agent" }]); });
|
|
await render({ ...issue, assigneeAgentId });
|
|
expect(loading).toBe(false);
|
|
expect(panels).toEqual([{ entityId: "running-agent", runId: "run", autoOpen: false, slot: manual }]);
|
|
});
|
|
|
|
it("offers manual access while idle without reading activation history or readiness", async () => {
|
|
list.mockResolvedValue([]);
|
|
history.mockResolvedValue([]);
|
|
await render();
|
|
expect(panels).toEqual([{ entityId: "agent", autoOpen: false, slot }]);
|
|
expect(history).not.toHaveBeenCalled();
|
|
expect(get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("offers the assigned agent's view before activity and after run completion", async () => {
|
|
history.mockResolvedValue([]);
|
|
await render();
|
|
expect(panels).toEqual([{ entityId: "agent", runId: "run", autoOpen: false, slot }]);
|
|
expect(get).not.toHaveBeenCalled();
|
|
await act(async () => { client.setQueryData(queryKeys.issues.liveRuns("task"), []); });
|
|
await render();
|
|
expect(panels).toEqual([{ entityId: "agent", autoOpen: false, slot }]);
|
|
});
|
|
|
|
it("offers declared manual views without an activation mapping", async () => {
|
|
const manual = { ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } };
|
|
plugin.slots = [manual];
|
|
await render();
|
|
expect(panels).toEqual([{ entityId: "agent", runId: "run", autoOpen: false, slot: manual }]);
|
|
expect(list).toHaveBeenCalledWith("task");
|
|
expect(history).not.toHaveBeenCalled();
|
|
expect(get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the running agent for manual views without an activity mapping", async () => {
|
|
const manual = { ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } };
|
|
plugin.slots = [manual]; list.mockResolvedValue([{ ...run, agentId: "running-agent" }]);
|
|
await render();
|
|
expect(panels).toEqual([{ entityId: "running-agent", runId: "run", autoOpen: false, slot: manual }]);
|
|
expect(history).not.toHaveBeenCalled(); expect(get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("has no manual agent view for an unassigned idle task", async () => {
|
|
list.mockResolvedValue([]);
|
|
await render({ ...issue, assigneeAgentId: null });
|
|
expect(panels).toEqual([]);
|
|
});
|
|
|
|
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(panels).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("clears automatic activation 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 automatically open a task view without a declared activity mapping", async () => {
|
|
plugin.slots = [{ ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } }];
|
|
await render();
|
|
expect(list).toHaveBeenCalledWith("task"); 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);
|
|
});
|