Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/contribute/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ For dashboard visual QA without generating real Slack traffic, replace conversat
JUNIOR_DASHBOARD_MOCK_CONVERSATIONS=true pnpm dev
```

The fixtures are read-only dashboard data; mock mode does not query or merge real conversation records.
Mock mode does not query or merge real conversation records. Messages that you send get a fixed reply after 2 seconds, and no agent runs. They stay until the dev server restarts.

## Run sandboxes in local Docker

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/operate/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ When `SENTRY_DSN` is set, dashboard pages also send browser performance spans to
The conversation stats endpoint is separate from the recent feed. PostgreSQL computes complete 90-day counts, locations, actors, status, runtime, token usage, and estimated cost directly from durable conversation-index records; those aggregates are not derived from a bounded recent-row sample.
Dashboard dates use the viewer's browser timezone. `JUNIOR_TIMEZONE` does not change them.

For local dashboard visual QA, pass `mockConversations: true` in the dashboard config or set `JUNIOR_DASHBOARD_MOCK_CONVERSATIONS=true` for the env-configured path. The sample conversations are read-only reporting fixtures and appear before real conversation records.
For local dashboard visual QA, pass `mockConversations: true` in the dashboard config or set `JUNIOR_DASHBOARD_MOCK_CONVERSATIONS=true` for the env-configured path. The sample conversations are reporting fixtures and appear before real conversation records. Messages that you send get a fixed reply, and no agent runs.

## Configure Google auth

Expand Down
2 changes: 2 additions & 0 deletions packages/junior-dashboard/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
import { isAuthPath, type AuthenticatedRoute } from "./authenticated-routes";
import { resetMockConversationViewerState } from "./mock-reporting/fixtures";
import { resetMockReadPositions } from "./mock-reporting/read-positions";
import { resetMockSends } from "./mock-reporting/sends";
import { createMockReportingApi } from "./mock-reporting/routes";
import {
DASHBOARD_AVATAR_HEADER_PATH,
Expand Down Expand Up @@ -94,6 +95,7 @@ export function resetMockDashboardState(): void {
resetMockConversationViewerState();
mockAutoArchiveSettingsByEmail.clear();
resetMockReadPositions();
resetMockSends();
}

export interface JuniorDashboardOptions {
Expand Down
16 changes: 15 additions & 1 deletion packages/junior-dashboard/src/mock-reporting/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
AutomationList,
AutomationSummary,
} from "@sentry/junior/api/schema";
import { isUnlistedMockConversation, withMockSends } from "./sends";

/** Fixed current time for mock reports and browser tests. */
export const NOW = "2026-08-07T12:00:00.000Z";
Expand Down Expand Up @@ -1566,7 +1567,12 @@ function usage(cost: number) {
};
}

/** Fixture Conversations with the mock sends of this process. */
function mockConversations(nowMs: number): MockConversation[] {
return withMockSends(fixtureConversations(nowMs));
}

function fixtureConversations(nowMs: number): MockConversation[] {
return [
activeConversation(nowMs),
dashboardQaConversation(nowMs),
Expand Down Expand Up @@ -1682,6 +1688,9 @@ function summaryFromConversation(
brief: _brief,
eventHistory: _eventHistory,
events: _events,
forkedFromConversationId: _forkedFromConversationId,
forkedFromTitle: _forkedFromTitle,
forks: _forks,
generatedAt: _generatedAt,
modelUsage: _modelUsage,
parentConversationId: _parentConversationId,
Expand All @@ -1706,7 +1715,11 @@ function mockConversationFeed(nowMs: number): ConversationFeed {
source: "conversation_index",
generatedAt: iso(nowMs),
conversations: mockConversations(nowMs)
.filter((conversation) => !conversation.parentConversationId)
.filter(
(conversation) =>
!conversation.parentConversationId &&
!isUnlistedMockConversation(conversation.conversationId),
)
.map(summaryFromConversation),
};
}
Expand Down Expand Up @@ -2253,6 +2266,7 @@ export function readMockConversationDetail(
...(agentTree ? { agentTree } : undefined),
canFork:
!parentConversationId &&
!isUnlistedMockConversation(conversationId) &&
conversationDetail.eventHistory.status !== "redacted",
...(conversationDetail.isParticipant
? {
Expand Down
101 changes: 100 additions & 1 deletion packages/junior-dashboard/src/mock-reporting/routes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { mockAutomationCollection } from "./automation-collection";
import { Hono } from "hono";
import { jsonResponse, type JuniorApiVariables } from "@sentry/junior/api";
import type { User } from "@sentry/junior-plugin-api";
import {
acceptedConversationMessageSchema,
apiErrorSchema,
createConversationBodySchema,
createConversationMessageBodySchema,
forkConversationBodySchema,
forkConversationResponseSchema,
webMessageId,
actorDirectoryReportSchema,
actorProfileReportSchema,
archiveConversationBodySchema,
Expand Down Expand Up @@ -86,12 +93,44 @@ import {
setMockConversationAutoArchive,
archiveMockDoneConversations,
} from "./fixtures";
import {
createMockConversation,
forkMockConversation,
keepMockQuickQuestion,
recordMockSend,
} from "./sends";
import { readMockConversationWatches } from "./watches";

function errorResponse(error: string, status: 400 | 404): Response {
function errorResponse(error: string, status: 400 | 401 | 404): Response {
return jsonResponse(apiErrorSchema, { error }, { status });
}

function viewerIdentity(viewer: User) {
return { email: viewer.email, fullName: viewer.displayName ?? viewer.email };
}

/** Add one web message to a mock Conversation and answer like the API. */
async function acceptMockSend(
conversationId: string,
body: { idempotencyKey: string; message: string },
viewer: User,
): Promise<Response> {
const messageId = await webMessageId({
conversationId,
idempotencyKey: body.idempotencyKey,
});
const status = recordMockSend(conversationId, {
actorIdentity: viewerIdentity(viewer),
messageId,
text: body.message,
});
return jsonResponse(acceptedConversationMessageSchema, {
conversationId,
messageId,
status,
});
}

function mockNotificationSettings(schedule: NotificationSchedule) {
return jsonResponse(notificationSettingsSchema, {
deviceCount: 0,
Expand Down Expand Up @@ -221,6 +260,66 @@ export function createMockReportingApi(): Hono<{
),
});
});
// Sends get a fixed reply instead of an agent run. See `./sends`.
app.post("/conversations", async (c) => {
const viewer = c.get("viewer");
if (!viewer) return errorResponse("Sign in to send messages.", 401);
const body = createConversationBodySchema.safeParse(await c.req.json());
if (!body.success) return errorResponse("Invalid request body.", 400);
const conversationId = createMockConversation({
actorIdentity: viewerIdentity(viewer),
...body.data,
});
return acceptMockSend(conversationId, body.data, viewer);
});
app.post("/conversations/:conversationId/messages", async (c) => {
const viewer = c.get("viewer");
if (!viewer) return errorResponse("Sign in to send messages.", 401);
const params = conversationParamsSchema.safeParse(c.req.param());
if (!params.success) return errorResponse("Invalid route parameters.", 400);
const body = createConversationMessageBodySchema.safeParse(
await c.req.json(),
);
if (!body.success) return errorResponse("Invalid request body.", 400);
if (!readMockConversationDetail(params.data.conversationId)) {
return errorResponse("Conversation not found.", 404);
}
return acceptMockSend(params.data.conversationId, body.data, viewer);
});
app.post("/conversations/:conversationId/forks", async (c) => {
const viewer = c.get("viewer");
if (!viewer) return errorResponse("Sign in to fork conversations.", 401);
const params = conversationParamsSchema.safeParse(c.req.param());
if (!params.success) return errorResponse("Invalid route parameters.", 400);
const body = forkConversationBodySchema.safeParse(await c.req.json());
if (!body.success) return errorResponse("Invalid request body.", 400);
const source = readMockConversationDetail(params.data.conversationId);
const reply = source?.events.find(
({ data }) =>
data.type === "message" &&
data.role === "assistant" &&
data.messageId === body.data.messageId,
);
if (!source?.canFork || !reply) {
return errorResponse("Conversation not found.", 404);
}
return jsonResponse(forkConversationResponseSchema, {
conversationId: forkMockConversation({
actorIdentity: viewerIdentity(viewer),
idempotencyKey: body.data.idempotencyKey,
messageId: body.data.messageId,
quickQuestion: Boolean(body.data.quickQuestion),
sourceConversationId: params.data.conversationId,
}),
});
});
app.post("/conversations/:conversationId/quick-question/keep", (c) => {
const params = conversationParamsSchema.safeParse(c.req.param());
if (!params.success) return errorResponse("Invalid route parameters.", 400);
return keepMockQuickQuestion(params.data.conversationId)
? jsonResponse(forkConversationResponseSchema, params.data)
: errorResponse("Conversation not found.", 404);
});
app.get("/conversations/stats", () =>
jsonResponse(conversationStatsReportSchema, readMockConversationStats()),
);
Expand Down
Loading
Loading