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:
James Ritchie
2026-06-22 15:30:34 +01:00
committed by GitHub
parent 7efdbc8c4f
commit a90a495542
14 changed files with 90 additions and 6 deletions
+6
View File
@@ -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,
@@ -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>
@@ -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,
];
@@ -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?