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;
}