feat: session title fallback chain for PR-less sessions (#105)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-19 19:02:02 +05:30 committed by GitHub
parent 450507193e
commit 0e2ca70b0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1078 additions and 102 deletions

View File

@ -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 */

View File

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

View File

@ -279,11 +279,13 @@ async function parseJsonlFile(filePath: string): Promise<JsonlLine[]> {
}
/** 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,

View File

@ -12,6 +12,7 @@ export function makeSession(overrides: Partial<DashboardSession> = {}): 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,

View File

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

View File

@ -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<string, ProjectConfig>,
): 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) => {

View File

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

View File

@ -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}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text-primary)]">
{pr?.title ?? session.summary ?? session.status}
{getSessionTitle(session)}
</span>
{isRestorable && (
<button
@ -196,6 +197,7 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
className="text-xs text-[var(--color-accent-blue)] hover:underline"
>
{session.issueLabel || session.issueUrl}
{session.issueTitle && `: ${session.issueTitle}`}
</a>
</DetailSection>
)}

View File

@ -0,0 +1,196 @@
/**
* Tests for session title heuristic and branch humanization.
*/
import { describe, it, expect } from "vitest";
import { humanizeBranch, getSessionTitle } from "../format";
import type { DashboardSession } from "../types";
// ---------------------------------------------------------------------------
// Test helper
// ---------------------------------------------------------------------------
function makeSession(overrides?: Partial<DashboardSession>): DashboardSession {
return {
id: "ao-42",
projectId: "test",
status: "working",
activity: "active",
branch: null,
issueId: null,
issueUrl: null,
issueLabel: null,
issueTitle: null,
summary: null,
summaryIsFallback: false,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
pr: null,
metadata: {},
...overrides,
};
}
// ---------------------------------------------------------------------------
// humanizeBranch
// ---------------------------------------------------------------------------
describe("humanizeBranch", () => {
it("strips common prefixes and title-cases", () => {
expect(humanizeBranch("feat/infer-project-id")).toBe("Infer Project Id");
expect(humanizeBranch("fix/broken-auth-flow")).toBe("Broken Auth Flow");
expect(humanizeBranch("chore/update-deps")).toBe("Update Deps");
expect(humanizeBranch("refactor/session-manager")).toBe("Session Manager");
expect(humanizeBranch("docs/add-readme")).toBe("Add Readme");
expect(humanizeBranch("test/add-coverage")).toBe("Add Coverage");
expect(humanizeBranch("ci/fix-pipeline")).toBe("Fix Pipeline");
});
it("strips additional prefixes added for completeness", () => {
expect(humanizeBranch("release/1.0.0")).toBe("1.0.0");
expect(humanizeBranch("hotfix/urgent-patch")).toBe("Urgent Patch");
expect(humanizeBranch("feature/new-dashboard")).toBe("New Dashboard");
expect(humanizeBranch("bugfix/null-pointer")).toBe("Null Pointer");
expect(humanizeBranch("build/docker-image")).toBe("Docker Image");
expect(humanizeBranch("wip/experimental")).toBe("Experimental");
expect(humanizeBranch("improvement/faster-queries")).toBe("Faster Queries");
});
it("handles session/ prefix", () => {
expect(humanizeBranch("session/ao-52")).toBe("Ao 52");
});
it("handles underscores", () => {
expect(humanizeBranch("feat/add_new_feature")).toBe("Add New Feature");
});
it("handles branch with no prefix", () => {
expect(humanizeBranch("main")).toBe("Main");
expect(humanizeBranch("some-branch-name")).toBe("Some Branch Name");
});
it("handles branch with dots", () => {
expect(humanizeBranch("release/v2.1.0")).toBe("V2.1.0");
});
it("handles empty string", () => {
expect(humanizeBranch("")).toBe("");
});
it("does not strip unknown prefixes", () => {
expect(humanizeBranch("custom/my-branch")).toBe("Custom/My Branch");
});
});
// ---------------------------------------------------------------------------
// getSessionTitle — full fallback chain
// ---------------------------------------------------------------------------
describe("getSessionTitle", () => {
it("returns PR title when available (highest priority)", () => {
const session = makeSession({
summary: "Agent summary",
issueTitle: "Issue title",
branch: "feat/branch",
pr: {
number: 1,
url: "https://github.com/test/repo/pull/1",
title: "feat: add auth",
owner: "test",
repo: "repo",
branch: "feat/branch",
baseBranch: "main",
isDraft: false,
state: "open",
additions: 10,
deletions: 5,
ciStatus: "passing",
ciChecks: [],
reviewDecision: "approved",
mergeability: {
mergeable: true,
ciPassing: true,
approved: true,
noConflicts: true,
blockers: [],
},
unresolvedThreads: 0,
unresolvedComments: [],
},
});
expect(getSessionTitle(session)).toBe("feat: add auth");
});
it("returns agent summary over issue title", () => {
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",
);
});
it("skips fallback summaries in favor of issue title", () => {
const session = makeSession({
summary: "You are working on GitHub issue #42: Add authentication to API...",
summaryIsFallback: true,
issueTitle: "Add authentication to API",
branch: "feat/issue-42",
});
expect(getSessionTitle(session)).toBe("Add authentication to API");
});
it("uses 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...",
);
});
it("returns issue title when no summary exists", () => {
const session = makeSession({
summary: null,
issueTitle: "Add user authentication",
branch: "feat/auth",
});
expect(getSessionTitle(session)).toBe("Add user authentication");
});
it("returns humanized branch when no summary or issue title", () => {
const session = makeSession({
summary: null,
issueTitle: null,
branch: "feat/infer-project-id",
});
expect(getSessionTitle(session)).toBe("Infer Project Id");
});
it("returns status as absolute last resort", () => {
const session = makeSession({
summary: null,
issueTitle: null,
branch: null,
});
expect(getSessionTitle(session)).toBe("working");
});
it("prefers fallback summary over branch when no issue title", () => {
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",
);
});
});

