diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 94566d37a..70af67407 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -1672,3 +1672,106 @@ describe("rate limiting optimizations", () => { expect(getPendingMock).toHaveBeenCalledTimes(1); }); }); + +describe("summary pinning", () => { + it("pins first quality summary when pinnedSummary not set", async () => { + const session = makeSession({ + status: "working", + agentInfo: { + summary: "Implementing authentication flow", + summaryIsFallback: false, + agentSessionId: "abc", + }, + metadata: {}, + }); + const lm = setupCheck("app-1", { session }); + + await lm.check("app-1"); + + const meta = readMetadataRaw(env.sessionsDir, "app-1"); + expect(meta!["pinnedSummary"]).toBe("Implementing authentication flow"); + }); + + it("skips pinning when summaryIsFallback is true", async () => { + const session = makeSession({ + status: "working", + agentInfo: { + summary: "You are working on issue #42...", + summaryIsFallback: true, + agentSessionId: "abc", + }, + metadata: {}, + }); + const lm = setupCheck("app-1", { session }); + + await lm.check("app-1"); + + const meta = readMetadataRaw(env.sessionsDir, "app-1"); + expect(meta!["pinnedSummary"]).toBeUndefined(); + }); + + it("skips pinning when pinnedSummary already exists", async () => { + const session = makeSession({ + status: "working", + agentInfo: { + summary: "New summary that should not overwrite", + summaryIsFallback: false, + agentSessionId: "abc", + }, + metadata: { pinnedSummary: "Original pinned summary" }, + }); + const lm = setupCheck("app-1", { + session, + metaOverrides: { pinnedSummary: "Original pinned summary" }, + }); + + await lm.check("app-1"); + + const meta = readMetadataRaw(env.sessionsDir, "app-1"); + expect(meta!["pinnedSummary"]).toBe("Original pinned summary"); + }); + + it("skips pinning when trimmed summary is shorter than 5 chars", async () => { + const session = makeSession({ + status: "working", + agentInfo: { + summary: " Hi ", + summaryIsFallback: false, + agentSessionId: "abc", + }, + metadata: {}, + }); + const lm = setupCheck("app-1", { session }); + + await lm.check("app-1"); + + const meta = readMetadataRaw(env.sessionsDir, "app-1"); + expect(meta!["pinnedSummary"]).toBeUndefined(); + }); + + it("does not throw when metadata write fails", async () => { + const session = makeSession({ + status: "working", + agentInfo: { + summary: "Valid summary for pinning", + summaryIsFallback: false, + agentSessionId: "abc", + }, + metadata: {}, + }); + // Use a config with invalid path to trigger write failure + const badConfig = { + ...config, + projects: { + "my-app": { + ...config.projects["my-app"], + path: "/nonexistent/path/that/does/not/exist", + }, + }, + }; + const lm = setupCheck("app-1", { session, configOverride: badConfig }); + + // Should not throw — error is swallowed + await expect(lm.check("app-1")).resolves.not.toThrow(); + }); +}); diff --git a/packages/core/src/__tests__/metadata.test.ts b/packages/core/src/__tests__/metadata.test.ts index 369713742..a03e167f1 100644 --- a/packages/core/src/__tests__/metadata.test.ts +++ b/packages/core/src/__tests__/metadata.test.ts @@ -96,6 +96,18 @@ describe("writeMetadata + readMetadata", () => { expect(content).not.toContain("pr="); expect(content).not.toContain("summary="); }); + + it("serializes pinnedSummary field when present", () => { + writeMetadata(dataDir, "app-5", { + worktree: "/tmp/w", + branch: "feat/test", + status: "working", + pinnedSummary: "First quality summary", + }); + + const content = readFileSync(join(dataDir, "app-5"), "utf-8"); + expect(content).toContain("pinnedSummary=First quality summary\n"); + }); }); describe("readMetadataRaw", () => { diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 8e1841c24..86323a169 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -1268,6 +1268,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan states.set(session.id, newStatus); } + // Pin first quality summary for title stability + if ( + session.agentInfo?.summary && + !session.agentInfo.summaryIsFallback && + !session.metadata["pinnedSummary"] + ) { + const trimmed = session.agentInfo.summary.replace(/[\n\r]/g, " ").trim(); + if (trimmed.length >= 5) { + try { + updateSessionMetadata(session, { pinnedSummary: trimmed }); + } catch { + // Non-critical: title just won't be pinned this cycle + } + } + } + await Promise.allSettled([ maybeDispatchReviewBacklog(session, oldStatus, newStatus, transitionReaction), maybeDispatchCIFailureDetails(session, oldStatus, newStatus, transitionReaction), diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 7010e3f88..de5bda0b1 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -93,6 +93,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta ? Number(raw["directTerminalWsPort"]) : undefined, opencodeSessionId: raw["opencodeSessionId"], + pinnedSummary: raw["pinnedSummary"], }; } @@ -142,6 +143,7 @@ export function writeMetadata( if (metadata.directTerminalWsPort !== undefined) data["directTerminalWsPort"] = String(metadata.directTerminalWsPort); if (metadata.opencodeSessionId) data["opencodeSessionId"] = metadata.opencodeSessionId; + if (metadata.pinnedSummary) data["pinnedSummary"] = metadata.pinnedSummary; atomicWriteFileSync(path, serializeMetadata(data)); } diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index acd4da090..7284baba7 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -2333,6 +2333,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM createdAt: raw["createdAt"], runtimeHandle: raw["runtimeHandle"], opencodeSessionId: raw["opencodeSessionId"], + pinnedSummary: raw["pinnedSummary"], }); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0416bd6b3..463dc02ea 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1388,6 +1388,7 @@ export interface SessionMetadata { terminalWsPort?: number; directTerminalWsPort?: number; opencodeSessionId?: string; + pinnedSummary?: string; // First quality summary, pinned for display stability } // ============================================================================= diff --git a/packages/web/src/__tests__/components.test.tsx b/packages/web/src/__tests__/components.test.tsx index 0571993eb..49fd3cad6 100644 --- a/packages/web/src/__tests__/components.test.tsx +++ b/packages/web/src/__tests__/components.test.tsx @@ -184,8 +184,8 @@ describe("PRStatus", () => { // ── SessionCard ────────────────────────────────────────────────────── describe("SessionCard", () => { - it("renders session id and summary", () => { - const session = makeSession({ id: "backend-1", summary: "Fixing auth" }); + it("renders summary when no PR, issue title, or branch title exists", () => { + const session = makeSession({ id: "backend-1", summary: "Fixing auth", branch: null }); render(); expect(screen.getByText("backend-1")).toBeInTheDocument(); expect(screen.getByText("Fixing auth")).toBeInTheDocument(); diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index a2a288e7a..ce1380472 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -7,6 +7,7 @@ import { SessionDetail } from "@/components/SessionDetail"; import { type DashboardSession, type ActivityState, getAttentionLevel, type AttentionLevel } from "@/lib/types"; import { activityIcon } from "@/lib/activity-icons"; import type { ProjectInfo } from "@/lib/project-name"; +import { getSessionTitle } from "@/lib/format"; import { useSSESessionActivity } from "@/hooks/useSSESessionActivity"; function truncate(s: string, max: number): string { @@ -29,12 +30,8 @@ function buildSessionTitle( if (isOrchestrator) { detail = "Orchestrator Terminal"; - } else if (session.pr) { - detail = `#${session.pr.number} ${truncate(session.pr.branch, 30)}`; - } else if (session.branch) { - detail = truncate(session.branch, 30); } else { - detail = "Session Detail"; + detail = truncate(getSessionTitle(session), 40); } return emoji ? `${emoji} ${id} | ${detail}` : `${id} | ${detail}`; diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 567b73d12..0f7f548fd 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -6,6 +6,7 @@ import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery"; import { type DashboardSession, type DashboardPR, isPRMergeReady } from "@/lib/types"; import { CI_STATUS } from "@composio/ao-core/types"; import { cn } from "@/lib/cn"; +import { getSessionTitle } from "@/lib/format"; import { CICheckList } from "./CIBadge"; import { DirectTerminal } from "./DirectTerminal"; import { MobileBottomNav } from "./MobileBottomNav"; @@ -37,10 +38,6 @@ const activityMeta: Record = { exited: { label: "Exited", color: "var(--color-status-error)" }, }; -function getSessionHeadline(session: DashboardSession): string { - return session.issueTitle ?? session.summary ?? session.id; -} - function cleanBugbotComment(body: string): { title: string; description: string } { const isBugbot = body.includes("") || body.includes("### "); if (isBugbot) { @@ -329,7 +326,7 @@ export function SessionDetail({ label: session.activity ?? "unknown", color: "var(--color-text-muted)", }; - const headline = getSessionHeadline(session); + const headline = getSessionTitle(session); const accentColor = "var(--color-accent)"; const terminalVariant = isOrchestrator ? "orchestrator" : "agent"; diff --git a/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx b/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx index 104915659..2faff2fa6 100644 --- a/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx @@ -55,6 +55,7 @@ describe("Dashboard mobile layout", () => { makeSession({ id: `needs-input-${index + 1}`, summary: `Need approval ${index + 1}`, + branch: null, status: "needs_input", activity: "waiting_input", }), @@ -83,18 +84,18 @@ describe("Dashboard mobile layout", () => { render(); - expect(screen.getByRole("link", { name: /go to need approval to proceed/i })).toHaveAttribute( + expect(screen.getByRole("link", { name: /go to mobile density/i })).toHaveAttribute( "href", "/sessions/respond-1", ); await act(async () => { - fireEvent.click(screen.getByRole("button", { name: /open need approval to proceed/i })); + fireEvent.click(screen.getByRole("button", { name: /open mobile density/i })); }); expect(screen.getByRole("link", { name: "Open session" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Terminate" })).toBeInTheDocument(); - expect(screen.getAllByText("Need approval to proceed").length).toBeGreaterThan(1); + expect(screen.getAllByText("Mobile Density").length).toBeGreaterThan(1); expect(screen.getAllByText("respond").length).toBeGreaterThan(0); expect(screen.getAllByText("needs input").length).toBeGreaterThan(0); expect(screen.getByText("waiting input")).toBeInTheDocument(); @@ -116,7 +117,7 @@ describe("Dashboard mobile layout", () => { const { rerender } = render(); await act(async () => { - fireEvent.click(screen.getByRole("button", { name: /open need approval to proceed/i })); + fireEvent.click(screen.getByRole("button", { name: /open mobile density/i })); }); expect(screen.getByRole("button", { name: "Terminate" })).toBeInTheDocument(); @@ -219,12 +220,14 @@ describe("Dashboard mobile layout", () => { status: "needs_input", activity: "waiting_input", summary: "Need approval to proceed", + branch: null, }), makeSession({ id: "working-1", status: "running", activity: "active", summary: "Implement dashboard filters", + branch: null, }), ]} />, @@ -245,6 +248,7 @@ describe("Dashboard mobile layout", () => { status: "needs_input", activity: "waiting_input", summary: "Need approval to proceed", + branch: null, }), ]} />, @@ -264,12 +268,14 @@ describe("Dashboard mobile layout", () => { status: "needs_input", activity: "waiting_input", summary: "Need approval to proceed", + branch: null, }), makeSession({ id: "working-1", status: "running", activity: "active", summary: "Implement dashboard filters", + branch: null, }), ]} />, @@ -289,6 +295,7 @@ describe("Dashboard mobile layout", () => { status: "needs_input", activity: "waiting_input", summary: "Need approval to proceed", + branch: null, lastActivityAt: new Date(Date.now() + 1_000).toISOString(), }), makeSession({ @@ -296,6 +303,7 @@ describe("Dashboard mobile layout", () => { status: "running", activity: "active", summary: "Implement dashboard filters", + branch: null, lastActivityAt: new Date(Date.now() + 2_000).toISOString(), }), ]} diff --git a/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx b/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx index 4875f9841..db732c779 100644 --- a/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx @@ -40,6 +40,7 @@ describe("SessionDetail mobile navbar", () => { projectId: "my-app", metadata: { role: "orchestrator" }, summary: "Orchestrator session title", + branch: null, }); render( @@ -123,7 +124,7 @@ describe("SessionDetail mobile navbar", () => { />, ); - expect(screen.getByText("Compact mobile header")).toBeInTheDocument(); + expect(screen.getByText("Compact header polish")).toBeInTheDocument(); expect(screen.getByText("feat/compact-header")).toBeInTheDocument(); expect(screen.getByRole("link", { name: "PR #77" })).toHaveClass( "session-detail-link-pill--link", @@ -133,6 +134,24 @@ describe("SessionDetail mobile navbar", () => { ); }); + it("prefers issue title over changing summary text", () => { + render( + , + ); + + expect(screen.getByText("Fix stable session titles")).toBeInTheDocument(); + expect(screen.queryByText("Responding to latest review comment")).not.toBeInTheDocument(); + }); + it("preserves CI and unresolved review comment detail on mobile session pages", () => { render( { expect(getSessionTitle(session)).toBe("feat: add auth"); }); - it("returns agent summary over issue title", () => { + it("returns issue title over agent summary", () => { const session = makeSession({ summary: "Implementing OAuth2 authentication with JWT tokens", summaryIsFallback: false, issueTitle: "Add user authentication", branch: "feat/auth", }); - expect(getSessionTitle(session)).toBe( - "Implementing OAuth2 authentication with JWT tokens", - ); + expect(getSessionTitle(session)).toBe("Add user authentication"); }); it("skips fallback summaries in favor of issue title", () => { @@ -143,16 +141,14 @@ describe("getSessionTitle", () => { expect(getSessionTitle(session)).toBe("Add authentication to API"); }); - it("uses fallback summary when no issue title is available", () => { + it("uses branch before fallback summary when no issue title is available", () => { const session = makeSession({ summary: "You are working on GitHub issue #42: Add authentication to API...", summaryIsFallback: true, issueTitle: null, branch: "feat/issue-42", }); - expect(getSessionTitle(session)).toBe( - "You are working on GitHub issue #42: Add authentication to API...", - ); + expect(getSessionTitle(session)).toBe("Issue 42"); }); it("returns issue title when no summary exists", () => { @@ -182,15 +178,56 @@ describe("getSessionTitle", () => { expect(getSessionTitle(session)).toBe("working"); }); - it("prefers fallback summary over branch when no issue title", () => { + it("returns branch before summary when no issue title exists", () => { const session = makeSession({ summary: "You are working on Linear ticket INT-1327: Refactor session manager", summaryIsFallback: true, issueTitle: null, branch: "feat/INT-1327", }); - expect(getSessionTitle(session)).toBe( - "You are working on Linear ticket INT-1327: Refactor session manager", - ); + expect(getSessionTitle(session)).toBe("INT 1327"); + }); + + it("returns quality summary when neither issue title nor branch exists", () => { + const session = makeSession({ + summary: "Investigating flaky PR enrichment", + summaryIsFallback: false, + issueTitle: null, + branch: null, + }); + expect(getSessionTitle(session)).toBe("Investigating flaky PR enrichment"); + }); + + it("uses pinnedSummary from metadata before live summary when no branch or issue title", () => { + const session = makeSession({ + summary: "Drifting live summary from latest agent output", + summaryIsFallback: false, + issueTitle: null, + branch: null, + metadata: { pinnedSummary: "Stable pinned title" }, + }); + expect(getSessionTitle(session)).toBe("Stable pinned title"); + }); + + it("skips pinnedSummary when branch is present (branch takes priority)", () => { + const session = makeSession({ + summary: "Live summary", + summaryIsFallback: false, + issueTitle: null, + branch: "feat/my-feature", + metadata: { pinnedSummary: "Pinned summary" }, + }); + expect(getSessionTitle(session)).toBe("My Feature"); + }); + + it("falls through to live summary when pinnedSummary is empty", () => { + const session = makeSession({ + summary: "Live quality summary", + summaryIsFallback: false, + issueTitle: null, + branch: null, + metadata: { pinnedSummary: "" }, + }); + expect(getSessionTitle(session)).toBe("Live quality summary"); }); }); diff --git a/packages/web/src/lib/__tests__/serialize.test.ts b/packages/web/src/lib/__tests__/serialize.test.ts index f17e0d158..4e1e9892c 100644 --- a/packages/web/src/lib/__tests__/serialize.test.ts +++ b/packages/web/src/lib/__tests__/serialize.test.ts @@ -191,6 +191,51 @@ describe("sessionToDashboard", () => { expect(dashboard.summaryIsFallback).toBe(false); }); + it("should use live agentInfo summary even when pinnedSummary is set in metadata", () => { + const coreSession = createCoreSession({ + agentInfo: { + summary: "Latest live summary from agent", + summaryIsFallback: false, + agentSessionId: "abc123", + }, + metadata: { pinnedSummary: "First pinned summary" }, + }); + const dashboard = sessionToDashboard(coreSession); + + // pinnedSummary must NOT replace dashboard.summary — live summary wins + expect(dashboard.summary).toBe("Latest live summary from agent"); + expect(dashboard.summaryIsFallback).toBe(false); + // pinnedSummary remains accessible via metadata for title selection + expect(dashboard.metadata["pinnedSummary"]).toBe("First pinned summary"); + }); + + it("should use metadata summary when pinnedSummary is also set (pinnedSummary only for titles)", () => { + const coreSession = createCoreSession({ + agentInfo: null, + metadata: { pinnedSummary: "Pinned summary", summary: "Metadata summary" }, + }); + const dashboard = sessionToDashboard(coreSession); + + // pinnedSummary must NOT override the regular metadata summary + expect(dashboard.summary).toBe("Metadata summary"); + expect(dashboard.summaryIsFallback).toBe(false); + }); + + it("should use agentInfo summary regardless of pinnedSummary value", () => { + const coreSession = createCoreSession({ + agentInfo: { + summary: "Agent summary", + summaryIsFallback: false, + agentSessionId: "abc123", + }, + metadata: { pinnedSummary: "" }, + }); + const dashboard = sessionToDashboard(coreSession); + + expect(dashboard.summary).toBe("Agent summary"); + expect(dashboard.summaryIsFallback).toBe(false); + }); + it("should convert PRInfo to DashboardPR with defaults", () => { const pr = createPRInfo(); const coreSession = createCoreSession({ pr }); diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts index 8f81a890e..a2928a2c3 100644 --- a/packages/web/src/lib/format.ts +++ b/packages/web/src/lib/format.ts @@ -29,30 +29,35 @@ export function humanizeBranch(branch: string): string { * * Fallback chain (ordered by signal quality): * 1. PR title — human-visible deliverable name - * 2. Quality summary — real agent-generated summary (not a fallback) - * 3. Issue title — human-written task description - * 4. Any summary — even a fallback excerpt is better than nothing - * 5. Humanized branch — last resort with semantic content - * 6. Status text — absolute fallback + * 2. Issue title — human-written task description + * 3. Humanized branch — stable task identifier when no explicit title exists + * 4. Pinned summary — first quality summary, stable across agent updates + * 5. Quality summary — live summary, but can drift as the session evolves + * 6. Any summary — even a fallback excerpt is better than nothing + * 7. Status text — absolute fallback */ export function getSessionTitle(session: DashboardSession): string { // 1. PR title — always best if (session.pr?.title) return session.pr.title; - // 2. Quality summary — skip fallback summaries (truncated spawn prompts) + // 2. Issue title — human-written task description + if (session.issueTitle) return session.issueTitle; + + // 3. Humanized branch — stable semantic fallback + if (session.branch) return humanizeBranch(session.branch); + + // 4. Pinned summary — first quality summary, stable across agent updates + const pinnedSummary = session.metadata["pinnedSummary"]; + if (pinnedSummary) return pinnedSummary; + + // 5. Quality summary — skip fallback summaries (truncated spawn prompts) if (session.summary && !session.summaryIsFallback) { return session.summary; } - // 3. Issue title — human-written task description - if (session.issueTitle) return session.issueTitle; - - // 4. Any summary — even fallback excerpts beat branch names + // 6. Any summary — even fallback excerpts beat raw status text if (session.summary) return session.summary; - // 5. Humanized branch - if (session.branch) return humanizeBranch(session.branch); - - // 6. Status + // 7. Status return session.status; }