feat(webapp,database): show a Test column for agent sessions (#4011)
## Summary Sessions started from the agent Test playground were tagged with a `"playground"` tag that rendered in the Sessions table's Tags column. They are now flagged with a real `Session.isTest` boolean (mirroring `TaskRun.isTest`) and surfaced as a dedicated **Test** column with a check icon, to the left of Tags, on both the Sessions page and the Agent landing page, plus a matching **Test** property on the session detail page. This mirrors how Standard and Scheduled task runs already indicate test runs. ## Design `isTest` is a new `Session` column (Postgres) replicated into ClickHouse `sessions_v1` alongside the existing fields. The Sessions list reads `isTest` from Postgres for display (ClickHouse only supplies the ordered session IDs), so the column renders correctly without a ClickHouse backfill. The playground action now sets `isTest: true` on session create instead of writing the `"playground"` tag. The triggered run still carries `playground:true` in its own tags (unchanged). A migration backfills existing sessions, setting `isTest = true` and stripping the now-redundant `"playground"` tag where it is present, so the list and detail views render consistently without read-time tag filtering.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Agent sessions started from the Test playground are now flagged with a real `Session.isTest` boolean instead of a `"playground"` tag, surfaced as a dedicated "Test" column (check icon) in the Sessions table on both the Sessions and Agent pages, plus a matching property on the session detail page. The legacy `"playground"` tag is hidden from the Tags display on pre-existing sessions.
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { useLocation, useNavigation } from "@remix-run/react";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon";
|
||||
@@ -87,6 +88,7 @@ export function SessionsTable({
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
<TableHeaderCell>Agent ID</TableHeaderCell>
|
||||
<TableHeaderCell>Test</TableHeaderCell>
|
||||
<TableHeaderCell>Tags</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Duration</TableHeaderCell>
|
||||
@@ -97,7 +99,7 @@ export function SessionsTable({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.length === 0 ? (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<TableBlankRow colSpan={9}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">
|
||||
{hasFilters
|
||||
@@ -144,6 +146,19 @@ export function SessionsTable({
|
||||
<MiddleTruncate text={session.taskIdentifier} className="font-mono text-xs" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
<span className="sr-only">{session.isTest ? "Yes" : "No"}</span>
|
||||
{session.isTest ? (
|
||||
<CheckIcon
|
||||
aria-hidden
|
||||
className="size-4 text-charcoal-400 group-hover/table-row:text-text-bright"
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="text-text-dimmed">
|
||||
–
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
{session.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -168,7 +183,7 @@ export function SessionsTable({
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={8}
|
||||
colSpan={9}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading…</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type SessionStatus,
|
||||
SessionsRepository,
|
||||
LEGACY_PLAYGROUND_TAG,
|
||||
} from "~/services/sessionsRepository/sessionsRepository.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
@@ -225,7 +226,13 @@ export class SessionListPresenter {
|
||||
externalId: session.externalId,
|
||||
type: session.type,
|
||||
taskIdentifier: session.taskIdentifier,
|
||||
tags: session.tags ? [...session.tags].sort((a, b) => a.localeCompare(b)) : [],
|
||||
isTest: session.isTest,
|
||||
// Hide the legacy "playground" tag (pre-isTest sessions) from display.
|
||||
tags: session.tags
|
||||
? [...session.tags]
|
||||
.filter((t) => t !== LEGACY_PLAYGROUND_TAG)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
: [],
|
||||
status,
|
||||
closedAt: session.closedAt ? session.closedAt.toISOString() : undefined,
|
||||
closedReason: session.closedReason ?? undefined,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { env } from "~/env.server";
|
||||
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { chatSnapshotStorageKey } from "~/services/realtime/chatSnapshot.server";
|
||||
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
|
||||
import { LEGACY_PLAYGROUND_TAG } from "~/services/sessionsRepository/sessionsRepository.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { generatePresignedUrl } from "~/v3/objectStore.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
@@ -177,7 +178,13 @@ export class SessionPresenter {
|
||||
externalId: session.externalId,
|
||||
type: session.type,
|
||||
taskIdentifier: session.taskIdentifier,
|
||||
tags: session.tags ? [...session.tags].sort((a, b) => a.localeCompare(b)) : [],
|
||||
isTest: session.isTest,
|
||||
// Hide the legacy "playground" tag (pre-isTest sessions) from display.
|
||||
tags: session.tags
|
||||
? [...session.tags]
|
||||
.filter((t) => t !== LEGACY_PLAYGROUND_TAG)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
: [],
|
||||
metadata: session.metadata,
|
||||
triggerConfig: session.triggerConfig,
|
||||
streamBasinName: session.streamBasinName,
|
||||
|
||||
+14
-1
@@ -1,5 +1,5 @@
|
||||
import { BoltIcon, BoltSlashIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
@@ -818,6 +818,19 @@ function OverviewTab({ session, status }: { session: LoadedSession; status: Sess
|
||||
<span className="font-mono text-sm">{session.taskIdentifier}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Test</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="sr-only">{session.isTest ? "Yes" : "No"}</span>
|
||||
{session.isTest ? (
|
||||
<CheckIcon aria-hidden className="size-4 text-text-dimmed" />
|
||||
) : (
|
||||
<span aria-hidden className="text-text-dimmed">
|
||||
–
|
||||
</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{session.currentRun ? (
|
||||
<Property.Item>
|
||||
<Property.Label>Current run</Property.Label>
|
||||
|
||||
+4
-1
@@ -162,7 +162,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
type: "chat.agent",
|
||||
taskIdentifier: agentSlug,
|
||||
triggerConfig: triggerConfig as unknown as Prisma.InputJsonValue,
|
||||
tags: ["playground"],
|
||||
// Mark as a Test session — surfaced via the Test column in the
|
||||
// Sessions table. Session tags stay empty; the triggered run still
|
||||
// carries "playground:true" via triggerConfig.tags.
|
||||
isTest: true,
|
||||
projectId: project.id,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
|
||||
@@ -802,6 +802,7 @@ function toSessionInsertArray(
|
||||
session.expiresAt ? session.expiresAt.getTime() : null,
|
||||
session.createdAt.getTime(),
|
||||
session.updatedAt.getTime(),
|
||||
session.isTest ?? false,
|
||||
version.toString(),
|
||||
isDeleted ? 1 : 0,
|
||||
];
|
||||
|
||||
@@ -93,6 +93,7 @@ export class ClickHouseSessionsRepository implements ISessionsRepository {
|
||||
externalId: true,
|
||||
type: true,
|
||||
taskIdentifier: true,
|
||||
isTest: true,
|
||||
tags: true,
|
||||
metadata: true,
|
||||
closedAt: true,
|
||||
|
||||
@@ -24,6 +24,13 @@ export type SessionsRepositoryOptions = {
|
||||
export const SessionStatus = z.enum(["ACTIVE", "CLOSED", "EXPIRED"]);
|
||||
export type SessionStatus = z.infer<typeof SessionStatus>;
|
||||
|
||||
/**
|
||||
* Legacy marker tag for sessions created from the Test/playground before the
|
||||
* `Session.isTest` boolean existed. New sessions set `isTest` instead; this tag
|
||||
* is hidden from the Tags display so it doesn't surface on pre-isTest rows.
|
||||
*/
|
||||
export const LEGACY_PLAYGROUND_TAG = "playground";
|
||||
|
||||
const SessionListInputOptionsSchema = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
@@ -87,6 +94,7 @@ export type ListedSession = Prisma.SessionGetPayload<{
|
||||
externalId: true;
|
||||
type: true;
|
||||
taskIdentifier: true;
|
||||
isTest: true;
|
||||
tags: true;
|
||||
metadata: true;
|
||||
closedAt: true;
|
||||
|
||||
@@ -80,6 +80,7 @@ describe("SessionsReplicationService", () => {
|
||||
},
|
||||
tags: ["user:42", "plan:pro"],
|
||||
metadata: { plan: "pro", seats: 3 },
|
||||
isTest: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,6 +109,7 @@ describe("SessionsReplicationService", () => {
|
||||
environment_type: "DEVELOPMENT",
|
||||
task_identifier: "my-agent",
|
||||
tags: ["user:42", "plan:pro"],
|
||||
is_test: 1,
|
||||
_is_deleted: 0,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- Existing rows default to 0 and are intentionally NOT backfilled: the Sessions
|
||||
-- list reads isTest from Postgres (ClickHouse only supplies session IDs), so the
|
||||
-- UI is correct without it. A backfill is only needed if a ClickHouse-side
|
||||
-- isTest filter/aggregate over sessions_v1 is added later.
|
||||
ALTER TABLE trigger_dev.sessions_v1
|
||||
ADD COLUMN IF NOT EXISTS is_test UInt8 DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.sessions_v1
|
||||
DROP COLUMN IF EXISTS is_test;
|
||||
@@ -19,6 +19,7 @@ export const SessionV1 = z.object({
|
||||
expires_at: z.number().int().nullish(),
|
||||
created_at: z.number().int(),
|
||||
updated_at: z.number().int(),
|
||||
is_test: z.boolean().default(false),
|
||||
_version: z.string(),
|
||||
_is_deleted: z.number().int().default(0),
|
||||
});
|
||||
@@ -43,6 +44,7 @@ export const SESSION_COLUMNS = [
|
||||
"expires_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_test",
|
||||
"_version",
|
||||
"_is_deleted",
|
||||
] as const;
|
||||
@@ -70,6 +72,7 @@ export type SessionFieldTypes = {
|
||||
expires_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
is_test: boolean;
|
||||
_version: string;
|
||||
_is_deleted: number;
|
||||
};
|
||||
@@ -95,6 +98,7 @@ export type SessionInsertArray = [
|
||||
expires_at: number | null,
|
||||
created_at: number,
|
||||
updated_at: number,
|
||||
is_test: boolean,
|
||||
_version: string,
|
||||
_is_deleted: number,
|
||||
];
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Session" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -805,6 +805,10 @@ model Session {
|
||||
/// (chatId, messages, trigger) are merged at trigger time.
|
||||
triggerConfig Json
|
||||
|
||||
/// Whether this session was created from the Test/playground UI rather
|
||||
/// than by a real chat.agent() trigger. Mirrors TaskRun.isTest.
|
||||
isTest Boolean @default(false)
|
||||
|
||||
tags String[] @default([])
|
||||
metadata Json?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user