diff --git a/web/oss/tests/playwright/acceptance/agent-skills/skill-folder-upload.spec.ts b/web/oss/tests/playwright/acceptance/agent-skills/skill-folder-upload.spec.ts index ee92ea1fcf..121f227e11 100644 --- a/web/oss/tests/playwright/acceptance/agent-skills/skill-folder-upload.spec.ts +++ b/web/oss/tests/playwright/acceptance/agent-skills/skill-folder-upload.spec.ts @@ -9,7 +9,7 @@ import { TestSpeedType, TestcaseType, } from "@agenta/web-tests/playwright/config/testTags" -import {test} from "@agenta/web-tests/tests/fixtures/base.fixture" +import {test as baseTest} from "@agenta/web-tests/tests/fixtures/base.fixture" import {getProjectScopedBasePath} from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers" import {expect} from "@agenta/web-tests/utils" import type {Page} from "@playwright/test" @@ -19,6 +19,20 @@ import {expectAuthenticatedSession} from "../utils/auth" import {createScenarios} from "../utils/scenarios" import {buildAcceptanceTags} from "../utils/tags" +const test = baseTest.extend<{ + registerAgentAppForCleanup: (appId: string) => void +}>({ + registerAgentAppForCleanup: async ({apiHelpers}, use) => { + let appId: string | undefined + await use((createdAppId) => { + appId = createdAppId + }) + if (appId) { + await apiHelpers.archiveApp(appId) + } + }, +}) + const scenarios = createScenarios(test) const tags = buildAcceptanceTags({ @@ -89,7 +103,7 @@ const selectBundledFile = async (page: Page, label: string) => { test( "uploading a skill package parses the folded description and keeps bundled files isolated", {tag: tags}, - async ({page, uiHelpers}) => { + async ({page, uiHelpers, registerAgentAppForCleanup}) => { const appName = `e2e-skill-upload-${Date.now()}` const descriptionField = () => skillDrawer(page).getByPlaceholder("When the agent should reach for this skill") @@ -139,6 +153,7 @@ test( const createResponse = await createResponsePromise expect(createResponse.ok()).toBe(true) const created = (await createResponse.json()) as {workflow: {id: string}} + registerAgentAppForCleanup(created.workflow.id) await page.goto( `${getProjectScopedBasePath(page)}/apps/${created.workflow.id}/playground`, diff --git a/web/oss/tests/playwright/unit/api-helpers.spec.ts b/web/oss/tests/playwright/unit/api-helpers.spec.ts new file mode 100644 index 0000000000..98cdb5c5d9 --- /dev/null +++ b/web/oss/tests/playwright/unit/api-helpers.spec.ts @@ -0,0 +1,78 @@ +import { + appMatchesType, + selectLatestAppRevisions, +} from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers" +import type { + APP_TYPE, + ListAppsItem, +} from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers/types" +import {expect, test} from "@playwright/test" + +const app = (flags: ListAppsItem["flags"]): ListAppsItem => + ({ + id: "app-id", + name: "test-app", + app_type: "completion", + flags, + created_at: null, + }) as ListAppsItem + +test("agent revisions are excluded from prompt app matching", () => { + const artifact = app({is_application: true}) + const agentRevision = { + flags: {is_agent: true, is_chat: true, is_custom: true}, + } + + for (const type of ["completion", "chat", "custom"] satisfies APP_TYPE[]) { + expect(appMatchesType(artifact, type, agentRevision)).toBe(false) + } +}) + +test("latest revision flags classify prompt apps", () => { + const artifact = app({is_application: true}) + + expect(appMatchesType(artifact, "completion", {flags: {}})).toBe(true) + expect(appMatchesType(artifact, "chat", {flags: {is_chat: true}})).toBe(true) + expect(appMatchesType(artifact, "custom", {flags: {is_custom: true}})).toBe(true) +}) + +test("latest revision selection ignores v0 records", () => { + const latestByAppId = selectLatestAppRevisions([ + { + workflow_id: "app-id", + flags: {is_agent: true}, + version: "0", + created_at: "2026-07-31T10:00:00Z", + }, + { + workflow_id: "app-id", + flags: {}, + version: "1", + created_at: "2026-07-31T09:00:00Z", + }, + ]) + + const latest = latestByAppId.get("app-id") + expect(latest?.version).toBe("1") + expect(appMatchesType(app({is_application: true}), "completion", latest)).toBe(true) +}) + +test("latest revision selection keeps records with a missing version", () => { + const latestByAppId = selectLatestAppRevisions([ + { + workflow_id: "app-id", + flags: {is_agent: true}, + created_at: "2026-07-31T09:00:00Z", + }, + { + workflow_id: "app-id", + flags: {}, + version: "0", + created_at: "2026-07-31T10:00:00Z", + }, + ]) + + const latest = latestByAppId.get("app-id") + expect(latest?.version).toBeUndefined() + expect(latest?.flags?.is_agent).toBe(true) +}) diff --git a/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts b/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts index cbb0f0df94..42108391a9 100644 --- a/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts +++ b/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts @@ -8,25 +8,17 @@ import {getProjectMetadataPath} from "../../../../playwright/config/runtime.ts" import {UseFn} from "../../types" import {FixtureContext} from "../types" -import type {ApiHandlerOptions, ApiHelpers, CreateTestsetInput, CreatedTestset} from "./types" +import type { + ApiHandlerOptions, + ApiHelpers, + APP_TYPE, + CreateTestsetInput, + CreatedTestset, + ListAppsItem, +} from "./types" -type APP_TYPE = "completion" | "chat" | "custom" type CREATABLE_APP_TYPE = Exclude -interface ListAppsItem { - id: string - name: string - app_type: APP_TYPE - flags: { - is_chat?: boolean - is_custom?: boolean - is_application?: boolean - is_evaluator?: boolean - } | null - created_at: string | null - [key: string]: any -} - interface ApiVariant { id: string name: string | null @@ -45,6 +37,14 @@ interface ApiVariant { const latestRevisionIdByAppId = new Map() +export interface AppRevision { + workflow_id?: string | null + flags?: ListAppsItem["flags"] + version?: string | null + created_at?: string | null + updated_at?: string | null +} + interface TestProjectMetadata { project_id?: string workspace_id?: string @@ -410,6 +410,65 @@ async function createApp(page: Page, type: APP_TYPE): Promise { } as ListAppsItem } +const revisionRecencyScore = (revision: AppRevision): number => { + const createdAt = revision.created_at ? Date.parse(revision.created_at) : 0 + const updatedAt = revision.updated_at ? Date.parse(revision.updated_at) : 0 + return createdAt || updatedAt || Number(revision.version ?? 0) +} + +export const selectLatestAppRevisions = (revisions: AppRevision[]): Map => { + const latestByAppId = new Map() + + for (const revision of revisions) { + const appId = revision.workflow_id + if (!appId || revision.version === "0") continue + + const current = latestByAppId.get(appId) + if (!current || revisionRecencyScore(revision) > revisionRecencyScore(current)) { + latestByAppId.set(appId, revision) + } + } + + return latestByAppId +} + +const getLatestAppRevisions = async ( + page: Page, + apps: ListAppsItem[], +): Promise> => { + if (!apps.length) return new Map() + + const queryUrl = new URL(`${getApiURL(page)}/workflows/revisions/query`) + queryUrl.searchParams.set("project_id", getProjectId(page)) + + const response = await page.request.post(queryUrl.toString(), { + data: {workflow_refs: apps.map(({id}) => ({id}))}, + }) + if (!response.ok()) { + const body = await response.text().catch(() => "") + throw new Error( + `getLatestAppRevisions failed: HTTP ${response.status()} ${response.statusText()}.\n` + + `URL: ${queryUrl.toString()}\n` + + `Response body: ${body}`, + ) + } + + const data = (await response.json()) as {workflow_revisions?: AppRevision[]} + return selectLatestAppRevisions(data.workflow_revisions ?? []) +} + +export const appMatchesType = ( + app: ListAppsItem, + type: APP_TYPE, + latestRevision?: {flags?: ListAppsItem["flags"]}, +): boolean => { + const flags = {...(app.flags ?? {}), ...(latestRevision?.flags ?? {})} + if (flags.is_agent) return false + if (type === "chat") return !!flags.is_chat + if (type === "custom") return !!flags.is_custom + return !flags.is_chat && !flags.is_custom && !flags.is_evaluator +} + export const getApp = async (page: Page, type: APP_TYPE = "completion") => { const appsResponse = waitForApiResponse<{workflows: ListAppsItem[]; count: number}>(page, { route: "/workflows/query", @@ -425,18 +484,15 @@ export const getApp = async (page: Page, type: APP_TYPE = "completion") => { expect(Array.isArray(apps)).toBe(true) - const appMatchesType = (app: ListAppsItem) => { - if (type === "chat") return !!app.flags?.is_chat - if (type === "custom") return !!app.flags?.is_custom - // completion: exclude evaluator apps, which also lack is_chat/is_custom - return !app.flags?.is_chat && !app.flags?.is_custom && !app.flags?.is_evaluator - } + const latestRevisions = await getLatestAppRevisions(page, apps) let targetApp if (!apps.length) { targetApp = await createApp(page, type) } else if (type) { - const app = apps.find(appMatchesType) + const app = apps.find((candidate) => + appMatchesType(candidate, type, latestRevisions.get(candidate.id)), + ) if (!app) { targetApp = await createApp(page, type) } else { @@ -455,6 +511,21 @@ export const getApp = async (page: Page, type: APP_TYPE = "completion") => { return targetApp } +export const archiveApp = async (page: Page, appId: string): Promise => { + const archiveUrl = new URL(`${getApiURL(page)}/workflows/${appId}/archive`) + archiveUrl.searchParams.set("project_id", getProjectId(page)) + + const response = await page.request.post(archiveUrl.toString(), {data: {}}) + if (!response.ok()) { + const body = await response.text().catch(() => "") + throw new Error( + `archiveApp('${appId}') failed: HTTP ${response.status()} ${response.statusText()}.\n` + + `URL: ${archiveUrl.toString()}\n` + + `Response body: ${body}`, + ) + } +} + export const getAppById = async (page: Page, appId: string) => { const appsResponse = waitForApiResponse<{workflows: ListAppsItem[]; count: number}>(page, { route: "/workflows/query", @@ -667,6 +738,9 @@ export const apiHelpers = () => { getApp: async (type?: APP_TYPE): Promise => { return await getApp(page, type) }, + archiveApp: async (appId: string): Promise => { + await archiveApp(page, appId) + }, getAppById: async (appId: string): Promise => { return await getAppById(page, appId) }, diff --git a/web/tests/tests/fixtures/base.fixture/apiHelpers/types.d.ts b/web/tests/tests/fixtures/base.fixture/apiHelpers/types.d.ts index 8a7956c606..e14efcb4d0 100644 --- a/web/tests/tests/fixtures/base.fixture/apiHelpers/types.d.ts +++ b/web/tests/tests/fixtures/base.fixture/apiHelpers/types.d.ts @@ -1,8 +1,8 @@ import {testset} from "../../../../../oss/src/lib/Types" -type APP_TYPE = "completion" | "chat" | "custom" +export type APP_TYPE = "completion" | "chat" | "custom" -interface ListAppsItem { +export interface ListAppsItem { id: string name: string app_type: APP_TYPE @@ -11,6 +11,7 @@ interface ListAppsItem { is_custom?: boolean is_application?: boolean is_evaluator?: boolean + is_agent?: boolean } | null created_at: string | null [key: string]: any @@ -55,6 +56,7 @@ export interface ApiHelpers { waitForApiResponse: (options: ApiHandlerOptions) => Promise createApp: (type?: APP_TYPE) => Promise getApp: (type?: APP_TYPE) => Promise + archiveApp: (appId: string) => Promise getAppById: (appId: string) => Promise getTestsets: () => Promise createTestset: (input: CreateTestsetInput) => Promise