fix: preserve review requirements, output links, and decision outcomes

This commit is contained in:
scotttong committed 2026-10-27 22:12:56 -07:00
1 parent 50201a1f5a
commit faa52c7bf2
14 files changed
+153 -21

No files matched your search

@@ -127,6 +127,16 @@ const support = await getEmbeddedPostgresTestSupport();
}
});
it("persists required output review supplied through MCP creation", async () => {
const f = await server.fixture({ conversation: true });
const requirement = { version: 1, deliverables: [{ key: "copy", label: "Launch copy" }] };
const result = await callProjectTool({ name: "create_task", arguments: {
title: "MCP review gate", projectId: f.projectId, idempotencyKey: "mcp-quality-gate", requiredQualityReview: requirement,
}, apiUrl: server.apiUrl, token: createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true }) as any;
const [task] = await server.db.select().from(issues).where(eq(issues.id, result.id));
expect(task.executionPolicy).toMatchObject({ requiredQualityReview: requirement });
});
it("rejects creation, child helpers, and reparenting under a chat, while retaining legacy children", async () => {
const f = await server.fixture({ conversation: true });
const svc = issueService(server.db);
+22 -1
View File
@@ -2,11 +2,17 @@ import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, createDb, decisions, decisionTargetIssues, heartbeatRuns, issues, issueThreadInteractions } from "@paperclipai/db";
import { decisionHistoryService, decodeDecisionHistoryCursor, decisionHistoryHref, decisionHistoryTarget } from "../services/decision-history.js";
import { decisionHistoryService, decodeDecisionHistoryCursor, decisionHistoryHref, decisionHistoryTarget, itemVerdictsHistoryLabel } from "../services/decision-history.js";
import { documentService } from "../services/documents.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
describe("decision receipt navigation", () => {
it("preserves all verdict outcomes in a mixed review summary", () => {
expect(itemVerdictsHistoryLabel({ outcome: "resolved", items: [{ verdict: "approve" }, { verdict: "reject" }, { verdict: "defer" }] }))
.toBe("1 approved · 1 rejected · 1 deferred");
expect(itemVerdictsHistoryLabel({ outcome: "resolved", items: [{ verdict: "reject" }] })).toBe("1 rejected");
expect(itemVerdictsHistoryLabel({ outcome: "cancelled", items: [{ verdict: "approve" }] })).toBeNull();
});
it("pins the reviewed document and interaction and ignores arbitrary custom URLs", () => {
const target = decisionHistoryTarget({ target: { type: "issue_document", key: "launch copy", revisionId: "old-revision", href: "https://untrusted.example" } });
expect(decisionHistoryHref("PAP", "PAP-1", "issue_thread_interaction", "interaction", target)).toBe("/PAP/issues/PAP-1#document-launch%20copy&revision=old-revision&interaction=interaction");
@@ -33,6 +39,21 @@ const support = await getEmbeddedPostgresTestSupport();
}, 30_000);
afterAll(async () => { vi.unstubAllEnvs(); await temp?.cleanup(); });
it("projects mixed review verdicts into history instead of generic answers", async () => {
const companyId = randomUUID(), issueId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Review history", issuePrefix: "VRD" });
await db.insert(issues).values({ id: issueId, companyId, title: "Reviewed outputs" });
await db.insert(issueThreadInteractions).values({ companyId, issueId, kind: "request_item_verdicts", status: "answered",
resolvedAt: new Date(), resolvedByUserId: "reviewer", payload: { version: 1, prompt: "Review outputs", items: [] },
result: { version: 1, outcome: "resolved", complete: true, items: [
{ id: "copy", verdict: "approve", resolvedAt: new Date().toISOString() },
{ id: "art", verdict: "reject", resolvedAt: new Date().toISOString() },
{ id: "extra", verdict: "defer", resolvedAt: new Date().toISOString() },
] } });
const page = await decisionHistoryService(db).list(companyId, { actor: { type: "board", userId: "reviewer" }, userId: "reviewer" });
expect(page.items[0]?.label).toBe("1 approved · 1 rejected · 1 deferred");
});
it("returns a cross-task document receipt whose exact revision belongs to the target, not the origin", async () => {
const companyId = randomUUID(), originId = randomUUID(), documentOwnerId = randomUUID(), interactionId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Cross-task review", issuePrefix: "XDR" });
+14 -3
View File
@@ -7,6 +7,17 @@ import { badRequest } from "../errors.js";
type HistoryOptions = DecisionHistoryQuery & { actor: AuthorizationActor; userId: string; issueIds?: string[] };
type Cursor = { at: string; id: string; sort: "newest" | "oldest" };
export function itemVerdictsHistoryLabel(result: unknown): string | null {
if (!result || typeof result !== "object" || !("outcome" in result) || result.outcome !== "resolved"
|| !("items" in result) || !Array.isArray(result.items)) return null;
const counts = { approve: 0, reject: 0, defer: 0 };
for (const item of result.items) {
if (item?.verdict === "approve" || item?.verdict === "reject" || item?.verdict === "defer") counts[item.verdict as keyof typeof counts]++;
}
return [counts.approve ? `${counts.approve} approved` : null, counts.reject ? `${counts.reject} rejected` : null,
counts.defer ? `${counts.defer} deferred` : null].filter(Boolean).join(" · ") || "Review recorded";
}
export function decodeDecisionHistoryCursor(value: string | undefined, sort: Cursor["sort"]): Cursor | null {
if (!value) return null;
try {
@@ -84,7 +95,7 @@ export function decisionHistoryService(db: Db) {
agentId: agents.id, agentName: agents.name, projectId: projects.id, projectName: projects.name,
title: issueThreadInteractions.title, outcome: issueThreadInteractions.status, resolvedAt: issueThreadInteractions.resolvedAt,
resolvedByUserId: issueThreadInteractions.resolvedByUserId, resolvedByAgentId: issueThreadInteractions.resolvedByAgentId,
payload: issueThreadInteractions.payload, result: issueThreadInteractions.result })
kind: issueThreadInteractions.kind, payload: issueThreadInteractions.payload, result: issueThreadInteractions.result })
.from(issueThreadInteractions).innerJoin(issues, and(eq(issues.id, issueThreadInteractions.issueId), eq(issues.companyId, companyId)))
.leftJoin(agents, and(eq(agents.id, issueThreadInteractions.createdByAgentId), eq(agents.companyId, companyId)))
.leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, companyId)))
@@ -108,12 +119,12 @@ export function decisionHistoryService(db: Db) {
...conditions(decisionTime, decisionKey, decisions.title)))
.orderBy(order(decisionTime), order(decisionKey)).limit(limit + 1),
]);
const interactionItems: DecisionHistoryItem[] = interactionRows.map(({ payload, result, ...row }) => {
const interactionItems: DecisionHistoryItem[] = interactionRows.map(({ kind, payload, result, ...row }) => {
const target = decisionHistoryTarget(payload);
const resultOutcome = result && "outcome" in result ? String(result.outcome) : row.outcome;
const labels: Record<string, string> = { accepted: "Approved", rejected: "Changes requested", answered: "Answers recorded", cancelled: "Cancelled", expired: "Expired", failed: "Failed", stale_target: "Superseded", superseded_by_newer_request: "Superseded", superseded_by_comment: "Answered in task" };
return { ...row, title: row.title ?? row.issueTitle, resolvedAt: new Date(row.resolvedAt!).toISOString(),
id: `issue_thread_interaction:${row.sourceId}`, sourceKind: "issue_thread_interaction", label: labels[resultOutcome] ?? labels[row.outcome] ?? "Decision recorded", target,
id: `issue_thread_interaction:${row.sourceId}`, sourceKind: "issue_thread_interaction", label: (kind === "request_item_verdicts" ? itemVerdictsHistoryLabel(result) : null) ?? labels[resultOutcome] ?? labels[row.outcome] ?? "Decision recorded", target,
href: decisionHistoryHref(company.prefix, row.issueIdentifier ?? row.issueId, "issue_thread_interaction", row.sourceId, target) };
});
const decisionItems: DecisionHistoryItem[] = decisionRows.map(({ chosenOptionId, options: choices, ...row }) => ({
+4 -1
View File
@@ -1,4 +1,4 @@
import { projectDiscoverySchema, createProjectSchema, createIssueSchema, setIssueTitleSchema } from "@paperclipai/shared";
import { projectDiscoverySchema, createProjectSchema, createIssueSchema, setIssueTitleSchema, requiredQualityReviewSchema } from "@paperclipai/shared";
import { z } from "zod";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../vendor/paperclip-runner/index.js";
import { badRequest } from "../errors.js";
@@ -42,6 +42,9 @@ export async function callProjectTool(input: {
body = createIssueSchema.parse({
title: args.title, description: args.description, priority: args.priority,
projectId: args.projectId, initialPlan: args.initialPlan,
...(args.requiredQualityReview !== undefined ? {
executionPolicy: { requiredQualityReview: requiredQualityReviewSchema.parse(args.requiredQualityReview) },
} : {}),
assigneeAgentId: args.assigneeActorId ?? (args.assigneeUserId ? null : input.agentId),
assigneeUserId: args.assigneeUserId,
parentId: input.conversation ? null : input.issueId,
@@ -1148,6 +1148,16 @@ describe("IssueThreadInteractionCard", () => {
);
});
it("links a bound review item to its exact document without requiring href", () => {
const host = renderCard({ interaction: { ...pendingRequestItemVerdictsInteraction,
payload: { ...pendingRequestItemVerdictsInteraction.payload, items: [{ id: "copy", label: "Reviewed copy", target: {
type: "issue_document", issueId: "document-owner", key: "copy", revisionId: "reviewed-v1",
} }] },
} });
expect(host.querySelector('a[href*="document-copy&revision=reviewed-v1"]')?.getAttribute("href"))
.toBe(`/issues/document-owner#document-copy&revision=reviewed-v1&interaction=${pendingRequestItemVerdictsInteraction.id}`);
});
it("submits an approve verdict once a draft is marked and applied", async () => {
const onSubmitInteractionVerdicts = vi.fn(async () => undefined);
const host = renderCard({
@@ -6,6 +6,7 @@ import { formatAssigneeUserLabel } from "../lib/assignees";
import { describeInteractionAudience, type InteractionAudienceDescription } from "../lib/interaction-audience";
import { interactionResolutionErrorMessage } from "../lib/interaction-resolution-error";
import { buildDocumentAnnotationHash } from "../lib/document-annotation-hash";
import { itemVerdictHref } from "@/lib/item-verdict-links";
import {
isInteractionPreparingApproval,
buildSuggestedTaskTree,
@@ -3239,8 +3240,8 @@ function VerdictConsequenceChip({ verdict }: { verdict: RequestItemVerdictValue
);
}
function ItemVerdictDeepLink({ item }: { item: RequestItemVerdictsItem }) {
const href = item.href ? normalizeRequestConfirmationTargetHref(item.href) : null;
function ItemVerdictDeepLink({ item, interaction }: { item: RequestItemVerdictsItem; interaction: RequestItemVerdictsInteraction }) {
const href = itemVerdictHref(item, interaction);
if (!href) return null;
const isInternal = href.startsWith("/") || href.startsWith("#");
const className =
@@ -3516,7 +3517,7 @@ function RequestItemVerdictsCard({
<div className="min-w-0 flex-1 basis-64">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium leading-5 text-foreground">{item.label}</span>
<ItemVerdictDeepLink item={item} />
<ItemVerdictDeepLink item={item} interaction={interaction} />
</div>
{item.description ? (
<p className="mt-0.5 text-sm leading-5 text-muted-foreground">{item.description}</p>
@@ -7,7 +7,7 @@ import type { AttentionItem, DecisionHistoryItem } from "@paperclipai/shared";
import { CoordinatorNeedsYou } from "./CoordinatorNeedsYou";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const fixture = vi.hoisted(() => ({ pending: [] as AttentionItem[], history: [] as DecisionHistoryItem[], openPanel: vi.fn(), closePanel: vi.fn(), setPanelVisible: vi.fn(), navigate: vi.fn(), breadcrumbs: vi.fn() }));
const fixture = vi.hoisted(() => ({ pending: [] as AttentionItem[], history: [] as DecisionHistoryItem[], historyFail: false, feedFail: false, openPanel: vi.fn(), closePanel: vi.fn(), setPanelVisible: vi.fn(), navigate: vi.fn(), breadcrumbs: vi.fn() }));
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company" }) }));
vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: fixture.breadcrumbs }) }));
vi.mock("@/context/PanelContext", () => ({ usePanel: () => ({ openPanel: fixture.openPanel, closePanel: fixture.closePanel, setPanelVisible: fixture.setPanelVisible }) }));
@@ -16,8 +16,8 @@ vi.mock("@/lib/router", () => ({ useNavigate: () => fixture.navigate }));
vi.mock("@/api/agents", () => ({ agentsApi: { list: async () => [] } }));
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } }));
vi.mock("@/api/attention", () => ({ attentionApi: {
list: async () => ({ items: [...fixture.pending] }),
history: async () => ({ items: [...fixture.history], nextCursor: null }),
list: async () => { if (fixture.feedFail) throw new Error("Feed unavailable"); return { items: [...fixture.pending] }; },
history: async () => { if (fixture.historyFail) throw new Error("History unavailable"); return { items: [...fixture.history], nextCursor: null }; },
} }));
vi.mock("motion/react", () => ({
LayoutGroup: ({ children }: { children: ReactNode }) => children,
@@ -42,6 +42,7 @@ let container: HTMLDivElement;
beforeEach(() => {
fixture.pending = ["1", "2", "3"].map((id) => ({ id, sourceKind: "issue_thread_interaction", inlineResolvable: true, subject: { id, kind: "interaction", title: `Review ${id}`, metadata: { issueId: `task-${id}` } }, relatedIssue: { id: `task-${id}`, identifier: `PAP-${id}`, title: `Task ${id}`, href: `/PAP/issues/PAP-${id}` }, activityAt: new Date().toISOString() } as unknown as AttentionItem));
fixture.history = []; vi.clearAllMocks();
fixture.historyFail = false; fixture.feedFail = false;
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.useRealTimers(); vi.restoreAllMocks(); });
@@ -52,6 +53,17 @@ async function click(text: string) {
await act(async () => button!.click()); await flush();
}
describe("Coordinator Needs You", () => {
it.each([false, true])("does not restore a saved decision when history refresh fails (stale feed: %s)", async staleFeed => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}><CoordinatorNeedsYou /></QueryClientProvider>));
await flush();
fixture.historyFail = true; fixture.feedFail = staleFeed;
await click("Resolve 1"); await flush();
expect([...container.querySelectorAll("button")].some(button => button.textContent === "Resolve 1")).toBe(false);
expect(container.textContent).toContain("Recent decisions could not be loaded");
expect(container.textContent).toContain("Resolve 2");
client.clear();
});
it("sorts, groups, and filters the recent decisions shown beneath the toolbar", async () => {
fixture.pending = [];
const now = Date.now();
@@ -89,6 +89,8 @@ export function CoordinatorNeedsYou() {
const [historyOpen, setHistoryOpen] = useState(false);
const [now, setNow] = useState(Date.now);
const [settling, setSettling] = useState<AttentionItem[]>([]);
const [savedIds, setSavedIds] = useState<Set<string>>(() => new Set());
useEffect(() => { setSettling([]); setSavedIds(new Set()); }, [selectedCompanyId, userId]);
const [renderLimit, setRenderLimit] = useState(40);
const feed = useQuery({ queryKey: coordinatorAttentionQueryKey(selectedCompanyId!),
queryFn: () => attentionApi.list(selectedCompanyId!, { all: true }), enabled: !!selectedCompanyId, refetchOnWindowFocus: true });
@@ -122,8 +124,8 @@ export function CoordinatorNeedsYou() {
const receipts = new Set(recentItems.map((item) => item.id));
const items = new Map((feed.data?.items ?? []).map((item) => [`${item.sourceKind}:${item.subject.id}`, item]));
for (const item of settling) if (!items.has(`${item.sourceKind}:${item.subject.id}`)) items.set(`${item.sourceKind}:${item.subject.id}`, item);
return [...items].filter(([key]) => !receipts.has(key) || settlingIds.has(key)).map(([, item]) => item);
}, [feed.data, settling, settlingIds, recentItems]);
return [...items].filter(([key]) => settlingIds.has(key) || (!receipts.has(key) && !savedIds.has(key))).map(([, item]) => item);
}, [feed.data, settling, settlingIds, recentItems, savedIds]);
const filtered = useMemo(() => pendingItems.filter((item) => {
const agentId = String(item.subject.metadata?.createdByAgentId ?? item.subject.metadata?.originAgentId ?? "");
if (agentFilter && agentId !== agentFilter && item.originAgentName !== agentMap.get(agentFilter)?.name) return false;
@@ -161,7 +163,15 @@ export function CoordinatorNeedsYou() {
const timer = window.setTimeout(() => setNow(Date.now()), Math.max(0, next - Date.now()) + 1);
return () => window.clearTimeout(timer);
}, [recentItems]);
const onResolved = (item: AttentionItem) => { setSettling((current) => current.some((entry) => entry.id === item.id) ? current : [...current, item]); void recent.refetch(); void feed.refetch(); };
const onResolved = (item: AttentionItem) => {
const id = `${item.sourceKind}:${item.subject.id}`;
setSavedIds(current => new Set([...current, id]));
setSettling(current => current.some(entry => entry.id === item.id) ? current : [...current, item]);
void recent.refetch().then(result => {
if (result.isError) setSettling(current => current.filter(entry => `${entry.sourceKind}:${entry.subject.id}` !== id));
});
void feed.refetch();
};
const duration = typeof document === "undefined" ? 0 : parseCssTimeMs(getComputedStyle(document.documentElement).getPropertyValue("--motion-duration-base")) / 1_000;
if (!selectedCompanyId) return <p className="text-sm text-muted-foreground">Choose a company to see its decisions.</p>;
return <LayoutGroup id="coordinator-decisions"><section className="coordinator-needs-you mx-auto w-full max-w-4xl space-y-6" aria-label="Needs You">
@@ -1,3 +1,4 @@
import { itemVerdictHref } from "@/lib/item-verdict-links";
import {
useEffect,
useMemo,
@@ -1641,9 +1642,7 @@ function ItemVerdictsCard({
);
const resolved = resolvedById.get(item.id);
const draft = drafts.get(item.id);
const itemHref = item.href
? normalizeRequestConfirmationTargetHref(item.href)
: null;
const itemHref = itemVerdictHref(item, interaction);
const rejectionNeedsConfirmation = Boolean(
!resolved &&
draft &&
@@ -838,6 +838,15 @@ describe("TaskChatInteractionCard", () => {
}]);
});
it("opens the bound output revision from a compact review item", () => {
const interaction = { ...pendingRequestItemVerdictsInteraction, payload: { ...pendingRequestItemVerdictsInteraction.payload,
items: [{ id: "copy", label: "Reviewed copy", target: { type: "issue_document" as const, issueId: "owner", key: "copy", revisionId: "reviewed-v1" } }],
} };
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionVerdicts={() => undefined} /></ThemeProvider></TooltipProvider>));
expect(container.querySelector('a[href*="document-copy&revision=reviewed-v1"]')?.getAttribute("href"))
.toBe(`/issues/owner#document-copy&revision=reviewed-v1&interaction=${interaction.id}`);
});
it("paginates item verdicts instead of expanding the whole review set", async () => {
flushSync(() => {
root.render(
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { itemVerdictHref } from "./item-verdict-links";
describe("review output links", () => {
const interaction = { issueId: "review-task", id: "review" };
it("pins the exact document owner and revision ahead of a supplied display URL", () => {
expect(itemVerdictHref({ id: "copy", label: "Copy", href: "https://example.com/latest", target: {
type: "issue_document", issueId: "output-task", key: "launch copy", revisionId: "reviewed-v2",
} }, interaction)).toBe("/issues/output-task#document-launch%20copy&revision=reviewed-v2&interaction=review");
});
it("opens the immutable attachment in its owning task", () => {
expect(itemVerdictHref({ id: "art", label: "Artwork", target: { type: "issue_attachment", issueId: "owner", attachmentId: "asset-file", assetId: "asset", sha256: "hash" } }, interaction))
.toBe("/issues/owner#interaction-review&attachment=asset-file");
});
it("keeps safe legacy links and rejects unsafe custom URLs", () => {
expect(itemVerdictHref({ id: "legacy", label: "Legacy", href: "javascript:alert(1)" }, interaction)).toBeNull();
expect(itemVerdictHref({ id: "legacy", label: "Legacy", href: "/issues/old" }, interaction)).toBe("/issues/old");
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { RequestItemVerdictsItem } from "@paperclipai/shared";
import { buildDocumentAnnotationHash } from "./document-annotation-hash";
import { normalizeRequestConfirmationTargetHref } from "./issue-thread-interactions";
/** The bound output takes precedence over an optional display URL. */
export function itemVerdictHref(item: RequestItemVerdictsItem, interaction: { issueId: string; id: string }): string | null {
const target = item.target;
if (target?.type === "issue_document") {
return `/issues/${encodeURIComponent(target.issueId ?? interaction.issueId)}${buildDocumentAnnotationHash({
documentKey: target.key, revisionId: target.revisionId, interactionId: interaction.id, threadId: null, commentId: null,
})}`;
}
if (target?.type === "issue_attachment") {
return `/issues/${encodeURIComponent(target.issueId ?? interaction.issueId)}#interaction-${encodeURIComponent(interaction.id)}&attachment=${encodeURIComponent(target.attachmentId)}`;
}
return item.href ? normalizeRequestConfirmationTargetHref(item.href) : null;
}
+13 -3
View File
@@ -9,14 +9,14 @@ import { ApiError } from "@/api/client";
import { queryKeys } from "@/lib/queryKeys";
import { AgentChat } from "./AgentChat";
const api = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), getChat: vi.fn(), ensure: vi.fn(), session: vi.fn() }));
const api = vi.hoisted(() => ({ enabled: true, list: vi.fn(), get: vi.fn(), getChat: vi.fn(), ensure: vi.fn(), session: vi.fn() }));
const agentId = "18c5ad50-9d70-4e24-bd82-337e1d45a414";
vi.mock("@/api/agents", () => ({ agentsApi: { list: api.list, get: api.get } }));
vi.mock("@/api/agentChats", () => ({ agentChatsApi: { get: api.getChat, ensure: api.ensure } }));
vi.mock("@/api/auth", () => ({ authApi: { getSession: api.session } }));
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-a" }) }));
vi.mock("@/hooks/useAgentChatEnabled", () => ({ useAgentChatEnabled: () => ({ enabled: true, loaded: true }) }));
vi.mock("@/lib/router", () => ({ useParams: () => ({ agentRef: "18c5ad50-9d70-4e24-bd82-337e1d45a414" }) }));
vi.mock("@/hooks/useAgentChatEnabled", () => ({ useAgentChatEnabled: () => ({ enabled: api.enabled, loaded: true }) }));
vi.mock("@/lib/router", () => ({ useParams: () => ({ agentRef: "18c5ad50-9d70-4e24-bd82-337e1d45a414" }), Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a> }));
vi.mock("./IssueDetail", () => ({ TaskDetailSurface: ({ conversation }: { conversation: { agent: Agent } }) => <div>History for {conversation.agent.name}</div> }));
let root: Root;
@@ -42,6 +42,7 @@ async function failRoster(status: number, cached = true) {
}
beforeEach(() => {
vi.clearAllMocks();
api.enabled = true;
client = new QueryClient({ defaultOptions: { queries: { retry: false, retryOnMount: false, staleTime: Infinity, gcTime: Infinity } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "viewer-a" } });
api.session.mockResolvedValue({ user: { id: "viewer-a" } });
@@ -51,6 +52,15 @@ beforeEach(() => {
});
afterEach(() => { flushSync(() => root.unmount()); client.clear(); container.remove(); });
it("explains when Agent Chat is disabled without creating a conversation", async () => {
api.enabled = false;
api.list.mockResolvedValue([{ id: agentId, companyId: "company-a", name: "Helper" }]);
render();
await waitFor(() => expect(container.textContent).toContain("Agent Chat is disabled"));
expect(api.ensure).not.toHaveBeenCalled();
expect(api.getChat).not.toHaveBeenCalled();
});
it.each(["cached_refetch_failed", "fresh"])("opens historical chat from an available roster (%s)", async state => {
if (state === "cached_refetch_failed") await failRoster(429);
else client.setQueryData(queryKeys.agents.list("company-a"), []);
+1 -1
View File
@@ -86,7 +86,7 @@ export function AgentChat() {
if (!enabled && !chat.data)
return (
<p className="text-sm text-muted-foreground">
Agent Chat is off for this instance. An administrator can enable it in{" "}
Agent Chat is disabled for this instance. An administrator can enable it in{" "}
<Link className="underline underline-offset-4" to="/company/settings/instance/experimental#experimental-enableAgentChat">Experimental settings</Link>.
{" "}Your workspace preference does not change Chat availability. Existing history remains available through task links.
</p>