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 && (