From 0e2ca70b0b2de02306e81041ab030b9297202d4c Mon Sep 17 00:00:00 2001 From: prateek Date: Thu, 19 Feb 2026 19:02:02 +0530 Subject: [PATCH] feat: session title fallback chain for PR-less sessions (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: session title fallback chain — PR title → summary → issue title → branch Sessions without PRs now always show a meaningful title on the dashboard instead of just the status text. The fallback chain is: 1. PR title (already worked) 2. Agent summary (now fetched from JSONL via getSessionInfo()) 3. Issue title (now fetched via tracker.getIssue()) 4. Humanized branch name (e.g., "feat/infer-project-id" → "Infer Project ID") Key changes: - Enrich agent summaries by calling getSessionInfo() for sessions without summaries (local file I/O, not API calls) - Enrich issue titles via tracker.getIssue() with 5-min TTL cache - Add humanizeBranch() utility for last-resort branch name display - Add issueTitle field to DashboardSession type - Show issue title in expanded detail panel Co-Authored-By: Claude Opus 4.6 * fix: extract humanizeBranch to separate module to avoid client-side timer leaks Moves humanizeBranch() from serialize.ts to format.ts — a pure utility module with no side effects. This prevents the client bundle from pulling in TTLCache instantiations (which create setInterval timers) when SessionCard.tsx imports the function. Co-Authored-By: Claude Opus 4.6 * fix: remove dead re-export of humanizeBranch from serialize.ts No consumer imports humanizeBranch from serialize — SessionCard imports directly from format.ts. The re-export was unused surface area. Co-Authored-By: Claude Opus 4.6 * fix: add missing first-project fallback in summary enrichment block Matches the pattern used by all other enrichment blocks in page.tsx (issue labels, issue titles, PR enrichment) which fall back to the first configured project when projectId and sessionPrefix both miss. Co-Authored-By: Claude Opus 4.6 * feat: smarter title heuristic — skip prompt excerpts, prefer issue titles The agent summary fallback from extractSummary() often returns truncated spawn prompts ("You are working on GitHub issue #42: Add auth...") which make poor titles. The new heuristic detects these prompt excerpts and prefers the issue title when available. Updated fallback chain: PR title → quality summary → issue title → any summary → humanized branch → status Changes: - Add looksLikePromptExcerpt() to detect spawn prompt patterns - Add getSessionTitle() to encapsulate the smart fallback logic - Expand humanizeBranch() with more prefix patterns (release, hotfix, etc.) - SessionCard now uses getSessionTitle() instead of inline ?? chain - Add 25 unit tests covering all functions and edge cases - Fix missing issueTitle field in serialize.test.ts fixture Co-Authored-By: Claude Opus 4.6 * refactor: extract shared resolveProject() to eliminate duplication Moves resolveProject() from route.ts into serialize.ts as a shared export. Both page.tsx and route.ts now use the same function instead of duplicating the 3-step project resolution logic (projectId → sessionPrefix → first project fallback) inline. Co-Authored-By: Claude Opus 4.6 * feat: replace looksLikePromptExcerpt heuristic with summaryIsFallback metadata Instead of fragile string matching to detect truncated spawn prompts, the agent plugin now sets summaryIsFallback: true when the summary is a first-message fallback rather than a real agent-generated summary. - Add summaryIsFallback to AgentSessionInfo (core/types.ts) - extractSummary() returns { summary, isFallback } in claude-code plugin - Add summaryIsFallback to DashboardSession, propagate in serialize.ts - Replace looksLikePromptExcerpt() with !session.summaryIsFallback - Fix .js extension in format.ts import (review feedback) - Add thorough tests for all layers of propagation Co-Authored-By: Claude Sonnet 4.6 * test: add resolveProject and enrichSessionIssueTitle coverage - resolveProject: 5 tests covering direct match, prefix fallback, first-project fallback, empty projects, and priority ordering - enrichSessionIssueTitle: 7 tests covering enrichment, # prefix stripping, Linear-style labels, skip conditions, error handling, and cross-call caching Co-Authored-By: Claude Sonnet 4.6 * refactor: extract shared enrichSessionsMetadata, fix session detail route - Extract duplicated enrichment orchestration (issue labels, agent summaries, issue titles) from page.tsx and route.ts into a single enrichSessionsMetadata() function in serialize.ts - Fix /api/sessions/[id] route: was missing agent summary and issue title enrichment, and had hand-rolled project resolution instead of using resolveProject() (also missing the first-project fallback) - Optimize: resolve projects once per session instead of 3x - Add 8 tests for enrichSessionsMetadata covering full pipeline, skip conditions, missing plugins, no-tracker config, multiple sessions, and default agent fallback Co-Authored-By: Claude Sonnet 4.6 * fix: remove dead getAgent and getTracker exports from services.ts These helpers became unused when enrichSessionsMetadata was extracted to serialize.ts with inline registry.get() calls (to avoid coupling serialize.ts to services.ts and pulling plugin packages into webpack). Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.6 --- packages/core/src/types.ts | 2 + .../agent-claude-code/src/index.test.ts | 9 +- .../plugins/agent-claude-code/src/index.ts | 15 +- packages/web/src/__tests__/helpers.ts | 1 + .../web/src/app/api/sessions/[id]/route.ts | 30 +- packages/web/src/app/api/sessions/route.ts | 35 +- packages/web/src/app/page.tsx | 37 +- packages/web/src/components/SessionCard.tsx | 4 +- packages/web/src/lib/__tests__/format.test.ts | 196 ++++++ .../web/src/lib/__tests__/serialize.test.ts | 636 +++++++++++++++++- packages/web/src/lib/__tests__/types.test.ts | 2 + packages/web/src/lib/format.ts | 58 ++ packages/web/src/lib/serialize.ts | 143 +++- packages/web/src/lib/services.ts | 9 - packages/web/src/lib/types.ts | 3 + 15 files changed, 1078 insertions(+), 102 deletions(-) create mode 100644 packages/web/src/lib/__tests__/format.test.ts create mode 100644 packages/web/src/lib/format.ts diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ae0346506..ab97d9798 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -346,6 +346,8 @@ export interface WorkspaceHooksConfig { export interface AgentSessionInfo { /** Agent's auto-generated summary of what it's working on */ summary: string | null; + /** True when summary is a fallback (e.g. truncated first user message), not a real agent summary */ + summaryIsFallback?: boolean; /** Agent's internal session ID (for resume) */ agentSessionId: string | null; /** Estimated cost so far */ diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index 24c32e6c4..0ea3a3ce8 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -441,7 +441,7 @@ describe("getSessionInfo", () => { }); describe("summary extraction", () => { - it("extracts summary from last summary event", async () => { + it("extracts summary from last summary event and marks as not fallback", async () => { const jsonl = [ '{"type":"summary","summary":"First summary"}', '{"type":"user","message":{"content":"do something"}}', @@ -450,9 +450,10 @@ describe("getSessionInfo", () => { mockJsonlFiles(jsonl); const result = await agent.getSessionInfo(makeSession()); expect(result?.summary).toBe("Latest summary"); + expect(result?.summaryIsFallback).toBe(false); }); - it("falls back to first user message when no summary", async () => { + it("falls back to first user message and marks as fallback", async () => { const jsonl = [ '{"type":"user","message":{"content":"Implement the login feature"}}', '{"type":"assistant","message":{"content":"I will implement..."}}', @@ -460,6 +461,7 @@ describe("getSessionInfo", () => { mockJsonlFiles(jsonl); const result = await agent.getSessionInfo(makeSession()); expect(result?.summary).toBe("Implement the login feature"); + expect(result?.summaryIsFallback).toBe(true); }); it("truncates long user message to 120 chars", async () => { @@ -469,6 +471,7 @@ describe("getSessionInfo", () => { const result = await agent.getSessionInfo(makeSession()); expect(result?.summary).toBe("A".repeat(120) + "..."); expect(result!.summary!.length).toBe(123); + expect(result?.summaryIsFallback).toBe(true); }); it("returns null summary when no summary and no user messages", async () => { @@ -476,6 +479,7 @@ describe("getSessionInfo", () => { mockJsonlFiles(jsonl); const result = await agent.getSessionInfo(makeSession()); expect(result?.summary).toBeNull(); + expect(result?.summaryIsFallback).toBeUndefined(); }); it("skips user messages with empty content", async () => { @@ -486,6 +490,7 @@ describe("getSessionInfo", () => { mockJsonlFiles(jsonl); const result = await agent.getSessionInfo(makeSession()); expect(result?.summary).toBe("Real content"); + expect(result?.summaryIsFallback).toBe(true); }); }); diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 21f1109c8..faa75fe51 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -279,11 +279,13 @@ async function parseJsonlFile(filePath: string): Promise { } /** Extract auto-generated summary from JSONL (last "summary" type entry) */ -function extractSummary(lines: JsonlLine[]): string | null { +function extractSummary( + lines: JsonlLine[], +): { summary: string; isFallback: boolean } | null { for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; if (line?.type === "summary" && line.summary) { - return line.summary; + return { summary: line.summary, isFallback: false }; } } // Fallback: first user message truncated to 120 chars @@ -295,7 +297,10 @@ function extractSummary(lines: JsonlLine[]): string | null { ) { const msg = line.message.content.trim(); if (msg.length > 0) { - return msg.length > 120 ? msg.substring(0, 120) + "..." : msg; + return { + summary: msg.length > 120 ? msg.substring(0, 120) + "..." : msg, + isFallback: true, + }; } } } @@ -715,8 +720,10 @@ function createClaudeCodeAgent(): Agent { // Extract session ID from filename const agentSessionId = basename(sessionFile, ".jsonl"); + const summaryResult = extractSummary(lines); return { - summary: extractSummary(lines), + summary: summaryResult?.summary ?? null, + summaryIsFallback: summaryResult?.isFallback, agentSessionId, cost: extractCost(lines), lastLogModified, diff --git a/packages/web/src/__tests__/helpers.ts b/packages/web/src/__tests__/helpers.ts index 358b62f10..579c2f895 100644 --- a/packages/web/src/__tests__/helpers.ts +++ b/packages/web/src/__tests__/helpers.ts @@ -12,6 +12,7 @@ export function makeSession(overrides: Partial = {}): Dashboar issueUrl: "https://linear.app/test/issue/INT-100", issueLabel: "INT-100", summary: "Test session", + summaryIsFallback: false, createdAt: new Date().toISOString(), lastActivityAt: new Date().toISOString(), pr: null, diff --git a/packages/web/src/app/api/sessions/[id]/route.ts b/packages/web/src/app/api/sessions/[id]/route.ts index d959f92a7..e8cfb7ca7 100644 --- a/packages/web/src/app/api/sessions/[id]/route.ts +++ b/packages/web/src/app/api/sessions/[id]/route.ts @@ -1,6 +1,11 @@ import { NextResponse, type NextRequest } from "next/server"; -import { getServices, getSCM, getTracker } from "@/lib/services"; -import { sessionToDashboard, enrichSessionPR, enrichSessionIssue } from "@/lib/serialize"; +import { getServices, getSCM } from "@/lib/services"; +import { + sessionToDashboard, + resolveProject, + enrichSessionPR, + enrichSessionsMetadata, +} from "@/lib/serialize"; export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { @@ -14,25 +19,12 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ const dashboardSession = sessionToDashboard(coreSession); - // Get project config for enrichments - let project = config.projects[coreSession.projectId]; - if (!project) { - const entry = Object.entries(config.projects).find(([, p]) => - coreSession.id.startsWith(p.sessionPrefix), - ); - if (entry) project = entry[1]; - } - - // Enrich issue label using tracker plugin - if (dashboardSession.issueUrl && project) { - const tracker = getTracker(registry, project); - if (tracker) { - enrichSessionIssue(dashboardSession, tracker, project); - } - } + // Enrich metadata (issue labels, agent summaries, issue titles) + await enrichSessionsMetadata([coreSession], [dashboardSession], config, registry); // Enrich PR with live data from SCM - if (coreSession.pr && project) { + if (coreSession.pr) { + const project = resolveProject(coreSession, config.projects); const scm = getSCM(registry, project); if (scm) { await enrichSessionPR(dashboardSession, scm, coreSession.pr); diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 2acba40e7..7e65a833b 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -1,31 +1,14 @@ -import { ACTIVITY_STATE, type Session, type ProjectConfig } from "@composio/ao-core"; +import { ACTIVITY_STATE } from "@composio/ao-core"; import { NextResponse } from "next/server"; -import { getServices, getSCM, getTracker } from "@/lib/services"; +import { getServices, getSCM } from "@/lib/services"; import { sessionToDashboard, + resolveProject, enrichSessionPR, - enrichSessionIssue, + enrichSessionsMetadata, computeStats, } from "@/lib/serialize"; -/** Resolve which project a session belongs to. */ -function resolveProject( - core: Session, - projects: Record, -): ProjectConfig | undefined { - // Try explicit projectId first - const direct = projects[core.projectId]; - if (direct) return direct; - - // Match by session prefix - const entry = Object.entries(projects).find(([, p]) => core.id.startsWith(p.sessionPrefix)); - if (entry) return entry[1]; - - // Fall back to first project - const firstKey = Object.keys(projects)[0]; - return firstKey ? projects[firstKey] : undefined; -} - /** GET /api/sessions — List all sessions with full state * Query params: * - active=true: Only return non-exited sessions @@ -53,14 +36,8 @@ export async function GET(request: Request) { dashboardSessions = activeIndices.map((i) => dashboardSessions[i]); } - // Enrich issue labels using tracker plugin (synchronous) - workerSessions.forEach((core, i) => { - if (!dashboardSessions[i].issueUrl) return; - const project = resolveProject(core, config.projects); - const tracker = getTracker(registry, project); - if (!tracker || !project) return; - enrichSessionIssue(dashboardSessions[i], tracker, project); - }); + // Enrich metadata (issue labels, agent summaries, issue titles) + await enrichSessionsMetadata(workerSessions, dashboardSessions, config, registry); // Enrich sessions that have PRs with live SCM data (CI, reviews, mergeability) const enrichPromises = workerSessions.map((core, i) => { diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index b3a5f80b2..c06aaeea7 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,11 +1,12 @@ import type { Metadata } from "next"; import { Dashboard } from "@/components/Dashboard"; import type { DashboardSession } from "@/lib/types"; -import { getServices, getSCM, getTracker } from "@/lib/services"; +import { getServices, getSCM } from "@/lib/services"; import { sessionToDashboard, + resolveProject, enrichSessionPR, - enrichSessionIssue, + enrichSessionsMetadata, computeStats, } from "@/lib/serialize"; import { prCache, prCacheKey } from "@/lib/cache"; @@ -38,24 +39,8 @@ export default async function Home() { const coreSessions = allSessions.filter((s) => !s.id.endsWith("-orchestrator")); sessions = coreSessions.map(sessionToDashboard); - // Enrich issue labels using tracker plugin (synchronous) - coreSessions.forEach((core, i) => { - if (!sessions[i].issueUrl) return; - let project = config.projects[core.projectId]; - if (!project) { - const entry = Object.entries(config.projects).find(([, p]) => - core.id.startsWith(p.sessionPrefix), - ); - if (entry) project = entry[1]; - } - if (!project) { - const firstKey = Object.keys(config.projects)[0]; - if (firstKey) project = config.projects[firstKey]; - } - const tracker = getTracker(registry, project); - if (!tracker || !project) return; - enrichSessionIssue(sessions[i], tracker, project); - }); + // Enrich metadata (issue labels, agent summaries, issue titles) + await enrichSessionsMetadata(coreSessions, sessions, config, registry); // Enrich sessions that have PRs with live SCM data // Skip enrichment for terminal sessions (merged, closed, done, terminated) @@ -102,17 +87,7 @@ export default async function Home() { } } - let project = config.projects[core.projectId]; - if (!project) { - const entry = Object.entries(config.projects).find(([, p]) => - core.id.startsWith(p.sessionPrefix), - ); - if (entry) project = entry[1]; - } - if (!project) { - const firstKey = Object.keys(config.projects)[0]; - if (firstKey) project = config.projects[firstKey]; - } + const project = resolveProject(core, config.projects); const scm = getSCM(registry, project); if (!scm) return Promise.resolve(); return enrichSessionPR(sessions[i], scm, core.pr); diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index 2391e0bb2..a08c0e5bd 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -11,6 +11,7 @@ import { import { CI_STATUS } from "@composio/ao-core/types"; import { cn } from "@/lib/cn"; import { activityIcon } from "@/lib/activity-icons"; +import { getSessionTitle } from "@/lib/format"; import { PRStatus } from "./PRStatus"; import { CICheckList } from "./CIBadge"; @@ -90,7 +91,7 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses {session.id} - {pr?.title ?? session.summary ?? session.status} + {getSessionTitle(session)} {isRestorable && (