Compare commits

...
Author SHA1 Message Date
Alan Buscaglia d32ec32954 feat(ui): add Lighthouse request outcome feedback 2026-08-11 11:16:19 +02:00
9 changed files with 338 additions and 4 deletions
@@ -122,6 +122,35 @@ describe("lighthouse-v2.adapter", () => {
});
});
it("should map the durable run outcome attached to a message", () => {
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
id: "message-1",
type: "lighthouse-messages",
attributes: {
role: "assistant",
model: "gpt-5.5",
token_usage: null,
inserted_at: "2026-06-24T10:01:00Z",
parts: [],
run: {
id: "run-1",
status: "completed",
terminal_code: null,
has_assistant_message: true,
feedback_rating: "up",
},
},
};
expect(mapLighthouseV2Message(resource).run).toEqual({
id: "run-1",
status: "completed",
terminalCode: null,
hasAssistantMessage: true,
feedbackRating: "up",
});
});
it("should give id-less parts stable fallback keys instead of empty strings", () => {
// Given
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
@@ -4,11 +4,14 @@ import {
type LighthouseV2ConfigurationInput,
type LighthouseV2ConfigurationUpdateInput,
type LighthouseV2Credentials,
type LighthouseV2FeedbackRating,
type LighthouseV2Message,
type LighthouseV2MessageRole,
type LighthouseV2Part,
type LighthouseV2PartType,
type LighthouseV2ProviderType,
type LighthouseV2RunFeedbackInput,
type LighthouseV2RunStatus,
type LighthouseV2Session,
type LighthouseV2SupportedModel,
type LighthouseV2SupportedProvider,
@@ -63,6 +66,15 @@ interface MessageAttributes {
token_usage: unknown;
inserted_at: string;
parts?: UnknownPartResource[];
run?: RunAttributes | null;
}
interface RunAttributes {
id: string;
status: LighthouseV2RunStatus;
terminal_code: string | null;
has_assistant_message: boolean;
feedback_rating: LighthouseV2FeedbackRating | null;
}
interface PartAttributes {
@@ -180,6 +192,15 @@ export function mapLighthouseV2Message(
parts: (resource.attributes.parts ?? []).map((part, index) =>
mapLighthouseV2Part(part, index),
),
run: resource.attributes.run
? {
id: resource.attributes.run.id,
status: resource.attributes.run.status,
terminalCode: resource.attributes.run.terminal_code,
hasAssistantMessage: resource.attributes.run.has_assistant_message,
feedbackRating: resource.attributes.run.feedback_rating,
}
: null,
};
}
@@ -255,6 +276,20 @@ export function buildLighthouseV2SessionUpdatePayload(
};
}
export function buildLighthouseV2RunFeedbackPayload(
input: LighthouseV2RunFeedbackInput,
) {
return {
data: {
type: "lighthouse-agent-run-feedbacks",
attributes: {
rating: input.rating,
idempotency_key: input.idempotencyKey,
},
},
};
}
export function buildLighthouseV2MessagePayload(input: {
displayText: string;
context?: LighthouseContextEnvelope;
@@ -27,6 +27,7 @@ vi.mock("@/lib/helper", () => ({
import {
createLighthouseV2Session,
getLighthouseV2SupportedModels,
submitLighthouseV2RunFeedback,
updateLighthouseV2Configuration,
updateLighthouseV2Session,
} from "./lighthouse-v2";
@@ -156,4 +157,45 @@ describe("Lighthouse v2 session write actions", () => {
}),
);
});
it("submits only the allowlisted run feedback fields", async () => {
const fetchMock = vi.fn().mockResolvedValue(
Response.json(
{
data: {
id: "feedback-1",
type: "lighthouse-agent-run-feedbacks",
attributes: { rating: "down", revision: 1 },
},
},
{ status: 201 },
),
);
vi.stubGlobal("fetch", fetchMock);
await submitLighthouseV2RunFeedback({
sessionId: "session-1",
runId: "run-1",
rating: "down",
idempotencyKey: "8b18c413-8596-4b50-92ee-ab6d712279aa",
});
expect(fetchMock).toHaveBeenCalledWith(
new URL(
"https://api.example.com/api/v1/lighthouse/sessions/session-1/runs/run-1/feedback",
),
expect.objectContaining({
method: "POST",
body: JSON.stringify({
data: {
type: "lighthouse-agent-run-feedbacks",
attributes: {
rating: "down",
idempotency_key: "8b18c413-8596-4b50-92ee-ab6d712279aa",
},
},
}),
}),
);
});
});
@@ -7,6 +7,7 @@ import type {
LighthouseV2ConfigurationUpdateInput,
LighthouseV2Message,
LighthouseV2ProviderType,
LighthouseV2RunFeedbackInput,
LighthouseV2SendMessageInput,
LighthouseV2SendMessageResult,
LighthouseV2Session,
@@ -23,6 +24,7 @@ import {
buildLighthouseV2ConfigurationPayload,
buildLighthouseV2ConfigurationUpdatePayload,
buildLighthouseV2MessagePayload,
buildLighthouseV2RunFeedbackPayload,
buildLighthouseV2SessionCreatePayload,
buildLighthouseV2SessionUpdatePayload,
getJsonApiArray,
@@ -251,6 +253,20 @@ export async function sendLighthouseV2Message(
}
}
export async function submitLighthouseV2RunFeedback(
input: LighthouseV2RunFeedbackInput,
): Promise<LighthouseV2ActionResult<true>> {
return mutateSingle(
`${SESSIONS_ENDPOINT}/${encodeURIComponent(input.sessionId)}/runs/${encodeURIComponent(input.runId)}/feedback`,
{
method: "POST",
body: JSON.stringify(buildLighthouseV2RunFeedbackPayload(input)),
},
() => true,
"",
);
}
async function getCollection<TResource, TOutput>(
path: string,
mapper: (resource: TResource) => TOutput,
@@ -60,6 +60,7 @@ export function LighthouseV2ChatView({
const state = useLighthouseChatStore((current) => current);
const {
config,
activeSessionId,
messages,
streamState,
input,
@@ -186,7 +187,11 @@ export function LighthouseV2ChatView({
scrollClassName="minimal-scrollbar overflow-x-hidden overflow-y-auto"
>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
<MessageBubble
key={message.id}
message={message}
sessionId={activeSessionId ?? undefined}
/>
))}
{hasLiveAssistantActivity && (
<StreamingAssistantMessage streamState={streamState} />
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
@@ -10,6 +10,14 @@ import {
import { MessageBubble } from "./message-bubble";
const { submitFeedbackMock } = vi.hoisted(() => ({
submitFeedbackMock: vi.fn(),
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
submitLighthouseV2RunFeedback: submitFeedbackMock,
}));
vi.mock("streamdown", () => ({
Streamdown: ({ children }: { children: ReactNode }) => {
const text = String(children);
@@ -157,6 +165,62 @@ describe("MessageBubble", () => {
expect(isBefore(firstText, toolCall)).toBe(true);
expect(isBefore(toolCall, secondText)).toBe(true);
});
it("should submit passive feedback for a completed assistant outcome", async () => {
submitFeedbackMock.mockResolvedValue({ data: true });
const message = {
...buildAssistantMessage([textPart("part-1", "Done")]),
run: {
id: "run-1",
status: "completed" as const,
terminalCode: null,
hasAssistantMessage: true,
feedbackRating: null,
},
};
render(<MessageBubble message={message} sessionId="session-1" />);
fireEvent.click(
screen.getByRole("button", { name: "Mark outcome as not helpful" }),
);
await waitFor(() => expect(submitFeedbackMock).toHaveBeenCalledOnce());
expect(submitFeedbackMock).toHaveBeenCalledWith({
sessionId: "session-1",
runId: "run-1",
rating: "down",
idempotencyKey: expect.any(String),
});
const selectedButton = screen.getByRole("button", {
name: "Mark outcome as not helpful",
});
expect(selectedButton).toHaveAttribute("aria-pressed", "true");
expect(selectedButton).toHaveClass("bg-button-primary", "text-black");
});
it("should expose feedback for failed outcomes without an assistant message", () => {
const message: LighthouseV2Message = {
id: "message-user-failed",
role: LIGHTHOUSE_V2_MESSAGE_ROLE.USER,
model: null,
tokenUsage: null,
insertedAt: "2026-06-25T10:00:00Z",
parts: [textPart("part-user", "Run this check")],
run: {
id: "run-failed",
status: "failed",
terminalCode: "llm_error",
hasAssistantMessage: false,
feedbackRating: null,
},
};
render(<MessageBubble message={message} sessionId="session-1" />);
expect(
screen.getByRole("button", { name: "Mark outcome as helpful" }),
).toBeInTheDocument();
});
});
function isBefore(first: HTMLElement, second: HTMLElement): boolean {
@@ -1,16 +1,27 @@
"use client";
import { Bot, Check, Copy, UserRound } from "lucide-react";
import {
Bot,
Check,
Copy,
ThumbsDown,
ThumbsUp,
UserRound,
} from "lucide-react";
import { useState } from "react";
import { submitLighthouseV2RunFeedback } from "@/app/(prowler)/lighthouse/_actions";
import { formatMessageTimestamp } from "@/app/(prowler)/lighthouse/_lib/format";
import {
getLighthouseContext,
getTextContent,
} from "@/app/(prowler)/lighthouse/_lib/messages";
import {
LIGHTHOUSE_V2_FEEDBACK_RATING,
LIGHTHOUSE_V2_MESSAGE_ROLE,
LIGHTHOUSE_V2_PART_TYPE,
LIGHTHOUSE_V2_RUN_STATUS,
type LighthouseV2FeedbackRating,
type LighthouseV2Message,
type LighthouseV2Part,
} from "@/app/(prowler)/lighthouse/_types";
@@ -35,7 +46,12 @@ interface AssistantPartGroup {
parts: LighthouseV2Part[];
}
export function MessageBubble({ message }: { message: LighthouseV2Message }) {
interface MessageBubbleProps {
message: LighthouseV2Message;
sessionId?: string;
}
export function MessageBubble({ message, sessionId }: MessageBubbleProps) {
const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER;
// Text-only join feeds the copy button; tool calls are rendered separately.
const messageText = message.parts
@@ -85,6 +101,8 @@ export function MessageBubble({ message }: { message: LighthouseV2Message }) {
isUser={isUser}
text={messageText}
insertedAt={message.insertedAt}
message={message}
sessionId={sessionId}
/>
</div>
{isUser && (
@@ -164,10 +182,14 @@ function MessageMeta({
isUser,
text,
insertedAt,
message,
sessionId,
}: {
isUser: boolean;
text: string;
insertedAt: string;
message: LighthouseV2Message;
sessionId?: string;
}) {
// Copy is always shown; the timestamp only reveals on hover over the message.
// Agent footer reads left-to-right ([copy] [time]); user footer mirrors it.
@@ -179,6 +201,11 @@ function MessageMeta({
)}
>
<CopyMessageButton text={text} />
<RunFeedbackControls
message={message}
isUser={isUser}
sessionId={sessionId}
/>
<time
dateTime={insertedAt}
className="text-text-neutral-tertiary text-xs opacity-0 transition-opacity group-hover:opacity-100"
@@ -189,6 +216,85 @@ function MessageMeta({
);
}
function RunFeedbackControls({
message,
isUser,
sessionId,
}: {
message: LighthouseV2Message;
isUser: boolean;
sessionId?: string;
}) {
const run = message.run;
const [rating, setRating] = useState<LighthouseV2FeedbackRating | null>(
run?.feedbackRating ?? null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const feedbackEligible =
run?.status === LIGHTHOUSE_V2_RUN_STATUS.COMPLETED ||
run?.status === LIGHTHOUSE_V2_RUN_STATUS.BLOCKED ||
run?.status === LIGHTHOUSE_V2_RUN_STATUS.FAILED;
const belongsOnMessage = isUser
? run?.hasAssistantMessage === false
: run?.hasAssistantMessage === true;
if (!run || !sessionId || !feedbackEligible || !belongsOnMessage) return null;
const submitRating = async (nextRating: LighthouseV2FeedbackRating) => {
if (isSubmitting || rating === nextRating) return;
const previousRating = rating;
setRating(nextRating);
setIsSubmitting(true);
const result = await submitLighthouseV2RunFeedback({
sessionId,
runId: run.id,
rating: nextRating,
idempotencyKey: crypto.randomUUID(),
});
if ("error" in result) {
setRating(previousRating);
}
setIsSubmitting(false);
};
return (
<div className="flex items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Mark outcome as helpful"
aria-pressed={rating === LIGHTHOUSE_V2_FEEDBACK_RATING.UP}
disabled={isSubmitting}
onClick={() => void submitRating(LIGHTHOUSE_V2_FEEDBACK_RATING.UP)}
className={cn(
"text-text-neutral-tertiary hover:text-text-neutral-primary size-6",
rating === LIGHTHOUSE_V2_FEEDBACK_RATING.UP &&
"bg-button-primary hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50 text-black hover:text-black",
)}
>
<ThumbsUp className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Mark outcome as not helpful"
aria-pressed={rating === LIGHTHOUSE_V2_FEEDBACK_RATING.DOWN}
disabled={isSubmitting}
onClick={() => void submitRating(LIGHTHOUSE_V2_FEEDBACK_RATING.DOWN)}
className={cn(
"text-text-neutral-tertiary hover:text-text-neutral-primary size-6",
rating === LIGHTHOUSE_V2_FEEDBACK_RATING.DOWN &&
"bg-button-primary hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50 text-black hover:text-black",
)}
>
<ThumbsDown className="size-3.5" />
</Button>
</div>
);
}
function CopyMessageButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
@@ -36,6 +36,34 @@ export interface LighthouseV2Part {
updatedAt: string | null;
}
export const LIGHTHOUSE_V2_RUN_STATUS = {
QUEUED: "queued",
RUNNING: "running",
COMPLETED: "completed",
BLOCKED: "blocked",
FAILED: "failed",
CANCELLED: "cancelled",
} as const;
export type LighthouseV2RunStatus =
(typeof LIGHTHOUSE_V2_RUN_STATUS)[keyof typeof LIGHTHOUSE_V2_RUN_STATUS];
export const LIGHTHOUSE_V2_FEEDBACK_RATING = {
UP: "up",
DOWN: "down",
} as const;
export type LighthouseV2FeedbackRating =
(typeof LIGHTHOUSE_V2_FEEDBACK_RATING)[keyof typeof LIGHTHOUSE_V2_FEEDBACK_RATING];
export interface LighthouseV2Run {
id: string;
status: LighthouseV2RunStatus;
terminalCode: string | null;
hasAssistantMessage: boolean;
feedbackRating: LighthouseV2FeedbackRating | null;
}
// Normalized shape of a TOOL_CALL part's `content`. The backend persists this
// blob in snake_case (tool_call_id, tool_name, ...); `getToolCallContent`
// maps it to this camelCase form so the UI never touches the raw keys.
@@ -54,6 +82,14 @@ export interface LighthouseV2Message {
tokenUsage: unknown;
insertedAt: string;
parts: LighthouseV2Part[];
run?: LighthouseV2Run | null;
}
export interface LighthouseV2RunFeedbackInput {
sessionId: string;
runId: string;
rating: LighthouseV2FeedbackRating;
idempotencyKey: string;
}
export interface LighthouseV2SendMessageInput {
@@ -0,0 +1 @@
In Prowler Cloud, Lighthouse request outcomes can be rated with persistent thumbs-up and thumbs-down controls.