diff --git a/packages/web/src/lib/__tests__/project-utils.test.ts b/packages/web/src/lib/__tests__/project-utils.test.ts new file mode 100644 index 0000000000..9675eb2c0a --- /dev/null +++ b/packages/web/src/lib/__tests__/project-utils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { filterProjectSessions } from "../project-utils"; + +describe("filterProjectSessions", () => { + const projects = { + app: { sessionPrefix: "app" }, + appx: { sessionPrefix: "appx" }, + }; + + it("does not match another project's longer prefix as the selected project", () => { + // Regression: prefix containment leaked appx sessions into app views. + // Found by /qa on 2026-05-01. + const sessions = [ + { id: "app-1", projectId: "unknown" }, + { id: "appx-1", projectId: "unknown" }, + ]; + + expect(filterProjectSessions(sessions, "app", projects)).toEqual([ + { id: "app-1", projectId: "unknown" }, + ]); + }); +}); diff --git a/packages/web/src/lib/__tests__/serialize.test.ts b/packages/web/src/lib/__tests__/serialize.test.ts index 34eaa89169..140dc09bac 100644 --- a/packages/web/src/lib/__tests__/serialize.test.ts +++ b/packages/web/src/lib/__tests__/serialize.test.ts @@ -17,6 +17,7 @@ import { sessionToDashboard, resolveProject, enrichSessionPR, + enrichSessionIssue, readPREnrichmentFromMetadata, enrichSessionAgentSummary, enrichSessionIssueTitle, @@ -349,6 +350,17 @@ describe("resolveProject", () => { expect(resolveProject(session, projects)).toBe(projects.lib); }); + it("should not match another project's longer prefix", () => { + const projects = { + app: makeProject({ name: "app", sessionPrefix: "app" }), + appx: makeProject({ name: "appx", sessionPrefix: "appx" }), + }; + // Regression: prefix containment could resolve appx sessions as app. + // Found by /qa on 2026-05-01. + const session = createCoreSession({ id: "appx-1", projectId: "unknown" }); + expect(resolveProject(session, projects)).toBe(projects.appx); + }); + it("should fall back to first project when nothing matches", () => { const projects = { app: makeProject({ name: "app", sessionPrefix: "app" }), diff --git a/packages/web/src/lib/project-utils.ts b/packages/web/src/lib/project-utils.ts index e37935e845..6157155c44 100644 --- a/packages/web/src/lib/project-utils.ts +++ b/packages/web/src/lib/project-utils.ts @@ -1,4 +1,5 @@ import { isOrchestratorSession } from "@aoagents/ao-core/types"; +import { matchesSessionPrefix } from "./session-utils"; type ProjectWithPrefix = { sessionPrefix?: string }; type SessionLike = { id: string; projectId: string; metadata?: Record }; @@ -18,7 +19,9 @@ function matchesProject( ): boolean { if (session.projectId === projectId) return true; const project = projects[projectId]; - if (project?.sessionPrefix && session.id.startsWith(project.sessionPrefix)) return true; + if (project?.sessionPrefix && matchesSessionPrefix(session.id, project.sessionPrefix)) { + return true; + } return projects[session.projectId]?.sessionPrefix === projectId; } diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts index fd207f3dfd..b2231a7a18 100644 --- a/packages/web/src/lib/serialize.ts +++ b/packages/web/src/lib/serialize.ts @@ -26,6 +26,7 @@ import { getAttentionLevel, } from "./types"; import { TTLCache, type PREnrichmentData } from "./cache"; +import { matchesSessionPrefix } from "./session-utils"; /** Cache for issue titles (5 min TTL — issue titles rarely change) */ const issueTitleCache = new TTLCache(300_000); @@ -51,7 +52,9 @@ export function resolveProject( if (direct) return direct; // Match by session prefix - const entry = Object.entries(projects).find(([, p]) => core.id.startsWith(p.sessionPrefix)); + const entry = Object.entries(projects).find(([, p]) => + matchesSessionPrefix(core.id, p.sessionPrefix), + ); if (entry) return entry[1]; // Fall back to first project diff --git a/packages/web/src/lib/session-utils.ts b/packages/web/src/lib/session-utils.ts new file mode 100644 index 0000000000..6b81c1de3b --- /dev/null +++ b/packages/web/src/lib/session-utils.ts @@ -0,0 +1,3 @@ +export function matchesSessionPrefix(sessionId: string, prefix: string): boolean { + return sessionId === prefix || sessionId.startsWith(`${prefix}-`); +}