View File

@ -3,8 +3,24 @@
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { Session, PRInfo, SCM } from "@composio/ao-core";
import { sessionToDashboard, enrichSessionPR } from "../serialize";
import type {
Session,
PRInfo,
SCM,
Agent,
Tracker,
ProjectConfig,
OrchestratorConfig,
PluginRegistry,
} from "@composio/ao-core";
import {
sessionToDashboard,
resolveProject,
enrichSessionPR,
enrichSessionAgentSummary,
enrichSessionIssueTitle,
enrichSessionsMetadata,
} from "../serialize";
import { prCache, prCacheKey } from "../cache";
import type { DashboardSession } from "../types";
@ -109,19 +125,49 @@ describe("sessionToDashboard", () => {
expect(dashboard.lastActivityAt).toBe("2025-01-01T01:00:00.000Z");
});
it("should use agentInfo summary if available", () => {
it("should use agentInfo summary with summaryIsFallback false", () => {
const coreSession = createCoreSession({
agentInfo: {
summary: "Working on feature X",
summaryIsFallback: false,
agentSessionId: "abc123",
},
});
const dashboard = sessionToDashboard(coreSession);
expect(dashboard.summary).toBe("Working on feature X");
expect(dashboard.summaryIsFallback).toBe(false);
});
it("should fall back to metadata summary if agentInfo is null", () => {
it("should propagate summaryIsFallback true from agentInfo", () => {
const coreSession = createCoreSession({
agentInfo: {
summary: "You are working on issue #42...",
summaryIsFallback: true,
agentSessionId: "abc123",
},
});
const dashboard = sessionToDashboard(coreSession);
expect(dashboard.summary).toBe("You are working on issue #42...");
expect(dashboard.summaryIsFallback).toBe(true);
});
it("should default summaryIsFallback to false when agentInfo omits it", () => {
const coreSession = createCoreSession({
agentInfo: {
summary: "Working on feature X",
agentSessionId: "abc123",
// summaryIsFallback intentionally omitted (older plugin)
},
});
const dashboard = sessionToDashboard(coreSession);
expect(dashboard.summary).toBe("Working on feature X");
expect(dashboard.summaryIsFallback).toBe(false);
});
it("should set summaryIsFallback false for metadata summary", () => {
const coreSession = createCoreSession({
agentInfo: null,
metadata: { summary: "Metadata summary" },
@ -129,6 +175,18 @@ describe("sessionToDashboard", () => {
const dashboard = sessionToDashboard(coreSession);
expect(dashboard.summary).toBe("Metadata summary");
expect(dashboard.summaryIsFallback).toBe(false);
});
it("should set summaryIsFallback false when no summary exists", () => {
const coreSession = createCoreSession({
agentInfo: null,
metadata: {},
});
const dashboard = sessionToDashboard(coreSession);
expect(dashboard.summary).toBeNull();
expect(dashboard.summaryIsFallback).toBe(false);
});
it("should convert PRInfo to DashboardPR with defaults", () => {
@ -156,6 +214,60 @@ describe("sessionToDashboard", () => {
});
});
describe("resolveProject", () => {
function makeProject(overrides?: Partial<ProjectConfig>): ProjectConfig {
return {
name: "test",
repo: "test/repo",
path: "/test",
defaultBranch: "main",
sessionPrefix: "test",
...overrides,
};
}
it("should match by explicit projectId", () => {
const projects = {
app: makeProject({ name: "app", sessionPrefix: "app" }),
lib: makeProject({ name: "lib", sessionPrefix: "lib" }),
};
const session = createCoreSession({ projectId: "app" });
expect(resolveProject(session, projects)).toBe(projects.app);
});
it("should fall back to session prefix match", () => {
const projects = {
app: makeProject({ name: "app", sessionPrefix: "app" }),
lib: makeProject({ name: "lib", sessionPrefix: "lib" }),
};
const session = createCoreSession({ id: "lib-42", projectId: "unknown" });
expect(resolveProject(session, projects)).toBe(projects.lib);
});
it("should fall back to first project when nothing matches", () => {
const projects = {
app: makeProject({ name: "app", sessionPrefix: "app" }),
};
const session = createCoreSession({ id: "other-1", projectId: "unknown" });
expect(resolveProject(session, projects)).toBe(projects.app);
});
it("should return undefined for empty projects", () => {
const session = createCoreSession();
expect(resolveProject(session, {})).toBeUndefined();
});
it("should prefer exact projectId over prefix match", () => {
const projects = {
app: makeProject({ name: "app", sessionPrefix: "lib" }),
lib: makeProject({ name: "lib", sessionPrefix: "app" }),
};
// session id starts with "app" (matches lib's prefix), but projectId is "app" (direct match)
const session = createCoreSession({ id: "app-1", projectId: "app" });
expect(resolveProject(session, projects)).toBe(projects.app);
});
});
describe("enrichSessionPR", () => {
beforeEach(() => {
prCache.clear();
@ -286,7 +398,9 @@ describe("enrichSessionPR", () => {
issueId: null,
issueUrl: null,
issueLabel: null,
issueTitle: null,
summary: null,
summaryIsFallback: false,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
pr: null,
@ -319,6 +433,520 @@ describe("enrichSessionPR", () => {
});
});
describe("enrichSessionAgentSummary", () => {
function createMockAgent(
info: Partial<Awaited<ReturnType<Agent["getSessionInfo"]>>> | null = null,
): Agent {
return {
name: "mock",
processName: "mock",
getLaunchCommand: vi.fn().mockReturnValue("mock"),
getEnvironment: vi.fn().mockReturnValue({}),
detectActivity: vi.fn().mockReturnValue("active"),
getActivityState: vi.fn().mockResolvedValue({ activity: "active" }),
getSessionInfo: vi.fn().mockResolvedValue(
info
? {
summary: info.summary ?? null,
summaryIsFallback: info.summaryIsFallback,
agentSessionId: info.agentSessionId ?? null,
}
: null,
),
sendMessage: vi.fn(),
};
}
it("should set summary and summaryIsFallback false from agent", async () => {
const core = createCoreSession();
const dashboard = sessionToDashboard(core);
expect(dashboard.summary).toBeNull();
const agent = createMockAgent({
summary: "Working on feature X",
summaryIsFallback: false,
});
await enrichSessionAgentSummary(dashboard, core, agent);
expect(dashboard.summary).toBe("Working on feature X");
expect(dashboard.summaryIsFallback).toBe(false);
});
it("should propagate summaryIsFallback true from agent", async () => {
const core = createCoreSession();
const dashboard = sessionToDashboard(core);
const agent = createMockAgent({
summary: "You are working on issue #42...",
summaryIsFallback: true,
});
await enrichSessionAgentSummary(dashboard, core, agent);
expect(dashboard.summary).toBe("You are working on issue #42...");
expect(dashboard.summaryIsFallback).toBe(true);
});
it("should default summaryIsFallback to false when agent omits it", async () => {
const core = createCoreSession();
const dashboard = sessionToDashboard(core);
const agent = createMockAgent({
summary: "Working on feature X",
// summaryIsFallback intentionally omitted (backwards compat)
});
await enrichSessionAgentSummary(dashboard, core, agent);
expect(dashboard.summary).toBe("Working on feature X");
expect(dashboard.summaryIsFallback).toBe(false);
});
it("should skip enrichment when dashboard already has a summary", async () => {
const core = createCoreSession({
agentInfo: {
summary: "Existing summary",
summaryIsFallback: false,
agentSessionId: "abc",
},
});
const dashboard = sessionToDashboard(core);
expect(dashboard.summary).toBe("Existing summary");
const agent = createMockAgent({
summary: "New summary from agent",
summaryIsFallback: false,
});
await enrichSessionAgentSummary(dashboard, core, agent);
// Should keep original summary, not overwrite
expect(dashboard.summary).toBe("Existing summary");
expect(agent.getSessionInfo).not.toHaveBeenCalled();
});
it("should handle agent.getSessionInfo throwing", async () => {
const core = createCoreSession();
const dashboard = sessionToDashboard(core);
const agent: Agent = {
...createMockAgent(),
getSessionInfo: vi.fn().mockRejectedValue(new Error("JSONL corrupted")),
};
await enrichSessionAgentSummary(dashboard, core, agent);
expect(dashboard.summary).toBeNull();
expect(dashboard.summaryIsFallback).toBe(false);
});
it("should not update when agent returns null info", async () => {
const core = createCoreSession();
const dashboard = sessionToDashboard(core);
const agent = createMockAgent(null);
await enrichSessionAgentSummary(dashboard, core, agent);
expect(dashboard.summary).toBeNull();
expect(dashboard.summaryIsFallback).toBe(false);
});
it("should not update when agent returns info with null summary", async () => {
const core = createCoreSession();
const dashboard = sessionToDashboard(core);
const agent = createMockAgent({ summary: null });
await enrichSessionAgentSummary(dashboard, core, agent);
expect(dashboard.summary).toBeNull();
expect(dashboard.summaryIsFallback).toBe(false);
});
});
describe("enrichSessionIssueTitle", () => {
function makeProject(overrides?: Partial<ProjectConfig>): ProjectConfig {
return {
name: "test",
repo: "test/repo",
path: "/test",
defaultBranch: "main",
sessionPrefix: "test",
...overrides,
};
}
function createMockTracker(title = "Add user authentication"): Tracker {
return {
name: "mock",
getIssue: vi.fn().mockResolvedValue({
id: "42",
title,
description: "Description",
url: "https://github.com/test/repo/issues/42",
state: "open",
labels: [],
}),
isCompleted: vi.fn().mockResolvedValue(false),
issueUrl: vi.fn().mockReturnValue("https://github.com/test/repo/issues/42"),
issueLabel: vi.fn().mockReturnValue("#42"),
branchName: vi.fn().mockReturnValue("feat/issue-42"),
generatePrompt: vi.fn().mockResolvedValue("prompt"),
};
}
function makeDashboard(overrides?: Partial<DashboardSession>): DashboardSession {
return {
id: "test-1",
projectId: "test",
status: "working",
activity: "active",
branch: "feat/test",
issueId: null,
issueUrl: null,
issueLabel: null,
issueTitle: null,
summary: null,
summaryIsFallback: false,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
pr: null,
metadata: {},
...overrides,
};
}
it("should enrich issue title from tracker", async () => {
const dashboard = makeDashboard({
issueUrl: "https://github.com/test/repo/issues/42",
issueLabel: "#42",
});
const tracker = createMockTracker();
const project = makeProject();
await enrichSessionIssueTitle(dashboard, tracker, project);
expect(dashboard.issueTitle).toBe("Add user authentication");
expect(tracker.getIssue).toHaveBeenCalledWith("42", project);
});
it("should strip # prefix from GitHub-style labels", async () => {
const dashboard = makeDashboard({
issueUrl: "https://github.com/test/repo/issues/99",
issueLabel: "#99",
});
const tracker = createMockTracker();
const project = makeProject();
await enrichSessionIssueTitle(dashboard, tracker, project);
expect(tracker.getIssue).toHaveBeenCalledWith("99", project);
});
it("should pass through non-GitHub labels unchanged", async () => {
const dashboard = makeDashboard({
issueUrl: "https://linear.app/team/INT-100",
issueLabel: "INT-100",
});
const tracker = createMockTracker();
const project = makeProject();
await enrichSessionIssueTitle(dashboard, tracker, project);
expect(tracker.getIssue).toHaveBeenCalledWith("INT-100", project);
});
it("should skip when issueUrl is null", async () => {
const dashboard = makeDashboard({ issueUrl: null, issueLabel: "#42" });
const tracker = createMockTracker();
const project = makeProject();
await enrichSessionIssueTitle(dashboard, tracker, project);
expect(tracker.getIssue).not.toHaveBeenCalled();
expect(dashboard.issueTitle).toBeNull();
});
it("should skip when issueLabel is null", async () => {
const dashboard = makeDashboard({
issueUrl: "https://github.com/test/repo/issues/42",
issueLabel: null,
});
const tracker = createMockTracker();
const project = makeProject();
await enrichSessionIssueTitle(dashboard, tracker, project);
expect(tracker.getIssue).not.toHaveBeenCalled();
expect(dashboard.issueTitle).toBeNull();
});
it("should handle tracker errors gracefully", async () => {
// Unique URL to avoid cache from other tests
const dashboard = makeDashboard({
issueUrl: "https://github.com/test/repo/issues/error-test",
issueLabel: "#error-test",
});
const tracker: Tracker = {
...createMockTracker(),
getIssue: vi.fn().mockRejectedValue(new Error("API error")),
};
const project = makeProject();
await enrichSessionIssueTitle(dashboard, tracker, project);
expect(dashboard.issueTitle).toBeNull();
});
it("should cache results across calls", async () => {
// Unique URL to avoid cache from other tests
const issueUrl = "https://github.com/test/repo/issues/cache-test";
const dashboard1 = makeDashboard({ issueUrl, issueLabel: "#cache-test" });
const dashboard2 = makeDashboard({ issueUrl, issueLabel: "#cache-test" });
const tracker = createMockTracker();
const project = makeProject();
await enrichSessionIssueTitle(dashboard1, tracker, project);
await enrichSessionIssueTitle(dashboard2, tracker, project);
expect(tracker.getIssue).toHaveBeenCalledTimes(1);
expect(dashboard2.issueTitle).toBe("Add user authentication");
});
});
describe("enrichSessionsMetadata", () => {
// Unique URL base to avoid cross-test cache collisions
const urlBase = "https://github.com/test/repo/issues/meta";
function mockTracker(title = "Add user authentication"): Tracker {
return {
name: "mock-tracker",
getIssue: vi.fn().mockResolvedValue({
id: "42",
title,
description: "",
url: `${urlBase}-default`,
state: "open",
labels: [],
}),
isCompleted: vi.fn().mockResolvedValue(false),
issueUrl: vi.fn().mockReturnValue(`${urlBase}-default`),
issueLabel: vi.fn().mockReturnValue("#42"),
branchName: vi.fn().mockReturnValue("feat/issue-42"),
generatePrompt: vi.fn().mockResolvedValue("prompt"),
};
}
function mockAgent(summary = "Working on feature"): Agent {
return {
name: "mock-agent",
processName: "mock",
getLaunchCommand: vi.fn().mockReturnValue("mock"),
getEnvironment: vi.fn().mockReturnValue({}),
detectActivity: vi.fn().mockReturnValue("active"),
getActivityState: vi.fn().mockResolvedValue({ activity: "active" }),
getSessionInfo: vi.fn().mockResolvedValue({
summary,
summaryIsFallback: false,
agentSessionId: "abc",
}),
sendMessage: vi.fn(),
};
}
function mockRegistry(tracker: Tracker | null, agent: Agent | null): PluginRegistry {
return {
get: vi.fn((slot: string) => {
if (slot === "tracker") return tracker;
if (slot === "agent") return agent;
return null;
}),
register: vi.fn(),
list: vi.fn().mockReturnValue([]),
loadBuiltins: vi.fn(),
loadFromConfig: vi.fn(),
} as unknown as PluginRegistry;
}
const testProject: ProjectConfig = {
name: "test",
repo: "test/repo",
path: "/test",
defaultBranch: "main",
sessionPrefix: "test",
tracker: { plugin: "mock-tracker" },
agent: "mock-agent",
};
const testConfig = {
configPath: "/test",
defaults: { runtime: "tmux", agent: "mock-agent", workspace: "worktree", notifiers: [] },
projects: { test: testProject },
notifiers: {},
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
reactions: {},
readyThresholdMs: 300000,
} as OrchestratorConfig;
it("should enrich issue labels, agent summaries, and issue titles", async () => {
const tracker = mockTracker("Fix auth bug");
const agent = mockAgent("Implementing auth fix");
const registry = mockRegistry(tracker, agent);
const core = createCoreSession({ issueId: `${urlBase}-full` });
const dashboard = sessionToDashboard(core);
expect(dashboard.summary).toBeNull();
await enrichSessionsMetadata([core], [dashboard], testConfig, registry);
// Issue label enriched (sync)
expect(dashboard.issueLabel).toBe("#42");
// Summary enriched (async)
expect(dashboard.summary).toBe("Implementing auth fix");
// Issue title enriched (async, depends on issueLabel from sync step)
expect(dashboard.issueTitle).toBe("Fix auth bug");
});
it("should skip sessions without issue URLs", async () => {
const tracker = mockTracker();
const agent = mockAgent();
const registry = mockRegistry(tracker, agent);
const core = createCoreSession({ issueId: null });
const dashboard = sessionToDashboard(core);
await enrichSessionsMetadata([core], [dashboard], testConfig, registry);
expect(tracker.issueLabel).not.toHaveBeenCalled();
expect(tracker.getIssue).not.toHaveBeenCalled();
// Summary still enriched (independent of issue)
expect(dashboard.summary).toBe("Working on feature");
});
it("should skip summary enrichment when session already has one", async () => {
const tracker = mockTracker();
const agent = mockAgent();
const registry = mockRegistry(tracker, agent);
const core = createCoreSession({
agentInfo: { summary: "Existing summary", summaryIsFallback: false, agentSessionId: "x" },
});
const dashboard = sessionToDashboard(core);
await enrichSessionsMetadata([core], [dashboard], testConfig, registry);
expect(agent.getSessionInfo).not.toHaveBeenCalled();
expect(dashboard.summary).toBe("Existing summary");
});
it("should handle missing tracker plugin gracefully", async () => {
const agent = mockAgent();
const registry = mockRegistry(null, agent);
const core = createCoreSession({ issueId: `${urlBase}-no-tracker` });
const dashboard = sessionToDashboard(core);
await enrichSessionsMetadata([core], [dashboard], testConfig, registry);
// Issue enrichment skipped (no tracker)
expect(dashboard.issueLabel).toBeNull();
expect(dashboard.issueTitle).toBeNull();
// Agent summary still works
expect(dashboard.summary).toBe("Working on feature");
});
it("should handle missing agent plugin gracefully", async () => {
const tracker = mockTracker();
const registry = mockRegistry(tracker, null);
const core = createCoreSession({ issueId: `${urlBase}-no-agent` });
const dashboard = sessionToDashboard(core);
await enrichSessionsMetadata([core], [dashboard], testConfig, registry);
// Issue enrichment still works
expect(dashboard.issueLabel).toBe("#42");
// Summary stays null (no agent)
expect(dashboard.summary).toBeNull();
});
it("should handle project with no tracker config", async () => {
const agent = mockAgent();
const registry = mockRegistry(null, agent);
const configNoTracker = {
...testConfig,
projects: {
test: { ...testProject, tracker: undefined } as ProjectConfig,
},
} as OrchestratorConfig;
const core = createCoreSession({ issueId: `${urlBase}-no-tracker-cfg` });
const dashboard = sessionToDashboard(core);
await enrichSessionsMetadata([core], [dashboard], configNoTracker, registry);
// No tracker resolution attempted
expect(registry.get).not.toHaveBeenCalledWith("tracker", expect.anything());
expect(dashboard.issueLabel).toBeNull();
// Agent still works
expect(dashboard.summary).toBe("Working on feature");
});
it("should enrich multiple sessions independently", async () => {
const tracker = mockTracker();
const agent = mockAgent();
const registry = mockRegistry(tracker, agent);
const cores = [
createCoreSession({ id: "test-1", issueId: `${urlBase}-multi-1` }),
createCoreSession({ id: "test-2", issueId: null }), // no issue
createCoreSession({
id: "test-3",
issueId: `${urlBase}-multi-3`,
agentInfo: { summary: "Already has one", summaryIsFallback: false, agentSessionId: "y" },
}),
];
const dashboards = cores.map(sessionToDashboard);
await enrichSessionsMetadata(cores, dashboards, testConfig, registry);
// Session 1: full enrichment
expect(dashboards[0].issueLabel).toBe("#42");
expect(dashboards[0].summary).toBe("Working on feature");
// Session 2: no issue, but summary enriched
expect(dashboards[1].issueLabel).toBeNull();
expect(dashboards[1].summary).toBe("Working on feature");
// Session 3: issue enriched, summary kept from agentInfo
expect(dashboards[2].issueLabel).toBe("#42");
expect(dashboards[2].summary).toBe("Already has one");
// Agent not called for session 3 (already had summary)
expect(agent.getSessionInfo).toHaveBeenCalledTimes(2); // sessions 1 and 2 only
});
it("should use default agent when project has no agent override", async () => {
const tracker = mockTracker();
const agent = mockAgent("From default agent");
const registry = mockRegistry(tracker, agent);
const configNoProjectAgent = {
...testConfig,
projects: {
test: { ...testProject, agent: undefined } as ProjectConfig,
},
} as OrchestratorConfig;
const core = createCoreSession();
const dashboard = sessionToDashboard(core);
await enrichSessionsMetadata([core], [dashboard], configNoProjectAgent, registry);
// Falls back to config.defaults.agent
expect(registry.get).toHaveBeenCalledWith("agent", "mock-agent");
expect(dashboard.summary).toBe("From default agent");
});
});
describe("basicPRToDashboard defaults", () => {
it("should not look like failing CI", () => {
const pr = createPRInfo();

View File

@ -27,7 +27,9 @@ function createSession(overrides?: Partial<DashboardSession>): DashboardSession
issueId: null,
issueUrl: null,
issueLabel: null,
issueTitle: null,
summary: "Test session",
summaryIsFallback: false,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
pr: null,

View File

@ -0,0 +1,58 @@
/**
* Pure formatting utilities safe for both server and client components.
* No side effects, no external dependencies.
*/
import type { DashboardSession } from "./types.js";
/**
* Humanize a git branch name into a readable title.
* e.g., "feat/infer-project-id" "Infer Project ID"
* "fix/broken-auth-flow" "Broken Auth Flow"
* "session/ao-52" "ao-52"
*/
export function humanizeBranch(branch: string): string {
// Remove common prefixes
const withoutPrefix = branch.replace(
/^(?:feat|fix|chore|refactor|docs|test|ci|session|release|hotfix|feature|bugfix|build|wip|improvement)\//,
"",
);
// Replace hyphens and underscores with spaces, then title-case each word
return withoutPrefix
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())
.trim();
}
/**
* Compute the best display title for a session card.
*
* 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
*/
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)
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
if (session.summary) return session.summary;
// 5. Humanized branch
if (session.branch) return humanizeBranch(session.branch);
// 6. Status
return session.status;
}

View File

@ -5,12 +5,45 @@
* (string dates, flattened DashboardPR) suitable for JSON serialization.
*/
import type { Session, SCM, PRInfo, Tracker, ProjectConfig } from "@composio/ao-core";
import type {
Session,
Agent,
SCM,
PRInfo,
Tracker,
ProjectConfig,
OrchestratorConfig,
PluginRegistry,
} from "@composio/ao-core";
import type { DashboardSession, DashboardPR, DashboardStats } from "./types.js";
import { prCache, prCacheKey, type PREnrichmentData } from "./cache";
import { TTLCache, prCache, prCacheKey, type PREnrichmentData } from "./cache";
/** Cache for issue titles (5 min TTL — issue titles rarely change) */
const issueTitleCache = new TTLCache<string>(300_000);
/** Resolve which project a session belongs to. */
export function resolveProject(
core: Session,
projects: Record<string, ProjectConfig>,
): 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;
}
/** Convert a core Session to a DashboardSession (without PR/issue enrichment). */
export function sessionToDashboard(session: Session): DashboardSession {
const agentSummary = session.agentInfo?.summary;
const summary = agentSummary ?? session.metadata["summary"] ?? null;
return {
id: session.id,
projectId: session.projectId,
@ -20,7 +53,11 @@ export function sessionToDashboard(session: Session): DashboardSession {
issueId: session.issueId, // Deprecated: kept for backwards compatibility
issueUrl: session.issueId, // issueId is actually the full URL
issueLabel: null, // Will be enriched by enrichSessionIssue()
summary: session.agentInfo?.summary ?? session.metadata["summary"] ?? null,
issueTitle: null, // Will be enriched by enrichSessionIssueTitle()
summary,
summaryIsFallback: agentSummary
? (session.agentInfo?.summaryIsFallback ?? false)
: false,
createdAt: session.createdAt.toISOString(),
lastActivityAt: session.lastActivityAt.toISOString(),
pr: session.pr ? basicPRToDashboard(session.pr) : null,
@ -221,6 +258,106 @@ export function enrichSessionIssue(
}
}
/**
* Enrich a DashboardSession's summary by calling agent.getSessionInfo().
* Only fetches when the session doesn't already have a summary.
* Reads the agent's JSONL file on disk fast local I/O, not an API call.
*/
export async function enrichSessionAgentSummary(
dashboard: DashboardSession,
coreSession: Session,
agent: Agent,
): Promise<void> {
if (dashboard.summary) return;
try {
const info = await agent.getSessionInfo(coreSession);
if (info?.summary) {
dashboard.summary = info.summary;
dashboard.summaryIsFallback = info.summaryIsFallback ?? false;
}
} catch {
// Can't read agent session info — keep summary null
}
}
/**
* Enrich a DashboardSession's issue title by calling tracker.getIssue().
* Extracts the identifier from the issue URL using issueLabel(),
* then fetches full issue details for the title.
*/
export async function enrichSessionIssueTitle(
dashboard: DashboardSession,
tracker: Tracker,
project: ProjectConfig,
): Promise<void> {
if (!dashboard.issueUrl || !dashboard.issueLabel) return;
// Check cache first
const cached = issueTitleCache.get(dashboard.issueUrl);
if (cached) {
dashboard.issueTitle = cached;
return;
}
try {
// Strip "#" prefix from GitHub-style labels to get the identifier
const identifier = dashboard.issueLabel.replace(/^#/, "");
const issue = await tracker.getIssue(identifier, project);
if (issue.title) {
dashboard.issueTitle = issue.title;
issueTitleCache.set(dashboard.issueUrl, issue.title);
}
} catch {
// Can't fetch issue — keep issueTitle null
}
}
/**
* Enrich dashboard sessions with metadata (issue labels, agent summaries, issue titles).
* Orchestrates sync + async enrichment in parallel. Does NOT enrich PR data callers
* handle that separately since strategies differ (e.g. terminal-session cache optimization).
*/
export async function enrichSessionsMetadata(
coreSessions: Session[],
dashboardSessions: DashboardSession[],
config: OrchestratorConfig,
registry: PluginRegistry,
): Promise<void> {
// Resolve projects once per session (avoids repeated Object.entries lookups)
const projects = coreSessions.map((core) => resolveProject(core, config.projects));
// Enrich issue labels (synchronous — must run before async title enrichment)
projects.forEach((project, i) => {
if (!dashboardSessions[i].issueUrl || !project?.tracker) return;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker) return;
enrichSessionIssue(dashboardSessions[i], tracker, project);
});
// Enrich agent summaries (reads agent's JSONL — local I/O, not an API call)
const summaryPromises = coreSessions.map((core, i) => {
if (dashboardSessions[i].summary) return Promise.resolve();
const agentName = projects[i]?.agent ?? config.defaults.agent;
if (!agentName) return Promise.resolve();
const agent = registry.get<Agent>("agent", agentName);
if (!agent) return Promise.resolve();
return enrichSessionAgentSummary(dashboardSessions[i], core, agent);
});
// Enrich issue titles (fetches from tracker API, cached with TTL)
const issueTitlePromises = projects.map((project, i) => {
if (!dashboardSessions[i].issueUrl || !dashboardSessions[i].issueLabel) {
return Promise.resolve();
}
if (!project?.tracker) return Promise.resolve();
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker) return Promise.resolve();
return enrichSessionIssueTitle(dashboardSessions[i], tracker, project);
});
await Promise.allSettled([...summaryPromises, ...issueTitlePromises]);
}
/** Compute dashboard stats from a list of sessions. */
export function computeStats(sessions: DashboardSession[]): DashboardStats {
return {

View File

@ -18,7 +18,6 @@ import {
type PluginRegistry,
type SessionManager,
type SCM,
type Tracker,
type ProjectConfig,
} from "@composio/ao-core";
@ -83,11 +82,3 @@ export function getSCM(registry: PluginRegistry, project: ProjectConfig | undefi
return registry.get<SCM>("scm", project.scm.plugin);
}
/** Resolve the Tracker plugin for a project. Returns null if not configured. */
export function getTracker(
registry: PluginRegistry,
project: ProjectConfig | undefined,
): Tracker | null {
if (!project?.tracker) return null;
return registry.get<Tracker>("tracker", project.tracker.plugin);
}

View File

@ -64,7 +64,10 @@ export interface DashboardSession {
issueId: string | null; // Deprecated: use issueUrl instead
issueUrl: string | null; // Full issue URL
issueLabel: string | null; // Human-readable label (e.g., "INT-1327", "#42")
issueTitle: string | null; // Full issue title (e.g., "Add user authentication flow")
summary: string | null;
/** True when the summary is a low-quality fallback (e.g. truncated spawn prompt) */
summaryIsFallback: boolean;
createdAt: string;
lastActivityAt: string;
pr: DashboardPR | null;