Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement> & { 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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
});
});
60 changes: 60 additions & 0 deletions src/__tests__/unit/components/agent-logs/MessageBubble.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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("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.getByTestId("icon-flag")).toBeDefined();
});

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();
});
});
2 changes: 1 addition & 1 deletion src/app/w/[slug]/agent-logs/[logId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>

<FlagAsEvalModal
Expand Down
7 changes: 6 additions & 1 deletion src/components/agent-logs/LogDetailContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,11 @@ export function MessageBubble({
/>
))}
</div>
{onFlag && (
<Button variant="ghost" size="icon" onClick={onFlag} className="h-6 w-6 shrink-0 mt-0.5 self-start">
<Flag className="h-3.5 w-3.5" />
</Button>
)}
</div>
);
}
Expand Down Expand Up @@ -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}
/>
))}
</div>
Expand Down
24 changes: 23 additions & 1 deletion src/components/agent-logs/LogsArtifactPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string | null>(() => defaultId);
const [logStates, setLogStates] = useState<Record<string, LogState>>({});
const [captureOpen, setCaptureOpen] = useState(false);
const [captureTurnIndex, setCaptureTurnIndex] = useState<number | undefined>(undefined);

// Scorer insights state
const [insights, setInsights] = useState<ScorerInsight[]>([]);
Expand Down Expand Up @@ -486,7 +494,11 @@ export function LogsArtifactPanel({
{current.conversation ? (
<div className="space-y-3">
{current.conversation.map((msg, i) => (
<MessageBubble key={i} message={msg} />
<MessageBubble
key={i}
message={msg}
onFlag={isStakwork && msg.role === "assistant" ? () => { setCaptureTurnIndex(i - 1); setCaptureOpen(true); } : undefined}
/>
))}
</div>
) : (
Expand All @@ -498,6 +510,16 @@ export function LogsArtifactPanel({
)}
</>
)}

{isStakwork && selectedId && selectedId !== PROVISIONAL_ID && selectedId !== INSIGHTS_ID && (
<AgentSessionCaptureModal
open={captureOpen}
onOpenChange={setCaptureOpen}
slug={slug as string}
logId={selectedId}
turnIndex={captureTurnIndex}
/>
)}
</div>
);
}
Loading