From 7eb5295c16ddeb0cfed9bdfc45d8b90756a009c3 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Tue, 23 Jun 2026 09:02:57 +0000 Subject: [PATCH 1/2] Generated with Hive: Gate agent session eval flagging UI to stakwork workspace and add flag buttons to all assistant messages --- .../agent-logs/AgentLogDetailPage.test.tsx | 76 ++++++++++++++++ .../agent-logs/LogsArtifactPanel.test.tsx | 88 ++++++++++++++++++- .../agent-logs/MessageBubble.test.tsx | 60 +++++++++++++ src/app/w/[slug]/agent-logs/[logId]/page.tsx | 2 +- .../agent-logs/LogDetailContent.tsx | 9 +- .../agent-logs/LogsArtifactPanel.tsx | 24 ++++- 6 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/unit/components/agent-logs/AgentLogDetailPage.test.tsx diff --git a/src/__tests__/unit/components/agent-logs/AgentLogDetailPage.test.tsx b/src/__tests__/unit/components/agent-logs/AgentLogDetailPage.test.tsx new file mode 100644 index 0000000000..0b7ff45e08 --- /dev/null +++ b/src/__tests__/unit/components/agent-logs/AgentLogDetailPage.test.tsx @@ -0,0 +1,76 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render } from "@testing-library/react"; + +globalThis.React = React; + +let mockSlug = "other-workspace"; +const mockLogId = "log-abc"; + +vi.mock("next/navigation", () => ({ + useParams: () => ({ slug: mockSlug, logId: mockLogId }), + useRouter: () => ({ push: vi.fn() }), +})); + +const capturedOnFlagTurn: Array<((i: number) => void) | undefined> = []; + +vi.mock("@/components/agent-logs/LogDetailContent", () => ({ + LogDetailContent: ({ onFlagTurn }: { onFlagTurn?: (i: number) => void }) => { + capturedOnFlagTurn.push(onFlagTurn); + return React.createElement("div", { "data-testid": "log-detail-content" }); + }, +})); + +vi.mock("@/components/evals/FlagAsEvalModal", () => ({ + FlagAsEvalModal: () => React.createElement("div", { "data-testid": "flag-eval-modal" }), +})); + +vi.mock("@/components/evals/AgentSessionCaptureModal", () => ({ + AgentSessionCaptureModal: () => React.createElement("div", { "data-testid": "capture-modal" }), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, onClick, disabled }: React.ButtonHTMLAttributes & { children?: React.ReactNode }) => + React.createElement("button", { onClick, disabled }, children), +})); + +vi.mock("@/components/ui/page-header", () => ({ + PageHeader: () => React.createElement("div", null, "Page Header"), +})); + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +vi.mock("lucide-react", () => ({ + ArrowLeft: () => React.createElement("span", null, "←"), + FileText: () => React.createElement("span", null, "📄"), + Flag: () => React.createElement("span", null, "🚩"), + Share2: () => React.createElement("span", null, "🔗"), +})); + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +describe("AgentLogDetailPage — onFlagTurn gate", () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedOnFlagTurn.length = 0; + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ conversation: [], stats: null }) }); + }); + + it("does NOT pass onFlagTurn to LogDetailContent when slug is not 'stakwork'", async () => { + mockSlug = "other-workspace"; + const { default: AgentLogDetailPage } = await import("@/app/w/[slug]/agent-logs/[logId]/page"); + render(React.createElement(AgentLogDetailPage)); + expect(capturedOnFlagTurn[0]).toBeUndefined(); + }); + + it("passes onFlagTurn to LogDetailContent when slug is 'stakwork'", async () => { + mockSlug = "stakwork"; + const { default: AgentLogDetailPage } = await import("@/app/w/[slug]/agent-logs/[logId]/page"); + render(React.createElement(AgentLogDetailPage)); + expect(capturedOnFlagTurn[0]).toBeTypeOf("function"); + }); +}); diff --git a/src/__tests__/unit/components/agent-logs/LogsArtifactPanel.test.tsx b/src/__tests__/unit/components/agent-logs/LogsArtifactPanel.test.tsx index 1d42e95f0d..994a62ef64 100644 --- a/src/__tests__/unit/components/agent-logs/LogsArtifactPanel.test.tsx +++ b/src/__tests__/unit/components/agent-logs/LogsArtifactPanel.test.tsx @@ -10,9 +10,31 @@ globalThis.React = React; // ── Mocks ───────────────────────────────────────────────────────────────────── +let mockSlug = "other-workspace"; +vi.mock("next/navigation", () => ({ + useParams: () => ({ slug: mockSlug }), +})); + +vi.mock("@/components/evals/AgentSessionCaptureModal", () => ({ + AgentSessionCaptureModal: ({ open, slug, logId, turnIndex }: { open: boolean; slug: string; logId: string; turnIndex?: number }) => + open + ? React.createElement("div", { + "data-testid": "capture-modal", + "data-slug": slug, + "data-log-id": logId, + "data-turn-index": turnIndex ?? "undefined", + }) + : null, +})); + vi.mock("@/components/agent-logs/LogDetailContent", () => ({ - MessageBubble: ({ message }: { message: { role: string; content: string } }) => - React.createElement("div", { "data-testid": "log-message" }, `${message.role}:${message.content}`), + MessageBubble: ({ message, onFlag }: { message: { role: string; content: string }; onFlag?: () => void }) => + React.createElement( + "div", + { "data-testid": "log-message", "data-role": message.role }, + `${message.role}:${message.content}`, + onFlag ? React.createElement("button", { "data-testid": "flag-btn", onClick: onFlag }, "flag") : null, + ), StatsBar: ({ stats }: { stats: { messageCount: number } }) => React.createElement("div", { "data-testid": "log-stats" }, `messages:${stats.messageCount}`), unescapeLogString: (s: string) => s, @@ -56,6 +78,7 @@ const singleLogWithTimestamp = [{ id: "log-123", agent: `coding-agent-${FEATURE_ describe("LogsArtifactPanel", () => { beforeEach(() => { vi.clearAllMocks(); + mockSlug = "other-workspace"; globalThis.URL.createObjectURL = vi.fn(() => "blob:fake"); globalThis.URL.revokeObjectURL = vi.fn(); }); @@ -429,3 +452,64 @@ describe("LogsArtifactPanel — lastUpdated cache invalidation", () => { }); }); }); + +// ── Flag button / stakwork gate tests ───────────────────────────────────────── + +describe("LogsArtifactPanel — flag buttons and stakwork gate", () => { + const assistantStats = { + conversation: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, + ], + stats: { messageCount: 2, tokenEstimate: 50, toolUsage: {}, bashCommands: [] }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockSlug = "other-workspace"; + globalThis.URL.createObjectURL = vi.fn(() => "blob:fake"); + globalThis.URL.revokeObjectURL = vi.fn(); + }); + + it("does not render flag buttons when slug is not 'stakwork'", async () => { + mockSlug = "other-workspace"; + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => assistantStats }); + const { LogsArtifactPanel } = await import("@/components/agent-logs/LogsArtifactPanel"); + render(React.createElement(LogsArtifactPanel, { logs: singleLog })); + await waitFor(() => screen.getAllByTestId("log-message")); + expect(screen.queryByTestId("flag-btn")).toBeNull(); + }); + + it("renders flag buttons only on assistant messages when slug is 'stakwork'", async () => { + mockSlug = "stakwork"; + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => assistantStats }); + const { LogsArtifactPanel } = await import("@/components/agent-logs/LogsArtifactPanel"); + render(React.createElement(LogsArtifactPanel, { logs: singleLog })); + await waitFor(() => screen.getAllByTestId("log-message")); + expect(screen.queryAllByTestId("flag-btn")).toHaveLength(1); + }); + + it("opens capture modal with i-1 turnIndex when flag button is clicked", async () => { + mockSlug = "stakwork"; + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => assistantStats }); + const { LogsArtifactPanel } = await import("@/components/agent-logs/LogsArtifactPanel"); + render(React.createElement(LogsArtifactPanel, { logs: singleLog })); + await waitFor(() => screen.getAllByTestId("log-message")); + await userEvent.click(screen.getByTestId("flag-btn")); + await waitFor(() => { + const modal = screen.getByTestId("capture-modal"); + expect(modal).toBeDefined(); + // assistant is at index 1, so turnIndex = i-1 = 0 + expect(modal.getAttribute("data-turn-index")).toBe("0"); + }); + }); + + it("does not render AgentSessionCaptureModal when slug is not 'stakwork'", async () => { + mockSlug = "other-workspace"; + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => assistantStats }); + const { LogsArtifactPanel } = await import("@/components/agent-logs/LogsArtifactPanel"); + render(React.createElement(LogsArtifactPanel, { logs: singleLog })); + await waitFor(() => screen.getAllByTestId("log-message")); + expect(screen.queryByTestId("capture-modal")).toBeNull(); + }); +}); diff --git a/src/__tests__/unit/components/agent-logs/MessageBubble.test.tsx b/src/__tests__/unit/components/agent-logs/MessageBubble.test.tsx index 61da8a1ba6..79966d73c5 100644 --- a/src/__tests__/unit/components/agent-logs/MessageBubble.test.tsx +++ b/src/__tests__/unit/components/agent-logs/MessageBubble.test.tsx @@ -62,6 +62,7 @@ vi.mock("lucide-react", () => ({ ChevronRight: () => React.createElement("span", { "data-testid": "chevron-right" }), Copy: () => React.createElement("span", { "data-testid": "icon-copy" }), Check: () => React.createElement("span", { "data-testid": "icon-check" }), + Flag: () => React.createElement("span", { "data-testid": "icon-flag" }), })); describe("MessageBubble reasoning rendering", () => { @@ -178,3 +179,62 @@ describe("MessageBubble timestamp rendering", () => { expect(screen.queryByTestId("tooltip-content")).toBeNull(); }); }); + +describe("MessageBubble flag button", () => { + it("renders flag button on assistant message with text when onFlag is provided", () => { + const onFlag = vi.fn(); + const message: ParsedMessage = { role: "assistant", content: "Hello" }; + render(React.createElement(MessageBubble, { message, onFlag })); + expect(screen.getByTestId("icon-flag")).toBeDefined(); + }); + + it("does not render flag button on assistant message when onFlag is not provided", () => { + const message: ParsedMessage = { role: "assistant", content: "Hello" }; + render(React.createElement(MessageBubble, { message })); + expect(screen.queryByTestId("icon-flag")).toBeNull(); + }); + + it("renders flag button on tool-only assistant message when onFlag is provided", () => { + const onFlag = vi.fn(); + const message: ParsedMessage = { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "tc1", toolName: "search", input: {} }], + }; + render(React.createElement(MessageBubble, { message, onFlag })); + expect(screen.getByTestId("icon-flag")).toBeDefined(); + }); + + it("does not render flag button on user message even when onFlag is provided", () => { + const onFlag = vi.fn(); + const message: ParsedMessage = { role: "user", content: "Hello" }; + render(React.createElement(MessageBubble, { message, onFlag })); + expect(screen.queryByTestId("icon-flag")).toBeNull(); + }); + + it("does not render flag button on system message even when onFlag is provided", () => { + const onFlag = vi.fn(); + const message: ParsedMessage = { role: "system", content: "System prompt" }; + render(React.createElement(MessageBubble, { message, onFlag })); + expect(screen.queryByTestId("icon-flag")).toBeNull(); + }); + + it("does not render flag button on tool-result message even when onFlag is provided", () => { + const onFlag = vi.fn(); + const message: ParsedMessage = { + role: "tool", + content: [{ type: "tool-result", toolCallId: "tc1", toolName: "search", output: "result" }], + }; + render(React.createElement(MessageBubble, { message, onFlag })); + expect(screen.queryByTestId("icon-flag")).toBeNull(); + }); + + it("calls onFlag when flag button is clicked on assistant message", async () => { + const user = userEvent.setup(); + const onFlag = vi.fn(); + const message: ParsedMessage = { role: "assistant", content: "Hello" }; + render(React.createElement(MessageBubble, { message, onFlag })); + const flagBtn = screen.getByTestId("icon-flag").closest("button")!; + await user.click(flagBtn); + expect(onFlag).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/w/[slug]/agent-logs/[logId]/page.tsx b/src/app/w/[slug]/agent-logs/[logId]/page.tsx index e6e4f8752e..3065aa13a9 100644 --- a/src/app/w/[slug]/agent-logs/[logId]/page.tsx +++ b/src/app/w/[slug]/agent-logs/[logId]/page.tsx @@ -131,7 +131,7 @@ export default function AgentLogDetailPage() { rawContent={rawContent} loading={loading} error={error} - onFlagTurn={(i) => { setCaptureTurnIndex(i); setCaptureOpen(true); }} + onFlagTurn={slug === "stakwork" ? (i) => { setCaptureTurnIndex(i); setCaptureOpen(true); } : undefined} /> ))} + {onFlag && ( + + )} ); } @@ -534,7 +539,7 @@ export function MessageBubble({ )} - {onFlag && ( + {onFlag && isAssistant && ( @@ -755,7 +760,7 @@ export function LogDetailContent({ message={msg} toolCallIndex={toolCallIndex} consumedResultIds={consumedResultIds} - onFlag={onFlagTurn ? () => onFlagTurn(i) : undefined} + onFlag={onFlagTurn && msg.role === "assistant" ? () => onFlagTurn(i - 1) : undefined} /> ))} diff --git a/src/components/agent-logs/LogsArtifactPanel.tsx b/src/components/agent-logs/LogsArtifactPanel.tsx index 0297d2c5c8..48890ababd 100644 --- a/src/components/agent-logs/LogsArtifactPanel.tsx +++ b/src/components/agent-logs/LogsArtifactPanel.tsx @@ -1,12 +1,14 @@ "use client"; import React, { useEffect, useMemo, useState } from "react"; +import { useParams } from "next/navigation"; import { Download, Loader2, Sparkles, ChevronDown, ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { MessageBubble, StatsBar, unescapeLogString } from "./LogDetailContent"; +import { AgentSessionCaptureModal } from "@/components/evals/AgentSessionCaptureModal"; import type { ParsedMessage, AgentLogStats } from "@/lib/utils/agent-log-stats"; import type { ConversationMessage } from "@/hooks/useStreamedAgentLog"; import type { AgentEventsStatus } from "@/hooks/useAgentEvents"; @@ -84,12 +86,18 @@ export function LogsArtifactPanel({ featureId, isSuperAdmin, }: LogsArtifactPanelProps) { + const params = useParams(); + const slug = params?.slug as string | undefined; + const isStakwork = slug === "stakwork"; + const hasProvisional = !!streamingLog && streamingLog.status === "streaming"; const defaultId = logs[logs.length - 1]?.id ?? (hasProvisional ? PROVISIONAL_ID : null); const [selectedId, setSelectedId] = useState(() => defaultId); const [logStates, setLogStates] = useState>({}); + const [captureOpen, setCaptureOpen] = useState(false); + const [captureTurnIndex, setCaptureTurnIndex] = useState(undefined); // Scorer insights state const [insights, setInsights] = useState([]); @@ -486,7 +494,11 @@ export function LogsArtifactPanel({ {current.conversation ? (
{current.conversation.map((msg, i) => ( - + { setCaptureTurnIndex(i - 1); setCaptureOpen(true); } : undefined} + /> ))}
) : ( @@ -498,6 +510,16 @@ export function LogsArtifactPanel({ )} )} + + {isStakwork && selectedId && selectedId !== PROVISIONAL_ID && selectedId !== INSIGHTS_ID && ( + + )} ); } From 9eb528d8b43b2dcf309ea6f6a17b24fc75208f38 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Tue, 23 Jun 2026 09:12:48 +0000 Subject: [PATCH 2/2] Fix flag button to render for user messages too; update MessageBubble test expectations --- .../unit/components/agent-logs/MessageBubble.test.tsx | 4 ++-- src/components/agent-logs/LogDetailContent.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/unit/components/agent-logs/MessageBubble.test.tsx b/src/__tests__/unit/components/agent-logs/MessageBubble.test.tsx index 79966d73c5..b511434bf7 100644 --- a/src/__tests__/unit/components/agent-logs/MessageBubble.test.tsx +++ b/src/__tests__/unit/components/agent-logs/MessageBubble.test.tsx @@ -204,11 +204,11 @@ describe("MessageBubble flag button", () => { expect(screen.getByTestId("icon-flag")).toBeDefined(); }); - it("does not render flag button on user message even when onFlag is provided", () => { + it("renders flag button on user message when onFlag is provided", () => { const onFlag = vi.fn(); const message: ParsedMessage = { role: "user", content: "Hello" }; render(React.createElement(MessageBubble, { message, onFlag })); - expect(screen.queryByTestId("icon-flag")).toBeNull(); + expect(screen.getByTestId("icon-flag")).toBeDefined(); }); it("does not render flag button on system message even when onFlag is provided", () => { diff --git a/src/components/agent-logs/LogDetailContent.tsx b/src/components/agent-logs/LogDetailContent.tsx index 966ffeb9e4..21b92532e6 100644 --- a/src/components/agent-logs/LogDetailContent.tsx +++ b/src/components/agent-logs/LogDetailContent.tsx @@ -539,7 +539,7 @@ export function MessageBubble({ )} - {onFlag && isAssistant && ( + {onFlag && (