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:
parent
d8b8645bc7
commit
1bb80ef67c
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2333,6 +2333,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
createdAt: raw["createdAt"],
|
||||
runtimeHandle: raw["runtimeHandle"],
|
||||
opencodeSessionId: raw["opencodeSessionId"],
|
||||
pinnedSummary: raw["pinnedSummary"],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1388,6 +1388,7 @@ export interface SessionMetadata {
|
|||
terminalWsPort?: number;
|
||||
directTerminalWsPort?: number;
|
||||
opencodeSessionId?: string;
|
||||
pinnedSummary?: string; // First quality summary, pinned for display stability
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue