feat: stable session titles via pinnedSummary metadata (#946)

* feat(core): pin first quality summary for stable session titles

When a session's first non-fallback summary is obtained, persist it to
metadata as pinnedSummary. This prevents the dashboard title from drifting
as the agent generates new summaries each turn.

Changes:
- Add pinnedSummary to SessionMetadata type
- Serialize/deserialize in writeMetadata/readMetadata
- Pin in lifecycle manager's checkSession (correct write path)
- Propagate through restore path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(web): stable session titles via unified getSessionTitle

Reorder title fallback: PR > issue > branch > summary (was: summary > issue > branch).
Replace inline getSessionHeadline with shared getSessionTitle in both
SessionDetail and page.tsx for consistent behavior across all components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: coverage for pinnedSummary and title fallback chain

9 new tests:
- 3 serialize tests: pinnedSummary priority over agent/metadata summary
- 5 lifecycle-manager tests: pinning logic guards and error handling
- 1 metadata test: pinnedSummary serialization in writeMetadata

Updated existing tests to match new title fallback order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: fix mobile title expectations

* fix(web): use pinnedSummary only for title selection, not dashboard.summary

Previously, pinnedSummary was assigned to dashboard.summary in
sessionToDashboard(), causing two problems:
1. enrichSessionAgentSummary() never fetched newer summaries because
   dashboard.summary was already set
2. Every UI surface rendering session.summary showed the stale pinned
   value instead of live agent output

Fix: pinnedSummary is now read only by getSessionTitle() via
session.metadata["pinnedSummary"], keeping title stable without
freezing the live summary field. dashboard.summary always reflects the
current agentInfo or metadata summary.

Addresses review comment on PR #946.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ashish Huddar 2026-04-07 09:52:48 +05:30 committed by GitHub
parent d8b8645bc7
commit 1bb80ef67c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 286 additions and 43 deletions

View File

@ -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();
});
});

View File

@ -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", () => {

View File

@ -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),

View File

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

View File

@ -2333,6 +2333,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
createdAt: raw["createdAt"],
runtimeHandle: raw["runtimeHandle"],
opencodeSessionId: raw["opencodeSessionId"],
pinnedSummary: raw["pinnedSummary"],
});
}

View File

@ -1388,6 +1388,7 @@ export interface SessionMetadata {
terminalWsPort?: number;
directTerminalWsPort?: number;
opencodeSessionId?: string;
pinnedSummary?: string; // First quality summary, pinned for display stability
}
// =============================================================================

View File

@ -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(<SessionCard session={session} />);
expect(screen.getByText("backend-1")).toBeInTheDocument();
expect(screen.getByText("Fixing auth")).toBeInTheDocument();

View File

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

View File

@ -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<string, { label: string; color: string }> = {
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("<!-- DESCRIPTION START -->") || 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";

View File

@ -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(<Dashboard initialSessions={[session]} />);
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(<Dashboard initialSessions={[session]} />);
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(),
}),
]}

View File

@ -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(
<SessionDetail
session={makeSession({
id: "worker-stable-title",
projectId: "my-app",
issueTitle: "Fix stable session titles",
summary: "Responding to latest review comment",
branch: "fix/stable-session-titles",
})}
projectOrchestratorId="my-app-orchestrator"
/>,
);
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(
<SessionDetail

View File

@ -121,16 +121,14 @@ describe("getSessionTitle", () => {
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");
});
});

View File

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

View File

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