feat: add built-in summarizer and summary slots (#9713)

## Thinking Path

> - Paperclip is the open source control plane people use to organize,
govern, and understand AI-agent work
> - Operators need concise, current status views across projects and
execution workspaces without manually reading every issue and run
> - Paperclip already has auditable issues, documents, built-in agents,
routines, and live run events, but no first-class summary-slot workflow
connecting those systems
> - A built-in Summarizer can generate status prose through ordinary
governed tasks while summary slots provide stable, revisioned
destinations for that output
> - The UI needs to show current summaries, generation progress,
failures, revisions, and streaming draft status in the places operators
already work
> - This pull request adds the end-to-end summary-slot data, API, agent,
orchestration, and UI surfaces behind an experimental setting
> - The benefit is decision-oriented status context that remains
company-scoped, auditable, retryable, and inexpensive by default

## Linked Issues or Issue Description

No public GitHub issue exists for this feature.

**Problem**

Operators currently have to reconstruct project and workspace status by
reading many issues, runs, and comments. This makes it hard to identify
decisions, review queues, recent work, and the next event worth
watching.

**Proposed capability**

Add an experimental summary system with revisioned summary slots for
projects and workspaces, a paused-by-default built-in Summarizer agent,
governed generation tasks, live draft status, and reusable UI cards.

**Expected behavior**

- Summary data remains company-scoped and revisions remain auditable.
- Generation runs through normal issue/agent orchestration and
deduplicates active requests.
- Only the linked built-in Summarizer generation task can author a slot
revision.
- Operators can generate, retry, inspect revisions, and follow draft
progress from project and workspace views.
- The feature remains opt-in and background generation remains paused by
default.

## What Changed

- Added summary-slot schema, idempotent migrations, shared contracts,
validators, API paths, and service tests.
- Added company-scoped summary-slot routes for reading revisions,
requesting generation, and guarded Summarizer writes with activity
logging.
- Added terminal generation finalization, failure reasons, assignment
wakeups, and orchestration integration.
- Added the paused-by-default built-in Summarizer bundle, low-cost
runtime defaults, status-summarization skill, and stale-summary routine.
- Added summary cards, revision selection, retry/configuration states,
live draft streaming, transcript chunk handling, and project/workspace
integrations.
- Updated Claude local parsing for streamed status output and expanded
server, adapter, shared, database, catalog, and UI coverage.

## Verification

- `pnpm -r typecheck`
- `pnpm exec vitest run packages/db/src/summary-slots-schema.test.ts
packages/shared/src/summary-slot.test.ts
server/src/__tests__/summary-slot-routes.test.ts
server/src/__tests__/summary-slots.test.ts
server/src/__tests__/built-in-agents.test.ts
ui/src/components/SummarySlotCard.test.tsx
ui/src/components/SummarySlotCard.status.test.tsx
ui/src/components/useSummaryDraftStream.test.tsx
ui/src/lib/summary-draft-stream.test.ts
ui/src/lib/run-log-chunks.test.ts
ui/src/context/LiveUpdatesProvider.hook.test.tsx` — 113 tests passed
- `pnpm test:run` — server and UI suites passed; one CLI AWS doctor test
was affected by inherited `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`,
and passed when those host credentials were removed
- `pnpm exec vitest run cli/src/__tests__/secrets.test.ts` with
inherited AWS credential variables removed — 8 tests passed
- `pnpm build`
- `pnpm check:token-gates` currently reports nine `#9627` comment
references introduced by current `master`; none are in this PR diff

## Risks

- Database risk is limited by incrementally ordered, idempotent
migrations and migration safety checks.
- Summary generation creates normal issues/runs, so misconfiguration can
produce failed slots; the UI exposes retryable failure reasons and agent
configuration entry points.
- Streaming draft parsing depends on the documented `STATUS:` protocol;
final persisted revisions remain the source of truth.
- The feature is experimental, opt-in, and its built-in routine is
paused with no background token spend by default.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5.3 Codex with reasoning, repository tool use, code
execution, GitHub CLI, and Paperclip control-plane integration. Earlier
branch commits also record Claude model co-authorship where applicable.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-07-17 11:03:07 -05:00 committed by GitHub
parent 009410164f
commit 1f1f545238
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 6667 additions and 250 deletions

View File

@ -12,7 +12,6 @@ export const models = [
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
{ id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
];

View File

@ -65,6 +65,7 @@ import {
isClaudeUnknownSessionError,
isClaudePoisonedPreviousMessageIdError,
isClaudeImageProcessingError,
isClaudeModelNotFoundError,
} from "./parse.js";
import {
materializeRemoteClaudeConfig,
@ -985,6 +986,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: null;
const errorCode = loginMeta.requiresLogin
? "claude_auth_required"
: isClaudeModelNotFoundError({
parsed: null,
stdout: proc.stdout,
stderr: proc.stderr,
errorMessage: fallbackErrorMessage,
})
? "model_not_found"
: providerQuota
? "provider_quota"
: transientUpstream
@ -1111,6 +1119,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: null;
const resolvedErrorCode = loginMeta.requiresLogin
? "claude_auth_required"
: failed && isClaudeModelNotFoundError({
parsed,
stdout: proc.stdout,
stderr: proc.stderr,
errorMessage,
})
? "model_not_found"
: failed && clearSessionForMaxTurns
? "max_turns_exhausted"
: failed && poisonedPreviousMessageId

View File

@ -10,6 +10,7 @@ import {
isClaudeRefusalResult,
isClaudeUnknownSessionError,
isClaudeImageProcessingError,
isClaudeModelNotFoundError,
} from "./parse.js";
describe("detectClaudeLoginRequired", () => {
@ -34,6 +35,25 @@ describe("detectClaudeLoginRequired", () => {
});
});
describe("isClaudeModelNotFoundError", () => {
it("detects model resolution failures from structured and fallback output", () => {
expect(isClaudeModelNotFoundError({
parsed: {
result: "API Error: 404 model not found: claude-haiku-4-6",
},
})).toBe(true);
expect(isClaudeModelNotFoundError({
stderr: "Unknown model claude-haiku-4-6",
})).toBe(true);
});
it("does not classify unrelated provider failures as model resolution errors", () => {
expect(isClaudeModelNotFoundError({
errorMessage: "API Error: 503 service unavailable",
})).toBe(false);
});
});
describe("isClaudeTransientUpstreamError", () => {
it("classifies the 'out of extra usage' subscription window failure as provider quota", () => {
expect(

View File

@ -13,6 +13,8 @@ const CLAUDE_TRANSIENT_UPSTREAM_RE =
/(?:rate[-\s]?limit(?:ed)?|rate_limit_error|too\s+many\s+requests|\b429\b|overloaded(?:_error)?|server\s+overloaded|service\s+unavailable|\b503\b|\b529\b|high\s+demand|try\s+again\s+later|temporarily\s+unavailable|throttl(?:ed|ing)|throttlingexception|servicequotaexceededexception|out\s+of\s+extra\s+usage|extra\s+usage\b|claude\s+usage\s+limit\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|usage\s+limit\s+reached|usage\s+cap\s+reached)/i;
const CLAUDE_PROVIDER_QUOTA_RE =
/(?:you(?:'|)ve\s+hit\s+your\s+session\s+limit|session\s+limit\s+(?:reached|exceeded)|out\s+of\s+extra\s+usage|extra\s+usage\b|claude\s+usage\s+limit\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|usage\s+limit\s+reached|usage\s+cap\s+reached|servicequotaexceededexception)/i;
const CLAUDE_MODEL_NOT_FOUND_RE =
/(?:\b404\b[\s\S]{0,120})?(?:model[\s_-]*(?:not[\s_-]*found|does not exist|unknown|invalid)|unknown[\s_-]*model)/i;
const CLAUDE_EXTRA_USAGE_RESET_RE =
/(?:you(?:'|)ve\s+hit\s+your\s+session\s+limit|session\s+limit\s+(?:reached|exceeded)|out\s+of\s+extra\s+usage|extra\s+usage|usage\s+limit\s+reached|usage\s+cap\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|claude\s+usage\s+limit\s+reached)[\s\S]{0,120}?\bresets?\s+(?:at\s+)?([^\n()]+?)(?:\s*\(([^)]+)\))?(?:[.!]|\n|$)/i;
@ -196,6 +198,23 @@ export function describeClaudeFailure(parsed: Record<string, unknown>): string |
return parts.length > 1 ? parts.join(": ") : null;
}
export function isClaudeModelNotFoundError(input: {
parsed?: Record<string, unknown> | null;
stdout?: string | null;
stderr?: string | null;
errorMessage?: string | null;
}): boolean {
const parsed = input.parsed ?? null;
const messages = [
input.errorMessage ?? "",
input.stdout ?? "",
input.stderr ?? "",
parsed ? asString(parsed.result, "") : "",
...(parsed ? extractClaudeErrorMessages(parsed) : []),
];
return messages.some((message) => CLAUDE_MODEL_NOT_FOUND_RE.test(message));
}
export function isClaudeMaxTurnsResult(parsed: Record<string, unknown> | null | undefined): boolean {
if (!parsed) return false;

View File

@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS "summary_slots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"scope_kind" text NOT NULL,
"scope_id" uuid,
"slot_key" text NOT NULL,
"document_id" uuid,
"status" text DEFAULT 'idle' NOT NULL,
"generating_issue_id" uuid,
"last_generated_at" timestamp with time zone,
"last_generated_by_agent_id" uuid,
"last_model" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "summary_slots_company_scope_slot_uq" UNIQUE NULLS NOT DISTINCT("company_id","scope_kind","scope_id","slot_key")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "summary_slots" ADD CONSTRAINT "summary_slots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "summary_slots" ADD CONSTRAINT "summary_slots_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "summary_slots" ADD CONSTRAINT "summary_slots_generating_issue_id_issues_id_fk" FOREIGN KEY ("generating_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "summary_slots" ADD CONSTRAINT "summary_slots_last_generated_by_agent_id_agents_id_fk" FOREIGN KEY ("last_generated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "summary_slots_document_uq" ON "summary_slots" USING btree ("document_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "summary_slots_company_scope_idx" ON "summary_slots" USING btree ("company_id","scope_kind","scope_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "summary_slots_company_generating_issue_idx" ON "summary_slots" USING btree ("company_id","generating_issue_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "summary_slots_company_updated_idx" ON "summary_slots" USING btree ("company_id","updated_at");

View File

@ -0,0 +1 @@
ALTER TABLE "summary_slots" ADD COLUMN IF NOT EXISTS "failure_reason" text;

View File

@ -1233,6 +1233,20 @@
"when": 1784241826832,
"tag": "0177_activity_log_responsible_user",
"breakpoints": true
},
{
"idx": 178,
"version": "7",
"when": 1784241827832,
"tag": "0178_summary_slots",
"breakpoints": true
},
{
"idx": 179,
"version": "7",
"when": 1784241828832,
"tag": "0179_summary_slot_failure_reason",
"breakpoints": true
}
]
}

View File

@ -84,6 +84,7 @@ export { issueAttachments } from "./issue_attachments.js";
export { documents } from "./documents.js";
export { documentRevisions } from "./document_revisions.js";
export { issueDocuments } from "./issue_documents.js";
export { summarySlots } from "./summary_slots.js";
export { routineDocuments } from "./routine_documents.js";
export { documentAnnotationThreads } from "./document_annotation_threads.js";
export { documentAnnotationComments } from "./document_annotation_comments.js";

View File

@ -0,0 +1,40 @@
import { index, pgTable, text, timestamp, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import type { SummarySlotKey, SummarySlotScopeKind, SummarySlotStatus } from "@paperclipai/shared";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { documents } from "./documents.js";
import { issues } from "./issues.js";
export const summarySlots = pgTable(
"summary_slots",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
scopeKind: text("scope_kind").$type<SummarySlotScopeKind>().notNull(),
scopeId: uuid("scope_id"),
slotKey: text("slot_key").$type<SummarySlotKey>().notNull(),
documentId: uuid("document_id").references(() => documents.id, { onDelete: "set null" }),
status: text("status").$type<SummarySlotStatus>().notNull().default("idle"),
failureReason: text("failure_reason"),
generatingIssueId: uuid("generating_issue_id").references(() => issues.id, { onDelete: "set null" }),
lastGeneratedAt: timestamp("last_generated_at", { withTimezone: true }),
lastGeneratedByAgentId: uuid("last_generated_by_agent_id").references(() => agents.id, {
onDelete: "set null",
}),
lastModel: text("last_model"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyScopeSlotUq: unique("summary_slots_company_scope_slot_uq")
.on(table.companyId, table.scopeKind, table.scopeId, table.slotKey)
.nullsNotDistinct(),
documentUq: uniqueIndex("summary_slots_document_uq").on(table.documentId),
companyScopeIdx: index("summary_slots_company_scope_idx").on(table.companyId, table.scopeKind, table.scopeId),
companyGeneratingIssueIdx: index("summary_slots_company_generating_issue_idx").on(
table.companyId,
table.generatingIssueId,
),
companyUpdatedIdx: index("summary_slots_company_updated_idx").on(table.companyId, table.updatedAt),
}),
);

View File

@ -0,0 +1,67 @@
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import { summarySlots } from "./schema/summary_slots.js";
function indexColumns(table: Parameters<typeof getTableConfig>[0], indexName: string): string[] {
const index = getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName);
if (!index) return [];
return index.config.columns.map((column) => (column as { name: string }).name);
}
function uniqueConstraint(table: Parameters<typeof getTableConfig>[0], constraintName: string) {
return getTableConfig(table).uniqueConstraints.find((candidate) => candidate.name === constraintName);
}
function column(table: Parameters<typeof getTableConfig>[0], columnName: string) {
const match = getTableConfig(table).columns.find((candidate) => candidate.name === columnName);
if (!match) throw new Error(`Column ${columnName} not found`);
return match;
}
describe("summary slot schema", () => {
it("enforces one slot per company scope and key, including null singleton scope ids", () => {
const constraint = uniqueConstraint(summarySlots, "summary_slots_company_scope_slot_uq");
expect(constraint?.columns.map((candidate) => candidate.name)).toEqual([
"company_id",
"scope_kind",
"scope_id",
"slot_key",
]);
expect(constraint?.nullsNotDistinct).toBe(true);
});
it("keeps lookup indexes company-scoped", () => {
expect(indexColumns(summarySlots, "summary_slots_company_scope_idx")).toEqual([
"company_id",
"scope_kind",
"scope_id",
]);
expect(indexColumns(summarySlots, "summary_slots_company_generating_issue_idx")).toEqual([
"company_id",
"generating_issue_id",
]);
expect(indexColumns(summarySlots, "summary_slots_company_updated_idx")).toEqual([
"company_id",
"updated_at",
]);
});
it("wires document revisions and generation metadata without inline markdown", () => {
const columnNames = getTableConfig(summarySlots).columns.map((candidate) => candidate.name);
expect(columnNames).toEqual(expect.arrayContaining([
"document_id",
"status",
"generating_issue_id",
"last_generated_at",
"last_generated_by_agent_id",
"last_model",
]));
expect(columnNames).not.toContain("markdown");
expect(columnNames).not.toContain("body");
expect(column(summarySlots, "document_id").notNull).toBe(false);
expect(column(summarySlots, "status").notNull).toBe(true);
expect(column(summarySlots, "status").default).toBe("idle");
});
});

View File

@ -24,6 +24,9 @@ export const API = {
issueWatchdog: `${API_PREFIX}/issues/:issueId/watchdog`,
issueTreeControl: `${API_PREFIX}/issues/:issueId/tree-control`,
issueTreeHolds: `${API_PREFIX}/issues/:issueId/tree-holds`,
summarySlot: `${API_PREFIX}/companies/:companyId/summary-slots/:scopeKind/:slotKey`,
summarySlotRevisions: `${API_PREFIX}/companies/:companyId/summary-slots/:scopeKind/:slotKey/revisions`,
summarySlotGenerate: `${API_PREFIX}/companies/:companyId/summary-slots/:scopeKind/:slotKey/generate`,
goals: `${API_PREFIX}/goals`,
approvals: `${API_PREFIX}/approvals`,
secrets: `${API_PREFIX}/secrets`,

View File

@ -219,6 +219,13 @@ export const ISSUE_HARNESS_KINDS = ["skill_test"] as const;
export type IssueHarnessKind = (typeof ISSUE_HARNESS_KINDS)[number];
export const MAX_ISSUE_REQUEST_DEPTH = 1024;
export const SUMMARY_SLOT_SCOPE_KINDS = ["project", "workspaces_overview", "project_workspace"] as const;
export type SummarySlotScopeKind = (typeof SUMMARY_SLOT_SCOPE_KINDS)[number];
export const SUMMARY_SLOT_KEYS = ["header"] as const;
export type SummarySlotKey = (typeof SUMMARY_SLOT_KEYS)[number];
export const SUMMARY_SLOT_STATUSES = ["idle", "generating", "failed"] as const;
export type SummarySlotStatus = (typeof SUMMARY_SLOT_STATUSES)[number];
export const ISSUE_COMMENT_AUTHOR_TYPES = ["user", "agent", "system"] as const;
export type IssueCommentAuthorType = (typeof ISSUE_COMMENT_AUTHOR_TYPES)[number];

View File

@ -173,6 +173,9 @@ export {
ISSUE_WORK_MODES,
ISSUE_HARNESS_KINDS,
MAX_ISSUE_REQUEST_DEPTH,
SUMMARY_SLOT_SCOPE_KINDS,
SUMMARY_SLOT_KEYS,
SUMMARY_SLOT_STATUSES,
ISSUE_COMMENT_AUTHOR_TYPES,
ISSUE_COMMENT_METADATA_ROW_TYPES,
ISSUE_COMMENT_PRESENTATION_KINDS,
@ -354,6 +357,9 @@ export {
type IssuePriority,
type IssueWorkMode,
type IssueHarnessKind,
type SummarySlotScopeKind,
type SummarySlotKey,
type SummarySlotStatus,
type IssueCommentAuthorType,
type IssueCommentMetadataRowType,
type IssueCommentPresentationKind,
@ -481,6 +487,19 @@ export {
type PluginBridgeErrorCode,
} from "./constants.js";
export {
generateSummarySlotSchema,
summarySlotKeySchema,
summarySlotQuerySchema,
summarySlotScopeKindSchema,
summarySlotScopeSelectorSchema,
summarySlotStatusSchema,
writeSummarySlotSchema,
type GenerateSummarySlotInput,
type SummarySlotScopeSelectorInput,
type WriteSummarySlotInput,
} from "./validators/summary-slot.js";
export {
ALL_INTERFACES_BIND_HOST,
LOOPBACK_BIND_HOST,
@ -502,6 +521,17 @@ export {
export type {
Company,
GenerateSummarySlotRequest,
GenerateSummarySlotResponse,
GetSummarySlotResponse,
ListSummarySlotRevisionsResponse,
SummarySlot,
SummarySlotDocument,
SummarySlotIssueRef,
SummarySlotRevision,
SummarySlotScopeSelector,
WriteSummarySlotRequest,
WriteSummarySlotResponse,
Environment,
EnvironmentDeleteBlastRadius,
EnvironmentDeleteBlockedReason,

View File

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { API } from "./api.js";
import {
summarySlotScopeSelectorSchema,
writeSummarySlotSchema,
} from "./validators/summary-slot.js";
const scopeId = "11111111-1111-4111-8111-111111111111";
const revisionId = "22222222-2222-4222-8222-222222222222";
const issueId = "33333333-3333-4333-8333-333333333333";
describe("summary slot shared contract", () => {
it("defines stable summary slot API path constants", () => {
expect(API.summarySlot).toBe("/api/companies/:companyId/summary-slots/:scopeKind/:slotKey");
expect(API.summarySlotRevisions).toBe(
"/api/companies/:companyId/summary-slots/:scopeKind/:slotKey/revisions",
);
expect(API.summarySlotGenerate).toBe(
"/api/companies/:companyId/summary-slots/:scopeKind/:slotKey/generate",
);
});
it("allows scoped project and project-workspace header slots", () => {
expect(summarySlotScopeSelectorSchema.parse({
scopeKind: "project",
scopeId,
slotKey: "header",
})).toEqual({ scopeKind: "project", scopeId, slotKey: "header" });
expect(summarySlotScopeSelectorSchema.parse({
scopeKind: "project_workspace",
scopeId,
slotKey: "header",
})).toEqual({ scopeKind: "project_workspace", scopeId, slotKey: "header" });
});
it("treats workspaces_overview as a company-scoped singleton", () => {
expect(summarySlotScopeSelectorSchema.parse({
scopeKind: "workspaces_overview",
slotKey: "header",
})).toEqual({ scopeKind: "workspaces_overview", slotKey: "header" });
expect(() => summarySlotScopeSelectorSchema.parse({
scopeKind: "workspaces_overview",
scopeId,
slotKey: "header",
})).toThrow("workspaces_overview summary slots must not include scopeId");
});
it("requires scope ids for entity-backed scopes", () => {
expect(() => summarySlotScopeSelectorSchema.parse({
scopeKind: "project",
slotKey: "header",
})).toThrow("project summary slots require scopeId");
expect(() => summarySlotScopeSelectorSchema.parse({
scopeKind: "project_workspace",
slotKey: "header",
})).toThrow("project_workspace summary slots require scopeId");
});
it("validates summary write payload revision and generation metadata", () => {
expect(writeSummarySlotSchema.parse({
scopeId,
markdown: "## Needs you\nNothing right now.",
title: "Project summary",
changeSummary: "Refresh project header summary",
baseRevisionId: revisionId,
generationIssueId: issueId,
model: "cheap-model",
})).toEqual({
scopeId,
markdown: "## Needs you\nNothing right now.",
title: "Project summary",
changeSummary: "Refresh project header summary",
baseRevisionId: revisionId,
generationIssueId: issueId,
model: "cheap-model",
});
expect(() => writeSummarySlotSchema.parse({ markdown: " " })).toThrow();
});
});

View File

@ -1,4 +1,17 @@
export type { Company } from "./company.js";
export type {
GenerateSummarySlotRequest,
GenerateSummarySlotResponse,
GetSummarySlotResponse,
ListSummarySlotRevisionsResponse,
SummarySlot,
SummarySlotDocument,
SummarySlotIssueRef,
SummarySlotRevision,
SummarySlotScopeSelector,
WriteSummarySlotRequest,
WriteSummarySlotResponse,
} from "./summary-slot.js";
export type {
AttentionDecisionVerb,
AttentionDetailImage,

View File

@ -59,6 +59,7 @@ export interface InstanceExperimentalSettings {
enableExternalObjects: boolean;
enableSmokeLab: boolean;
enableBuiltInAgents: boolean;
enableSummaries: boolean;
enableDecisions: boolean;
enableGoalsSidebarLink: boolean;
enableServerInfoDebugView: boolean;

View File

@ -0,0 +1,106 @@
import type {
IssueStatus,
SummarySlotKey,
SummarySlotScopeKind,
SummarySlotStatus,
} from "../constants.js";
import type { DocumentFormat } from "./issue.js";
export interface SummarySlot {
id: string;
companyId: string;
scopeKind: SummarySlotScopeKind;
scopeId: string | null;
slotKey: SummarySlotKey;
documentId: string | null;
status: SummarySlotStatus;
failureReason: string | null;
generatingIssueId: string | null;
lastGeneratedAt: Date | string | null;
lastGeneratedByAgentId: string | null;
lastModel: string | null;
createdAt: Date | string;
updatedAt: Date | string;
}
export interface SummarySlotDocument {
id: string;
companyId: string;
title: string | null;
format: DocumentFormat;
body: string;
latestRevisionId: string | null;
latestRevisionNumber: number;
createdByAgentId: string | null;
createdByUserId: string | null;
updatedByAgentId: string | null;
updatedByUserId: string | null;
createdAt: Date | string;
updatedAt: Date | string;
}
export interface SummarySlotRevision {
id: string;
companyId: string;
documentId: string;
revisionNumber: number;
title: string | null;
format: DocumentFormat;
body: string;
changeSummary: string | null;
createdByAgentId: string | null;
createdByUserId: string | null;
createdByRunId: string | null;
createdAt: Date | string;
}
export interface SummarySlotIssueRef {
id: string;
identifier: string | null;
title: string;
status: IssueStatus;
assigneeAgentId?: string | null;
}
export interface SummarySlotScopeSelector {
scopeKind: SummarySlotScopeKind;
scopeId?: string | null;
slotKey: SummarySlotKey;
}
export interface GetSummarySlotResponse {
slot: SummarySlot | null;
document: SummarySlotDocument | null;
generatingIssue: SummarySlotIssueRef | null;
}
export interface ListSummarySlotRevisionsResponse {
slot: SummarySlot | null;
revisions: SummarySlotRevision[];
}
export interface GenerateSummarySlotRequest {
scopeId?: string | null;
}
export interface GenerateSummarySlotResponse {
slot: SummarySlot;
generatingIssue: SummarySlotIssueRef;
alreadyGenerating: boolean;
}
export interface WriteSummarySlotRequest {
scopeId?: string | null;
markdown: string;
title?: string | null;
changeSummary?: string | null;
baseRevisionId?: string | null;
generationIssueId?: string | null;
model?: string | null;
}
export interface WriteSummarySlotResponse {
slot: SummarySlot;
document: SummarySlotDocument;
revision: SummarySlotRevision;
}

View File

@ -94,6 +94,18 @@ export {
updateResourceMembershipSchema,
type UpdateResourceMembership,
} from "./resource-memberships.js";
export {
generateSummarySlotSchema,
summarySlotKeySchema,
summarySlotQuerySchema,
summarySlotScopeKindSchema,
summarySlotScopeSelectorSchema,
summarySlotStatusSchema,
writeSummarySlotSchema,
type GenerateSummarySlotInput,
type SummarySlotScopeSelectorInput,
type WriteSummarySlotInput,
} from "./summary-slot.js";
export {
externalObjectStatusCategorySchema,

View File

@ -53,6 +53,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableExternalObjects: z.boolean().default(false),
enableSmokeLab: z.boolean().default(false),
enableBuiltInAgents: z.boolean().default(false),
enableSummaries: z.boolean().default(false),
enableDecisions: z.boolean().default(false),
enableGoalsSidebarLink: z.boolean().default(false),
enableServerInfoDebugView: z.boolean().default(false),

View File

@ -0,0 +1,64 @@
import { z } from "zod";
import {
SUMMARY_SLOT_KEYS,
SUMMARY_SLOT_SCOPE_KINDS,
SUMMARY_SLOT_STATUSES,
} from "../constants.js";
const optionalScopeIdSchema = z.string().uuid().optional().nullable();
export const summarySlotScopeKindSchema = z.enum(SUMMARY_SLOT_SCOPE_KINDS);
export const summarySlotKeySchema = z.enum(SUMMARY_SLOT_KEYS);
export const summarySlotStatusSchema = z.enum(SUMMARY_SLOT_STATUSES);
export const summarySlotScopeSelectorSchema = z
.object({
scopeKind: summarySlotScopeKindSchema,
scopeId: optionalScopeIdSchema,
slotKey: summarySlotKeySchema,
})
.strict()
.superRefine((value, ctx) => {
const hasScopeId = typeof value.scopeId === "string";
if (value.scopeKind === "workspaces_overview") {
if (hasScopeId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "workspaces_overview summary slots must not include scopeId",
path: ["scopeId"],
});
}
return;
}
if (!hasScopeId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${value.scopeKind} summary slots require scopeId`,
path: ["scopeId"],
});
}
});
export const summarySlotQuerySchema = z
.object({
scopeId: optionalScopeIdSchema,
})
.strict();
export const generateSummarySlotSchema = summarySlotQuerySchema;
export const writeSummarySlotSchema = z
.object({
scopeId: optionalScopeIdSchema,
markdown: z.string().trim().min(1).max(200_000),
title: z.string().trim().min(1).max(200).optional().nullable(),
changeSummary: z.string().trim().min(1).max(1_000).optional().nullable(),
baseRevisionId: z.string().uuid().optional().nullable(),
generationIssueId: z.string().uuid().optional().nullable(),
model: z.string().trim().min(1).max(200).optional().nullable(),
})
.strict();
export type SummarySlotScopeSelectorInput = z.infer<typeof summarySlotScopeSelectorSchema>;
export type GenerateSummarySlotInput = z.infer<typeof generateSummarySlotSchema>;
export type WriteSummarySlotInput = z.infer<typeof writeSummarySlotSchema>;

View File

@ -0,0 +1,227 @@
---
name: summarize-status
description: Write a short, colloquial summary for a Paperclip summary slot: open with the one or two decisions the reader must make — or, when nothing needs deciding, what to review — each with a recommendation, close with one or two recent pieces of work and where they stand, streaming status as it works.
key: paperclipai/bundled/paperclip-operations/summarize-status
recommendedForRoles:
- general
- manager
tags:
- paperclip
- summary
- status
- reporting
- operations
---
# Summarize status
You are the Summarizer. Your job is to turn the current state of a Paperclip scope — a project, the workspaces overview, or a single project workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's **summary slot** as a new revision.
A summary is **not a task list**. The board already shows every issue; repeating that list is noise. Your value is judgment: out of everything happening in the scope, pick the **one or two decisions (max) the reader actually has to make**, open with those, and commit to a recommendation on each.
Every summary answers, in order:
1. **What do I need to decide?** — the summary **starts** with the decisions: at most two bullets, each giving enough context to understand the decision, a link, and what you recommend. If nothing needs a decision, pivot to review: say so in one line, then tell the reader what to **review** — which items they can approve on a skim and which genuinely need their eyes — each with your recommendation. Only if there's nothing to decide *and* nothing to review do you fall back to one line naming the next event worth watching.
2. **What's the headline?** — after the decisions, at most one or two short paragraphs of plain conversational language on what's moving. Everything else stays off the page.
3. **What just happened?** — the summary **ends** with a `**Recent work:**` block: one or two recent pieces of work, each in a single line saying what it is and where it stands ("just merged", "through QA, waiting on review", "started this morning"). Not a changelog — only the one or two most recent things worth knowing about.
The summary renders next to the board itself, so the reader can already see every issue and link. Never dump a list of issue links anywhere in the summary — reference **at most three or four issues total**, inline, where they're mentioned.
This is a **read-and-report** loop. You never change the underlying issues, workspaces, or code. You only write one Markdown revision back to the slot you were asked to summarize.
## When to use
- A summary-generation issue is assigned to you naming a scope (`project`, `workspaces_overview`, or `project_workspace`) and slot (`header`).
- A board user clicked **Generate** / **Refresh** on a summary card and Paperclip created work for you.
- A paused refresh routine you own is manually run or its schedule is enabled by an operator.
## When not to use
- You were asked to change issue state, reassign work, or edit code. That is out of scope — summarize only.
- No scope was given, or the scope is in another company. Refuse and ask for a scoped generation issue. Every read stays company-scoped.
- You are asked to invent status the source data does not support. Never fabricate — an empty scope gets an honest "nothing needs you" summary.
## Inputs
From the generation issue / run context:
- `scopeKind``project`, `workspaces_overview`, or `project_workspace`.
- `scopeId` — the project or project-workspace id. Omitted for `workspaces_overview` (it has no scopeId).
- `slotKey` — currently always `header`.
- `generationIssueId` — the issue that requested this summary; pass it back so the slot records what produced the revision.
- The previous revision (if any) — read it so you can tell what's new and lead with that instead of repeating a headline the reader already saw.
## API quick reference
Use these routes directly. Do not guess unscoped `/api/issues` or alternate summary paths:
- Read the current slot: `GET /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}?scopeId=...`
- Read revision history only when the current-slot response is missing its latest document: `GET /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}/revisions?scopeId=...`
- Gather project issues: `GET /api/companies/{companyId}/issues?projectId=...`
- Write the new revision: `PUT /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}` with `scopeId`, `markdown`, `changeSummary`, `baseRevisionId`, `generationIssueId`, and `model` in the JSON body.
For `workspaces_overview`, omit `scopeId` from the read query and send it as `null` in the write body. All calls use the run-scoped Paperclip API URL and bearer token already present in the environment.
Complete project-slot write example:
```sh
COMPANY_ID="<company-id>"
PROJECT_ID="<project-id>"
GENERATION_ISSUE_ID="<generation-issue-id>"
BASE_REVISION_ID="<previous-revision-id-or-empty>"
MODEL="<model-used>"
SUMMARY_MARKDOWN=$(cat <<'MARKDOWN'
**Nothing to decide right now.** Quiet scope — nothing is in flight and nothing is waiting on you. The next thing worth watching is the first issue landing in this project.
MARKDOWN
)
jq -n \
--arg scopeId "$PROJECT_ID" \
--arg markdown "$SUMMARY_MARKDOWN" \
--arg changeSummary "First summary for this scope" \
--arg baseRevisionId "$BASE_REVISION_ID" \
--arg generationIssueId "$GENERATION_ISSUE_ID" \
--arg model "$MODEL" \
'{
scopeId: $scopeId,
markdown: $markdown,
changeSummary: $changeSummary,
baseRevisionId: (if $baseRevisionId == "" then null else $baseRevisionId end),
generationIssueId: $generationIssueId,
model: $model
}' |
curl -sS -X PUT \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/companies/$COMPANY_ID/summary-slots/project/header" \
--data-binary @-
```
## Cost discipline
You run on the **low-cost model profile lane** (`cheap`) by default. Keep the loop tight:
- Pull only the data you need to pick the headline and the next action. Do not fan out into full issue histories.
- Prefer list/summary endpoints over per-issue detail fetches; open a single issue only when it decides the headline or the suggestion.
- Keep the output short (see budget below). A summary that reads like a task list has failed its job.
An operator can override the cheap default with a specific model in the built-in agent's `cheap` model profile configuration; respect whatever model the run actually gives you.
## Procedure
Use this streaming output protocol throughout the procedure:
- **Post the first status update immediately, before doing anything else.** Do not read the slot, fetch data, or think deeply first — take the first task you can see in the context you were handed (the generation issue's scope snapshot, or whatever issue is named first) and emit a `STATUS:` line naming it, e.g. `STATUS: considering "Fix login redirect loop"…`. This line is reflexive, not analytical; its whole job is to show the reader something is happening the moment work starts.
- Keep thinking out loud the entire time you work. Emit a fresh `STATUS:` line every time your attention moves — each task or cluster you weigh, each candidate headline you consider, each decision you're sizing up: `STATUS: reading the current slot revision…`, `STATUS: weighing whether the API split or the failed deploy matters more…`, `STATUS: writing the summary…`. These lines stream to the summary card while the reader waits, so frequent short updates are the user experience — long silent stretches between tool calls are a failure of this protocol even when the final summary is good.
- Each `STATUS:` line is one short line of plain assistant text, not inside a tool call, using the `STATUS: <current action>…` convention.
- Before the summary-slot write in step 4, emit the complete final Markdown as plain assistant text between these exact sentinels, each on its own line:
```text
<<<SUMMARY-DRAFT>>>
<complete final Markdown>
<<<END-SUMMARY-DRAFT>>>
```
Then perform the existing write with exactly the same Markdown. Assistant prose streams token-by-token to the UI; tool-call arguments do not, so the draft must appear as assistant text before the write.
- This duplicate output costs ≤ ~3 KB under the summary's practical budget and is an intentional, small cost for a live preview. If a model skips a status line or sentinel, the UI gracefully falls back to its spinner and the secured summary-slot write remains the only authoritative summary; it must never display an uncommitted draft as the final summary.
### 1) Confirm scope and read the current slot
Read the summary slot for the scope you were given. Its response includes the latest document body and `latestRevisionId`; use those directly. Only call revision history if the current-slot response is malformed or missing that document.
### 2) Gather current state (company-scoped, minimal)
Generation issues normally include a `Prebuilt scope snapshot` grouped into blocked, in-review, in-progress, and recently done work. When that snapshot is present, use it as the issue source of truth and make zero issue-list calls. Only gather from the API when an older generation issue does not include a snapshot.
You are **triaging, not enumerating**. Read the scope's state and rank: what single item most needs a human decision or is most at risk? What one other item (if any) genuinely changes the picture? Everything below that line stays out of the summary.
Ranking order for the headline:
1. A decision waiting on a person — approval, review, an asked question, a blocked item only a human can unblock.
2. Something at risk or newly failed that a person should know about before it gets worse.
3. Meaningful progress or a completed milestone since the last revision.
### 3) Write the summary (Markdown)
Shape every summary like this — **decisions first**:
```markdown
**Decide:**
- <What the decision is, with enough context to understand it without clicking "The API
split is done and the PR is sitting unreviewed"> — [PAP-123](/PAP/issues/PAP-123).
**I suggest:** <one concrete recommendation and why, in a clause>.
- <The second decision, same shape only if a second one genuinely needs the reader.>
<At most one or two short paragraphs, plain conversational language, on what else
matters. Talk like a person: "The API split is basically done and waiting on your
sign-off" — not "PAP-123: in_review (high)". No headings, no status-by-status lists.>
**Recent work:**
- <One recent piece of work and where it stands "the streaming card polish just
merged; nothing left there">.
- <A second, only if it genuinely helps "QA started on the folder rework this
morning; still early".>
```
- The summary **opens** with the `**Decide:**` block: at most two bullets, each pairing the decision's context with a link and a committed **I suggest:** recommendation. This block is the point of the whole summary.
- If nothing needs a decision but work is sitting in review, open with `**Nothing to decide right now.**` and follow it immediately with a `**Review:**` block — same shape and budget as **Decide:**, at most two bullets — that triages the review pile for the reader: which items they can approve on a skim, and which genuinely need their eyes and why. Each bullet still carries a link and a committed **I suggest:**:
```markdown
**Nothing to decide right now.**
**Review:**
- <The easy one "the banner contrast fixes are two-line CSS changes and tests are
green"> — [PAP-456](/PAP/issues/PAP-456). **I suggest:** approve on a skim.
- <The one that needs eyes "the auth change rewrites token refresh">
[PAP-789](/PAP/issues/PAP-789). **I suggest:** read the token-handling diff closely
before you approve.
```
- If there's nothing to decide *and* nothing to review, open with `**Nothing to decide right now.**` followed by one clause naming the next event worth watching — then the prose paragraph if there's anything worth saying.
- Never hedge the suggestion into a menu. Pick one option and say why in half a sentence. The reader can disagree — that's fine — but "you could do A or B or C" is a task list wearing a disguise.
- The summary **ends** with a `**Recent work:**` block: at most two bullets, one line each, naming a recent piece of work and where it stands in plain language ("just merged", "through QA, waiting on a reviewer", "started this morning"). Pick recency plus significance — the most recent things the reader would actually want to know about, not a changelog of every touch. Links here count toward the summary's total link budget.
Rules:
- **Two decisions max, two topics max.** If you're tempted to add a third bullet or a third paragraph, the summary is becoming a list. Cut it.
- **No issue-link dumps — anywhere.** The summary sits right next to the board, which already lists every issue. Reference at most three or four issues in the whole summary, inline where they're mentioned. No trailing "Issues:" line, no link roundup, no evidence appendix. A claim you can't tie to one of those few links still has to be true of the source data — if it isn't, cut it.
- **Colloquial, not clinical.** Write the way you'd catch a colleague up out loud. Contractions are fine. Status jargon ("in_review", "P2") is not.
- **Honest emptiness.** A quiet scope gets `**Nothing to decide right now.**` and one sentence, not filler.
- **No secrets.** Never surface API keys, tokens, or raw credentials that appear in issue bodies or configs.
### 4) Write the revision back to the slot
Write the Markdown to the slot as a new revision using the summary-slot write action for the scope. Include:
- `markdown` — the body from step 3.
- `changeSummary` — one line describing what moved since the last revision (e.g. "Headline shifted: API split now waiting on sign-off").
- `baseRevisionId` — the previous revision id you read in step 1, if any, so concurrent writes are detected.
- `generationIssueId` — the issue that requested this summary.
- `model` — the model you actually ran on, for provenance.
Writing the revision is the deliverable. Do not also comment the whole summary onto unrelated issues.
### 5) Close out the generation issue
Leave a short comment on the generation issue: scope summarized, revision number written, and the headline in one clause. Mark it done. If you could not read the scope (permissions, missing scope), mark it blocked and name the exact unblock owner and action.
## Budget
- Opening **Decide:** block: at most two bullets. When empty it becomes one `**Nothing to decide right now.**` line, plus a **Review:** block of at most two bullets when review work is waiting.
- Body after the decisions: one or two short paragraphs, ~120 words total, two topics max.
- Closing **Recent work:** block: at most two bullets, one line each.
- At most three or four issue links in the entire summary, inline — never a list of links.
- Workspaces overview: same shape — the decisions and headline come from the one or two workspaces that most need attention, not one line per workspace.
- Never exceed the slot write limit (200 KB); in practice a good header summary is well under 1 KB.
## Verification (self-check before writing the revision)
- [ ] The summary **opens** with the **Decide:** block — at most two bullets, each with decision context, a link, and a committed **I suggest** recommendation. If there are no decisions, it opens with `**Nothing to decide right now.**` followed by a **Review:** block (easy approves vs needs-your-eyes, each with **I suggest**) when anything is in review.
- [ ] The prose after it covers at most two topics, in plain conversational language — no headings, no status lists, no jargon.
- [ ] The summary **ends** with a `**Recent work:**` block — at most two bullets, one line each, each naming a recent piece of work and where it stands.
- [ ] At most three or four issue links total, all inline — no trailing issue list, no link dump anywhere.
- [ ] No fabricated status, no secrets, no cross-company data.
- [ ] `baseRevisionId`, `generationIssueId`, and `model` are set on the write.
- [ ] The summary reads in one glance — if it scrolls or looks like a task list, cut it down.
- [ ] The first STATUS line went out immediately (named from the first task in context, before any analysis); STATUS lines kept flowing while working; draft emitted between `<<<SUMMARY-DRAFT>>>` and `<<<END-SUMMARY-DRAFT>>>` before the write.

View File

@ -2,7 +2,7 @@
"schemaVersion": 1,
"packageName": "@paperclipai/skills-catalog",
"packageVersion": "0.3.1",
"generatedAt": "2026-07-10T13:15:25.878Z",
"generatedAt": "2026-07-15T22:20:53.895Z",
"skills": [
{
"id": "paperclipai:bundled:docs:doc-maintenance",
@ -108,6 +108,41 @@
],
"contentHash": "sha256:1c7a82cd9638a1d845b238032da3ff4ad80c5b6a87dca46082f501fa4583db55"
},
{
"id": "paperclipai:bundled:paperclip-operations:summarize-status",
"key": "paperclipai/bundled/paperclip-operations/summarize-status",
"kind": "bundled",
"category": "paperclip-operations",
"slug": "summarize-status",
"name": "summarize-status",
"description": "Write a short, colloquial summary for a Paperclip summary slot: open with the one or two decisions the reader must make — or, when nothing needs deciding, what to review — each with a recommendation, close with one or two recent pieces of work and where they stand, streaming status as it works.",
"path": "catalog/bundled/paperclip-operations/summarize-status",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"general",
"manager"
],
"requires": [],
"tags": [
"paperclip",
"summary",
"status",
"reporting",
"operations"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 16744,
"sha256": "6bfacf153b602cdbba4c0edef64956adf8d11c1819bf1b9494a67abb6d4705eb"
}
],
"contentHash": "sha256:d7e2a979d95f99ee9d7a341a860602dcdfb7a2feb4d2390fccbaddc838d2da51"
},
{
"id": "paperclipai:bundled:paperclip-operations:task-planning",
"key": "paperclipai/bundled/paperclip-operations/task-planning",

View File

@ -8,6 +8,7 @@ const EXPECTED_BUNDLED_KEYS = [
"paperclipai/bundled/docs/doc-maintenance",
"paperclipai/bundled/paperclip-operations/issue-triage",
"paperclipai/bundled/paperclip-operations/reflection-coach",
"paperclipai/bundled/paperclip-operations/summarize-status",
"paperclipai/bundled/paperclip-operations/task-planning",
"paperclipai/bundled/product/paperclip-capsules",
"paperclipai/bundled/product/wireframe",
@ -66,6 +67,28 @@ function readFrontmatterDescription(markdown: string): string | null {
}
describe("shipped skills catalog", () => {
it("ships the summarize-status streaming protocol", () => {
const skill = readFileSync(
path.join(
REPO_ROOT,
"packages/skills-catalog/catalog/bundled/paperclip-operations/summarize-status/SKILL.md",
),
"utf8",
);
expect(skill).toContain("Post the first status update immediately, before doing anything else.");
expect(skill).toContain('STATUS: considering "Fix login redirect loop"…');
expect(skill).toContain("STATUS: reading the current slot revision…");
expect(skill).toContain("<<<SUMMARY-DRAFT>>>");
expect(skill).toContain("<<<END-SUMMARY-DRAFT>>>");
expect(skill).toContain("Assistant prose streams token-by-token to the UI; tool-call arguments do not");
expect(skill).toContain("UI gracefully falls back to its spinner");
expect(skill).toContain("**Review:**");
expect(skill).toContain("approve on a skim");
expect(skill).toContain("**Recent work:**");
expect(skill).toContain("Not a changelog");
});
it("keeps repo and catalog skill descriptions within the prompt budget cap", () => {
const violations: string[] = [];
for (const skillFile of SKILL_FRONTMATTER_ROOTS.flatMap(listSkillFiles)) {

View File

@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import {
activityLog,
agentConfigRevisions,
@ -156,7 +156,14 @@ describeEmbeddedPostgres("built-in agents", () => {
}
it("validates the static registry and rejects invalid definitions", () => {
expect(listBuiltInAgentDefinitions().map((definition) => definition.key).sort()).toEqual(["briefs", "learning", "reflection-coach"]);
const definitions = listBuiltInAgentDefinitions();
expect(definitions.map((definition) => definition.key).sort()).toEqual(["briefs", "learning", "reflection-coach", "summarizer"]);
const summarizer = definitions.find((definition) => definition.key === "summarizer");
expect(summarizer).toMatchObject({
defaultAdapterType: "claude_local",
defaultAdapterConfig: { model: "claude-haiku-4-5" },
});
expect(summarizer?.defaultRuntimeConfig).toBeUndefined();
expect(() => validateBuiltInAgentDefinitions([
{
key: "briefs",
@ -185,6 +192,18 @@ describeEmbeddedPostgres("built-in agents", () => {
defaultRole: "general",
},
])).toThrow("Invalid built-in agent key");
expect(() => validateBuiltInAgentDefinitions([
{
key: "bad-default",
displayName: "Bad default",
featureKeys: ["bad-default"],
shortPurpose: "Bad default adapter",
defaultInstructions: "Do work",
defaultRole: "general",
allowedAdapterTypes: ["codex_local"],
defaultAdapterType: "claude_local",
},
])).toThrow("defaultAdapterType must be allowed");
});
it("lazily provisions one agent per company/key and updates the same row on setup", async () => {
@ -358,6 +377,26 @@ describeEmbeddedPostgres("built-in agents", () => {
});
});
it("rejects unknown built-in adapter models before saving setup", async () => {
const companyId = await seedCompany();
await expect(builtInAgentService(db).ensure(companyId, "summarizer", {
adapterType: "claude_local",
adapterConfig: { model: "claude-haiku-4-6" },
})).rejects.toMatchObject({
status: 422,
details: {
code: "built_in_agent_model_unknown",
key: "summarizer",
adapterType: "claude_local",
model: "claude-haiku-4-6",
},
});
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
expect(rows).toHaveLength(0);
});
it("recovers an orphaned marked row instead of creating a duplicate", async () => {
const companyId = await seedCompany();
const orphanId = randomUUID();
@ -394,7 +433,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const ready = await builtIns.ensure(companyId, "learning", {
adapterType: "claude_local",
adapterConfig: { model: "claude-sonnet-4" },
adapterConfig: { model: "claude-sonnet-4-5" },
});
expect(ready.status).toBe("ready");
@ -627,8 +666,8 @@ describeEmbeddedPostgres("built-in agents", () => {
const result = await reconcileBuiltInAgentsOnStartup(db);
expect(result).toMatchObject({
autoEnsured: 1,
pendingApprovals: 1,
autoEnsured: 2,
pendingApprovals: 2,
});
const state = await builtInAgentService(db).get(companyId, "reflection-coach");
expect(state).toMatchObject({
@ -646,7 +685,10 @@ describeEmbeddedPostgres("built-in agents", () => {
});
expect(state.resources.map((resource) => resource.stockStatus)).toEqual(["missing", "missing", "missing"]);
const [approval] = await db.select().from(approvals).where(eq(approvals.companyId, companyId));
const allApprovals = await db.select().from(approvals).where(eq(approvals.companyId, companyId));
const approval = allApprovals.find(
(row) => (row.payload as { agentId?: string } | null)?.agentId === state.agentId,
)!;
expect(approval).toMatchObject({
type: "hire_agent",
status: "pending",
@ -662,7 +704,7 @@ describeEmbeddedPostgres("built-in agents", () => {
});
const pendingReconcile = await reconcileBuiltInAgentsOnStartup(db);
expect(pendingReconcile.pendingApprovals).toBe(1);
expect(pendingReconcile.pendingApprovals).toBe(2);
const stillPending = await builtInAgentService(db).get(companyId, "reflection-coach");
expect(stillPending).toMatchObject({
status: "pending_approval",
@ -698,7 +740,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const agentRows = await db.select().from(agents).where(eq(agents.companyId, companyId));
expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1);
const approvalRows = await db.select().from(approvals).where(eq(approvals.companyId, companyId));
expect(approvalRows).toHaveLength(1);
expect(approvalRows).toHaveLength(2);
});
it("preserves Reflection Coach instruction drift on reconcile and restores it on reset", async () => {
@ -937,6 +979,140 @@ describeEmbeddedPostgres("built-in agents", () => {
expect(grantKeys).not.toContain("skills:create");
});
it("materializes the Summarizer bundle paused on Claude Haiku with a disabled routine", async () => {
const companyId = await seedCompany();
const root = await agentService(db).create(companyId, {
name: "CEO",
role: "ceo",
status: "idle",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
runtimeConfig: {},
permissions: {},
});
const state = await builtInAgentService(db).ensure(companyId, "summarizer");
expect(state.agent).toMatchObject({
companyId,
name: "Summarizer",
title: "Summarizer",
icon: "sparkles",
role: "general",
reportsTo: root.id,
adapterType: "claude_local",
adapterConfig: { model: "claude-haiku-4-5" },
budgetMonthlyCents: 0,
});
expect(state.status).toBe("paused");
expect(state.pauseReason).toBe("Built-in Summarizer is disabled until explicitly configured.");
expect(readBuiltInAgentMarker(state.agent?.metadata)).toEqual({
key: "summarizer",
featureKeys: ["summarizer"],
});
expect(state.agent?.runtimeConfig).not.toHaveProperty("modelProfiles.cheap");
expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus])).toEqual([
["instructions", "stock_current"],
["skill", "stock_current"],
["routine", "stock_current"],
]);
const [skill] = await db
.select()
.from(companySkills)
.where(eq(companySkills.key, "paperclipai/bundled/paperclip-operations/summarize-status"));
expect(skill).toMatchObject({
key: "paperclipai/bundled/paperclip-operations/summarize-status",
slug: "summarize-status",
});
expect(readPaperclipSkillSyncPreference(state.agent!.adapterConfig).desiredSkills).toContain(skill!.key);
const [routine] = await db
.select()
.from(routines)
.where(and(eq(routines.companyId, companyId), eq(routines.assigneeAgentId, state.agentId!)));
expect(routine).toMatchObject({
title: "Refresh stale summary slots",
status: "paused",
assigneeAgentId: state.agentId,
originKind: "built_in_agent_bundle",
originId: "summarizer:refresh-stale-summaries",
});
const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, routine!.id));
expect(trigger).toMatchObject({
kind: "schedule",
enabled: false,
cronExpression: "0 8 * * *",
timezone: "UTC",
});
});
it("keeps the Summarizer not-configured until an adapter model is set", async () => {
const companyId = await seedCompany();
await expect(builtInAgentService(db).requireBuiltInAgent(companyId, "summarizer")).rejects.toMatchObject({
status: 412,
details: {
code: "built_in_agent_not_configured",
key: "summarizer",
},
});
// Provisioning without a model leaves it paused (default), still not runnable as "ready".
const paused = await builtInAgentService(db).ensure(companyId, "summarizer");
expect(paused.status).toBe("paused");
await expect(builtInAgentService(db).requireBuiltInAgent(companyId, "summarizer")).resolves.toMatchObject({
warning: { code: "built_in_agent_paused", key: "summarizer" },
});
});
it("preserves an operator-overridden cheap summariser model across reconcile", async () => {
const companyId = await seedCompany();
const builtIns = builtInAgentService(db);
const created = await builtIns.ensure(companyId, "summarizer");
// Operator overrides the cheap lane with a provider-specific low-cost model.
await agentService(db).update(created.agentId!, {
runtimeConfig: {
modelProfiles: { cheap: { enabled: true, label: "Cheap", adapterConfig: { model: "haiku-cheap" } } },
},
}, { allowBuiltInAgentMetadata: true });
const reconciled = await builtIns.ensure(companyId, "summarizer");
expect(reconciled.agent?.runtimeConfig).toMatchObject({
modelProfiles: { cheap: { adapterConfig: { model: "haiku-cheap" } } },
});
});
it("restores Summarizer instruction drift on reset", async () => {
const companyId = await seedCompany();
const builtIns = builtInAgentService(db);
const created = await builtIns.ensure(companyId, "summarizer");
const instructions = agentInstructionsService();
await instructions.writeFile(created.agent!, "AGENTS.md", "# Custom Summarizer\n\nOperator edit.\n");
const reconciled = await builtIns.ensure(companyId, "summarizer");
expect(reconciled.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({
stockStatus: "operator_modified",
resetAvailable: true,
changedFiles: ["AGENTS.md"],
});
const reset = await builtIns.reset(companyId, "summarizer");
expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({
stockStatus: "stock_current",
resetAvailable: false,
});
const resetFile = await instructions.readFile(reset.agent!, "AGENTS.md");
expect(resetFile.content).toContain("Summarizer");
expect(resetFile.content).toContain("<<<SUMMARY-DRAFT>>>");
expect(resetFile.content).toContain("<<<END-SUMMARY-DRAFT>>>");
expect(resetFile.content).not.toContain("Operator edit.");
});
it("controls the Reflection Coach routine schedule without enabling it by default", async () => {
const companyId = await seedCompany();
await agentService(db).create(companyId, {

View File

@ -107,7 +107,10 @@ describeEmbeddedPostgres("companyService", () => {
slug: "reflection-coach",
});
const [routine] = await db.select().from(routines).where(eq(routines.companyId, created.id));
const [routine] = await db
.select()
.from(routines)
.where(and(eq(routines.companyId, created.id), eq(routines.assigneeAgentId, reflectionRows[0]!.id)));
expect(routine).toMatchObject({
status: "paused",
assigneeAgentId: reflectionRows[0]!.id,

View File

@ -7,6 +7,7 @@ import {
mergeModelProfileAdapterConfig,
normalizeModelProfileWakeContext,
resolveModelProfileApplication,
isConfigurationIncompleteFailedRun,
} from "../services/heartbeat.ts";
const cheapProfile: AdapterModelProfileDefinition = {
@ -144,4 +145,9 @@ describe("heartbeat model profile application", () => {
expect(contextSnapshot).toMatchObject({ modelProfile: "cheap" });
});
it("treats model resolution failures as non-retryable configuration failures", () => {
expect(isConfigurationIncompleteFailedRun({ errorCode: "model_not_found" })).toBe(true);
expect(isConfigurationIncompleteFailedRun({ errorCode: "provider_quota" })).toBe(false);
});
});

View File

@ -1855,6 +1855,21 @@ describe("effective run session config freshness", () => {
expect(decision.reasons).toEqual(["effective run configuration fingerprint metadata is missing"]);
});
it("uses persisted fingerprint metadata even when an adapter codec omits it from resume params", async () => {
const metadata = await buildSessionConfigMetadata();
const persistedParams = sessionParamsWithConfigMetadata(metadata);
const decision = resolveTaskSessionConfigFreshness({
hasTaskSession: true,
configuredModel: "gpt-5.4-mini",
taskSessionParams: persistedParams,
configMetadata: metadata,
});
expect(decision.reset).toBe(false);
expect(decision.reasons).toEqual([]);
});
it("preserves legacy metadata gaps only for active accepted-plan continuation sessions", async () => {
const metadata = await buildSessionConfigMetadata();

View File

@ -40,6 +40,7 @@ describe("instance settings service", () => {
enableCloudSync: true,
enableSmokeLab: false,
enableBuiltInAgents: true,
enableSummaries: false,
enableDecisions: false,
enableGoalsSidebarLink: true,
enableServerInfoDebugView: true,

View File

@ -49,6 +49,7 @@ const apiPrefixes: Record<string, string> = {
"secrets.ts": "/api",
"sidebar-badges.ts": "/api",
"sidebar-preferences.ts": "/api",
"summary-slots.ts": "/api",
"teams-catalog.ts": "/api",
"tool-access.ts": "/api",
"tool-gateway.ts": "/api",

View File

@ -0,0 +1,297 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const companyId = "22222222-2222-4222-8222-222222222222";
const otherCompanyId = "33333333-3333-4333-8333-333333333333";
const agentId = "11111111-1111-4111-8111-111111111111";
const projectId = "44444444-4444-4444-8444-444444444444";
const slotId = "55555555-5555-4555-8555-555555555555";
const generatingIssueId = "66666666-6666-4666-8666-666666666666";
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
canUser: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
const mockSummarySlotService = vi.hoisted(() => ({
getSlot: vi.fn(),
listRevisions: vi.fn(),
generate: vi.fn(),
write: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockHeartbeatWakeup = vi.hoisted(() => vi.fn());
function slot(overrides: Record<string, unknown> = {}) {
return {
id: slotId,
companyId,
scopeKind: "project",
scopeId: projectId,
slotKey: "header",
documentId: null,
status: "idle",
failureReason: null,
generatingIssueId: null,
lastGeneratedAt: null,
lastGeneratedByAgentId: null,
lastModel: null,
createdAt: new Date("2026-07-14T00:00:00.000Z"),
updatedAt: new Date("2026-07-14T00:00:00.000Z"),
...overrides,
};
}
function generatingIssue(overrides: Record<string, unknown> = {}) {
return {
id: generatingIssueId,
identifier: "PAP-1000",
title: "Summarize project",
status: "todo",
assigneeAgentId: agentId,
...overrides,
};
}
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
heartbeatService: () => ({ wakeup: mockHeartbeatWakeup }),
instanceSettingsService: () => mockInstanceSettingsService,
logActivity: mockLogActivity,
}));
vi.doMock("../services/summary-slots.js", () => ({
summarySlotService: () => mockSummarySlotService,
}));
}
async function createApp(actor: Record<string, unknown>) {
const [{ summarySlotRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/summary-slots.js")>("../routes/summary-slots.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", summarySlotRoutes({} as any));
app.use(errorHandler);
return app;
}
const boardActor = {
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
};
const agentActor = {
type: "agent",
agentId,
companyId,
source: "agent_jwt",
runId: "run-123",
};
const slotPath = `/api/companies/${companyId}/summary-slots/project/header?scopeId=${projectId}`;
describe("summary slot routes", () => {
beforeEach(() => {
vi.resetModules();
registerModuleMocks();
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({ allowed: true, explanation: "Allowed." });
mockAccessService.canUser.mockResolvedValue(true);
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableSummaries: true });
mockHeartbeatWakeup.mockResolvedValue({ id: "run-1" });
mockSummarySlotService.getSlot.mockResolvedValue({ slot: slot(), document: null, generatingIssue: null });
mockSummarySlotService.listRevisions.mockResolvedValue({ slot: slot(), revisions: [] });
mockSummarySlotService.generate.mockResolvedValue({
slot: slot({ status: "generating", generatingIssueId }),
generatingIssue: generatingIssue(),
alreadyGenerating: false,
});
mockSummarySlotService.write.mockResolvedValue({
slot: slot({ documentId: "doc-1", status: "idle" }),
document: { id: "doc-1", companyId, format: "markdown", body: "# Summary" },
revision: { id: "rev-1", documentId: "doc-1", revisionNumber: 1 },
});
});
describe("read routes", () => {
it("returns slot state for actors with company access", async () => {
const app = await createApp(boardActor);
const res = await request(app).get(slotPath);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockSummarySlotService.getSlot).toHaveBeenCalledWith({
companyId,
scopeKind: "project",
slotKey: "header",
scopeId: projectId,
});
});
it("lists revisions for actors with company access", async () => {
const app = await createApp(boardActor);
const res = await request(app).get(
`/api/companies/${companyId}/summary-slots/project/header/revisions?scopeId=${projectId}`,
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockSummarySlotService.listRevisions).toHaveBeenCalledOnce();
});
it("returns 404 and does not load state when the summaries flag is disabled", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableSummaries: false });
const app = await createApp(boardActor);
const res = await request(app).get(slotPath);
expect(res.status, JSON.stringify(res.body)).toBe(404);
expect(mockSummarySlotService.getSlot).not.toHaveBeenCalled();
});
it("denies reads outside the actor company boundary", async () => {
const app = await createApp({ ...boardActor, companyIds: [otherCompanyId] });
const res = await request(app).get(slotPath);
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockSummarySlotService.getSlot).not.toHaveBeenCalled();
});
});
describe("generate route", () => {
it("creates a generation task and logs activity for board operators", async () => {
const app = await createApp(boardActor);
const res = await request(app).post(
`/api/companies/${companyId}/summary-slots/project/header/generate`,
).send({ scopeId: projectId });
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(res.body.alreadyGenerating).toBe(false);
expect(mockSummarySlotService.generate).toHaveBeenCalledWith(
{ companyId, scopeKind: "project", slotKey: "header", scopeId: projectId },
expect.objectContaining({ userId: "board-user", agentId: null }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "summary_slot.generate_requested",
entityType: "summary_slot",
entityId: slotId,
}),
);
expect(mockHeartbeatWakeup).toHaveBeenCalledWith(
agentId,
expect.objectContaining({
reason: "summary_slot_generation_requested",
payload: expect.objectContaining({
issueId: generatingIssueId,
taskKey: `summary-slot:${companyId}:project:${projectId}:header`,
}),
contextSnapshot: expect.objectContaining({
issueId: generatingIssueId,
taskKey: `summary-slot:${companyId}:project:${projectId}:header`,
}),
}),
);
});
it("returns 200 without re-creating a task when a generation is already active", async () => {
mockSummarySlotService.generate.mockResolvedValue({
slot: slot({ status: "generating", generatingIssueId }),
generatingIssue: generatingIssue(),
alreadyGenerating: true,
});
const app = await createApp(boardActor);
const res = await request(app).post(
`/api/companies/${companyId}/summary-slots/project/header/generate`,
).send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.alreadyGenerating).toBe(true);
expect(mockHeartbeatWakeup).not.toHaveBeenCalled();
});
it("denies generate when the operator lacks tasks:assign", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp(boardActor);
const res = await request(app).post(
`/api/companies/${companyId}/summary-slots/project/header/generate`,
).send({});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockSummarySlotService.generate).not.toHaveBeenCalled();
});
it("denies generate for agent actors", async () => {
const app = await createApp(agentActor);
const res = await request(app).post(
`/api/companies/${companyId}/summary-slots/project/header/generate`,
).send({});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockSummarySlotService.generate).not.toHaveBeenCalled();
});
it("returns 404 when the summaries flag is disabled", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableSummaries: false });
const app = await createApp(boardActor);
const res = await request(app).post(
`/api/companies/${companyId}/summary-slots/project/header/generate`,
).send({});
expect(res.status, JSON.stringify(res.body)).toBe(404);
expect(mockSummarySlotService.generate).not.toHaveBeenCalled();
});
});
describe("write route", () => {
it("accepts Summarizer agent writes and logs activity", async () => {
const app = await createApp(agentActor);
const res = await request(app).put(slotPath).send({
markdown: "# Summary\n\nNeeds you: nothing.",
generationIssueId: generatingIssueId,
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockSummarySlotService.write).toHaveBeenCalledWith(
expect.objectContaining({
companyId,
scopeKind: "project",
slotKey: "header",
scopeId: projectId,
markdown: "# Summary\n\nNeeds you: nothing.",
generationIssueId: generatingIssueId,
}),
expect.objectContaining({ agentId, runId: "run-123" }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "summary_slot.write", entityType: "summary_slot" }),
);
});
it("rejects writes from board actors", async () => {
const app = await createApp(boardActor);
const res = await request(app).put(slotPath).send({ markdown: "# Summary" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockSummarySlotService.write).not.toHaveBeenCalled();
});
it("returns 404 when the summaries flag is disabled", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableSummaries: false });
const app = await createApp(agentActor);
const res = await request(app).put(slotPath).send({ markdown: "# Summary" });
expect(res.status, JSON.stringify(res.body)).toBe(404);
expect(mockSummarySlotService.write).not.toHaveBeenCalled();
});
it("rejects writes with an empty markdown body", async () => {
const app = await createApp(agentActor);
const res = await request(app).put(slotPath).send({ markdown: "" });
expect(res.status, JSON.stringify(res.body)).toBe(400);
expect(mockSummarySlotService.write).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,527 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import {
activityLog,
agents,
companies,
createDb,
documentRevisions,
documents,
heartbeatRuns,
issues,
projectWorkspaces,
projects,
summarySlots,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { summarySlotService } from "../services/summary-slots.ts";
import { withBuiltInAgentMarker } from "../services/built-in-agent-metadata.ts";
import { issueService } from "../services/issues.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function issuePrefix(id: string) {
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres summary-slot tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("summary slot service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-summary-slots-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(summarySlots);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(activityLog);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: issuePrefix(companyId),
defaultResponsibleUserId: "responsible-user",
});
return companyId;
}
async function seedProject(companyId: string) {
const projectId = randomUUID();
await db.insert(projects).values({ id: projectId, companyId, name: "Paperclip App" });
return projectId;
}
async function seedSummarizer(companyId: string, ready = true) {
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "Summarizer",
role: "general",
status: "idle",
adapterType: "codex_local",
adapterConfig: ready ? { model: "gpt-5.4" } : {},
metadata: withBuiltInAgentMarker(null, { key: "summarizer", featureKeys: ["summarizer"] }),
});
return agentId;
}
async function seedPlainAgent(companyId: string) {
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
});
return agentId;
}
async function seedRun(companyId: string, agentId: string) {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" });
return runId;
}
function projectSelector(companyId: string, projectId: string) {
return { companyId, scopeKind: "project", slotKey: "header", scopeId: projectId };
}
describe("reads and target visibility", () => {
it("returns an empty slot state before any generation", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
const svc = summarySlotService(db);
const result = await svc.getSlot(projectSelector(companyId, projectId));
expect(result).toEqual({ slot: null, document: null, generatingIssue: null });
});
it("rejects targets that do not exist in the company", async () => {
const companyId = await seedCompany();
const svc = summarySlotService(db);
await expect(svc.getSlot(projectSelector(companyId, randomUUID()))).rejects.toMatchObject({
status: 404,
});
});
it("rejects a project owned by another company (company scoping)", async () => {
const companyId = await seedCompany();
const otherCompanyId = await seedCompany();
const foreignProjectId = await seedProject(otherCompanyId);
const svc = summarySlotService(db);
await expect(svc.getSlot(projectSelector(companyId, foreignProjectId))).rejects.toMatchObject({
status: 404,
});
});
it("rejects a workspaces_overview selector that carries a scopeId", async () => {
const companyId = await seedCompany();
const svc = summarySlotService(db);
await expect(
svc.getSlot({ companyId, scopeKind: "workspaces_overview", slotKey: "header", scopeId: randomUUID() }),
).rejects.toMatchObject({ status: 422 });
});
});
describe("generate", () => {
it("fails when the Summarizer built-in is not configured", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
const svc = summarySlotService(db);
await expect(
svc.generate(projectSelector(companyId, projectId), { userId: "board-user" }),
).rejects.toMatchObject({ status: 422, details: { code: "summarizer_not_configured" } });
});
it("creates a summarizer task, links it, and marks the slot generating", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
const otherProjectId = await seedProject(companyId);
const summarizerAgentId = await seedSummarizer(companyId);
const svc = summarySlotService(db);
await db.insert(issues).values([
{
companyId,
projectId,
identifier: `${issuePrefix(companyId)}-101`,
issueNumber: 101,
title: "Waiting on board approval",
status: "blocked",
priority: "high",
},
{
companyId,
projectId,
identifier: `${issuePrefix(companyId)}-102`,
issueNumber: 102,
title: "Implement summary cards",
status: "in_progress",
priority: "medium",
},
{
companyId,
projectId,
identifier: `${issuePrefix(companyId)}-103`,
issueNumber: 103,
title: "Ship the previous summary",
status: "done",
priority: "low",
},
{
companyId,
projectId: otherProjectId,
identifier: `${issuePrefix(companyId)}-104`,
issueNumber: 104,
title: "Other project issue",
status: "blocked",
priority: "critical",
},
]);
const result = await svc.generate(projectSelector(companyId, projectId), { userId: "board-user" });
expect(result.alreadyGenerating).toBe(false);
expect(result.slot.status).toBe("generating");
expect(result.slot.generatingIssueId).toBe(result.generatingIssue.id);
const issueRow = await db
.select()
.from(issues)
.where(eq(issues.id, result.generatingIssue.id))
.then((rows) => rows[0]!);
expect(issueRow.assigneeAgentId).toBe(summarizerAgentId);
expect(issueRow.companyId).toBe(companyId);
expect(issueRow.title).toMatch(/^Summarize project on \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC$/);
expect(issueRow.hiddenAt).toBeInstanceOf(Date);
expect(issueRow.description).toContain(
'"generationIssueId": "' + result.generatingIssue.id + '"',
);
expect(issueRow.description).toContain("Call `/summarize-status`");
expect(issueRow.description).not.toContain("Follow the Summarizer skill");
expect(issueRow.description).toContain(
`GET /api/companies/${companyId}/summary-slots/project/header?scopeId=${projectId}`,
);
expect(issueRow.description).toContain(
"do not call the revisions or issues-list endpoints",
);
expect(issueRow.description).toContain(
`PUT /api/companies/${companyId}/summary-slots/project/header`,
);
expect(issueRow.description).toContain(
"one or two plain-prose paragraphs on the (max two) things that matter most",
);
expect(issueRow.description).toContain("opens with a `**Decide:**` block");
expect(issueRow.description).toContain("`**I suggest:**` recommendation");
expect(issueRow.description).toContain("followed by a `**Review:**` block");
expect(issueRow.description).toContain(
"what the reader can approve on a skim vs what needs their eyes",
);
expect(issueRow.description).toContain(
"End the summary with a `**Recent work:**` block",
);
expect(issueRow.description).toContain(
"at most three or four issues inline; never a trailing list of issue links",
);
expect(issueRow.description).toContain("Not a task list");
expect(issueRow.description).toContain(
"first plain-text `STATUS:` line immediately",
);
expect(issueRow.description).toContain("sentinel-wrapped summary draft");
expect(issueRow.description).toContain("## Prebuilt scope snapshot");
expect(issueRow.description).toContain("### Blocked");
expect(issueRow.description).toContain("Waiting on board approval");
expect(issueRow.description).toContain("### In progress");
expect(issueRow.description).toContain("Implement summary cards");
expect(issueRow.description).toContain("### Recently done");
expect(issueRow.description).toContain("Ship the previous summary");
expect(issueRow.description).toContain(`/${issuePrefix(companyId)}/issues/`);
expect(issueRow.description).not.toContain("/PAP/issues/");
expect(issueRow.description).not.toContain("Other project issue");
});
it("dedupes concurrent generate clicks without creating an orphan task", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
await seedSummarizer(companyId);
const svc = summarySlotService(db);
const [first, second] = await Promise.all([
svc.generate(projectSelector(companyId, projectId), { userId: "board-user" }),
svc.generate(projectSelector(companyId, projectId), { userId: "board-user" }),
]);
expect(second.generatingIssue.id).toBe(first.generatingIssue.id);
expect([first.alreadyGenerating, second.alreadyGenerating].sort()).toEqual([false, true]);
const issueRows = await db.select().from(issues).where(eq(issues.companyId, companyId));
expect(issueRows).toHaveLength(1);
});
it("creates a fresh task once the previous generation task is terminal", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
await seedSummarizer(companyId);
const svc = summarySlotService(db);
const first = await svc.generate(projectSelector(companyId, projectId), { userId: "board-user" });
await issueService(db).update(first.generatingIssue.id, { status: "done" });
const failed = await svc.getSlot(projectSelector(companyId, projectId));
expect(failed.slot).toMatchObject({
status: "failed",
generatingIssueId: first.generatingIssue.id,
failureReason: expect.stringContaining("finished without writing a summary"),
});
const second = await svc.generate(projectSelector(companyId, projectId), { userId: "board-user" });
expect(second.alreadyGenerating).toBe(false);
expect(second.generatingIssue.id).not.toBe(first.generatingIssue.id);
expect(second.slot).toMatchObject({
status: "generating",
failureReason: null,
generatingIssueId: second.generatingIssue.id,
});
const issueRows = await db.select().from(issues).where(eq(issues.companyId, companyId));
expect(issueRows).toHaveLength(2);
});
it("marks the slot failed when its generation task is cancelled without a write", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
await seedSummarizer(companyId);
const svc = summarySlotService(db);
const generated = await svc.generate(projectSelector(companyId, projectId), { userId: "board-user" });
await issueService(db).update(generated.generatingIssue.id, { status: "cancelled" });
const result = await svc.getSlot(projectSelector(companyId, projectId));
expect(result.slot).toMatchObject({
status: "failed",
generatingIssueId: generated.generatingIssue.id,
failureReason: expect.stringContaining("was cancelled before writing a summary"),
});
});
});
describe("summarizer writes", () => {
async function startGeneration(companyId: string, projectId: string, summarizerAgentId: string) {
const svc = summarySlotService(db);
const generated = await svc.generate(projectSelector(companyId, projectId), { userId: "board-user" });
const runId = await seedRun(companyId, summarizerAgentId);
// Simulate the summarizer run checking out its linked generation task.
await db.update(issues).set({ checkoutRunId: runId }).where(eq(issues.id, generated.generatingIssue.id));
return { svc, generationIssueId: generated.generatingIssue.id, runId };
}
it("writes a board-readable revision, preserves the previous revision, and clears the generating state", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
const summarizerAgentId = await seedSummarizer(companyId);
const { svc, generationIssueId, runId } = await startGeneration(companyId, projectId, summarizerAgentId);
const initial = await svc.write(
{
...projectSelector(companyId, projectId),
markdown:
"Quiet scope — nothing is in flight and nothing is waiting on you. First summary for this scope.\n\n**Next:** nothing needs a decision from you right now; the next thing worth watching is the first issue landing here.",
model: "cheap-model",
generationIssueId,
},
{ agentId: summarizerAgentId, runId },
);
const nextGeneration = await svc.generate(projectSelector(companyId, projectId), {
userId: "board-user",
});
const nextRunId = await seedRun(companyId, summarizerAgentId);
await db
.update(issues)
.set({ checkoutRunId: nextRunId })
.where(eq(issues.id, nextGeneration.generatingIssue.id));
const written = await svc.write(
{
...projectSelector(companyId, projectId),
markdown:
"**Decide:**\n- The change is done and the review is sitting with you — [T-123](/T/issues/T-123). **I suggest:** approve it, the tests are green.\n\nNothing else moved since last time.",
baseRevisionId: initial.revision.id,
generationIssueId: nextGeneration.generatingIssue.id,
model: "cheap-model",
},
{ agentId: summarizerAgentId, runId: nextRunId },
);
expect(written.revision.revisionNumber).toBe(2);
expect(written.document.body).toMatch(/^\*\*Decide:\*\*[\s\S]*\*\*I suggest:\*\*/m);
expect(written.document.body).not.toMatch(/^Issues: /m);
expect(written.slot.status).toBe("idle");
expect(written.slot.generatingIssueId).toBeNull();
expect(written.slot.documentId).toBe(written.document.id);
expect(written.slot.lastGeneratedByAgentId).toBe(summarizerAgentId);
expect(written.slot.lastModel).toBe("cheap-model");
const revisions = await svc.listRevisions(projectSelector(companyId, projectId));
expect(revisions.revisions).toHaveLength(2);
expect(revisions.revisions[0]!.id).toBe(written.revision.id);
expect(revisions.revisions[1]!.id).toBe(initial.revision.id);
expect(revisions.revisions[1]!.body).toContain("First summary for this scope.");
});
it("returns only the 20 most recent summary revisions", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
const summarizerAgentId = await seedSummarizer(companyId);
const { svc, generationIssueId, runId } = await startGeneration(companyId, projectId, summarizerAgentId);
const written = await svc.write(
{ ...projectSelector(companyId, projectId), markdown: "# Summary v1", generationIssueId },
{ agentId: summarizerAgentId, runId },
);
await db.insert(documentRevisions).values(
Array.from({ length: 24 }, (_, index) => ({
companyId,
documentId: written.document.id,
revisionNumber: index + 2,
body: `# Summary v${index + 2}`,
})),
);
const revisions = await svc.listRevisions(projectSelector(companyId, projectId));
expect(revisions.revisions).toHaveLength(20);
expect(revisions.revisions[0]!.revisionNumber).toBe(25);
expect(revisions.revisions.at(-1)!.revisionNumber).toBe(6);
});
it("appends further revisions and enforces optimistic baseRevisionId", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
const summarizerAgentId = await seedSummarizer(companyId);
const { svc, generationIssueId, runId } = await startGeneration(companyId, projectId, summarizerAgentId);
const first = await svc.write(
{ ...projectSelector(companyId, projectId), markdown: "# Summary v1", generationIssueId },
{ agentId: summarizerAgentId, runId },
);
// A stale baseRevisionId must be rejected.
const second = await summarySlotService(db).generate(projectSelector(companyId, projectId), {
userId: "board-user",
});
const runId2 = await seedRun(companyId, summarizerAgentId);
await db.update(issues).set({ checkoutRunId: runId2 }).where(eq(issues.id, second.generatingIssue.id));
await expect(
svc.write(
{
...projectSelector(companyId, projectId),
markdown: "# Summary v2",
baseRevisionId: randomUUID(),
generationIssueId: second.generatingIssue.id,
},
{ agentId: summarizerAgentId, runId: runId2 },
),
).rejects.toMatchObject({ status: 409 });
const ok = await svc.write(
{
...projectSelector(companyId, projectId),
markdown: "# Summary v2",
baseRevisionId: first.revision.id,
generationIssueId: second.generatingIssue.id,
},
{ agentId: summarizerAgentId, runId: runId2 },
);
expect(ok.revision.revisionNumber).toBe(2);
});
it("rejects writes from a non-Summarizer agent", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
const summarizerAgentId = await seedSummarizer(companyId);
const plainAgentId = await seedPlainAgent(companyId);
const { svc, runId } = await startGeneration(companyId, projectId, summarizerAgentId);
await expect(
svc.write(
{ ...projectSelector(companyId, projectId), markdown: "# Sneaky" },
{ agentId: plainAgentId, runId },
),
).rejects.toMatchObject({ status: 403 });
});
it("rejects Summarizer writes that do not run from the linked generation task", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
const summarizerAgentId = await seedSummarizer(companyId);
const { generationIssueId } = await startGeneration(companyId, projectId, summarizerAgentId);
const svc = summarySlotService(db);
await expect(
svc.write(
{ ...projectSelector(companyId, projectId), markdown: "# Wrong run", generationIssueId },
{ agentId: summarizerAgentId, runId: randomUUID() },
),
).rejects.toMatchObject({ status: 403 });
});
it("rejects using one generation task to write a different slot", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
const otherProjectId = await seedProject(companyId);
const summarizerAgentId = await seedSummarizer(companyId);
const { svc, generationIssueId, runId } = await startGeneration(companyId, projectId, summarizerAgentId);
await expect(
svc.write(
{ ...projectSelector(companyId, otherProjectId), markdown: "# Wrong slot", generationIssueId },
{ agentId: summarizerAgentId, runId },
),
).rejects.toMatchObject({ status: 403 });
});
it("rejects writes when there is no active generation", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
const summarizerAgentId = await seedSummarizer(companyId);
const svc = summarySlotService(db);
await expect(
svc.write(
{ ...projectSelector(companyId, projectId), markdown: "# No generation" },
{ agentId: summarizerAgentId, runId: randomUUID() },
),
).rejects.toMatchObject({ status: 403 });
});
});
});

View File

@ -18,6 +18,7 @@ import { companySkillPolicyRoutes } from "./routes/company-skill-policy.js";
import { inboxAgentPolicyRoutes } from "./routes/inbox-agent-policy.js";
import { builtInAgentRoutes } from "./routes/built-in-agents.js";
import { folderRoutes } from "./routes/folders.js";
import { summarySlotRoutes } from "./routes/summary-slots.js";
import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
import { agentRoutes } from "./routes/agents.js";
import { projectRoutes } from "./routes/projects.js";
@ -242,6 +243,7 @@ export async function createApp(
api.use(companySkillPolicyRoutes(db));
api.use(inboxAgentPolicyRoutes(db));
api.use(builtInAgentRoutes(db));
api.use(summarySlotRoutes(db));
api.use(teamsCatalogRoutes(db));
api.use(agentRoutes(db, { pluginWorkerManager: workerManager }));
api.use(assetRoutes(db, opts.storageService));

View File

@ -0,0 +1,40 @@
You are Summarizer, a built-in reporting agent at Paperclip.
When you wake up, follow the Paperclip heartbeat procedure. Work only on issues assigned to you. Always leave a task comment before exiting a heartbeat.
Your job is to turn the current state of a Paperclip scope — a project, the workspaces overview, or a single project workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. When an issue asks you to generate or refresh a summary, use the `summarize-status` skill as your operating procedure and start with its API quick reference instead of discovering routes.
## Core responsibilities
- Read the scope named by the generation issue (`scopeKind` = `project` | `workspaces_overview` | `project_workspace`, plus `scopeId` and `slotKey`).
- Read the summary slot's most recent revision first, so you lead with what's new instead of repeating a headline the reader already saw.
- Triage, don't enumerate: pick the one or two decisions (max) that most need the reader — a decision waiting on a human first, then risk, then progress — and leave everything else off the page.
- Open every summary with a `**Decide:**` block: at most two bullets, each giving the decision's context, a link, and a committed `**I suggest:**` recommendation. When nothing needs a decision, open with one `**Nothing to decide right now.**` line followed by a `**Review:**` block (at most two bullets) triaging what is waiting on review — what the reader can approve on a skim vs what needs their eyes, each with a link and an `**I suggest:**` recommendation. Follow the opening block with at most one or two short paragraphs of plain, colloquial prose (no headings, no status lists).
- End every summary with a `**Recent work:**` block: at most two bullets, one line each, naming a recent piece of work and where it stands in plain language ("just merged", "through QA, waiting on a reviewer") — the most recent things worth knowing about, not a changelog.
- Never dump issue links: at most three or four issue references in the whole summary, inline where mentioned — no trailing `Issues:` line or link roundup. The summary renders next to the board, which already lists everything.
- Write one Markdown revision back to the slot with a one-line `changeSummary`, the `baseRevisionId` you read, the `generationIssueId`, and the `model` you ran on.
- Follow the skill's streaming protocol: post the first `STATUS:` line immediately — named from the first task you see in context, before any reads or analysis — keep emitting `STATUS:` lines as your thinking moves so the reader gets live feedback, then emit the complete final Markdown between `<<<SUMMARY-DRAFT>>>` and `<<<END-SUMMARY-DRAFT>>>` before writing that exact Markdown to the slot.
- Close the generation issue with a short comment: scope summarized, revision number, and the headline in one clause.
## Hard boundaries
- Read-and-report only. Never change issues, workspaces, code, or agent configuration. Your only write is the summary revision.
- Cite, don't assert. Every concrete claim links the issue identifier it came from; drop any line you cannot back with source data.
- Never fabricate status. A quiet scope gets an honest "nothing is next" summary, not filler.
- Keep every read company-scoped. Do not cross company boundaries.
- Never surface secrets (API keys, tokens, credentials) that appear in issue bodies or configs.
## Cost discipline
You run on the low-cost model profile lane (`cheap`) by default and spend no tokens in the background. Only generate when a summary-generation issue is assigned or a manual refresh is triggered.
- Pull only the data you need to pick the headline and the next action; prefer list endpoints over per-issue detail fetches.
- Keep summaries short — a header summary that scrolls or reads like a task list has failed its job.
- An operator may override the cheap default with a specific model in this agent's `cheap` model profile configuration. Respect whatever model the run actually provides.
## Execution contract
- Start concrete work in the same heartbeat when the issue is actionable; do not stop at a plan.
- The deliverable is the written slot revision, not a comment restating the summary. Leave durable progress and a clear next-step owner.
- If you cannot read the scope (permissions, missing scope, unknown slot), mark the issue blocked and name the exact unblock owner and action needed.
- Respect budget, pause/cancel, approval gates, execution policy stages, and company boundaries.

View File

@ -0,0 +1,66 @@
---
routineKey: refresh-stale-summaries
title: Refresh stale summary slots
description: Bounded, paused-by-default sweep that regenerates summary slots whose underlying scope has changed since the last revision. Spends no tokens until an operator enables its schedule or runs it manually. Read-and-report only — it never mutates issues, workspaces, or code.
assigneeRef:
resourceKind: agent
resourceKey: summarizer
status: paused
priority: medium
concurrencyPolicy: coalesce_if_active
catchUpPolicy: skip_missed
variables:
- name: staleAfterHours
label: Refresh slots older than (hours)
type: number
defaultValue: 24
required: false
options: []
- name: maxSlots
label: Max slots to refresh per run
type: number
defaultValue: 10
required: false
options: []
- name: scopeKinds
label: Scope kinds to include
type: select
defaultValue: all
required: false
options:
- all
- project
- workspaces_overview
- project_workspace
triggers:
- kind: schedule
label: Daily stale-summary refresh
enabled: false
cronExpression: "0 8 * * *"
timezone: UTC
signingMode: none
replayWindowSec: 0
issueTemplate:
surfaceVisibility: normal
---
# Refresh stale summary slots
This routine is **paused by default** and spends no tokens until an operator enables its schedule or triggers a manual run. The first release of the Summarizer is manual-generation-first; this routine exists so operators can opt into scheduled refreshes without background spend by default.
## What this run must do
1. Select summary slots whose scope has changed since their last revision and whose `lastGeneratedAt` is older than `{{staleAfterHours}}` hours. Restrict to `{{scopeKinds}}` when a specific kind is chosen. Cap the set at `{{maxSlots}}`, most-stale first.
2. For each selected slot, run the `summarize-status` skill as the operating procedure: read the current revision, gather minimal company-scoped state, and write one new Markdown revision back to the slot.
3. Skip slots with no meaningful change since their last revision — do not spend tokens rewriting an unchanged summary.
## Hard limits for this routine
- Read-and-report only. This routine must never change issues, workspaces, code, or agent configuration — its only write is the summary revision.
- Keep every read company-scoped. Do not cross company boundaries.
- Run on the low-cost model profile lane (`cheap`). Keep each summary short and pull only the data the summary needs.
- Never fabricate status and never surface secrets from issue bodies or configs.
## Output
A single bounded routine issue that links the slots refreshed this run, plus a summary comment listing: scopes summarized, revisions written, slots skipped as unchanged, and any slot that could not be read (with the unblock owner).

View File

@ -5,6 +5,7 @@ export { companySkillPolicyRoutes } from "./company-skill-policy.js";
export { inboxAgentPolicyRoutes } from "./inbox-agent-policy.js";
export { builtInAgentRoutes } from "./built-in-agents.js";
export { folderRoutes } from "./folders.js";
export { summarySlotRoutes } from "./summary-slots.js";
export { teamsCatalogRoutes } from "./teams-catalog.js";
export { agentRoutes } from "./agents.js";
export { projectRoutes } from "./projects.js";

View File

@ -12,6 +12,8 @@ import {
createAgentKeySchema,
builtInAgentEmptyMutationSchema,
builtInAgentProvisionSchema,
generateSummarySlotSchema,
writeSummarySlotSchema,
wakeAgentSchema,
resetAgentSessionSchema,
agentSkillSyncSchema,
@ -1368,6 +1370,65 @@ for (const route of [
});
}
const summarySlotParams = z.object({
companyId: z.string(),
scopeKind: z.string(),
slotKey: z.string(),
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}",
tags: ["summaries"],
summary: "Get a summary slot with its latest document and generation state",
request: { params: summarySlotParams },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}/revisions",
tags: ["summaries"],
summary: "List dated revisions for a summary slot",
request: { params: summarySlotParams },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}/generate",
tags: ["summaries"],
summary: "Manually generate (or refresh) a summary slot",
request: { params: summarySlotParams, body: jsonBody(generateSummarySlotSchema) },
responses: {
200: r.ok(),
202: r.ok(),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
409: r.conflict,
422: r.unprocessable,
},
});
registry.registerPath({
method: "put",
path: "/api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}",
tags: ["summaries"],
summary: "Write a summary revision (Summarizer built-in agent only)",
request: { params: summarySlotParams, body: jsonBody(writeSummarySlotSchema) },
responses: {
200: r.ok(),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
409: r.conflict,
422: r.unprocessable,
},
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/agents",

View File

@ -0,0 +1,208 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import { generateSummarySlotSchema, writeSummarySlotSchema } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { forbidden, notFound } from "../errors.js";
import { accessService, heartbeatService, instanceSettingsService, logActivity } from "../services/index.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
import { summarySlotService } from "../services/summary-slots.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
function readScopeId(req: Request): string | null {
const raw = req.query.scopeId;
if (typeof raw === "string" && raw.trim().length > 0) return raw;
return null;
}
export function summarySlotSessionTaskKey(input: {
companyId: string;
scopeKind: string;
slotKey: string;
scopeId: string | null;
}) {
return `summary-slot:${input.companyId}:${input.scopeKind}:${input.scopeId ?? "company"}:${input.slotKey}`;
}
export function summarySlotRoutes(db: Db) {
const router = Router();
const access = accessService(db);
const settings = instanceSettingsService(db);
const svc = summarySlotService(db);
const heartbeat = heartbeatService(db);
async function assertSummariesEnabled() {
const experimental = await settings.getExperimental();
if (experimental.enableSummaries !== true) {
throw notFound("Summaries are not enabled");
}
}
/** Manual generate is a board/user action; agents cannot trigger it. */
async function assertCanGenerateSummary(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") {
throw forbidden("Only board operators can generate summaries.");
}
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
if (!allowed) {
throw forbidden("Missing permission: tasks:assign");
}
}
async function logSummaryMutation(
req: Request,
input: {
companyId: string;
action: "summary_slot.generate_requested" | "summary_slot.write";
slotId: string;
details: Record<string, unknown>;
},
) {
const actor = getActorInfo(req);
await logActivity(db, {
companyId: input.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
action: input.action,
entityType: "summary_slot",
entityId: input.slotId,
...(actor.agentId ? { agentId: actor.agentId } : {}),
...(actor.runId ? { runId: actor.runId } : {}),
details: input.details,
});
}
router.get("/companies/:companyId/summary-slots/:scopeKind/:slotKey", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
await assertSummariesEnabled();
const result = await svc.getSlot({
companyId,
scopeKind: req.params.scopeKind as string,
slotKey: req.params.slotKey as string,
scopeId: readScopeId(req),
});
res.json(result);
});
router.get("/companies/:companyId/summary-slots/:scopeKind/:slotKey/revisions", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
await assertSummariesEnabled();
const result = await svc.listRevisions({
companyId,
scopeKind: req.params.scopeKind as string,
slotKey: req.params.slotKey as string,
scopeId: readScopeId(req),
});
res.json(result);
});
router.post(
"/companies/:companyId/summary-slots/:scopeKind/:slotKey/generate",
validate(generateSummarySlotSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertSummariesEnabled();
await assertCanGenerateSummary(req, companyId);
const actor = getActorInfo(req);
const result = await svc.generate(
{
companyId,
scopeKind: req.params.scopeKind as string,
slotKey: req.params.slotKey as string,
scopeId: (req.body?.scopeId as string | null | undefined) ?? readScopeId(req),
},
{
agentId: actor.actorType === "agent" ? actor.actorId : null,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId ?? null,
},
);
await logSummaryMutation(req, {
companyId,
action: "summary_slot.generate_requested",
slotId: result.slot.id,
details: {
scopeKind: result.slot.scopeKind,
scopeId: result.slot.scopeId,
slotKey: result.slot.slotKey,
generatingIssueId: result.generatingIssue.id,
alreadyGenerating: result.alreadyGenerating,
},
});
if (!result.alreadyGenerating) {
await queueIssueAssignmentWakeup({
heartbeat,
issue: {
id: result.generatingIssue.id,
assigneeAgentId: result.generatingIssue.assigneeAgentId ?? null,
status: result.generatingIssue.status,
},
reason: "summary_slot_generation_requested",
mutation: "summary_slot.generate",
contextSource: "summary-slot.generate",
requestedByActorType: actor.actorType === "agent" ? "agent" : "user",
requestedByActorId: actor.actorId,
taskKey: summarySlotSessionTaskKey({
companyId,
scopeKind: result.slot.scopeKind,
slotKey: result.slot.slotKey,
scopeId: result.slot.scopeId,
}),
rethrowOnError: true,
});
}
res.status(result.alreadyGenerating ? 200 : 202).json(result);
},
);
router.put(
"/companies/:companyId/summary-slots/:scopeKind/:slotKey",
validate(writeSummarySlotSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
await assertSummariesEnabled();
if (req.actor.type !== "agent") {
throw forbidden("Only the Summarizer built-in agent may write summaries");
}
const actor = getActorInfo(req);
const result = await svc.write(
{
companyId,
scopeKind: req.params.scopeKind as string,
slotKey: req.params.slotKey as string,
scopeId: (req.body?.scopeId as string | null | undefined) ?? readScopeId(req),
markdown: req.body.markdown,
title: req.body.title ?? null,
changeSummary: req.body.changeSummary ?? null,
baseRevisionId: req.body.baseRevisionId ?? null,
generationIssueId: req.body.generationIssueId ?? null,
model: req.body.model ?? null,
},
{
agentId: actor.agentId,
runId: actor.runId ?? null,
},
);
await logSummaryMutation(req, {
companyId,
action: "summary_slot.write",
slotId: result.slot.id,
details: {
scopeKind: result.slot.scopeKind,
scopeId: result.slot.scopeId,
slotKey: result.slot.slotKey,
documentId: result.document.id,
revisionId: result.revision.id,
revisionNumber: result.revision.revisionNumber,
},
});
res.json(result);
},
);
return router;
}

View File

@ -21,6 +21,7 @@ import {
import { companySkillService } from "./company-skills.js";
import { routineService } from "./routines.js";
import { accessService } from "./access.js";
import { listAdapterModels } from "../adapters/registry.js";
export type BuiltInAgentStatus = "not_provisioned" | "pending_approval" | "needs_setup" | "ready" | "paused";
@ -37,7 +38,10 @@ export interface BuiltInAgentDefinition {
defaultStatus?: "idle" | "paused";
defaultManager?: "single_root_agent" | null;
allowedAdapterTypes?: string[];
defaultAdapterType?: string;
defaultAdapterConfig?: Record<string, unknown>;
defaultBudgetMonthlyCents?: number;
defaultRuntimeConfig?: Record<string, unknown>;
bundle?: BuiltInAgentBundleDefinition;
}
@ -173,6 +177,35 @@ const FALLBACK_REFLECTION_COACH_SKILL = [
"",
].join("\n");
const FALLBACK_SUMMARIZER_INSTRUCTIONS = [
"You are Summarizer, a built-in reporting agent at Paperclip.",
"",
"Turn the current state of a Paperclip scope (project, workspaces overview, or a single project workspace) into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. Use the `summarize-status` skill as your operating procedure.",
"",
"Read-and-report only: never change issues, workspaces, or code. Cite issue identifiers, never fabricate status, keep every read company-scoped, and run on the low-cost model profile lane by default.",
"",
].join("\n");
const FALLBACK_SUMMARIZER_ROUTINE = [
"Regenerate summary slots whose scope has changed since their last revision.",
"",
"Paused by default; spends no tokens until an operator enables the schedule or runs it manually. Read-and-report only — the only write is the summary revision.",
"",
].join("\n");
const FALLBACK_SUMMARIZER_SKILL = [
"---",
"name: summarize-status",
"description: Write a short, colloquial summary for a Paperclip summary slot: open with the one or two decisions the reader must make — or, when nothing needs deciding, what to review — each with a recommendation, close with one or two recent pieces of work and where they stand, streaming status as it works.",
"key: paperclipai/bundled/paperclip-operations/summarize-status",
"---",
"",
"# Summarize status",
"",
"Turn a Paperclip scope's current state into a short, colloquial Markdown summary — opening with a `**Decide:**` block of at most two bullets (each with the decision's context, a link, and an `**I suggest:**` recommendation), followed by plain prose on the one or two things that matter most, with at most three or four inline issue links and never a trailing link list — then write it back to the scope's summary slot. When nothing needs a decision, open with `**Nothing to decide right now.**` plus a `**Review:**` block (at most two bullets) triaging what is waiting on review — easy approves vs what needs the reader's eyes — each with a link and an `**I suggest:**` recommendation. End every summary with a `**Recent work:**` block: at most two bullets, one line each, naming a recent piece of work and where it stands. Post the first `STATUS:` line immediately from the first task in context and keep streaming `STATUS:` lines while working. Not a task list. Read-and-report only; never fabricate status.",
"",
].join("\n");
const warnedBuiltInTextFallbacks = new Set<string>();
const warnedBuiltInTextReadErrors = new Set<string>();
@ -247,6 +280,25 @@ const REFLECTION_COACH_SKILL = readBuiltInTextWithFallback(
FALLBACK_REFLECTION_COACH_SKILL,
);
const SUMMARIZER_INSTRUCTIONS = readBuiltInText("summarizer/AGENTS.md", FALLBACK_SUMMARIZER_INSTRUCTIONS);
const SUMMARIZER_ROUTINE = readBuiltInText(
"summarizer/routines/refresh-stale-summaries.md",
FALLBACK_SUMMARIZER_ROUTINE,
);
const SUMMARIZER_SKILL = readBuiltInTextWithFallback(
"summarizer/SKILL.md",
[
path.resolve(
moduleDir,
"../../../packages/skills-catalog/catalog/bundled/paperclip-operations/summarize-status/SKILL.md",
),
...(skillsCatalogRoot
? [path.join(skillsCatalogRoot, "catalog/bundled/paperclip-operations/summarize-status/SKILL.md")]
: []),
],
FALLBACK_SUMMARIZER_SKILL,
);
const DEFINITIONS = validateBuiltInAgentDefinitions([
{
key: "briefs",
@ -343,6 +395,77 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
},
},
},
{
key: "summarizer",
displayName: "Summarizer",
featureKeys: ["summarizer"],
shortPurpose:
"Writes short, human-readable Markdown status summaries into project, workspaces-overview, and project-workspace summary slots on demand.",
defaultInstructions: SUMMARIZER_INSTRUCTIONS,
defaultRole: "general",
defaultTitle: "Summarizer",
defaultIcon: "sparkles",
defaultPermissions: {
canCreateAgents: false,
canCreateSkills: false,
},
defaultStatus: "paused",
defaultManager: "single_root_agent",
allowedAdapterTypes: ["claude_local", "codex_local", "gemini_local", "opencode_local", "process"],
defaultAdapterType: "claude_local",
defaultAdapterConfig: {
model: "claude-haiku-4-5",
},
defaultBudgetMonthlyCents: 0,
bundle: {
stockVersion: "2026-07-15",
instructions: {
entryFile: "AGENTS.md",
files: {
"AGENTS.md": SUMMARIZER_INSTRUCTIONS,
},
},
skill: {
skillKey: "summarize-status",
displayName: "Summarize status",
slug: "summarize-status",
canonicalKey: "paperclipai/bundled/paperclip-operations/summarize-status",
files: {
"summarize-status/SKILL.md": SUMMARIZER_SKILL,
},
},
routine: {
routineKey: "refresh-stale-summaries",
title: "Refresh stale summary slots",
description: SUMMARIZER_ROUTINE,
status: "paused",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [
{ name: "staleAfterHours", label: "Refresh slots older than (hours)", type: "number", defaultValue: 24, required: true, options: [] },
{ name: "maxSlots", label: "Max slots to refresh per run", type: "number", defaultValue: 10, required: true, options: [] },
{
name: "scopeKinds",
label: "Scope kinds to include",
type: "select",
defaultValue: "all",
required: true,
options: ["all", "project", "workspaces_overview", "project_workspace"],
},
],
triggers: [
{
kind: "schedule",
label: "Daily stale-summary refresh",
enabled: false,
cronExpression: "0 8 * * *",
timezone: "UTC",
},
],
},
},
},
]);
const DEFINITIONS_BY_KEY = new Map(DEFINITIONS.map((definition) => [definition.key, definition]));
@ -484,6 +607,13 @@ export function validateBuiltInAgentDefinitions(definitions: BuiltInAgentDefinit
) {
throw new Error(`Built-in agent ${definition.key} allowedAdapterTypes must be unique non-empty strings`);
}
if (
definition.defaultAdapterType
&& definition.allowedAdapterTypes
&& !definition.allowedAdapterTypes.includes(definition.defaultAdapterType)
) {
throw new Error(`Built-in agent ${definition.key} defaultAdapterType must be allowed`);
}
if (
definition.defaultBudgetMonthlyCents !== undefined
&& (!Number.isInteger(definition.defaultBudgetMonthlyCents) || definition.defaultBudgetMonthlyCents < 0)
@ -509,6 +639,7 @@ export function validateBuiltInAgentDefinitions(definitions: BuiltInAgentDefinit
...definition,
featureKeys: [...definition.featureKeys],
allowedAdapterTypes: definition.allowedAdapterTypes ? [...definition.allowedAdapterTypes] : undefined,
defaultAdapterConfig: definition.defaultAdapterConfig ? { ...definition.defaultAdapterConfig } : undefined,
bundle: definition.bundle ? {
...definition.bundle,
instructions: {
@ -529,7 +660,12 @@ export function validateBuiltInAgentDefinitions(definitions: BuiltInAgentDefinit
}
export function listBuiltInAgentDefinitions() {
return DEFINITIONS.map((definition) => ({ ...definition, featureKeys: [...definition.featureKeys] }));
return DEFINITIONS.map((definition) => ({
...definition,
featureKeys: [...definition.featureKeys],
allowedAdapterTypes: definition.allowedAdapterTypes ? [...definition.allowedAdapterTypes] : undefined,
defaultAdapterConfig: definition.defaultAdapterConfig ? { ...definition.defaultAdapterConfig } : undefined,
}));
}
export function getBuiltInAgentDefinition(key: string) {
@ -543,7 +679,7 @@ export function requireBuiltInAgentDefinition(key: string) {
}
function defaultAdapterType(definition: BuiltInAgentDefinition) {
return definition.allowedAdapterTypes?.[0] ?? "process";
return definition.defaultAdapterType ?? definition.allowedAdapterTypes?.[0] ?? "process";
}
function normalizeAdapterType(value: unknown) {
@ -618,12 +754,33 @@ function definitionPatch(definition: BuiltInAgentDefinition, input: BuiltInAgent
icon: definition.defaultIcon ?? null,
capabilities: definition.shortPurpose,
adapterType,
adapterConfig: input.adapterConfig ?? {},
adapterConfig: input.adapterConfig ?? definition.defaultAdapterConfig ?? {},
permissions: definition.defaultPermissions ?? {},
budgetMonthlyCents: input.budgetMonthlyCents ?? definition.defaultBudgetMonthlyCents ?? 0,
};
}
async function assertKnownBuiltInAgentModel(
definition: BuiltInAgentDefinition,
input: BuiltInAgentProvisionInput,
) {
const adapterType = input.adapterType ?? defaultAdapterType(definition);
const adapterConfig = input.adapterConfig ?? definition.defaultAdapterConfig ?? {};
const model = typeof adapterConfig.model === "string" ? adapterConfig.model.trim() : "";
if (!model || !hasCompleteAdapterConfig(adapterType, adapterConfig)) return;
const models = await listAdapterModels(adapterType);
if (models.length === 0 || models.some((candidate) => candidate.id === model)) return;
throw unprocessable(`Model "${model}" is not available for adapter ${adapterType}.`, {
code: "built_in_agent_model_unknown",
key: definition.key,
adapterType,
model,
availableModelIds: models.map((candidate) => candidate.id),
});
}
function builtInAgentNotConfiguredError(state: BuiltInAgentState) {
return new HttpError(412, `Built-in agent is not configured: ${state.definition.key}`, {
code: "built_in_agent_not_configured",
@ -707,6 +864,13 @@ export function builtInAgentService(db: Db) {
async function defaultProvisionInput(companyId: string, definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput) {
if (input.adapterType || input.adapterConfig) return input;
if (definition.defaultAdapterType || definition.defaultAdapterConfig) {
return {
...input,
adapterType: definition.defaultAdapterType,
adapterConfig: definition.defaultAdapterConfig ? { ...definition.defaultAdapterConfig } : undefined,
};
}
if (!definition.bundle) return input;
const rows = await db
.select({
@ -1412,6 +1576,9 @@ export function builtInAgentService(db: Db) {
const resolvedInput = existingPendingApproval || preserveExistingAdapter
? input
: await defaultProvisionInput(companyId, definition, input);
if (!existingPendingApproval && !preserveExistingAdapter) {
await assertKnownBuiltInAgentModel(definition, resolvedInput);
}
if (existing) {
const patch: Partial<typeof agents.$inferInsert> = {
metadata: builtInMetadata(definition, existing.metadata),
@ -1455,11 +1622,13 @@ export function builtInAgentService(db: Db) {
const created = await agentSvc.create(companyId, {
...definitionPatch(definition, resolvedInput),
status: definition.defaultStatus ?? "idle",
pauseReason: definition.defaultStatus === "paused" ? "Built-in Reflection Coach is disabled until explicitly configured." : null,
pauseReason: definition.defaultStatus === "paused"
? `Built-in ${definition.displayName} is disabled until explicitly configured.`
: null,
pausedAt: definition.defaultStatus === "paused" ? new Date() : null,
reportsTo,
metadata: builtInMetadata(definition),
runtimeConfig: {},
runtimeConfig: definition.defaultRuntimeConfig ?? {},
permissions: definition.defaultPermissions ?? {},
spentMonthlyCents: 0,
lastHeartbeatAt: null,
@ -1495,6 +1664,7 @@ export function builtInAgentService(db: Db) {
if (!company.requireBoardApprovalForNewAgents) {
return { state: await ensure(companyId, key, input), approval: null };
}
await assertKnownBuiltInAgentModel(definition, input);
const existing = await findSingleAgent(companyId, definition);
if (existing) {
@ -1532,7 +1702,7 @@ export function builtInAgentService(db: Db) {
status: "pending_approval",
reportsTo,
metadata: builtInMetadata(definition),
runtimeConfig: {},
runtimeConfig: definition.defaultRuntimeConfig ?? {},
permissions: definition.defaultPermissions ?? {},
spentMonthlyCents: 0,
lastHeartbeatAt: null,

View File

@ -1426,10 +1426,10 @@ function isConfigurationIncompleteFailure(error: unknown): error is Configuratio
return error instanceof ConfigurationIncompleteFailure;
}
function isConfigurationIncompleteFailedRun(
export function isConfigurationIncompleteFailedRun(
run: Pick<typeof heartbeatRuns.$inferSelect, "errorCode"> | null | undefined,
) {
return run?.errorCode === CONFIGURATION_INCOMPLETE_FAILURE_CODE;
return run?.errorCode === CONFIGURATION_INCOMPLETE_FAILURE_CODE || run?.errorCode === "model_not_found";
}
async function hasGitMetadata(cwd: string | null | undefined) {
@ -12351,7 +12351,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const sessionConfigFreshness = resolveTaskSessionConfigFreshness({
hasTaskSession: taskSession != null,
configuredModel,
taskSessionParams: taskSessionDecodedParams,
taskSessionParams: taskSession?.sessionParamsJson ?? taskSessionDecodedParams,
configMetadata: sessionConfigMetadata,
wakeResetReason: wakeSessionResetReason,
preserveLegacySessionWithoutConfigMetadata: acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision,

View File

@ -136,6 +136,7 @@ export { workspaceOperationService } from "./workspace-operations.js";
export { workspaceFileResourceService } from "./workspace-file-resources.js";
export { workProductService } from "./work-products.js";
export { logActivity, type LogActivityInput } from "./activity-log.js";
export { summarySlotService, SUMMARIZER_BUILT_IN_KEY } from "./summary-slots.js";
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
export {

View File

@ -216,6 +216,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
enableSmokeLab: parsed.data.enableSmokeLab ?? false,
enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false,
enableSummaries: parsed.data.enableSummaries ?? false,
enableDecisions: parsed.data.enableDecisions ?? false,
enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false,
enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false,
@ -247,6 +248,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableExternalObjects: false,
enableSmokeLab: false,
enableBuiltInAgents: false,
enableSummaries: false,
enableDecisions: false,
enableGoalsSidebarLink: false,
enableServerInfoDebugView: false,

View File

@ -26,6 +26,7 @@ export function queueIssueAssignmentWakeup(input: {
contextSource: string;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
taskKey?: string | null;
rethrowOnError?: boolean;
}) {
if (!input.issue.assigneeAgentId || input.issue.status === "backlog") return;
@ -35,10 +36,18 @@ export function queueIssueAssignmentWakeup(input: {
source: "assignment",
triggerDetail: "system",
reason: input.reason,
payload: { issueId: input.issue.id, mutation: input.mutation },
payload: {
issueId: input.issue.id,
mutation: input.mutation,
...(input.taskKey ? { taskKey: input.taskKey } : {}),
},
requestedByActorType: input.requestedByActorType,
requestedByActorId: input.requestedByActorId ?? null,
contextSnapshot: { issueId: input.issue.id, source: input.contextSource },
contextSnapshot: {
issueId: input.issue.id,
source: input.contextSource,
...(input.taskKey ? { taskKey: input.taskKey } : {}),
},
})
.catch((err) => {
logger.warn({ err, issueId: input.issue.id }, "failed to wake assignee on issue assignment");

View File

@ -22,6 +22,7 @@ import {
type IssueTreePreviewWarning,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { finalizeSummarySlotsForTerminalIssue } from "./summary-slot-finalization.js";
type IssueRow = typeof issues.$inferSelect;
type HoldRow = typeof issueTreeHolds.$inferSelect;
@ -869,30 +870,43 @@ export function issueTreeControlService(db: Db) {
if (issueIds.length === 0) return { updatedIssueIds: [], updatedIssues: [] };
const now = new Date();
const updated = await db
.update(issues)
.set({
status: "cancelled",
cancelledAt: now,
completedAt: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: now,
})
.where(
and(
eq(issues.companyId, companyId),
inArray(issues.id, issueIds),
notInArray(issues.status, ["done", "cancelled"]),
),
)
.returning({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
});
const updated = await db.transaction(async (tx) => {
const rows = await tx
.update(issues)
.set({
status: "cancelled",
cancelledAt: now,
completedAt: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: now,
})
.where(
and(
eq(issues.companyId, companyId),
inArray(issues.id, issueIds),
notInArray(issues.status, ["done", "cancelled"]),
),
)
.returning({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
});
for (const issue of rows) {
await finalizeSummarySlotsForTerminalIssue(tx, {
...issue,
status: coerceIssueStatus(issue.status),
});
}
return rows;
});
return {
updatedIssueIds: updated.map((issue) => issue.id),

View File

@ -107,6 +107,7 @@ import {
} from "./recovery/origins.js";
import { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recovery/issue-graph-liveness.js";
import { visibleIssueCondition } from "./issue-visibility.js";
import { finalizeSummarySlotsForTerminalIssue } from "./summary-slot-finalization.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
@ -6608,6 +6609,12 @@ export function issueService(db: Db) {
.returning()
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!updated) return null;
if (
(updated.status === "done" || updated.status === "cancelled") &&
existing.status !== updated.status
) {
await finalizeSummarySlotsForTerminalIssue(tx, updated);
}
if (nextLabelIds !== undefined) {
await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx);
}

View File

@ -50,6 +50,7 @@ import { logActivity } from "./activity-log.js";
import { assertAssignableAgent } from "./agent-assignability.js";
import { authorizationService } from "./authorization.js";
import { visibleIssueCondition } from "./issue-visibility.js";
import { finalizeSummarySlotsForTerminalIssue } from "./summary-slot-finalization.js";
import {
formatPipelineCaseOutputContextMarkdown,
pipelineCaseOutputsService,
@ -4610,14 +4611,27 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
? effects.linkedAutomationIssueIds
: [];
if (issueIdsToCancel.length > 0) {
await tx
const cancelledIssues = await tx
.update(issues)
.set({ status: "cancelled", updatedAt: now })
.where(and(
eq(issues.companyId, input.companyId),
inArray(issues.id, issueIdsToCancel),
ne(issues.status, "done"),
));
))
.returning({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
});
for (const issue of cancelledIssues) {
await finalizeSummarySlotsForTerminalIssue(tx, {
...issue,
status: "cancelled",
});
}
await tx
.update(pipelineCaseIssueLinks)
.set({

View File

@ -0,0 +1,44 @@
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { summarySlots } from "@paperclipai/db";
import type { IssueStatus } from "@paperclipai/shared";
const TERMINAL_ISSUE_STATUSES = new Set<IssueStatus>(["done", "cancelled"]);
interface TerminalGenerationIssue {
id: string;
companyId: string;
identifier: string | null;
title: string;
status: IssueStatus;
}
function failureReasonForIssue(issue: TerminalGenerationIssue) {
const label = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title;
return issue.status === "cancelled"
? `Summary generation task ${label} was cancelled before writing a summary.`
: `Summary generation task ${label} finished without writing a summary.`;
}
export async function finalizeSummarySlotsForTerminalIssue(
dbOrTx: Pick<Db, "update">,
issue: TerminalGenerationIssue,
) {
if (!TERMINAL_ISSUE_STATUSES.has(issue.status)) return [];
return dbOrTx
.update(summarySlots)
.set({
status: "failed",
failureReason: failureReasonForIssue(issue),
updatedAt: new Date(),
})
.where(
and(
eq(summarySlots.companyId, issue.companyId),
eq(summarySlots.generatingIssueId, issue.id),
eq(summarySlots.status, "generating"),
),
)
.returning({ id: summarySlots.id });
}

View File

@ -0,0 +1,729 @@
import { and, desc, eq, gte, inArray, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
documentRevisions,
documents,
issues,
projectWorkspaces,
projects,
summarySlots,
} from "@paperclipai/db";
import {
type GenerateSummarySlotResponse,
type GetSummarySlotResponse,
type IssueStatus,
type ListSummarySlotRevisionsResponse,
type SummarySlot,
type SummarySlotDocument,
type SummarySlotIssueRef,
type SummarySlotRevision,
type SummarySlotScopeKind,
type SummarySlotScopeSelector,
summarySlotScopeSelectorSchema,
type WriteSummarySlotResponse,
} from "@paperclipai/shared";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
import { builtInAgentService } from "./built-in-agents.js";
import { agentService } from "./agents.js";
import { issueService } from "./issues.js";
/** Built-in agent key for the Summarizer bundle (see PAP-13920). */
export const SUMMARIZER_BUILT_IN_KEY = "summarizer";
/** Generation issues in these statuses are no longer active and can be superseded. */
const TERMINAL_ISSUE_STATUSES = new Set<IssueStatus>(["done", "cancelled"]);
const DEFAULT_SUMMARY_FORMAT = "markdown";
const SUMMARY_SLOT_REVISION_LIMIT = 20;
const SUMMARY_SNAPSHOT_GROUP_LIMIT = 12;
const SUMMARY_SNAPSHOT_INITIAL_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1_000;
export interface SummarySlotSelectorInput {
companyId: string;
scopeKind: string;
slotKey: string;
scopeId?: string | null;
}
export interface SummaryGenerateActor {
agentId?: string | null;
userId?: string | null;
runId?: string | null;
}
export interface SummaryWriteActor {
agentId?: string | null;
runId?: string | null;
}
type ResolvedSelector = SummarySlotScopeSelector & {
companyId: string;
scopeId: string | null;
};
type SummarySlotRow = typeof summarySlots.$inferSelect;
function mapSlot(row: SummarySlotRow): SummarySlot {
return {
id: row.id,
companyId: row.companyId,
scopeKind: row.scopeKind,
scopeId: row.scopeId ?? null,
slotKey: row.slotKey,
documentId: row.documentId ?? null,
status: row.status,
failureReason: row.failureReason ?? null,
generatingIssueId: row.generatingIssueId ?? null,
lastGeneratedAt: row.lastGeneratedAt ?? null,
lastGeneratedByAgentId: row.lastGeneratedByAgentId ?? null,
lastModel: row.lastModel ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function mapDocument(row: typeof documents.$inferSelect): SummarySlotDocument {
return {
id: row.id,
companyId: row.companyId,
title: row.title ?? null,
format: row.format as SummarySlotDocument["format"],
body: row.latestBody,
latestRevisionId: row.latestRevisionId ?? null,
latestRevisionNumber: row.latestRevisionNumber,
createdByAgentId: row.createdByAgentId ?? null,
createdByUserId: row.createdByUserId ?? null,
updatedByAgentId: row.updatedByAgentId ?? null,
updatedByUserId: row.updatedByUserId ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function mapRevision(row: typeof documentRevisions.$inferSelect): SummarySlotRevision {
return {
id: row.id,
companyId: row.companyId,
documentId: row.documentId,
revisionNumber: row.revisionNumber,
title: row.title ?? null,
format: row.format as SummarySlotRevision["format"],
body: row.body,
changeSummary: row.changeSummary ?? null,
createdByAgentId: row.createdByAgentId ?? null,
createdByUserId: row.createdByUserId ?? null,
createdByRunId: row.createdByRunId ?? null,
createdAt: row.createdAt,
};
}
function scopeLabel(scopeKind: SummarySlotScopeKind): string {
switch (scopeKind) {
case "project":
return "project";
case "project_workspace":
return "workspace";
case "workspaces_overview":
return "workspaces overview";
default:
return "target";
}
}
export function summarySlotService(db: Db) {
const builtIns = builtInAgentService(db);
const agents = agentService(db);
const issuesSvc = issueService(db);
function resolveSelector(input: SummarySlotSelectorInput): ResolvedSelector {
const parsed = summarySlotScopeSelectorSchema.safeParse({
scopeKind: input.scopeKind,
slotKey: input.slotKey,
scopeId: input.scopeId ?? undefined,
});
if (!parsed.success) {
throw unprocessable("Invalid summary slot selector", parsed.error.issues);
}
return {
...parsed.data,
companyId: input.companyId,
scopeId: parsed.data.scopeId ?? null,
};
}
/** Enforce that the scope target exists inside the company boundary. */
async function assertTargetVisible(sel: ResolvedSelector): Promise<void> {
if (sel.scopeKind === "workspaces_overview") return;
if (!sel.scopeId) {
// Guaranteed by the selector schema, but keep the invariant explicit.
throw unprocessable(`${sel.scopeKind} summary slots require scopeId`);
}
if (sel.scopeKind === "project") {
const row = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, sel.scopeId), eq(projects.companyId, sel.companyId)))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Summary target not found");
return;
}
if (sel.scopeKind === "project_workspace") {
const row = await db
.select({ id: projectWorkspaces.id })
.from(projectWorkspaces)
.where(and(eq(projectWorkspaces.id, sel.scopeId), eq(projectWorkspaces.companyId, sel.companyId)))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Summary target not found");
}
}
function findSlotRow(sel: ResolvedSelector) {
return db
.select()
.from(summarySlots)
.where(
and(
eq(summarySlots.companyId, sel.companyId),
eq(summarySlots.scopeKind, sel.scopeKind),
eq(summarySlots.slotKey, sel.slotKey),
sel.scopeId === null ? isNull(summarySlots.scopeId) : eq(summarySlots.scopeId, sel.scopeId),
),
)
.then((rows) => rows[0] ?? null);
}
async function loadDocument(companyId: string, documentId: string | null) {
if (!documentId) return null;
return db
.select()
.from(documents)
.where(and(eq(documents.id, documentId), eq(documents.companyId, companyId)))
.then((rows) => rows[0] ?? null);
}
async function loadIssueRef(companyId: string, issueId: string | null): Promise<{
ref: SummarySlotIssueRef | null;
row: typeof issues.$inferSelect | null;
}> {
if (!issueId) return { ref: null, row: null };
const row = await db
.select()
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
.then((rows) => rows[0] ?? null);
if (!row) return { ref: null, row: null };
return {
row,
ref: {
id: row.id,
identifier: row.identifier ?? null,
title: row.title,
status: row.status as IssueStatus,
assigneeAgentId: row.assigneeAgentId ?? null,
},
};
}
function isIssueActive(row: typeof issues.$inferSelect | null): boolean {
return !!row && !TERMINAL_ISSUE_STATUSES.has(row.status as IssueStatus);
}
async function getSlot(input: SummarySlotSelectorInput): Promise<GetSummarySlotResponse> {
const sel = resolveSelector(input);
await assertTargetVisible(sel);
const slotRow = await findSlotRow(sel);
if (!slotRow) return { slot: null, document: null, generatingIssue: null };
const [documentRow, issueRef] = await Promise.all([
loadDocument(sel.companyId, slotRow.documentId ?? null),
loadIssueRef(sel.companyId, slotRow.generatingIssueId ?? null),
]);
return {
slot: mapSlot(slotRow),
document: documentRow ? mapDocument(documentRow) : null,
generatingIssue: issueRef.ref,
};
}
async function listRevisions(input: SummarySlotSelectorInput): Promise<ListSummarySlotRevisionsResponse> {
const sel = resolveSelector(input);
await assertTargetVisible(sel);
const slotRow = await findSlotRow(sel);
if (!slotRow || !slotRow.documentId) {
return { slot: slotRow ? mapSlot(slotRow) : null, revisions: [] };
}
const revisions = await db
.select()
.from(documentRevisions)
.where(
and(
eq(documentRevisions.documentId, slotRow.documentId),
eq(documentRevisions.companyId, sel.companyId),
),
)
.orderBy(desc(documentRevisions.revisionNumber))
.limit(SUMMARY_SLOT_REVISION_LIMIT);
return { slot: mapSlot(slotRow), revisions: revisions.map(mapRevision) };
}
async function upsertSlot(
sel: ResolvedSelector,
patch: Partial<typeof summarySlots.$inferInsert>,
): Promise<SummarySlotRow> {
const now = new Date();
const [slot] = await db
.insert(summarySlots)
.values({
companyId: sel.companyId,
scopeKind: sel.scopeKind,
scopeId: sel.scopeId,
slotKey: sel.slotKey,
status: "idle",
createdAt: now,
updatedAt: now,
...patch,
})
.onConflictDoUpdate({
target: [
summarySlots.companyId,
summarySlots.scopeKind,
summarySlots.scopeId,
summarySlots.slotKey,
],
set: { ...patch, updatedAt: now },
})
.returning();
return slot;
}
async function resolveGenerationTargetProject(sel: ResolvedSelector): Promise<{
projectId: string | null;
projectWorkspaceId: string | null;
}> {
if (sel.scopeKind === "project") {
return { projectId: sel.scopeId, projectWorkspaceId: null };
}
if (sel.scopeKind === "project_workspace" && sel.scopeId) {
const row = await db
.select({ projectId: projectWorkspaces.projectId })
.from(projectWorkspaces)
.where(and(eq(projectWorkspaces.id, sel.scopeId), eq(projectWorkspaces.companyId, sel.companyId)))
.then((rows) => rows[0] ?? null);
return { projectId: row?.projectId ?? null, projectWorkspaceId: sel.scopeId };
}
return { projectId: null, projectWorkspaceId: null };
}
function scopeIssueConditions(sel: ResolvedSelector) {
if (sel.scopeKind === "project") return [eq(issues.projectId, sel.scopeId!)];
if (sel.scopeKind === "project_workspace") return [eq(issues.projectWorkspaceId, sel.scopeId!)];
return [];
}
async function buildScopeSnapshot(sel: ResolvedSelector, previousGeneratedAt: Date | null): Promise<string> {
const commonConditions = [
eq(issues.companyId, sel.companyId),
isNull(issues.hiddenAt),
...scopeIssueConditions(sel),
];
const recentlyDoneSince = previousGeneratedAt
?? new Date(Date.now() - SUMMARY_SNAPSHOT_INITIAL_LOOKBACK_MS);
const selectFields = {
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
updatedAt: issues.updatedAt,
};
const [blocked, inReview, inProgress, recentlyDone] = await Promise.all([
db.select(selectFields).from(issues)
.where(and(...commonConditions, eq(issues.status, "blocked")))
.orderBy(desc(issues.updatedAt)).limit(SUMMARY_SNAPSHOT_GROUP_LIMIT),
db.select(selectFields).from(issues)
.where(and(...commonConditions, eq(issues.status, "in_review")))
.orderBy(desc(issues.updatedAt)).limit(SUMMARY_SNAPSHOT_GROUP_LIMIT),
db.select(selectFields).from(issues)
.where(and(...commonConditions, eq(issues.status, "in_progress")))
.orderBy(desc(issues.updatedAt)).limit(SUMMARY_SNAPSHOT_GROUP_LIMIT),
db.select(selectFields).from(issues)
.where(and(
...commonConditions,
inArray(issues.status, ["done"]),
gte(issues.updatedAt, recentlyDoneSince),
))
.orderBy(desc(issues.updatedAt)).limit(SUMMARY_SNAPSHOT_GROUP_LIMIT),
]);
const formatGroup = (
heading: string,
rows: Array<typeof blocked[number]>,
) => [
`### ${heading}`,
...(rows.length > 0
? rows.map((row) => {
const identifier = row.identifier ?? "Unnumbered issue";
const companyPrefix = row.identifier?.split("-", 1)[0];
const issueLink = companyPrefix
? `[${identifier}](/${companyPrefix}/issues/${identifier})`
: identifier;
return `- ${issueLink}${row.title} (${row.priority}; updated ${row.updatedAt.toISOString()})`;
})
: ["- None."]),
];
return [
"## Prebuilt scope snapshot",
"",
`Snapshot generated at ${new Date().toISOString()}. Recently done means updated since ${recentlyDoneSince.toISOString()}.`,
"Use this bounded, company-scoped snapshot as the issue source of truth for this run. Do not call issue-list endpoints.",
"",
...formatGroup("Blocked", blocked),
"",
...formatGroup("In review", inReview),
"",
...formatGroup("In progress", inProgress),
"",
...formatGroup("Recently done", recentlyDone),
].join("\n");
}
function generationIssueDescription(
sel: ResolvedSelector,
scopeSnapshot: string,
generationIssueId: string | null = null,
): string {
const target = sel.scopeId ? `\`${sel.scopeId}\`` : "the workspaces overview";
const summarySlotPath = `/api/companies/${encodeURIComponent(sel.companyId)}/summary-slots/${encodeURIComponent(sel.scopeKind)}/${encodeURIComponent(sel.slotKey)}`;
const scopeQuery = sel.scopeId ? `?scopeId=${encodeURIComponent(sel.scopeId)}` : "";
return [
`Generate the ${scopeLabel(sel.scopeKind)} summary for ${target}.`,
"",
"Call `/summarize-status`. Its API quick reference has the full request shapes; use these resolved routes for this generation:",
"",
`- Read current slot: \`GET ${summarySlotPath}${scopeQuery}\``,
`- Write revision: \`PUT ${summarySlotPath}\``,
"",
"Use this write payload:",
"",
"```json",
JSON.stringify(
{
scopeKind: sel.scopeKind,
scopeId: sel.scopeId,
slotKey: sel.slotKey,
generationIssueId,
},
null,
2,
),
"```",
"",
"Write one short, colloquial Markdown summary that opens with a `**Decide:**` block: at most two bullets, each giving the decision's context, a link, and an `**I suggest:**` recommendation, then one or two plain-prose paragraphs on the (max two) things that matter most. If nothing needs a decision, open with one `**Nothing to decide right now.**` line followed by a `**Review:**` block (at most two bullets) that triages what is waiting on review — what the reader can approve on a skim vs what needs their eyes — each with a link and an `**I suggest:**` recommendation; if nothing is in review either, one clause naming the next event worth watching. End the summary with a `**Recent work:**` block: at most two bullets, one line each, naming a recent piece of work and where it stands in plain language. Reference at most three or four issues inline; never a trailing list of issue links or any link dump. Not a task list.",
"The current-slot response includes the latest document body and `latestRevisionId`; do not call the revisions or issues-list endpoints.",
"Follow the skill's streaming protocol: emit the first plain-text `STATUS:` line immediately — named from the first task in the snapshot, before any analysis — keep emitting `STATUS:` lines as you think, and emit the sentinel-wrapped summary draft before the authoritative summary-slot write.",
"Pass the `generationIssueId` from the payload, the previous revision id when present, and the model actually used to the summary-slot write API.",
"",
scopeSnapshot,
"",
"Close this task with a short comment once the summary revision is written.",
].join("\n");
}
function generationIssueTitle(sel: ResolvedSelector, createdAt = new Date()): string {
const timestamp = createdAt.toISOString().replace("T", " ").replace(/:\d{2}\.\d{3}Z$/, " UTC");
return `Summarize ${scopeLabel(sel.scopeKind)} on ${timestamp}`;
}
async function generate(
input: SummarySlotSelectorInput,
actor: SummaryGenerateActor,
): Promise<GenerateSummarySlotResponse> {
const sel = resolveSelector(input);
await assertTargetVisible(sel);
const builtIn = await builtIns.get(sel.companyId, SUMMARIZER_BUILT_IN_KEY);
if (builtIn.status !== "ready" || !builtIn.agentId) {
throw unprocessable("Summarizer built-in agent is not configured", {
code: "summarizer_not_configured",
status: builtIn.status,
});
}
const summarizerAgentId = builtIn.agentId;
// Dedupe: if a generation is already active, return the in-flight state.
const existing = await findSlotRow(sel);
if (existing && existing.status === "generating" && existing.generatingIssueId) {
const active = await loadIssueRef(sel.companyId, existing.generatingIssueId);
if (isIssueActive(active.row)) {
return {
slot: mapSlot(existing),
generatingIssue: active.ref!,
alreadyGenerating: true,
};
}
}
const { projectId, projectWorkspaceId } = await resolveGenerationTargetProject(sel);
const scopeSnapshot = await buildScopeSnapshot(sel, existing?.lastGeneratedAt ?? null);
const createdAt = new Date();
const generationVersion = existing?.generatingIssueId ?? existing?.updatedAt.toISOString() ?? "initial";
let issueDeduplicated = false;
const created = await issuesSvc.create(sel.companyId, {
projectId,
projectWorkspaceId,
title: generationIssueTitle(sel, createdAt),
description: generationIssueDescription(sel, scopeSnapshot),
status: "todo",
priority: "medium",
assigneeAgentId: summarizerAgentId,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
hiddenAt: createdAt,
idempotencyKey: [
"summary-slot-generation",
sel.scopeKind,
sel.scopeId ?? "global",
sel.slotKey,
generationVersion,
].join(":"),
onDeduplicated: (reason) => {
issueDeduplicated = reason === "idempotency_key";
},
});
const generationIssue = (
await issuesSvc.update(created.id, {
description: generationIssueDescription(sel, scopeSnapshot, created.id),
})
) ?? created;
const slotRow = await upsertSlot(sel, {
status: "generating",
failureReason: null,
generatingIssueId: generationIssue.id,
});
return {
slot: mapSlot(slotRow),
generatingIssue: {
id: generationIssue.id,
identifier: generationIssue.identifier ?? null,
title: generationIssue.title,
status: generationIssue.status as IssueStatus,
assigneeAgentId: generationIssue.assigneeAgentId ?? null,
},
alreadyGenerating: issueDeduplicated,
};
}
async function assertSummarizerWriter(
sel: ResolvedSelector,
slotRow: SummarySlotRow | null,
input: { generationIssueId?: string | null },
actor: SummaryWriteActor,
): Promise<void> {
if (!actor.agentId) {
throw forbidden("Only the Summarizer built-in agent may write summaries");
}
const agent = await agents.getById(actor.agentId);
if (!agent || agent.companyId !== sel.companyId) {
throw forbidden("Only the Summarizer built-in agent may write summaries");
}
const marker = readBuiltInAgentMarker(agent.metadata);
if (marker?.key !== SUMMARIZER_BUILT_IN_KEY) {
throw forbidden("Only the Summarizer built-in agent may write summaries");
}
// The write must originate from the linked, in-flight generation task.
const generationIssueId = input.generationIssueId ?? null;
if (!generationIssueId) {
throw forbidden("Summary writes must identify the active generation task");
}
if (!slotRow?.generatingIssueId || slotRow.generatingIssueId !== generationIssueId) {
throw forbidden("Summary write does not match the active generation task");
}
const issueRef = await loadIssueRef(sel.companyId, generationIssueId);
if (!issueRef.row) {
throw forbidden("Linked generation task not found");
}
const payloadMatch = issueRef.row.description?.match(/```json\n([\s\S]*?)\n```/);
let payload: Record<string, unknown> | null = null;
try {
payload = payloadMatch ? (JSON.parse(payloadMatch[1]) as Record<string, unknown>) : null;
} catch {
payload = null;
}
if (
payload?.generationIssueId !== generationIssueId ||
payload.scopeKind !== sel.scopeKind ||
(payload.scopeId ?? null) !== sel.scopeId ||
payload.slotKey !== sel.slotKey
) {
throw forbidden("Generation task does not target this summary slot");
}
if (issueRef.row.assigneeAgentId !== actor.agentId) {
throw forbidden("Generation task is not assigned to this agent");
}
const runId = actor.runId ?? null;
const runMatches =
!!runId && (issueRef.row.checkoutRunId === runId || issueRef.row.executionRunId === runId);
if (!runMatches) {
throw forbidden("Summary write must run from the linked generation task");
}
}
async function write(
input: SummarySlotSelectorInput & {
markdown: string;
title?: string | null;
changeSummary?: string | null;
baseRevisionId?: string | null;
generationIssueId?: string | null;
model?: string | null;
},
actor: SummaryWriteActor,
): Promise<WriteSummarySlotResponse> {
const sel = resolveSelector(input);
await assertTargetVisible(sel);
const slotRow = await findSlotRow(sel);
await assertSummarizerWriter(sel, slotRow, input, actor);
const now = new Date();
const result = await db.transaction(async (tx) => {
const currentSlot = slotRow
? await tx
.select()
.from(summarySlots)
.where(eq(summarySlots.id, slotRow.id))
.then((rows) => rows[0] ?? null)
: null;
if (!currentSlot || currentSlot.generatingIssueId !== input.generationIssueId) {
throw conflict("Summary generation was superseded by a newer task");
}
let documentRow: typeof documents.$inferSelect;
let revisionRow: typeof documentRevisions.$inferSelect;
const existingDocument = currentSlot?.documentId
? await tx
.select()
.from(documents)
.where(and(eq(documents.id, currentSlot.documentId), eq(documents.companyId, sel.companyId)))
.then((rows) => rows[0] ?? null)
: null;
if (existingDocument) {
if (input.baseRevisionId && input.baseRevisionId !== existingDocument.latestRevisionId) {
throw conflict("Summary was updated by someone else", {
currentRevisionId: existingDocument.latestRevisionId,
});
}
const nextRevisionNumber = existingDocument.latestRevisionNumber + 1;
[revisionRow] = await tx
.insert(documentRevisions)
.values({
companyId: sel.companyId,
documentId: existingDocument.id,
revisionNumber: nextRevisionNumber,
title: input.title ?? null,
format: DEFAULT_SUMMARY_FORMAT,
body: input.markdown,
changeSummary: input.changeSummary ?? null,
createdByAgentId: actor.agentId ?? null,
createdByRunId: actor.runId ?? null,
createdAt: now,
})
.returning();
[documentRow] = await tx
.update(documents)
.set({
title: input.title ?? null,
format: DEFAULT_SUMMARY_FORMAT,
latestBody: input.markdown,
latestRevisionId: revisionRow.id,
latestRevisionNumber: nextRevisionNumber,
updatedByAgentId: actor.agentId ?? null,
updatedAt: now,
})
.where(eq(documents.id, existingDocument.id))
.returning();
} else {
[documentRow] = await tx
.insert(documents)
.values({
companyId: sel.companyId,
title: input.title ?? null,
format: DEFAULT_SUMMARY_FORMAT,
latestBody: input.markdown,
latestRevisionId: null,
latestRevisionNumber: 1,
createdByAgentId: actor.agentId ?? null,
updatedByAgentId: actor.agentId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
[revisionRow] = await tx
.insert(documentRevisions)
.values({
companyId: sel.companyId,
documentId: documentRow.id,
revisionNumber: 1,
title: input.title ?? null,
format: DEFAULT_SUMMARY_FORMAT,
body: input.markdown,
changeSummary: input.changeSummary ?? null,
createdByAgentId: actor.agentId ?? null,
createdByRunId: actor.runId ?? null,
createdAt: now,
})
.returning();
[documentRow] = await tx
.update(documents)
.set({ latestRevisionId: revisionRow.id })
.where(eq(documents.id, documentRow.id))
.returning();
}
const slotPatch = {
documentId: documentRow.id,
status: "idle" as const,
failureReason: null,
generatingIssueId: null,
lastGeneratedAt: now,
lastGeneratedByAgentId: actor.agentId ?? null,
lastModel: input.model ?? null,
updatedAt: now,
};
const [nextSlot] = await tx
.update(summarySlots)
.set(slotPatch)
.where(
and(
eq(summarySlots.id, currentSlot.id),
eq(summarySlots.generatingIssueId, input.generationIssueId!),
),
)
.returning();
if (!nextSlot) {
throw conflict("Summary generation was superseded by a newer task");
}
return { slot: nextSlot, document: documentRow, revision: revisionRow };
});
return {
slot: mapSlot(result.slot),
document: mapDocument(result.document),
revision: mapRevision(result.revision),
};
}
return {
getSlot,
listRevisions,
generate,
write,
};
}

View File

@ -51,6 +51,8 @@ export interface BuiltInAgentDefinition {
defaultInstructions: string;
defaultRole: string;
allowedAdapterTypes?: string[];
defaultAdapterType?: string;
defaultAdapterConfig?: Record<string, unknown>;
defaultBudgetMonthlyCents?: number;
bundle?: BuiltInAgentBundleMeta;
}

View File

@ -0,0 +1,38 @@
import type {
GenerateSummarySlotResponse,
GetSummarySlotResponse,
ListSummarySlotRevisionsResponse,
SummarySlotKey,
SummarySlotScopeKind,
} from "@paperclipai/shared";
import { api } from "./client";
export interface SummarySlotSelector {
companyId: string;
scopeKind: SummarySlotScopeKind;
scopeId?: string | null;
slotKey: SummarySlotKey;
}
function summarySlotPath(selector: SummarySlotSelector, suffix = "") {
const params = new URLSearchParams();
if (selector.scopeId) params.set("scopeId", selector.scopeId);
const query = params.toString();
return [
`/companies/${selector.companyId}/summary-slots/${selector.scopeKind}/${selector.slotKey}`,
suffix,
query ? `?${query}` : "",
].join("");
}
export const summarySlotsApi = {
get: (selector: SummarySlotSelector) =>
api.get<GetSummarySlotResponse>(summarySlotPath(selector)),
revisions: (selector: SummarySlotSelector) =>
api.get<ListSummarySlotRevisionsResponse>(summarySlotPath(selector, "/revisions")),
generate: (selector: SummarySlotSelector) =>
api.post<GenerateSummarySlotResponse>(
summarySlotPath(selector, "/generate"),
{ scopeId: selector.scopeId ?? null },
),
};

View File

@ -3,33 +3,6 @@ import { cn } from "@/lib/utils";
import { brandChipBadge } from "@/lib/status-colors";
import type { BuiltInAgentStatus } from "@/api/builtInAgents";
/**
* Provenance label ("Built-in"). Constant for the life of a built-in agent
* this is NOT a lifecycle/status chip, so it never routes through
* `StatusBadge`/`AgentStatusBadge` (ux-spec D2).
*/
export function BuiltInAgentBadge({
className,
compact = false,
}: {
className?: string;
compact?: boolean;
}) {
return (
<Badge
variant="outline"
className={cn(
brandChipBadge.blue,
compact && "px-1.5 py-0 text-(length:--text-nano)",
className,
)}
title="Ships with Paperclip"
>
Built-in
</Badge>
);
}
/**
* Derived lifecycle chip. Rendered for the amber attention states
* (`needs_setup`, `pending_approval`). Kept separate from the real agent status

View File

@ -161,6 +161,55 @@ describe("ConfigureBuiltInAgentModal (PAP-12978)", () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("prefills a built-in's default adapter and model", async () => {
provisionMock.mockResolvedValue({ ...makeState(), status: "ready", agentId: "a1" });
await renderModal(makeState({
definition: {
...makeState().definition,
defaultAdapterType: "claude_local",
defaultAdapterConfig: { model: "claude-haiku-4-5" },
},
}));
expect(document.body.querySelector('[data-testid="adapter-dropdown"]')?.getAttribute("data-value"))
.toBe("claude_local");
expect(document.body.querySelector<HTMLInputElement>('[data-testid="model-input"]')?.value)
.toBe("claude-haiku-4-5");
const submit = findButton("Configure")!;
expect(submit.disabled).toBe(false);
flushSync(() => {
submit.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(provisionMock).toHaveBeenCalledWith("c1", "briefs", {
adapterType: "claude_local",
adapterConfig: { model: "claude-haiku-4-5" },
});
});
it("shows a visible error and blocks provisioning for an unknown model", async () => {
adapterModelsMock.mockResolvedValue([
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
]);
await renderModal(makeState({
definition: {
...makeState().definition,
defaultAdapterType: "claude_local",
defaultAdapterConfig: { model: "claude-haiku-4-6" },
},
}));
await flushReact();
expect(document.body.querySelector('[role="alert"]')?.textContent)
.toContain("claude-haiku-4-6");
expect(document.body.querySelector('[role="alert"]')?.textContent)
.toContain("not available");
expect(findButton("Configure")?.disabled).toBe(true);
expect(provisionMock).not.toHaveBeenCalled();
});
it("sends the budget with provisioning so approval-gated setup preserves it", async () => {
provisionMock.mockResolvedValue({
...makeState(),

View File

@ -29,7 +29,7 @@ function isModelBasedAdapter(adapterType: string): boolean {
}
function defaultAdapterType(state: BuiltInAgentState): string {
return state.definition.allowedAdapterTypes?.[0] ?? "codex_local";
return state.definition.defaultAdapterType ?? state.definition.allowedAdapterTypes?.[0] ?? "codex_local";
}
function parseBudgetMonthlyCents(value: string): number | undefined {
@ -68,9 +68,12 @@ export function ConfigureBuiltInAgentModal({
);
const [model, setModel] = useState<string>(() => {
const config = state.agent?.adapterConfig;
return typeof config === "object" && config !== null && typeof (config as Record<string, unknown>).model === "string"
? ((config as Record<string, unknown>).model as string)
: "";
const configuredModel = typeof config === "object" && config !== null
? (config as Record<string, unknown>).model
: null;
if (typeof configuredModel === "string") return configuredModel;
const defaultModel = state.definition.defaultAdapterConfig?.model;
return typeof defaultModel === "string" ? defaultModel : "";
});
const [modelOpen, setModelOpen] = useState(false);
const [budgetDollars, setBudgetDollars] = useState<string>(() => {
@ -101,9 +104,20 @@ export function ConfigureBuiltInAgentModal({
const models = fetchedModels ?? [];
const modelRequired = setupSupportedInModal;
const normalizedModel = model.trim();
const modelKnown =
!normalizedModel ||
models.length === 0 ||
models.some((candidate) => candidate.id === normalizedModel);
const modelError = modelKnown
? null
: `Model “${normalizedModel}” is not available for ${adapterType}. Choose a known model.`;
const budgetMonthlyCents = parseBudgetMonthlyCents(budgetDollars);
const budgetValid = !budgetDollars.trim() || budgetMonthlyCents !== undefined;
const canSubmit = budgetValid && (setupSupportedInModal ? !modelRequired || model.trim().length > 0 : true);
const canSubmit =
budgetValid &&
modelKnown &&
(setupSupportedInModal ? !modelRequired || normalizedModel.length > 0 : true);
const submitLabel = setupSupportedInModal
? `Configure & enable ${definition.displayName}`
: `Provision ${definition.displayName}`;
@ -174,6 +188,12 @@ export function ConfigureBuiltInAgentModal({
/>
)}
{modelError && (
<p className="text-sm text-destructive" role="alert">
{modelError}
</p>
)}
{!setupSupportedInModal && (
<InlineBanner tone="warning" compact>
This adapter needs command or endpoint fields before it can run. Provision the

View File

@ -1,5 +1,5 @@
import type { LucideIcon } from "lucide-react";
import { Info, AlertTriangle } from "lucide-react";
import { AlertCircle, AlertTriangle, Info } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
@ -8,10 +8,11 @@ import { brandBanner, type BannerTone } from "@/lib/status-colors";
const TONE_ICON: Record<BannerTone, LucideIcon> = {
info: Info,
warning: AlertTriangle,
danger: AlertCircle,
};
export interface InlineBannerProps {
/** Visual tone. `info` (blue) for provenance/context, `warning` (amber) for paused/attention. */
/** Visual tone. `info` for context, `warning` for attention, `danger` for failures. */
tone?: BannerTone;
/** Optional bold heading rendered above the body. */
title?: ReactNode;

View File

@ -19,7 +19,7 @@ import { useSidebar } from "../context/SidebarContext";
import { useToastActions } from "../context/ToastContext";
import { agentsApi } from "../api/agents";
import { builtInAgentsApi, type BuiltInAgentStatus } from "../api/builtInAgents";
import { BuiltInAgentBadge, BuiltInLifecycleChip } from "./BuiltInAgentBadges";
import { BuiltInLifecycleChip } from "./BuiltInAgentBadges";
import { authApi } from "../api/auth";
import { heartbeatsApi } from "../api/heartbeats";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
@ -153,8 +153,9 @@ function SidebarAgentItem({
: isPaused && hasInvalidOrgChain
? "Invalid org chain"
: pauseResumeLabel;
const showBuiltInLifecycle = builtInStatus === "needs_setup" || builtInStatus === "pending_approval";
const trailingLabel = [
builtInStatus ? `Built-in agent ${builtInStatus.replace(/_/g, " ")}` : null,
showBuiltInLifecycle ? `Built-in agent ${builtInStatus.replace(/_/g, " ")}` : null,
hasInvalidOrgChain ? "Invalid reporting chain" : null,
].filter(Boolean).join(", ") || undefined;
@ -167,7 +168,7 @@ function SidebarAgentItem({
iconNode={<AgentIcon icon={agent.icon} className="shrink-0 h-4 w-4" />}
active={isActive}
liveCount={runCount}
labelClassName={builtInStatus ? "min-w-(--sz-4_5rem) flex-initial" : undefined}
labelClassName={showBuiltInLifecycle ? "min-w-(--sz-4_5rem) flex-initial" : undefined}
className={cn(
"min-w-0 flex-1",
// Reserve room for the hover ⋯ menu; starred rows widen it for the
@ -175,14 +176,9 @@ function SidebarAgentItem({
starred && !isMobile ? "pr-14" : "pr-8",
)}
trailing={
builtInStatus || hasInvalidOrgChain ? (
showBuiltInLifecycle || hasInvalidOrgChain ? (
<span className="ml-1 flex shrink-0 items-center gap-1">
{builtInStatus ? (
<>
<BuiltInAgentBadge compact />
<BuiltInLifecycleChip status={builtInStatus} compact />
</>
) : null}
{showBuiltInLifecycle ? <BuiltInLifecycleChip status={builtInStatus} compact /> : null}
{hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
) : null}
@ -339,7 +335,7 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!selectedCompanyId,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
// Event-sourced via LiveUpdatesProvider (issue 9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});

View File

@ -0,0 +1,251 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { BuiltInAgentState } from "@/api/builtInAgents";
import type {
CompanyLiveEventHandler,
} from "@/context/LiveUpdatesProvider";
import type {
GetSummarySlotResponse,
ListSummarySlotRevisionsResponse,
LiveEvent,
SummarySlot,
SummarySlotIssueRef,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __liveUpdatesTestUtils } from "@/context/LiveUpdatesProvider";
import { SummarySlotCard, resolveGenerationStatusLine } from "./SummarySlotCard";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const { LiveEventSubscriptionContext, dispatchLiveEventToSubscribers } = __liveUpdatesTestUtils;
const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn() }));
const mockSummarySlotsApi = vi.hoisted(() => ({
get: vi.fn(),
revisions: vi.fn(),
generate: vi.fn(),
}));
const mockBuiltInAgentsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockAgentsApi = vi.hoisted(() => ({ resume: vi.fn() }));
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
vi.mock("@/api/summarySlots", () => ({ summarySlotsApi: mockSummarySlotsApi }));
vi.mock("@/api/builtInAgents", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/builtInAgents")>()),
builtInAgentsApi: mockBuiltInAgentsApi,
}));
vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
}));
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <div data-testid="markdown-body">{children}</div>,
}));
vi.mock("@/components/ConfigureBuiltInAgentModal", () => ({
ConfigureBuiltInAgentModal: () => null,
}));
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushQueries() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
function readySummarizer(): BuiltInAgentState {
return {
definition: {
key: "summarizer",
displayName: "Summarizer",
featureKeys: ["summarizer"],
shortPurpose: "Writes summaries",
defaultInstructions: "Summarize",
defaultRole: "Summarizer",
},
status: "ready",
agentId: "agent-summarizer",
agent: null,
pauseReason: null,
resources: [],
};
}
function slot(overrides: Partial<SummarySlot> = {}): SummarySlot {
return {
id: "slot-1",
companyId: "company-1",
scopeKind: "project",
scopeId: "project-1",
slotKey: "header",
documentId: null,
status: "generating",
failureReason: null,
generatingIssueId: "issue-1",
lastGeneratedAt: null,
lastGeneratedByAgentId: null,
lastModel: null,
createdAt: "2026-07-14T00:00:00.000Z",
updatedAt: "2026-07-14T00:00:00.000Z",
...overrides,
};
}
function issue(overrides: Partial<SummarySlotIssueRef> = {}): SummarySlotIssueRef {
return {
id: "issue-1",
identifier: "PAP-14000",
title: "Summarize project",
status: "in_progress",
...overrides,
};
}
function progressEvent(payload: Record<string, unknown>): LiveEvent {
return {
id: 1,
companyId: "company-1",
type: "heartbeat.run.progress",
createdAt: "2026-07-15T00:00:00.000Z",
payload,
};
}
function renderCard(container: HTMLDivElement, subscribers: Set<CompanyLiveEventHandler>) {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const subscription = {
subscribe: (fn: CompanyLiveEventHandler) => {
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
},
};
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<LiveEventSubscriptionContext.Provider value={subscription}>
<SummarySlotCard
companyId="company-1"
scopeKind="project"
scopeId="project-1"
title="Project summary"
/>
</LiveEventSubscriptionContext.Provider>
</QueryClientProvider>,
);
});
return root;
}
describe("resolveGenerationStatusLine", () => {
it("prefers the server status message", () => {
expect(
resolveGenerationStatusLine({
message: "reviewing 14 open issues",
currentToolName: "Read",
lastAssistantSnippet: "thinking about the launch",
}),
).toBe("reviewing 14 open issues");
});
it("falls back to the assistant snippet, then the tool name", () => {
expect(
resolveGenerationStatusLine({ message: null, currentToolName: "Grep", lastAssistantSnippet: "drafting" }),
).toBe("drafting");
expect(
resolveGenerationStatusLine({ message: null, currentToolName: "Grep", lastAssistantSnippet: null }),
).toBe("Working with Grep");
});
it("returns null when nothing has streamed", () => {
expect(resolveGenerationStatusLine(null)).toBeNull();
expect(
resolveGenerationStatusLine({ message: null, currentToolName: null, lastAssistantSnippet: null }),
).toBeNull();
});
});
describe("SummarySlotCard live status line", () => {
let root: Root | null = null;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableSummaries: true });
mockBuiltInAgentsApi.list.mockResolvedValue([readySummarizer()]);
mockSummarySlotsApi.get.mockResolvedValue({
slot: slot(),
document: null,
generatingIssue: issue(),
} satisfies GetSummarySlotResponse);
mockSummarySlotsApi.revisions.mockResolvedValue({ slot: slot(), revisions: [] } satisfies ListSummarySlotRevisionsResponse);
mockSummarySlotsApi.generate.mockResolvedValue({
slot: slot(),
generatingIssue: issue(),
alreadyGenerating: false,
});
});
afterEach(async () => {
await act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
});
it("renders a live status line from a matching progress event", async () => {
const subscribers = new Set<CompanyLiveEventHandler>();
root = renderCard(container, subscribers);
await flushQueries();
// Falls back to the static generating copy before any event arrives.
expect(container.querySelector('[data-testid="summary-generation-status-line"]')).toBeNull();
expect(container.textContent).toContain("Summarizer is working in");
await act(async () => {
dispatchLiveEventToSubscribers(
subscribers,
"company-1",
progressEvent({ issueId: "issue-1", message: "reviewing 14 open issues" }),
);
});
await flushQueries();
const statusLine = container.querySelector('[data-testid="summary-generation-status-line"]');
expect(statusLine).not.toBeNull();
expect(statusLine?.textContent).toBe("reviewing 14 open issues");
});
it("ignores progress events for a different generating issue", async () => {
const subscribers = new Set<CompanyLiveEventHandler>();
root = renderCard(container, subscribers);
await flushQueries();
await act(async () => {
dispatchLiveEventToSubscribers(
subscribers,
"company-1",
progressEvent({ issueId: "issue-999", message: "unrelated run" }),
);
});
await flushQueries();
expect(container.querySelector('[data-testid="summary-generation-status-line"]')).toBeNull();
expect(container.textContent).not.toContain("unrelated run");
});
});

View File

@ -0,0 +1,516 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { BuiltInAgentState } from "@/api/builtInAgents";
import type {
GetSummarySlotResponse,
ListSummarySlotRevisionsResponse,
SummarySlot,
SummarySlotDocument,
SummarySlotIssueRef,
SummarySlotRevision,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SummarySlotCard } from "./SummarySlotCard";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
if (!HTMLElement.prototype.hasPointerCapture) {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: () => false,
});
}
const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn() }));
const mockSummarySlotsApi = vi.hoisted(() => ({
get: vi.fn(),
revisions: vi.fn(),
generate: vi.fn(),
}));
const mockBuiltInAgentsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockAgentsApi = vi.hoisted(() => ({ resume: vi.fn() }));
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
vi.mock("@/api/summarySlots", () => ({ summarySlotsApi: mockSummarySlotsApi }));
vi.mock("@/api/builtInAgents", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/builtInAgents")>()),
builtInAgentsApi: mockBuiltInAgentsApi,
}));
vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
}));
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <div data-testid="markdown-body">{children}</div>,
}));
vi.mock("@/components/ConfigureBuiltInAgentModal", () => ({
ConfigureBuiltInAgentModal: ({
open,
onConfigured,
}: {
open: boolean;
onConfigured?: () => void;
}) => (open ? (
<div data-testid="configure-modal">
<button type="button" onClick={onConfigured}>Finish setup</button>
</div>
) : null),
}));
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushQueries() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
async function openRevisionSelect(container: HTMLElement) {
const trigger = container.querySelector<HTMLElement>('button[aria-label="Select summary revision"]');
expect(trigger).not.toBeNull();
await act(async () => {
trigger!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushQueries();
return trigger!;
}
async function chooseRevisionOption(labelPart: string) {
const option = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find(
(element) => element.textContent?.includes(labelPart),
);
expect(option).toBeTruthy();
await act(async () => {
option!.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, button: 0 }));
option!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushQueries();
}
function readySummarizer(): BuiltInAgentState {
return {
definition: {
key: "summarizer",
displayName: "Summarizer",
featureKeys: ["summarizer"],
shortPurpose: "Writes summaries",
defaultInstructions: "Summarize",
defaultRole: "Summarizer",
},
status: "ready",
agentId: "agent-summarizer",
agent: null,
pauseReason: null,
resources: [],
};
}
function needsSetupSummarizer(): BuiltInAgentState {
return {
...readySummarizer(),
status: "needs_setup",
agentId: "agent-summarizer",
};
}
function pausedSummarizer(): BuiltInAgentState {
return {
...readySummarizer(),
status: "paused",
agent: { id: "agent-summarizer" } as BuiltInAgentState["agent"],
};
}
function slot(overrides: Partial<SummarySlot> = {}): SummarySlot {
return {
id: "slot-1",
companyId: "company-1",
scopeKind: "project",
scopeId: "project-1",
slotKey: "header",
documentId: null,
status: "idle",
failureReason: null,
generatingIssueId: null,
lastGeneratedAt: null,
lastGeneratedByAgentId: null,
lastModel: null,
createdAt: "2026-07-14T00:00:00.000Z",
updatedAt: "2026-07-14T00:00:00.000Z",
...overrides,
};
}
function summaryDocument(overrides: Partial<SummarySlotDocument> = {}): SummarySlotDocument {
return {
id: "doc-1",
companyId: "company-1",
title: "Project summary",
format: "markdown",
body: "## Needs you\nLatest body",
latestRevisionId: "rev-2",
latestRevisionNumber: 2,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: "agent-summarizer",
updatedByUserId: null,
createdAt: "2026-07-13T00:00:00.000Z",
updatedAt: "2026-07-14T00:00:00.000Z",
...overrides,
};
}
function issue(overrides: Partial<SummarySlotIssueRef> = {}): SummarySlotIssueRef {
return {
id: "issue-1",
identifier: "PAP-14000",
title: "Summarize project",
status: "todo",
...overrides,
};
}
function revision(overrides: Partial<SummarySlotRevision> = {}): SummarySlotRevision {
return {
id: "rev-1",
companyId: "company-1",
documentId: "doc-1",
revisionNumber: 1,
title: "Project summary",
format: "markdown",
body: "## Old\nOld body",
changeSummary: null,
createdByAgentId: "agent-summarizer",
createdByUserId: null,
createdByRunId: null,
createdAt: "2026-07-13T00:00:00.000Z",
...overrides,
};
}
function renderCard(container: HTMLDivElement) {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SummarySlotCard
companyId="company-1"
scopeKind="project"
scopeId="project-1"
title="Project summary"
description="Project status at a glance."
/>
</QueryClientProvider>,
);
});
return root;
}
describe("SummarySlotCard", () => {
let root: Root | null = null;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableSummaries: true });
mockBuiltInAgentsApi.list.mockResolvedValue([readySummarizer()]);
mockSummarySlotsApi.get.mockResolvedValue({ slot: null, document: null, generatingIssue: null } satisfies GetSummarySlotResponse);
mockSummarySlotsApi.revisions.mockResolvedValue({ slot: null, revisions: [] } satisfies ListSummarySlotRevisionsResponse);
mockSummarySlotsApi.generate.mockResolvedValue({
slot: slot({ status: "generating", generatingIssueId: "issue-1" }),
generatingIssue: issue(),
alreadyGenerating: false,
});
});
afterEach(async () => {
await act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
});
it("renders nothing and does not fetch slots when the summaries flag is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableSummaries: false });
root = renderCard(container);
await flushQueries();
expect(container.textContent).toBe("");
expect(mockSummarySlotsApi.get).not.toHaveBeenCalled();
expect(mockBuiltInAgentsApi.list).not.toHaveBeenCalled();
});
it("shows setup CTA when the Summarizer built-in agent needs setup", async () => {
mockBuiltInAgentsApi.list.mockResolvedValue([needsSetupSummarizer()]);
root = renderCard(container);
await flushQueries();
expect(container.textContent).toContain("Set up the Summarizer");
const setupButton = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.textContent === "Set up Summarizer",
);
expect(setupButton).not.toBeNull();
await act(async () => {
setupButton?.click();
});
await flushQueries();
expect(container.querySelector('[data-testid="configure-modal"]')).not.toBeNull();
});
it("clears a stale generation failure after setup succeeds", async () => {
mockBuiltInAgentsApi.list.mockResolvedValue([needsSetupSummarizer()]);
mockSummarySlotsApi.get.mockResolvedValue({
slot: slot({ documentId: "doc-1" }),
document: summaryDocument(),
generatingIssue: null,
} satisfies GetSummarySlotResponse);
mockSummarySlotsApi.revisions.mockResolvedValue({
slot: slot({ documentId: "doc-1" }),
revisions: [revision({ id: "rev-2", revisionNumber: 2 })],
});
mockSummarySlotsApi.generate.mockRejectedValue(new Error("Summarizer built-in agent is not configured"));
root = renderCard(container);
await flushQueries();
const refreshButton = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.textContent?.includes("Refresh"),
);
await act(() => refreshButton?.click());
await flushQueries();
expect(container.textContent).toContain("Summary request failed");
const setupButton = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.textContent === "Set up Summarizer",
);
await act(() => setupButton?.click());
await flushQueries();
const finishSetupButton = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.textContent === "Finish setup",
);
await act(() => finishSetupButton?.click());
expect(container.textContent).not.toContain("Summary request failed");
});
it("shows the empty state and starts generation", async () => {
root = renderCard(container);
await flushQueries();
expect(container.textContent).toContain("No summary yet");
const generateButton = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.textContent?.includes("Generate summary"),
);
expect(generateButton).not.toBeNull();
await act(async () => {
generateButton?.click();
});
await flushQueries();
expect(mockSummarySlotsApi.generate).toHaveBeenCalledWith({
companyId: "company-1",
scopeKind: "project",
scopeId: "project-1",
slotKey: "header",
});
});
it("shows only the resume prerequisite when the Summarizer is paused", async () => {
mockBuiltInAgentsApi.list.mockResolvedValue([pausedSummarizer()]);
root = renderCard(container);
await flushQueries();
expect(container.textContent).toContain("Summarizer is paused");
expect(container.textContent).not.toContain("No summary yet");
expect(container.textContent).not.toContain("Generate summary");
expect([...container.querySelectorAll("button")].filter((button) => button.textContent === "Resume agent"))
.toHaveLength(1);
});
it("shows an active generating state with the linked task", async () => {
mockSummarySlotsApi.get.mockResolvedValue({
slot: slot({ status: "generating", generatingIssueId: "issue-1" }),
document: null,
generatingIssue: issue({ status: "in_progress" }),
} satisfies GetSummarySlotResponse);
root = renderCard(container);
await flushQueries();
expect(container.firstElementChild?.tagName).toBe("SECTION");
expect(container.firstElementChild?.className).toContain("space-y-4");
expect(container.querySelector('[data-slot="card"]')).toBeNull();
expect(container.textContent).toContain("Generating summary");
expect(container.textContent).toContain("PAP-14000: Summarize project");
expect(container.querySelector('a[href="/issues/PAP-14000"]')).not.toBeNull();
expect(container.querySelector('a[href="/issues/PAP-14000"]')?.closest("div")?.parentElement?.className)
.not.toContain("border");
});
it("renders the latest generated markdown", async () => {
mockSummarySlotsApi.get.mockResolvedValue({
slot: slot({ documentId: "doc-1" }),
document: summaryDocument({ body: "## Needs you\nReview the launch notes." }),
generatingIssue: null,
} satisfies GetSummarySlotResponse);
mockSummarySlotsApi.revisions.mockResolvedValue({
slot: slot({ documentId: "doc-1" }),
revisions: [revision({ id: "rev-2", revisionNumber: 2, body: "## Needs you\nReview the launch notes." })],
});
root = renderCard(container);
await flushQueries();
expect(container.textContent).toContain("Latest revision");
expect(container.textContent).toContain("Review the launch notes.");
expect(container.querySelector('[data-testid="markdown-body"]')?.parentElement?.className).not.toContain("border");
});
it("switches to a historical revision from a dated dropdown", async () => {
mockSummarySlotsApi.get.mockResolvedValue({
slot: slot({ documentId: "doc-1" }),
document: summaryDocument({
body: "## Latest\nCurrent body",
latestRevisionId: "rev-3",
latestRevisionNumber: 3,
updatedAt: "2026-07-14T17:10:00.000Z",
}),
generatingIssue: null,
} satisfies GetSummarySlotResponse);
mockSummarySlotsApi.revisions.mockResolvedValue({
slot: slot({ documentId: "doc-1" }),
revisions: [
revision({ id: "rev-1", revisionNumber: 1, body: "## Old\nOld body", createdAt: "2026-07-13T17:10:00.000Z" }),
revision({ id: "rev-2", revisionNumber: 2, body: "## Middle\nMiddle body", createdAt: "2026-07-14T09:15:00.000Z" }),
revision({ id: "rev-3", revisionNumber: 3, body: "## Latest\nCurrent body", createdAt: "2026-07-14T17:10:00.000Z" }),
],
});
root = renderCard(container);
await flushQueries();
expect(container.textContent).toContain("Current body");
expect(container.textContent).toContain("3 revisions");
expect(container.textContent).not.toContain("Latest (Rev 3)");
expect([...container.querySelectorAll<HTMLButtonElement>("button")].some(
(button) => button.textContent === "Revision 1" || button.textContent === "Rev 1",
)).toBe(false);
await openRevisionSelect(container);
expect(document.body.textContent).toContain("Latest (Rev 3) - Jul 14");
expect(document.body.textContent).toContain("Rev 1 - Jul 13");
expect(document.body.textContent).toContain("Rev 2 - Jul 14");
await chooseRevisionOption("Rev 1 - Jul 13");
expect(container.textContent).toContain("Historical revision");
expect(container.textContent).toContain("Old body");
const latestButton = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.textContent === "Latest",
);
expect(latestButton).not.toBeNull();
await act(async () => {
latestButton?.click();
});
await flushQueries();
expect(container.textContent).toContain("Current body");
expect(container.textContent).not.toContain("Historical revision");
});
it("limits the revision picker to the 30 newest revisions", async () => {
const revisions = Array.from({ length: 32 }, (_, index) => {
const revisionNumber = index + 1;
return revision({
id: `rev-${revisionNumber}`,
revisionNumber,
body: `Revision ${revisionNumber}`,
createdAt: new Date(Date.UTC(2026, 6, revisionNumber, 12)).toISOString(),
});
});
mockSummarySlotsApi.get.mockResolvedValue({
slot: slot({ documentId: "doc-1" }),
document: summaryDocument({
body: "Revision 32",
latestRevisionId: "rev-32",
latestRevisionNumber: 32,
}),
generatingIssue: null,
} satisfies GetSummarySlotResponse);
mockSummarySlotsApi.revisions.mockResolvedValue({
slot: slot({ documentId: "doc-1" }),
revisions,
});
root = renderCard(container);
await flushQueries();
expect(container.textContent).toContain("32 revisions");
await openRevisionSelect(container);
const options = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'));
expect(options).toHaveLength(30);
expect(options.some((option) => option.textContent?.includes("Latest (Rev 32)"))).toBe(true);
expect(options.some((option) => option.textContent?.includes("Rev 3 -"))).toBe(true);
expect(options.some((option) => option.textContent?.includes("Rev 2 -"))).toBe(false);
expect(options.some((option) => option.textContent?.includes("Rev 1 -"))).toBe(false);
});
it("shows stopped generation as a failed retryable state", async () => {
mockSummarySlotsApi.get.mockResolvedValue({
slot: slot({
status: "failed",
failureReason: "Summary generation task PAP-14000 finished without writing a summary.",
generatingIssueId: "issue-1",
}),
document: null,
generatingIssue: issue({ status: "done" }),
} satisfies GetSummarySlotResponse);
root = renderCard(container);
await flushQueries();
expect(container.textContent).toContain("Summary generation failed");
expect(container.textContent).toContain("PAP-14000 finished without writing a summary");
expect(container.querySelector('[role="note"]')?.className).toContain("text-red-700");
const retryButton = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.textContent === "Retry",
);
expect(retryButton).not.toBeNull();
await act(async () => {
retryButton?.click();
});
await flushQueries();
expect(mockSummarySlotsApi.generate).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,506 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
SummarySlotDocument,
SummarySlotIssueRef,
SummarySlotKey,
SummarySlotRevision,
SummarySlotScopeKind,
} from "@paperclipai/shared";
import { Bot, Clock3, History, Loader2, RefreshCw, Sparkles } from "lucide-react";
import { agentsApi } from "@/api/agents";
import { builtInAgentsApi, type BuiltInAgentState } from "@/api/builtInAgents";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { summarySlotsApi, type SummarySlotSelector } from "@/api/summarySlots";
import { MarkdownBody } from "@/components/MarkdownBody";
import { ConfigureBuiltInAgentModal } from "@/components/ConfigureBuiltInAgentModal";
import { InlineBanner } from "@/components/InlineBanner";
import { useSummaryDraftStream } from "@/components/useSummaryDraftStream";
import { useCompanyLiveEvent } from "@/context/LiveUpdatesProvider";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select";
import { queryKeys } from "@/lib/queryKeys";
import { cn, formatDateTime, relativeTime } from "@/lib/utils";
const SUMMARIZER_KEY = "summarizer";
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
const LATEST_REVISION_SELECT_VALUE = "__latest__";
const MAX_REVISION_OPTIONS = 30;
export interface SummarySlotCardProps {
companyId: string | null | undefined;
scopeKind: SummarySlotScopeKind;
scopeId?: string | null;
slotKey?: SummarySlotKey;
title: string;
description?: string;
className?: string;
}
function issueLabel(issue: SummarySlotIssueRef) {
return issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title;
}
function revisionLabel(revision: SummarySlotRevision) {
return `Rev ${revision.revisionNumber}`;
}
function formatRevisionTimestamp(date: Date | string) {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(new Date(date)).replace(",", "");
}
function revisionOptionLabel(revision: SummarySlotRevision) {
return `${revisionLabel(revision)} - ${formatRevisionTimestamp(revision.createdAt)}`;
}
function latestRevisionOptionLabel(
document: SummarySlotDocument,
revision: SummarySlotRevision | null,
) {
return `Latest (Rev ${document.latestRevisionNumber}) - ${
formatRevisionTimestamp(revision?.createdAt ?? document.updatedAt)
}`;
}
interface LiveGenerationStatus {
message: string | null;
currentToolName: string | null;
lastAssistantSnippet: string | null;
}
function readPayloadString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
/**
* Pick the single most useful line to show while a summary is generating.
* Prefers the server-derived status `message`, then the last streamed
* assistant snippet, then the active tool name. Returns null when nothing
* has streamed yet so the card falls back to its static generating text.
*/
export function resolveGenerationStatusLine(status: LiveGenerationStatus | null): string | null {
if (!status) return null;
if (status.message) return status.message;
if (status.lastAssistantSnippet) return status.lastAssistantSnippet;
if (status.currentToolName) return `Working with ${status.currentToolName}`;
return null;
}
/**
* Subscribe to the shared LiveUpdates socket and track the generation run's
* live status derived from `heartbeat.run.progress` events matching the slot's
* generating issue. Resets whenever the tracked generation changes, and stays
* null (static fallback) when no events arrive.
*/
function useGenerationStatus(generatingIssueId: string | null): LiveGenerationStatus | null {
const [status, setStatus] = useState<LiveGenerationStatus | null>(null);
useEffect(() => {
setStatus(null);
}, [generatingIssueId]);
useCompanyLiveEvent((event) => {
if (!generatingIssueId) return;
if (event.type !== "heartbeat.run.progress") return;
const payload = event.payload ?? {};
if (payload.issueId !== generatingIssueId) return;
setStatus({
message: readPayloadString(payload.message),
currentToolName: readPayloadString(payload.currentToolName),
lastAssistantSnippet: readPayloadString(payload.lastAssistantSnippet),
});
});
return generatingIssueId ? status : null;
}
function setupState(state: BuiltInAgentState | undefined) {
if (!state) return null;
return state.status === "not_provisioned"
|| state.status === "needs_setup"
|| state.status === "pending_approval"
? state
: null;
}
export function SummarySlotCard({
companyId,
scopeKind,
scopeId = null,
slotKey = "header",
title,
description,
className,
}: SummarySlotCardProps) {
const queryClient = useQueryClient();
const [selectedRevisionId, setSelectedRevisionId] = useState<string | null>(null);
const [configureOpen, setConfigureOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const selector: SummarySlotSelector | null = companyId
? { companyId, scopeKind, scopeId, slotKey }
: null;
const experimentalQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const summariesEnabled = experimentalQuery.data?.enableSummaries === true;
const builtInAgentsQuery = useQuery({
queryKey: queryKeys.builtInAgents.list(companyId ?? "__none__"),
queryFn: () => builtInAgentsApi.list(companyId!),
enabled: Boolean(companyId && summariesEnabled),
retry: false,
});
const summarizerState = builtInAgentsQuery.data?.find(
(entry) => entry.definition.key === SUMMARIZER_KEY,
);
const needsSetup = setupState(summarizerState);
const slotQueryKey = selector
? queryKeys.summarySlots.detail(selector.companyId, selector.scopeKind, selector.slotKey, selector.scopeId)
: queryKeys.summarySlots.detail("__none__", scopeKind, slotKey, scopeId);
const revisionsQueryKey = selector
? queryKeys.summarySlots.revisions(selector.companyId, selector.scopeKind, selector.slotKey, selector.scopeId)
: queryKeys.summarySlots.revisions("__none__", scopeKind, slotKey, scopeId);
const slotQuery = useQuery({
queryKey: slotQueryKey,
queryFn: () => summarySlotsApi.get(selector!),
enabled: Boolean(selector && summariesEnabled),
retry: false,
refetchInterval: (query) => query.state.data?.slot?.status === "generating" ? 3_000 : false,
});
const revisionsQuery = useQuery({
queryKey: revisionsQueryKey,
queryFn: () => summarySlotsApi.revisions(selector!),
enabled: Boolean(selector && summariesEnabled && slotQuery.data?.document),
retry: false,
});
const generateMutation = useMutation({
mutationFn: () => summarySlotsApi.generate(selector!),
onMutate: () => setActionError(null),
onSuccess: async () => {
setSelectedRevisionId(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: slotQueryKey }),
queryClient.invalidateQueries({ queryKey: revisionsQueryKey }),
]);
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Summary generation could not be started.");
},
});
const resumeSummarizer = useMutation({
mutationFn: (agentId: string) => agentsApi.resume(agentId, companyId ?? undefined),
onSuccess: async () => {
if (companyId) {
await queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(companyId) });
}
},
});
const revisions = revisionsQuery.data?.revisions ?? [];
const latestDocument = slotQuery.data?.document ?? null;
const selectedRevision = useMemo(
() => revisions.find((revision) => revision.id === selectedRevisionId) ?? null,
[revisions, selectedRevisionId],
);
const latestRevision = latestDocument
? revisions.find((revision) => revision.id === latestDocument.latestRevisionId) ?? null
: null;
const historicalRevision = selectedRevision && selectedRevision.id !== latestDocument?.latestRevisionId
? selectedRevision
: null;
const displayedBody = historicalRevision?.body ?? latestDocument?.body ?? "";
const displayingHistoricalRevision = Boolean(historicalRevision);
const historicalRevisionOptions = (latestDocument
? revisions.filter((revision) => revision.id !== latestDocument.latestRevisionId)
: revisions)
.toSorted((left, right) => right.revisionNumber - left.revisionNumber)
.slice(0, MAX_REVISION_OPTIONS - (latestDocument ? 1 : 0));
const revisionSelectValue = historicalRevision?.id ?? LATEST_REVISION_SELECT_VALUE;
const latestSelectLabel = latestDocument ? latestRevisionOptionLabel(latestDocument, latestRevision) : "Latest";
const generatingIssue = slotQuery.data?.generatingIssue ?? null;
const liveStatusLine = resolveGenerationStatusLine(useGenerationStatus(generatingIssue?.id ?? null));
const draftStream = useSummaryDraftStream(companyId, generatingIssue);
// The token-streamed STATUS line is more responsive than the server-derived
// progress snippet; prefer it and fall back to the Phase 1 status line.
const generationStatusLine = draftStream.statusLine ?? liveStatusLine;
const isGenerating = slotQuery.data?.slot?.status === "generating"
&& generatingIssue
&& !TERMINAL_ISSUE_STATUSES.has(generatingIssue.status);
const generationFailed = slotQuery.data?.slot?.status === "failed";
const canGenerateFirstSummary = summarizerState?.status === "ready";
if (experimentalQuery.isLoading || !summariesEnabled) return null;
const startGeneration = () => {
if (!selector || generateMutation.isPending) return;
generateMutation.mutate();
};
return (
<section className={cn("space-y-4", className)}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Sparkles className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<h2 className="text-sm font-semibold">{title}</h2>
{isGenerating ? <Badge variant="secondary">Generating</Badge> : null}
{displayingHistoricalRevision ? <Badge variant="outline">Historical revision</Badge> : null}
{latestDocument && !displayingHistoricalRevision ? <Badge variant="outline">Latest revision</Badge> : null}
</div>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{displayingHistoricalRevision ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setSelectedRevisionId(null)}
>
Latest
</Button>
) : null}
{latestDocument && !generationFailed ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={startGeneration}
disabled={!selector || generateMutation.isPending || Boolean(isGenerating)}
>
{generateMutation.isPending ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Refresh
</Button>
) : null}
</div>
</div>
{needsSetup ? (
<>
<div className="flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
{needsSetup.status === "pending_approval" ? (
<Clock3 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Bot className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">
{needsSetup.status === "pending_approval"
? "Summarizer setup is pending approval"
: "Set up the Summarizer"}
</p>
<p className="text-muted-foreground">
Summaries are generated by Paperclip's built-in Summarizer agent. Configure its adapter and model
before requesting this summary.
</p>
</div>
</div>
{needsSetup.status === "pending_approval" ? null : (
<Button type="button" size="sm" onClick={() => setConfigureOpen(true)}>
Set up Summarizer
</Button>
)}
</div>
{companyId ? (
<ConfigureBuiltInAgentModal
companyId={companyId}
state={needsSetup}
open={configureOpen}
onOpenChange={setConfigureOpen}
onConfigured={() => setActionError(null)}
/>
) : null}
</>
) : null}
{!needsSetup && summarizerState?.status === "paused" && summarizerState.agent ? (
<InlineBanner
tone="warning"
title="Summarizer is paused"
actions={
<Button
type="button"
size="sm"
onClick={() => summarizerState.agent && resumeSummarizer.mutate(summarizerState.agent.id)}
disabled={resumeSummarizer.isPending}
>
{resumeSummarizer.isPending ? "Resuming..." : "Resume agent"}
</Button>
}
>
Existing summaries remain readable, but new summaries will not be generated until the agent resumes.
</InlineBanner>
) : null}
{actionError ? (
<InlineBanner tone="warning" title="Summary request failed">
{actionError}
</InlineBanner>
) : null}
{slotQuery.isError ? (
<InlineBanner
tone="warning"
title="Summary could not be loaded"
actions={
<Button type="button" size="sm" variant="outline" onClick={() => void slotQuery.refetch()}>
Retry
</Button>
}
>
{slotQuery.error instanceof Error ? slotQuery.error.message : "Try loading the summary again."}
</InlineBanner>
) : null}
{!slotQuery.isError && generationFailed ? (
<InlineBanner
tone="danger"
title="Summary generation failed"
actions={
<Button
type="button"
size="sm"
onClick={startGeneration}
disabled={!selector || generateMutation.isPending}
>
{generateMutation.isPending ? "Retrying..." : "Retry"}
</Button>
}
>
{slotQuery.data?.slot?.failureReason ?? "The generation task ended before writing a summary."}
</InlineBanner>
) : null}
{!slotQuery.isError && isGenerating && generatingIssue ? (
<div className="flex items-start gap-3 text-sm">
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<div className="min-w-0 space-y-1">
<p className="font-medium text-foreground">Generating summary</p>
{generationStatusLine ? (
<p
className="animate-pulse truncate text-muted-foreground"
aria-live="polite"
data-testid="summary-generation-status-line"
title={generationStatusLine}
>
{generationStatusLine}
</p>
) : null}
{draftStream.draft ? (
<div
className="mt-2 rounded-md border border-border bg-muted/20 p-3"
data-testid="summary-generation-draft"
aria-live="polite"
>
<MarkdownBody className="text-sm leading-7 text-foreground">
{draftStream.draft}
</MarkdownBody>
{!draftStream.draftClosed ? (
<span
className="mt-1 inline-block h-4 w-px animate-pulse bg-foreground align-text-bottom"
aria-hidden="true"
data-testid="summary-generation-caret"
/>
) : null}
</div>
) : null}
<p className="text-muted-foreground">
Summarizer is working in{" "}
<Link className="underline" to={`/issues/${generatingIssue.identifier ?? generatingIssue.id}`}>
{issueLabel(generatingIssue)}
</Link>
.
</p>
</div>
</div>
) : null}
{!slotQuery.isError && !latestDocument && !isGenerating && !generationFailed && canGenerateFirstSummary ? (
<div className="flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">No summary yet</p>
<p className="text-muted-foreground">Generate a concise status snapshot for this surface.</p>
</div>
<Button
type="button"
size="sm"
onClick={startGeneration}
disabled={!selector || generateMutation.isPending}
>
{generateMutation.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
{generateMutation.isPending ? "Generating..." : "Generate summary"}
</Button>
</div>
) : null}
{latestDocument ? (
<div className="space-y-4">
<MarkdownBody className="text-sm leading-7 text-foreground">
{displayedBody}
</MarkdownBody>
<div className="flex flex-col gap-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span title={formatDateTime(historicalRevision?.createdAt ?? latestRevision?.createdAt ?? latestDocument.updatedAt)}>
Updated {relativeTime(historicalRevision?.createdAt ?? latestRevision?.createdAt ?? latestDocument.updatedAt)}
</span>
{revisions.length > 1 ? (
<Select
value={revisionSelectValue}
onValueChange={(value) => {
setSelectedRevisionId(value === LATEST_REVISION_SELECT_VALUE ? null : value);
}}
>
<SelectTrigger
size="sm"
className="h-auto border-0 bg-transparent p-0 text-xs shadow-none hover:text-foreground focus-visible:ring-0"
aria-label="Select summary revision"
title={historicalRevision ? revisionOptionLabel(historicalRevision) : latestSelectLabel}
>
<SelectValue>
<History className="size-3.5" aria-hidden="true" />
<span>{revisions.length} revisions</span>
</SelectValue>
</SelectTrigger>
<SelectContent align="end" position="popper">
<SelectItem value={LATEST_REVISION_SELECT_VALUE} className="text-xs">
{latestSelectLabel}
</SelectItem>
{historicalRevisionOptions.length > 0 ? <SelectSeparator /> : null}
{historicalRevisionOptions.map((revision) => (
<SelectItem
key={revision.id}
value={revision.id}
className="text-xs"
title={formatDateTime(revision.createdAt)}
>
{revisionOptionLabel(revision)}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
</div>
) : null}
</section>
);
}

View File

@ -7,6 +7,11 @@ import { heartbeatsApi } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys";
import { buildSameOriginWebSocketUrl } from "../../lib/websocket-url";
import {
mergeRunLogChunks,
parsePersistedLogContent,
readChunkSeq,
} from "../../lib/run-log-chunks";
// TODO(perf): this whole hook polls the log/runs endpoints on an interval. The
// durable fix is server push (SSE/websocket) for transcript deltas so idle tabs
@ -59,51 +64,6 @@ export function resolveInitialLogOffset(run: RunTranscriptSource, limitBytes: nu
return Math.max(0, knownBytes - Math.max(0, limitBytes));
}
function readChunkSeq(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function isStructuredStreamingTextDelta(chunk: string) {
return /"type"\s*:\s*"(?:acpx\.text_delta|text)"/.test(chunk);
}
function parsePersistedLogContent(
runId: string,
content: string,
pendingByRun: Map<string, string>,
): Array<RunLogChunk & { dedupeKey: string }> {
if (!content) return [];
const pendingKey = `${runId}:records`;
const combined = `${pendingByRun.get(pendingKey) ?? ""}${content}`;
const split = combined.split("\n");
pendingByRun.set(pendingKey, split.pop() ?? "");
const parsed: Array<RunLogChunk & { dedupeKey: string }> = [];
for (const line of split) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown; seq?: unknown };
const stream = raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
if (!chunk) continue;
parsed.push({
ts,
stream,
chunk,
seq: readChunkSeq(raw.seq),
dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`,
});
} catch {
// Ignore malformed log rows.
}
}
return parsed;
}
export function useLiveRunTranscripts({
runs,
companyId,
@ -165,66 +125,20 @@ export function useLiveRunTranscripts({
const appendChunks = (runId: string, chunks: Array<RunLogChunk & { dedupeKey: string }>) => {
if (chunks.length === 0) return;
setChunksByRun((prev) => {
const next = new Map(prev);
const existing = [...(next.get(runId) ?? [])];
let changed = false;
for (const chunk of chunks) {
// Sequenced log chunks (persisted rows and websocket log payloads)
// dedupe and order by the server-assigned monotonic seq. Identical
// token deltas from ACP-style adapters often share the same
// millisecond ts and chunk text, so content-based keys drop real
// output; seq keeps every record and restores emit order when the
// websocket and the poller interleave.
if (typeof chunk.seq === "number") {
const seqFloor = trimmedSeqFloorByRunRef.current.get(runId) ?? 0;
if (chunk.seq <= seqFloor) continue;
const duplicateAt = existing.findIndex((item) => item.seq === chunk.seq);
if (duplicateAt !== -1) {
// Same record arrived via the other delivery path. Prefer the
// longer payload: websocket chunks may be tail-truncated while
// the persisted row is complete.
if (chunk.chunk.length > existing[duplicateAt]!.chunk.length) {
existing[duplicateAt] = { ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk, seq: chunk.seq };
changed = true;
}
continue;
}
// Insert in seq order relative to the trailing sequenced chunks so
// late-arriving records from the slower delivery path land where
// they were emitted. Unsequenced chunks act as an ordering barrier.
let insertAt = existing.length;
while (insertAt > 0) {
const prior = existing[insertAt - 1]!;
if (typeof prior.seq !== "number" || prior.seq < chunk.seq) break;
insertAt -= 1;
}
existing.splice(insertAt, 0, { ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk, seq: chunk.seq });
changed = true;
continue;
}
if (!isStructuredStreamingTextDelta(chunk.chunk)) {
if (seenChunkKeysRef.current.has(chunk.dedupeKey)) continue;
seenChunkKeysRef.current.add(chunk.dedupeKey);
}
existing.push({ ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk });
changed = true;
}
const prevChunks = prev.get(runId) ?? [];
const { chunks: merged, changed } = mergeRunLogChunks(
runId,
prevChunks,
chunks,
{
seenChunkKeys: seenChunkKeysRef.current,
trimmedSeqFloorByRun: trimmedSeqFloorByRunRef.current,
},
maxChunksPerRun,
);
if (!changed) return prev;
if (seenChunkKeysRef.current.size > 12000) {
seenChunkKeysRef.current.clear();
}
if (existing.length > maxChunksPerRun) {
const trimmed = existing.splice(0, existing.length - maxChunksPerRun);
let seqFloor = trimmedSeqFloorByRunRef.current.get(runId) ?? 0;
for (const item of trimmed) {
if (typeof item.seq === "number" && item.seq > seqFloor) seqFloor = item.seq;
}
if (seqFloor > 0) trimmedSeqFloorByRunRef.current.set(runId, seqFloor);
}
next.set(runId, existing);
const next = new Map(prev);
next.set(runId, merged);
return next;
});
};

View File

@ -0,0 +1,291 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CompanyLiveEventHandler } from "@/context/LiveUpdatesProvider";
import type { LiveEvent, SummarySlotIssueRef } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __liveUpdatesTestUtils } from "@/context/LiveUpdatesProvider";
import { useSummaryDraftStream } from "./useSummaryDraftStream";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const { LiveEventSubscriptionContext, dispatchLiveEventToSubscribers } = __liveUpdatesTestUtils;
const mockHeartbeatsApi = vi.hoisted(() => ({ log: vi.fn(), activeRunForIssue: vi.fn() }));
vi.mock("@/api/heartbeats", () => ({ heartbeatsApi: mockHeartbeatsApi }));
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushQueries() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
function issue(id: string): SummarySlotIssueRef {
return { id, identifier: `PAP-${id}`, title: "Summarize project", status: "in_progress" };
}
function progressEvent(issueId: string, runId: string): LiveEvent {
return {
id: 1,
companyId: "company-1",
type: "heartbeat.run.progress",
createdAt: "2026-07-15T00:00:00.000Z",
payload: { issueId, runId },
};
}
function logEvent(runId: string, seq: number, text: string): LiveEvent {
return {
id: seq,
companyId: "company-1",
type: "heartbeat.run.log",
createdAt: `2026-07-15T00:00:0${seq}.000Z`,
payload: {
runId,
seq,
ts: `2026-07-15T00:00:0${seq}.000Z`,
stream: "stdout",
chunk: JSON.stringify({ type: "acpx.text_delta", text, channel: "output" }),
},
};
}
interface Captured {
runId: string | null;
statusLine: string | null;
draft: string | null;
draftClosed: boolean;
hasStream: boolean;
}
const captured: { current: Captured | null } = { current: null };
function Harness({ generatingIssue }: { generatingIssue: SummarySlotIssueRef | null }) {
const stream = useSummaryDraftStream("company-1", generatingIssue);
captured.current = stream;
return null;
}
function renderHarness(
generatingIssue: SummarySlotIssueRef | null,
subscribers: Set<CompanyLiveEventHandler>,
): { root: Root; rerender: (next: SummarySlotIssueRef | null) => void } {
const container = document.createElement("div");
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const subscription = {
subscribe: (fn: CompanyLiveEventHandler) => {
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
},
};
const render = (next: SummarySlotIssueRef | null) => {
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<LiveEventSubscriptionContext.Provider value={subscription}>
<Harness generatingIssue={next} />
</LiveEventSubscriptionContext.Provider>
</QueryClientProvider>,
);
});
};
render(generatingIssue);
return { root, rerender: render };
}
// Full protocol output split into tiny slices to simulate token streaming with
// markers landing mid-slice.
const PROTOCOL = [
"STATUS: reading the slot…",
"STATUS: writing the summary…",
"<<<SUMMARY-DRAFT>>>",
"## Needs you",
"- Approve the launch",
"<<<END-SUMMARY-DRAFT>>>",
].join("\n");
function sliceEvents(runId: string, text: string, size: number): LiveEvent[] {
const events: LiveEvent[] = [];
let seq = 1;
for (let i = 0; i < text.length; i += size) {
events.push(logEvent(runId, seq, text.slice(i, i + size)));
seq += 1;
}
return events;
}
describe("useSummaryDraftStream", () => {
let root: Root | null = null;
beforeEach(() => {
captured.current = null;
mockHeartbeatsApi.log.mockResolvedValue({ runId: "run-1", store: "s", logRef: "r", content: "", nextOffset: 0 });
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue(null);
});
afterEach(async () => {
await act(() => root?.unmount());
root = null;
vi.clearAllMocks();
});
it("streams status line and draft from markers split across delta boundaries", async () => {
const subscribers = new Set<CompanyLiveEventHandler>();
({ root } = renderHarness(issue("1"), subscribers));
await flushQueries();
// Learn the run id from a progress event.
await act(async () => {
dispatchLiveEventToSubscribers(subscribers, "company-1", progressEvent("1", "run-1"));
});
await flushQueries();
expect(captured.current?.runId).toBe("run-1");
// Stream the whole protocol in 4-char slices.
await act(async () => {
for (const event of sliceEvents("run-1", PROTOCOL, 4)) {
dispatchLiveEventToSubscribers(subscribers, "company-1", event);
}
});
await flushQueries();
expect(captured.current?.statusLine).toBe("writing the summary…");
expect(captured.current?.draft).toBe("## Needs you\n- Approve the launch");
expect(captured.current?.draftClosed).toBe(true);
expect(captured.current?.hasStream).toBe(true);
});
it("shows a partial draft and typing state before the closing sentinel", async () => {
const subscribers = new Set<CompanyLiveEventHandler>();
({ root } = renderHarness(issue("1"), subscribers));
await flushQueries();
await act(async () => {
dispatchLiveEventToSubscribers(subscribers, "company-1", progressEvent("1", "run-1"));
});
const partial = "<<<SUMMARY-DRAFT>>>\n## Needs you\n- Approve";
await act(async () => {
dispatchLiveEventToSubscribers(subscribers, "company-1", logEvent("run-1", 1, partial));
});
await flushQueries();
expect(captured.current?.draft).toBe("## Needs you\n- Approve");
expect(captured.current?.draftClosed).toBe(false);
});
it("falls back to spinner state when the model skips the markers", async () => {
const subscribers = new Set<CompanyLiveEventHandler>();
({ root } = renderHarness(issue("1"), subscribers));
await flushQueries();
await act(async () => {
dispatchLiveEventToSubscribers(subscribers, "company-1", progressEvent("1", "run-1"));
});
await act(async () => {
dispatchLiveEventToSubscribers(subscribers, "company-1", logEvent("run-1", 1, "just prose, no markers here"));
});
await flushQueries();
expect(captured.current?.draft).toBeNull();
expect(captured.current?.draftClosed).toBe(false);
expect(captured.current?.hasStream).toBe(false);
});
it("ignores log events for a different run", async () => {
const subscribers = new Set<CompanyLiveEventHandler>();
({ root } = renderHarness(issue("1"), subscribers));
await flushQueries();
await act(async () => {
dispatchLiveEventToSubscribers(subscribers, "company-1", progressEvent("1", "run-1"));
});
await act(async () => {
dispatchLiveEventToSubscribers(subscribers, "company-1", logEvent("run-999", 1, "<<<SUMMARY-DRAFT>>>\nleak"));
});
await flushQueries();
expect(captured.current?.draft).toBeNull();
});
it("resets state when the tracked generation is superseded", async () => {
const subscribers = new Set<CompanyLiveEventHandler>();
let rerender: (next: SummarySlotIssueRef | null) => void;
({ root, rerender } = renderHarness(issue("1"), subscribers));
await flushQueries();
await act(async () => {
dispatchLiveEventToSubscribers(subscribers, "company-1", progressEvent("1", "run-1"));
});
await flushQueries();
await act(async () => {
dispatchLiveEventToSubscribers(
subscribers,
"company-1",
logEvent("run-1", 1, "<<<SUMMARY-DRAFT>>>\nold draft\n<<<END-SUMMARY-DRAFT>>>"),
);
});
await flushQueries();
expect(captured.current?.draft).toBe("old draft");
// A new generation issue supersedes the old one → everything resets.
await act(async () => {
rerender(issue("2"));
});
await flushQueries();
expect(captured.current?.runId).toBeNull();
expect(captured.current?.draft).toBeNull();
expect(captured.current?.hasStream).toBe(false);
});
it("rehydrates the draft from the persisted run log after a refresh", async () => {
// Simulate a page refresh mid-generation: no live event has run id yet, so
// the hook resolves it from the active-run endpoint and reads the log.
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue({ id: "run-1", adapterType: "claude-local" });
const persistedRows =
[
"STATUS: writing the summary…",
"<<<SUMMARY-DRAFT>>>",
"## Needs you",
"Recovered after refresh.",
"<<<END-SUMMARY-DRAFT>>>",
]
.map((line, index) =>
JSON.stringify({
ts: `t${index}`,
stream: "stdout",
seq: index + 1,
chunk: JSON.stringify({ type: "acpx.text_delta", text: `${line}\n`, channel: "output" }),
}),
)
.join("\n") + "\n";
mockHeartbeatsApi.log.mockResolvedValue({
runId: "run-1",
store: "s",
logRef: "r",
content: persistedRows,
nextOffset: persistedRows.length,
});
const subscribers = new Set<CompanyLiveEventHandler>();
({ root } = renderHarness(issue("1"), subscribers));
await flushQueries();
expect(captured.current?.runId).toBe("run-1");
expect(captured.current?.draft).toBe("## Needs you\nRecovered after refresh.");
expect(captured.current?.draftClosed).toBe(true);
});
});

View File

@ -0,0 +1,180 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { LiveEvent, SummarySlotIssueRef } from "@paperclipai/shared";
import type { RunLogChunk } from "@/adapters";
import { heartbeatsApi } from "@/api/heartbeats";
import { useCompanyLiveEvent } from "@/context/LiveUpdatesProvider";
import { queryKeys } from "@/lib/queryKeys";
import {
mergeRunLogChunks,
parsePersistedLogContent,
readChunkSeq,
type ChunkMergeRefs,
type IncomingRunLogChunk,
} from "@/lib/run-log-chunks";
import {
closeDanglingCodeFence,
extractAssistantOutputText,
parseSummaryDraftStream,
} from "@/lib/summary-draft-stream";
const LOG_POLL_INTERVAL_MS = 1500;
const LOG_READ_LIMIT_BYTES = 256_000;
const MAX_CHUNKS = 400;
export interface SummaryDraftStream {
/** The generation run id, once learned from a live event or the fallback. */
runId: string | null;
/** Latest `STATUS:` line streamed by the Summarizer (prefix stripped). */
statusLine: string | null;
/** Accumulating draft Markdown (fence-guarded while still streaming). */
draft: string | null;
/** True once the closing draft sentinel has arrived. */
draftClosed: boolean;
/** Whether any protocol output (status line or draft) has streamed yet. */
hasStream: boolean;
}
function freshMergeRefs(): ChunkMergeRefs {
return { seenChunkKeys: new Set<string>(), trimmedSeqFloorByRun: new Map<string, number>() };
}
function readPayloadString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
/**
* Token-streamed draft for a generating summary slot.
*
* Learns the generation run's id from `heartbeat.run.progress` /
* `heartbeat.run.queued` events (matched by `issueId`), falling back to the
* issue's active-run endpoint after a page refresh. It then merges the run's
* assistant `acpx.text_delta` output from both the persisted run-log poller and
* the live company-events socket (reusing the shared chunk-merge/seq-dedupe
* util) and parses the STATUS lines + sentinel-wrapped draft out of that stream.
*
* Degrades gracefully: no run id / no deltas (WS down, non-ACP adapter, model
* skipped the protocol) simply yields an empty stream and the card keeps its
* spinner. State resets whenever the tracked generation changes.
*/
export function useSummaryDraftStream(
companyId: string | null | undefined,
generatingIssue: SummarySlotIssueRef | null,
): SummaryDraftStream {
const issueId = generatingIssue?.id ?? null;
const [runId, setRunId] = useState<string | null>(null);
const [chunks, setChunks] = useState<RunLogChunk[]>([]);
const mergeRefs = useRef<ChunkMergeRefs>(freshMergeRefs());
const pendingLogRowsRef = useRef(new Map<string, string>());
const logOffsetRef = useRef(0);
// Reset all stream state whenever the tracked generation changes — including
// a superseded generation (new issue id) or generation ending (null).
useEffect(() => {
setRunId(null);
setChunks([]);
mergeRefs.current = freshMergeRefs();
pendingLogRowsRef.current = new Map();
logOffsetRef.current = 0;
}, [issueId]);
const appendChunks = useCallback((incoming: IncomingRunLogChunk[]) => {
if (incoming.length === 0) return;
setChunks((prev) => {
const { chunks: merged, changed } = mergeRunLogChunks(
"summary-draft",
prev,
incoming,
mergeRefs.current,
MAX_CHUNKS,
);
return changed ? merged : prev;
});
}, []);
// Learn the generation run id from live progress/queued events for the issue.
useCompanyLiveEvent((event: LiveEvent) => {
if (!issueId) return;
if (event.type !== "heartbeat.run.progress" && event.type !== "heartbeat.run.queued") return;
const payload = event.payload ?? {};
if (payload.issueId !== issueId) return;
const nextRunId = readPayloadString(payload.runId);
if (nextRunId) setRunId((current) => (current === nextRunId ? current : nextRunId));
});
// Fallback: resolve the active run for the generation issue when no live event
// has surfaced the run id yet (e.g. a page refresh mid-generation).
const activeRunQuery = useQuery({
queryKey: queryKeys.issues.activeRun(issueId ?? "__none__"),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
enabled: Boolean(companyId) && Boolean(issueId) && !runId,
retry: false,
refetchInterval: runId ? false : 4000,
});
const fallbackRunId = activeRunQuery.data?.id ?? null;
useEffect(() => {
if (fallbackRunId) setRunId((current) => current ?? fallbackRunId);
}, [fallbackRunId]);
// Live token deltas over the shared company-events socket.
useCompanyLiveEvent((event: LiveEvent) => {
if (!runId) return;
if (event.type !== "heartbeat.run.log") return;
const payload = event.payload ?? {};
if (payload.runId !== runId) return;
const chunk = readPayloadString(payload.chunk);
if (!chunk) return;
const ts = readPayloadString(payload.ts) ?? event.createdAt;
const stream =
payload.stream === "stderr" ? "stderr" : payload.stream === "system" ? "system" : "stdout";
appendChunks([
{ ts, stream, chunk, seq: readChunkSeq(payload.seq), dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}` },
]);
});
// Hydrate already-emitted output and fill any gaps from the persisted run log.
useEffect(() => {
if (!runId) return;
logOffsetRef.current = 0;
pendingLogRowsRef.current = new Map();
let cancelled = false;
const read = async () => {
try {
const result = await heartbeatsApi.log(runId, logOffsetRef.current, LOG_READ_LIMIT_BYTES);
if (cancelled) return;
appendChunks(parsePersistedLogContent(runId, result.content, pendingLogRowsRef.current));
if (result.nextOffset !== undefined) {
logOffsetRef.current = result.nextOffset;
} else if (result.content.length > 0) {
logOffsetRef.current += result.content.length;
}
} catch {
// Ignore transient/404 reads (log not yet flushed, run just started).
}
};
void read();
const interval = window.setInterval(() => void read(), LOG_POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [runId, appendChunks]);
const parse = useMemo(() => parseSummaryDraftStream(extractAssistantOutputText(chunks)), [chunks]);
const draft = parse.draft !== null && !parse.draftClosed
? closeDanglingCodeFence(parse.draft)
: parse.draft;
return {
runId,
statusLine: parse.statusLine,
draft,
draftClosed: parse.draftClosed,
hasStream: parse.draft !== null || parse.statusLine !== null,
};
}

View File

@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import type { LiveEvent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
__liveUpdatesTestUtils,
useCompanyLiveEvent,
type CompanyLiveEventHandler,
} from "./LiveUpdatesProvider";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const { LiveEventSubscriptionContext, dispatchLiveEventToSubscribers } = __liveUpdatesTestUtils;
function act(callback: () => void) {
flushSync(callback);
}
function progressEvent(overrides: Partial<LiveEvent> = {}): LiveEvent {
return {
id: 1,
companyId: "company-1",
type: "heartbeat.run.progress",
createdAt: "2026-07-15T00:00:00.000Z",
payload: { issueId: "issue-1", message: "reviewing 14 open issues" },
...overrides,
};
}
describe("useCompanyLiveEvent", () => {
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
});
function renderWithSubscription(handler: CompanyLiveEventHandler) {
const subscribers = new Set<CompanyLiveEventHandler>();
const subscription = {
subscribe: (fn: CompanyLiveEventHandler) => {
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
},
};
function Consumer() {
useCompanyLiveEvent(handler);
return null;
}
root = createRoot(container);
act(() => {
root!.render(
<LiveEventSubscriptionContext.Provider value={subscription}>
<Consumer />
</LiveEventSubscriptionContext.Provider>,
);
});
return subscribers;
}
it("receives events dispatched through the shared registry", () => {
const received: LiveEvent[] = [];
const subscribers = renderWithSubscription((event) => received.push(event));
act(() => dispatchLiveEventToSubscribers(subscribers, "company-1", progressEvent()));
expect(received).toHaveLength(1);
expect(received[0].payload.message).toBe("reviewing 14 open issues");
});
it("stops receiving events after unmount", () => {
const received: LiveEvent[] = [];
const subscribers = renderWithSubscription((event) => received.push(event));
act(() => root?.unmount());
root = null;
act(() => dispatchLiveEventToSubscribers(subscribers, "company-1", progressEvent()));
expect(received).toHaveLength(0);
});
it("no-ops without a surrounding provider", () => {
function Consumer() {
useCompanyLiveEvent(() => {
throw new Error("should never be called");
});
return null;
}
root = createRoot(container);
expect(() =>
act(() => {
root!.render(<Consumer />);
}),
).not.toThrow();
});
});

View File

@ -1136,3 +1136,156 @@ describe("applyRunLifecycleToCompanyLiveRuns", () => {
expect(read()).toEqual([{ id: "run-1", status: "running" }]); // unchanged
});
});
describe("LiveUpdatesProvider summary slot invalidation", () => {
it("invalidates the slot and revisions queries on summary_slot.write", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: () => undefined,
};
__liveUpdatesTestUtils.invalidateActivityQueries(
queryClient as never,
"company-1",
{
entityType: "summary_slot",
entityId: "slot-1",
action: "summary_slot.write",
details: {
scopeKind: "project",
scopeId: "project-1",
slotKey: "header",
revisionId: "rev-2",
},
},
{ userId: null, agentId: null },
);
expect(invalidations).toContainEqual({
queryKey: queryKeys.summarySlots.detail("company-1", "project", "header", "project-1"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.summarySlots.revisions("company-1", "project", "header", "project-1"),
});
});
it("maps a null scopeId to a company-scoped slot key", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: () => undefined,
};
__liveUpdatesTestUtils.invalidateActivityQueries(
queryClient as never,
"company-1",
{
entityType: "summary_slot",
entityId: "slot-1",
action: "summary_slot.write",
details: { scopeKind: "company", scopeId: null, slotKey: "header" },
},
{ userId: null, agentId: null },
);
expect(invalidations).toContainEqual({
queryKey: queryKeys.summarySlots.detail("company-1", "company", "header", null),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.summarySlots.revisions("company-1", "company", "header", null),
});
});
it("skips slot invalidation when scope details are missing", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: () => undefined,
};
__liveUpdatesTestUtils.invalidateActivityQueries(
queryClient as never,
"company-1",
{
entityType: "summary_slot",
entityId: "slot-1",
action: "summary_slot.write",
details: null,
},
{ userId: null, agentId: null },
);
expect(
invalidations.some(
(entry) =>
Array.isArray((entry as { queryKey?: unknown[] }).queryKey) &&
(entry as { queryKey: unknown[] }).queryKey[0] === "summary-slots",
),
).toBe(false);
});
});
describe("dispatchLiveEventToSubscribers", () => {
const baseEvent = {
id: 1,
companyId: "company-1",
type: "heartbeat.run.progress" as const,
createdAt: "2026-07-15T00:00:00.000Z",
payload: { issueId: "issue-1" },
};
it("delivers events for the active company to every subscriber", () => {
const received: unknown[] = [];
const subscribers = new Set<(event: never) => void>([
(event) => received.push(["a", event]),
(event) => received.push(["b", event]),
]);
__liveUpdatesTestUtils.dispatchLiveEventToSubscribers(
subscribers as never,
"company-1",
baseEvent as never,
);
expect(received).toHaveLength(2);
});
it("drops events for other companies", () => {
const received: unknown[] = [];
const subscribers = new Set<(event: never) => void>([() => received.push("hit")]);
__liveUpdatesTestUtils.dispatchLiveEventToSubscribers(
subscribers as never,
"company-2",
baseEvent as never,
);
expect(received).toHaveLength(0);
});
it("isolates a throwing subscriber from the rest", () => {
const received: string[] = [];
const subscribers = new Set<(event: never) => void>([
() => {
throw new Error("boom");
},
() => received.push("still-called"),
]);
expect(() =>
__liveUpdatesTestUtils.dispatchLiveEventToSubscribers(
subscribers as never,
"company-1",
baseEvent as never,
),
).not.toThrow();
expect(received).toEqual(["still-called"]);
});
});

View File

@ -1,4 +1,12 @@
import { useEffect, useMemo, useRef, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
import { useQuery, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query";
import { createCoalescingQueryClient, createInvalidationBatcher } from "../lib/query-invalidation-batcher";
import { patchRunStatusInList, removeRunFromList } from "../lib/live-runs-cache";
@ -34,6 +42,53 @@ type LiveUpdatesSocketLike = {
close: (code?: number, reason?: string) => void;
};
export type CompanyLiveEventHandler = (event: LiveEvent) => void;
interface LiveEventSubscription {
subscribe: (handler: CompanyLiveEventHandler) => () => void;
}
const LiveEventSubscriptionContext = createContext<LiveEventSubscription | null>(null);
function dispatchLiveEventToSubscribers(
subscribers: Set<CompanyLiveEventHandler>,
expectedCompanyId: string,
event: LiveEvent,
) {
if (event.companyId !== expectedCompanyId) return;
// Snapshot so a handler that (un)subscribes mid-dispatch can't mutate the set
// we're iterating.
for (const handler of Array.from(subscribers)) {
try {
handler(event);
} catch {
// A misbehaving subscriber must never break the shared socket pipeline
// or the toast/invalidation handling that runs alongside it.
}
}
}
/**
* Subscribe to live company events off the single shared LiveUpdates socket.
* Components can react to `heartbeat.run.progress`, `activity.logged`, etc.
* without opening a WebSocket per mount. Events are already filtered to the
* active company. No-ops when rendered outside a LiveUpdatesProvider (e.g. in
* isolated tests), so callers get graceful degradation for free.
*/
export function useCompanyLiveEvent(handler: CompanyLiveEventHandler): void {
const subscription = useContext(LiveEventSubscriptionContext);
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
});
useEffect(() => {
if (!subscription) return;
return subscription.subscribe((event) => {
handlerRef.current(event);
});
}, [subscription]);
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
@ -1037,6 +1092,24 @@ function invalidateActivityQueries(
return;
}
if (entityType === "summary_slot") {
// The Summarizer's authoritative PUT logs `summary_slot.write`; refresh the
// affected slot + its revisions so the finished summary lands instantly
// instead of waiting for the card's 3s generating poll tick.
const scopeKind = readString(details?.scopeKind);
const slotKey = readString(details?.slotKey);
const scopeId = readString(details?.scopeId);
if (scopeKind && slotKey) {
queryClient.invalidateQueries({
queryKey: queryKeys.summarySlots.detail(companyId, scopeKind, slotKey, scopeId),
});
queryClient.invalidateQueries({
queryKey: queryKeys.summarySlots.revisions(companyId, scopeKind, slotKey, scopeId),
});
}
return;
}
if (entityType === "company") {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
}
@ -1209,6 +1282,8 @@ export const __liveUpdatesTestUtils = {
buildAgentStatusToast,
buildRunStatusToast,
closeSocketQuietly,
dispatchLiveEventToSubscribers,
LiveEventSubscriptionContext,
applyRunLiveStatusPatchToCaches,
hydrateVisibleIssueComment,
invalidateActivityQueries,
@ -1243,6 +1318,14 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
userId: currentUserId,
agentId: null,
});
const subscribersRef = useRef<Set<CompanyLiveEventHandler>>(new Set());
const subscribe = useCallback((handler: CompanyLiveEventHandler) => {
subscribersRef.current.add(handler);
return () => {
subscribersRef.current.delete(handler);
};
}, []);
const subscriptionValue = useMemo<LiveEventSubscription>(() => ({ subscribe }), [subscribe]);
// Coalesce the per-event invalidation storm. Optimistic setQueryData writes
// still pass straight through (immediate); only invalidateQueries is batched
@ -1322,6 +1405,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
userId: currentActorRef.current.userId,
agentId: currentActorRef.current.agentId,
});
// Fan the raw event out to component subscribers after cache
// handling so any reader sees fresh query data.
dispatchLiveEventToSubscribers(subscribersRef.current, liveCompanyId, parsed);
} catch {
// Ignore non-JSON payloads.
}
@ -1355,5 +1441,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
};
}, [coalescingClient, liveCompanyId, pushToast, canConnectSocket, socketAuthKey]);
return <>{children}</>;
return (
<LiveEventSubscriptionContext.Provider value={subscriptionValue}>
{children}
</LiveEventSubscriptionContext.Provider>
);
}

View File

@ -112,6 +112,12 @@ export const queryKeys = {
builtInAgents: {
list: (companyId: string) => ["built-in-agents", companyId] as const,
},
summarySlots: {
detail: (companyId: string, scopeKind: string, slotKey: string, scopeId?: string | null) =>
["summary-slots", companyId, scopeKind, slotKey, scopeId ?? null] as const,
revisions: (companyId: string, scopeKind: string, slotKey: string, scopeId?: string | null) =>
["summary-slots", companyId, scopeKind, slotKey, scopeId ?? null, "revisions"] as const,
},
issues: {
list: (companyId: string) => ["issues", companyId] as const,
mentionPool: (companyId: string) => ["issues", companyId, "mention-pool"] as const,

View File

@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import type { RunLogChunk } from "../adapters";
import {
isStructuredStreamingTextDelta,
mergeRunLogChunks,
parsePersistedLogContent,
readChunkSeq,
type ChunkMergeRefs,
type IncomingRunLogChunk,
} from "./run-log-chunks";
function freshRefs(): ChunkMergeRefs {
return { seenChunkKeys: new Set<string>(), trimmedSeqFloorByRun: new Map<string, number>() };
}
function seqChunk(seq: number, chunk: string): IncomingRunLogChunk {
return { ts: `t${seq}`, stream: "stdout", chunk, seq, dedupeKey: `k${seq}` };
}
describe("readChunkSeq", () => {
it("accepts finite numbers only", () => {
expect(readChunkSeq(3)).toBe(3);
expect(readChunkSeq(0)).toBe(0);
expect(readChunkSeq("3")).toBeUndefined();
expect(readChunkSeq(Number.NaN)).toBeUndefined();
expect(readChunkSeq(undefined)).toBeUndefined();
});
});
describe("isStructuredStreamingTextDelta", () => {
it("matches acpx.text_delta and text records", () => {
expect(isStructuredStreamingTextDelta('{"type":"acpx.text_delta","text":"x"}')).toBe(true);
expect(isStructuredStreamingTextDelta('{"type":"text"}')).toBe(true);
expect(isStructuredStreamingTextDelta('{"type":"acpx.tool_call"}')).toBe(false);
expect(isStructuredStreamingTextDelta("plain text")).toBe(false);
});
});
describe("parsePersistedLogContent", () => {
it("parses whole log rows and carries a partial trailing line across reads", () => {
const pending = new Map<string, string>();
const first = parsePersistedLogContent(
"run-1",
'{"ts":"a","stream":"stdout","chunk":"one","seq":1}\n{"ts":"b","stream":"std',
pending,
);
expect(first).toHaveLength(1);
expect(first[0]!.chunk).toBe("one");
expect(first[0]!.seq).toBe(1);
// Second read completes the partial row from the first.
const second = parsePersistedLogContent("run-1", 'out","chunk":"two","seq":2}\n', pending);
expect(second).toHaveLength(1);
expect(second[0]!.chunk).toBe("two");
expect(second[0]!.seq).toBe(2);
});
it("skips blank and malformed rows", () => {
const rows = parsePersistedLogContent(
"run-1",
'\n{"ts":"a","stream":"stdout","chunk":"ok","seq":1}\nnot-json\n{"chunk":""}\n',
new Map(),
);
expect(rows.map((r) => r.chunk)).toEqual(["ok"]);
});
});
describe("mergeRunLogChunks", () => {
it("orders sequenced chunks by seq regardless of arrival order", () => {
const refs = freshRefs();
let state: RunLogChunk[] = [];
({ chunks: state } = mergeRunLogChunks("r", state, [seqChunk(3, "c")], refs, 100));
({ chunks: state } = mergeRunLogChunks("r", state, [seqChunk(1, "a")], refs, 100));
({ chunks: state } = mergeRunLogChunks("r", state, [seqChunk(2, "b")], refs, 100));
expect(state.map((c) => c.chunk)).toEqual(["a", "b", "c"]);
});
it("dedupes by seq and keeps the longer payload from the other transport", () => {
const refs = freshRefs();
let state: RunLogChunk[] = [];
({ chunks: state } = mergeRunLogChunks("r", state, [seqChunk(1, "tru")], refs, 100));
// Same seq arrives complete via the other path → longer payload wins.
const result = mergeRunLogChunks("r", state, [seqChunk(1, "truncated")], refs, 100);
expect(result.changed).toBe(true);
expect(result.chunks.map((c) => c.chunk)).toEqual(["truncated"]);
// Same seq, not longer → no change.
const noChange = mergeRunLogChunks("r", result.chunks, [seqChunk(1, "short")], refs, 100);
expect(noChange.changed).toBe(false);
});
it("dedupes unsequenced chunks by content key but keeps repeated text deltas", () => {
const refs = freshRefs();
const delta: IncomingRunLogChunk = {
ts: "t",
stream: "stdout",
chunk: '{"type":"acpx.text_delta","text":"x"}',
dedupeKey: "delta",
};
const sys: IncomingRunLogChunk = { ts: "t", stream: "system", chunk: "run queued", dedupeKey: "sys" };
let state: RunLogChunk[] = [];
({ chunks: state } = mergeRunLogChunks("r", state, [delta, delta, sys, sys], refs, 100));
// Both identical text deltas kept; the duplicate system row dropped.
expect(state.map((c) => c.chunk)).toEqual([
'{"type":"acpx.text_delta","text":"x"}',
'{"type":"acpx.text_delta","text":"x"}',
"run queued",
]);
});
it("drops re-delivered chunks at or below the trimmed seq floor", () => {
const refs = freshRefs();
let state: RunLogChunk[] = [];
// maxChunksPerRun=2 forces seq 1 to be trimmed, raising the floor to 1.
({ chunks: state } = mergeRunLogChunks("r", state, [seqChunk(1, "a"), seqChunk(2, "b"), seqChunk(3, "c")], refs, 2));
expect(state.map((c) => c.chunk)).toEqual(["b", "c"]);
expect(refs.trimmedSeqFloorByRun.get("r")).toBe(1);
// Re-delivery of seq 1 is dropped rather than re-inserted ahead of newer output.
const redelivered = mergeRunLogChunks("r", state, [seqChunk(1, "a")], refs, 2);
expect(redelivered.changed).toBe(false);
});
it("returns the same reference when nothing changed", () => {
const refs = freshRefs();
const state: RunLogChunk[] = [];
const result = mergeRunLogChunks("r", state, [], refs, 100);
expect(result.chunks).toBe(state);
expect(result.changed).toBe(false);
});
});

View File

@ -0,0 +1,157 @@
import type { RunLogChunk } from "../adapters";
/**
* Chunk-merge / seq-dedupe primitives shared by the live run transcript view
* (`useLiveRunTranscripts`) and the summary draft stream (`useSummaryDraftStream`).
*
* Both consumers ingest the same run-log records from two transports the
* persisted offset-read poller and the company events WebSocket which
* interleave and re-deliver records. This module centralizes the ordering and
* de-duplication rules so the two hooks cannot drift apart.
*/
export type IncomingRunLogChunk = RunLogChunk & { dedupeKey: string };
/**
* Per-consumer merge state. `seenChunkKeys` is shared across every run tracked
* by a single consumer (bounded + cleared past a cap); `trimmedSeqFloorByRun`
* records, per run, the highest sequenced chunk that has been trimmed out of the
* retained window so re-delivered older records are dropped rather than
* re-inserted ahead of newer output.
*/
export interface ChunkMergeRefs {
seenChunkKeys: Set<string>;
trimmedSeqFloorByRun: Map<string, number>;
}
const SEEN_CHUNK_KEY_CAP = 12000;
export function readChunkSeq(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
export function isStructuredStreamingTextDelta(chunk: string): boolean {
return /"type"\s*:\s*"(?:acpx\.text_delta|text)"/.test(chunk);
}
/**
* Parse a raw persisted-log slice into ordered chunks. `pendingByRun` carries a
* partial trailing line across offset reads so a record split across a read
* boundary is not dropped.
*/
export function parsePersistedLogContent(
runId: string,
content: string,
pendingByRun: Map<string, string>,
): IncomingRunLogChunk[] {
if (!content) return [];
const pendingKey = `${runId}:records`;
const combined = `${pendingByRun.get(pendingKey) ?? ""}${content}`;
const split = combined.split("\n");
pendingByRun.set(pendingKey, split.pop() ?? "");
const parsed: IncomingRunLogChunk[] = [];
for (const line of split) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown; seq?: unknown };
const stream = raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
if (!chunk) continue;
parsed.push({
ts,
stream,
chunk,
seq: readChunkSeq(raw.seq),
dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`,
});
} catch {
// Ignore malformed log rows.
}
}
return parsed;
}
/**
* Merge incoming chunks into a run's retained window, preserving emit order and
* de-duplicating across the two delivery transports. Returns the same array
* reference when nothing changed so callers can bail out of React state updates.
*
* Ordering rules (unchanged from the original `useLiveRunTranscripts`
* implementation):
* - Sequenced chunks dedupe/order by the server-assigned monotonic `seq`. When
* the same `seq` arrives from both transports the longer payload wins (the
* websocket copy may be tail-truncated). Records at or below the trimmed
* floor are dropped.
* - Unsequenced chunks dedupe by content key (skipping structured streaming
* text deltas, which legitimately repeat) and act as an ordering barrier for
* subsequent sequenced inserts.
*/
export function mergeRunLogChunks(
runId: string,
prevChunks: RunLogChunk[],
incoming: IncomingRunLogChunk[],
refs: ChunkMergeRefs,
maxChunksPerRun: number,
): { chunks: RunLogChunk[]; changed: boolean } {
if (incoming.length === 0) return { chunks: prevChunks, changed: false };
const existing = [...prevChunks];
let changed = false;
for (const chunk of incoming) {
if (typeof chunk.seq === "number") {
const seqFloor = refs.trimmedSeqFloorByRun.get(runId) ?? 0;
if (chunk.seq <= seqFloor) continue;
const duplicateAt = existing.findIndex((item) => item.seq === chunk.seq);
if (duplicateAt !== -1) {
// Same record arrived via the other delivery path. Prefer the longer
// payload: websocket chunks may be tail-truncated while the persisted
// row is complete.
if (chunk.chunk.length > existing[duplicateAt]!.chunk.length) {
existing[duplicateAt] = { ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk, seq: chunk.seq };
changed = true;
}
continue;
}
// Insert in seq order relative to the trailing sequenced chunks so
// late-arriving records from the slower delivery path land where they
// were emitted. Unsequenced chunks act as an ordering barrier.
let insertAt = existing.length;
while (insertAt > 0) {
const prior = existing[insertAt - 1]!;
if (typeof prior.seq !== "number" || prior.seq < chunk.seq) break;
insertAt -= 1;
}
existing.splice(insertAt, 0, { ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk, seq: chunk.seq });
changed = true;
continue;
}
if (!isStructuredStreamingTextDelta(chunk.chunk)) {
if (refs.seenChunkKeys.has(chunk.dedupeKey)) continue;
refs.seenChunkKeys.add(chunk.dedupeKey);
}
existing.push({ ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk });
changed = true;
}
if (!changed) return { chunks: prevChunks, changed: false };
if (refs.seenChunkKeys.size > SEEN_CHUNK_KEY_CAP) {
refs.seenChunkKeys.clear();
}
if (existing.length > maxChunksPerRun) {
const trimmed = existing.splice(0, existing.length - maxChunksPerRun);
let seqFloor = refs.trimmedSeqFloorByRun.get(runId) ?? 0;
for (const item of trimmed) {
if (typeof item.seq === "number" && item.seq > seqFloor) seqFloor = item.seq;
}
if (seqFloor > 0) refs.trimmedSeqFloorByRun.set(runId, seqFloor);
}
return { chunks: existing, changed: true };
}

View File

@ -199,17 +199,23 @@ export const liveBlueBadge = "bg-blue-500/10 border-blue-500/30 text-blue-600 da
// Inline banner tones (built-in agents provenance / paused notices)
//
// Softer, full-width banner surface derived from the same brand hue anchors as
// `brandChipBadge`. `info` (blue) carries provenance/informational context;
// `warning` (amber) carries paused/attention context. Consumed by
// `brandChipBadge`. `info` carries provenance/informational context, `warning`
// carries paused/attention context, and `danger` carries failed actions. Consumed by
// `<InlineBanner>` so feature banners stay token-backed instead of hand-rolling
// per-instance `bg-yellow-*`/`bg-blue-*` recipes.
// ---------------------------------------------------------------------------
export type BannerTone = "info" | "warning";
export type BannerTone = "info" | "warning" | "danger";
export const brandBanner: Record<BannerTone, string> = {
info: "border-[#2563EB]/40 bg-[#DBEAFE]/50 text-[#1D4ED8] dark:border-[#2563eb59] dark:bg-[#2563eb14] dark:text-[#93C5FD]",
warning: "border-[#F59E0B]/50 bg-[#FEF3C7]/60 text-[#B45309] dark:border-[#f59e0b59] dark:bg-[#f59e0b12] dark:text-[#F59E0B]",
// PAP-14031: aligned to the proven `failed`/`error` chip recipe (bg-red-100 /
// text-red-700 pair) so title + body both clear WCAG AA 4.5:1 in light and
// dark on either `--background` or `--card`. The prior `text-destructive` on
// `bg-destructive/10` measured ~3.74.3:1 — under AA for normal text. Border
// keeps the destructive hue for continuity with other danger surfaces.
danger: "border-destructive/40 bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
};
export const issueStatusColor: Record<string, BrandChipColor> = {

View File

@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import type { RunLogChunk } from "../adapters";
import {
closeDanglingCodeFence,
extractAssistantOutputText,
parseSummaryDraftStream,
} from "./summary-draft-stream";
function delta(text: string, channel: "output" | "thought" = "output"): RunLogChunk {
return {
ts: "2026-07-15T00:00:00.000Z",
stream: "stdout",
chunk: JSON.stringify({ type: "acpx.text_delta", text, channel }),
};
}
/** Split a string into N pieces to simulate token-by-token streaming deltas. */
function splitDeltas(text: string, size = 3): RunLogChunk[] {
const chunks: RunLogChunk[] = [];
for (let i = 0; i < text.length; i += size) {
chunks.push(delta(text.slice(i, i + size)));
}
return chunks;
}
describe("extractAssistantOutputText", () => {
it("concatenates output-channel text deltas in order", () => {
const chunks = [delta("Hello "), delta("world")];
expect(extractAssistantOutputText(chunks)).toBe("Hello world");
});
it("ignores thought-channel deltas and non-text records", () => {
const toolCall: RunLogChunk = {
ts: "2026-07-15T00:00:00.000Z",
stream: "stdout",
chunk: JSON.stringify({ type: "acpx.tool_call", name: "Read", input: { path: "x" } }),
};
const nonJson: RunLogChunk = { ts: "t", stream: "system", chunk: "run queued" };
const chunks = [delta("visible "), delta("secret", "thought"), toolCall, nonJson, delta("text")];
expect(extractAssistantOutputText(chunks)).toBe("visible text");
});
});
describe("parseSummaryDraftStream", () => {
it("returns empty parse for empty text", () => {
expect(parseSummaryDraftStream("")).toEqual({
statusLine: null,
statusLines: [],
draft: null,
draftClosed: false,
});
});
it("extracts STATUS lines with the prefix stripped and tracks the latest", () => {
const text = "STATUS: reading the slot…\nsome noise\nSTATUS: writing the summary…\n";
const parsed = parseSummaryDraftStream(text);
expect(parsed.statusLines).toEqual(["reading the slot…", "writing the summary…"]);
expect(parsed.statusLine).toBe("writing the summary…");
});
it("requires STATUS markers at line start", () => {
const parsed = parseSummaryDraftStream("noise STATUS: not a real status\n");
expect(parsed.statusLine).toBeNull();
});
it("extracts a fully closed draft between the sentinels", () => {
const text = [
"STATUS: writing the summary…",
"<<<SUMMARY-DRAFT>>>",
"## Needs you",
"- Approve the launch",
"<<<END-SUMMARY-DRAFT>>>",
"",
].join("\n");
const parsed = parseSummaryDraftStream(text);
expect(parsed.draft).toBe("## Needs you\n- Approve the launch");
expect(parsed.draftClosed).toBe(true);
});
it("returns a partial draft while the closing sentinel has not arrived", () => {
const text = "<<<SUMMARY-DRAFT>>>\n## Needs you\n- Approve";
const parsed = parseSummaryDraftStream(text);
expect(parsed.draft).toBe("## Needs you\n- Approve");
expect(parsed.draftClosed).toBe(false);
});
it("rejoins markers split across streaming delta boundaries", () => {
// The full protocol output, streamed in tiny 3-char slices, then reassembled
// by extractAssistantOutputText exactly as the hook does.
const full = [
"STATUS: writing the summary…",
"<<<SUMMARY-DRAFT>>>",
"## Needs you",
"Nothing urgent.",
"<<<END-SUMMARY-DRAFT>>>",
].join("\n");
const reassembled = extractAssistantOutputText(splitDeltas(full, 3));
expect(reassembled).toBe(full);
const parsed = parseSummaryDraftStream(reassembled);
expect(parsed.statusLine).toBe("writing the summary…");
expect(parsed.draft).toBe("## Needs you\nNothing urgent.");
expect(parsed.draftClosed).toBe(true);
});
it("ignores an inline (non-line-start) start sentinel", () => {
const parsed = parseSummaryDraftStream("prefix <<<SUMMARY-DRAFT>>> still prose\n");
expect(parsed.draft).toBeNull();
});
it("yields no draft when the model skips the sentinels entirely", () => {
const parsed = parseSummaryDraftStream("STATUS: writing…\n## Needs you\nJust prose, no markers.\n");
expect(parsed.draft).toBeNull();
expect(parsed.draftClosed).toBe(false);
});
});
describe("closeDanglingCodeFence", () => {
it("appends a closing fence when a code block is left open", () => {
const md = "Here is code:\n```ts\nconst x = 1;";
expect(closeDanglingCodeFence(md)).toBe("Here is code:\n```ts\nconst x = 1;\n```");
});
it("leaves balanced fences untouched", () => {
const md = "```ts\nconst x = 1;\n```";
expect(closeDanglingCodeFence(md)).toBe(md);
});
it("leaves fence-free markdown untouched", () => {
const md = "## Needs you\n- one\n- two";
expect(closeDanglingCodeFence(md)).toBe(md);
});
});

View File

@ -0,0 +1,118 @@
import type { RunLogChunk } from "../adapters";
/**
* Streaming-summary output protocol parser (see the `summarize-status` skill).
*
* The Summarizer emits, as plain assistant text (never inside a tool call):
* - `STATUS: <action>…` lines, one before each procedure step; and
* - the final Markdown wrapped between the exact sentinels
* `<<<SUMMARY-DRAFT>>>` and `<<<END-SUMMARY-DRAFT>>>`, each on its own line,
* immediately before the authoritative summary-slot write.
*
* The card re-parses the full accumulated assistant text on every render, so
* markers split across streaming delta boundaries rejoin naturally once both
* halves have arrived. Markers are only recognized at the start of a line, so
* incidental occurrences inside prose or a partially-streamed marker never
* trigger a false draft.
*/
export const SUMMARY_DRAFT_START = "<<<SUMMARY-DRAFT>>>";
export const SUMMARY_DRAFT_END = "<<<END-SUMMARY-DRAFT>>>";
const STATUS_LINE_RE = /^STATUS:[ \t]?(.*)$/gm;
const DRAFT_START_RE = /^<<<SUMMARY-DRAFT>>>[ \t]*$/m;
const DRAFT_END_RE = /^<<<END-SUMMARY-DRAFT>>>[ \t]*$/m;
export interface SummaryDraftParse {
/** Latest `STATUS:` line (prefix stripped), or null if none has streamed. */
statusLine: string | null;
/** Every `STATUS:` line seen so far, in order. */
statusLines: string[];
/** Accumulated draft Markdown; may be partial while streaming. */
draft: string | null;
/** True once the closing sentinel has arrived. */
draftClosed: boolean;
}
const EMPTY_PARSE: SummaryDraftParse = {
statusLine: null,
statusLines: [],
draft: null,
draftClosed: false,
};
/**
* Extract the assistant "output"-channel prose from accumulated run-log chunks.
* Only `acpx.text_delta` records on the output channel carry the STATUS lines
* and sentinel-wrapped draft; thought-channel deltas and tool-call records are
* ignored. Chunks are assumed to already be in emit order.
*/
export function extractAssistantOutputText(chunks: RunLogChunk[]): string {
let text = "";
for (const chunk of chunks) {
const record = tryParseRecord(chunk.chunk);
if (!record) continue;
if (record.type !== "acpx.text_delta") continue;
const channel = typeof record.channel === "string" ? record.channel : "output";
if (channel === "thought" || channel === "thinking") continue;
if (typeof record.text === "string") text += record.text;
}
return text;
}
function tryParseRecord(chunk: string): Record<string, unknown> | null {
const trimmed = chunk.trim();
if (!trimmed.startsWith("{")) return null;
try {
const parsed = JSON.parse(trimmed) as unknown;
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
/** Parse the accumulated assistant text into status lines and the draft body. */
export function parseSummaryDraftStream(text: string): SummaryDraftParse {
if (!text) return EMPTY_PARSE;
const statusLines: string[] = [];
STATUS_LINE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = STATUS_LINE_RE.exec(text)) !== null) {
const value = (match[1] ?? "").trim();
if (value) statusLines.push(value);
}
const statusLine = statusLines.length > 0 ? statusLines[statusLines.length - 1]! : null;
const startMatch = text.match(DRAFT_START_RE);
let draft: string | null = null;
let draftClosed = false;
if (startMatch && startMatch.index !== undefined) {
const afterStart = text.slice(startMatch.index + startMatch[0].length).replace(/^\r?\n/, "");
const endMatch = afterStart.match(DRAFT_END_RE);
if (endMatch && endMatch.index !== undefined) {
draft = afterStart.slice(0, endMatch.index).replace(/\r?\n$/, "");
draftClosed = true;
} else {
draft = afterStart;
draftClosed = false;
}
}
return { statusLine, statusLines, draft, draftClosed };
}
/**
* Guard partial Markdown for live rendering: if the streamed draft ends inside
* an unclosed fenced code block, append a closing fence so `react-markdown`
* renders the partial code as a block instead of swallowing the rest of the
* document. Only needed while the draft is still streaming.
*/
export function closeDanglingCodeFence(markdown: string): string {
const fences = markdown.match(/^```/gm);
if (fences && fences.length % 2 === 1) {
const needsNewline = markdown.length > 0 && !markdown.endsWith("\n");
return `${markdown}${needsNewline ? "\n" : ""}\`\`\``;
}
return markdown;
}

View File

@ -45,7 +45,6 @@ import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentActionButtons } from "../components/AgentActionButtons";
import { InlineBanner } from "../components/InlineBanner";
import { BuiltInAgentBadge } from "../components/BuiltInAgentBadges";
import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel";
import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
@ -1129,7 +1128,6 @@ export function AgentDetail() {
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold truncate">{agent.name}</h2>
{builtInState && <BuiltInAgentBadge />}
</div>
<p className="text-sm text-muted-foreground truncate">
{roleLabels[agent.role] ?? agent.role}

View File

@ -885,6 +885,7 @@ describe("Agents", () => {
expect(container.textContent).toContain("Built-in");
expect(container.textContent).toContain("Briefs Agent");
expect(container.textContent).not.toContain("Regular Agent");
expect(container.querySelector('[title="Ships with Paperclip"]')).toBeNull();
expect(mockRouterState.navigate).not.toHaveBeenCalledWith("/agents/all", { replace: true });
});

View File

@ -16,7 +16,7 @@ import { AgentActionButtons } from "../components/AgentActionButtons";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { EntityRow } from "../components/EntityRow";
import { BuiltInAgentBadge, BuiltInLifecycleChip } from "../components/BuiltInAgentBadges";
import { BuiltInLifecycleChip } from "../components/BuiltInAgentBadges";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
@ -352,13 +352,13 @@ export function Agents() {
const agentJoinLeavePending = agentPending && membershipMutation.variables?.starred === undefined;
const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id);
const builtInState = builtInByAgentId.get(agent.id);
// Provenance badge + lifecycle chip + inline `Set up`. Rendered inline in
const showBuiltInLifecycle = builtInState?.status === "needs_setup" || builtInState?.status === "pending_approval";
// Lifecycle chip + inline `Set up`. Rendered inline in
// `meta` at xl (where there's room and the meta columns align) and on a
// dedicated full-width line beneath the name below xl, so the chips never
// starve the name — the row's primary identifier — at narrow widths.
const builtInCluster = builtInState ? (
const builtInCluster = builtInState && showBuiltInLifecycle ? (
<>
<BuiltInAgentBadge />
<BuiltInLifecycleChip status={builtInState.status} />
{builtInState.status === "needs_setup" && (
<span
@ -646,6 +646,7 @@ function OrgTreeNode({
}) {
const agent = agentMap.get(node.id);
const builtInState = builtInByAgentId.get(node.id);
const showBuiltInLifecycle = builtInState?.status === "needs_setup" || builtInState?.status === "pending_approval";
const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain");
const membershipState = resourceMembershipState(memberships, "agent", node.id);
const pending = membershipMutation.isPending &&
@ -681,9 +682,8 @@ function OrgTreeNode({
{agent?.title ? ` - ${agent.title}` : ""}
</span>
</div>
{builtInState && (
{builtInState && showBuiltInLifecycle && (
<div className="flex items-center gap-1.5 shrink-0">
<BuiltInAgentBadge />
<BuiltInLifecycleChip status={builtInState.status} />
{builtInState.status === "needs_setup" && (
<span

View File

@ -25,7 +25,7 @@ import {
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { InlineBanner } from "@/components/InlineBanner";
import { BuiltInAgentBadge, BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges";
import { BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
@ -427,7 +427,7 @@ export function DesignGuide() {
"StatusBadge", "StatusIcon", "PriorityIcon", "EntityRow", "EmptyState", "MetricCard",
"FilterBar", "InlineEditor", "PageSkeleton", "Identity", "CommentThread", "MarkdownEditor",
"PropertiesPanel", "Sidebar", "CommandPalette", "EnvironmentVariablesEditor",
"InlineBanner", "BuiltInAgentGate", "BuiltInAgentBadge",
"InlineBanner", "BuiltInAgentGate", "BuiltInLifecycleChip",
].map((name) => (
<Badge key={name} variant="ghost" className="font-mono text-(length:--text-nano)">
{name}
@ -2005,31 +2005,29 @@ export function DesignGuide() {
>
Its built-in agent was paused 2 days ago, so new briefs aren't being generated.
</InlineBanner>
<InlineBanner
tone="danger"
title="Summary generation failed."
actions={<Button size="sm">Retry</Button>}
>
The linked issue reached a terminal state before a summary was written.
</InlineBanner>
<InlineBanner tone="info" compact>
Compact variant for embedding inside dialogs and modals.
</InlineBanner>
</div>
</Section>
<Section title="Built-in Agent Badges">
<Section title="Built-in Agent Lifecycle Chips">
<p className="text-sm text-muted-foreground">
Provenance badge (constant, blue) plus a derived lifecycle chip (amber) for attention
states. The lifecycle chip is separate from the agent status vocabulary and only shows for{" "}
A derived lifecycle chip (amber) for attention states. The lifecycle chip is separate from
the agent status vocabulary and only shows for{" "}
<span className="font-mono">needs_setup</span> / <span className="font-mono">pending_approval</span>.
</p>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge />
<BuiltInLifecycleChip status="needs_setup" />
</div>
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge />
<BuiltInLifecycleChip status="pending_approval" />
</div>
<div className="flex items-center gap-1.5">
<BuiltInAgentBadge compact />
<BuiltInLifecycleChip status="needs_setup" compact />
</div>
<BuiltInLifecycleChip status="needs_setup" />
<BuiltInLifecycleChip status="pending_approval" />
<BuiltInLifecycleChip status="needs_setup" compact />
</div>
<p className="mt-3 text-sm text-muted-foreground">
<span className="font-mono">&lt;BuiltInAgentGate agentKey&gt;</span> composes{" "}

View File

@ -25,6 +25,7 @@ const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockPluginSlotOutlet = vi.hoisted(() => vi.fn());
const mockPluginSlotMount = vi.hoisted(() => vi.fn());
const mockSummarySlotCard = vi.hoisted(() => vi.fn());
const mockPluginSlotState = vi.hoisted(() => ({
slots: [] as unknown[],
isLoading: false,
@ -85,6 +86,12 @@ vi.mock("@/plugins/slots", () => ({
vi.mock("../components/IssuesList", () => ({
IssuesList: () => <div data-testid="issues-list" />,
}));
vi.mock("../components/SummarySlotCard", () => ({
SummarySlotCard: (props: unknown) => {
mockSummarySlotCard(props);
return <div data-testid="summary-slot-card" />;
},
}));
vi.mock("../components/ExecutionWorkspaceCloseDialog", () => ({
ExecutionWorkspaceCloseDialog: () => null,
}));
@ -281,6 +288,32 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
});
});
it("shows the linked project workspace summary above tasks", async () => {
mockExecutionWorkspacesApi.get.mockResolvedValue(workspace({ projectWorkspaceId: "project-workspace-1" }));
await render();
expect(mockSummarySlotCard).toHaveBeenCalledWith(expect.objectContaining({
companyId: "company-1",
scopeKind: "project_workspace",
scopeId: "project-workspace-1",
title: "Workspace summary",
}));
const summary = container.querySelector('[data-testid="summary-slot-card"]');
const issues = container.querySelector('[data-testid="issues-list"]');
expect(summary).not.toBeNull();
expect(issues).not.toBeNull();
if (!summary || !issues) throw new Error("Expected summary and issues list to render");
expect(summary.compareDocumentPosition(issues) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
});
it("does not show a project workspace summary for standalone execution workspaces", async () => {
await render();
expect(mockSummarySlotCard).not.toHaveBeenCalled();
expect(container.querySelector('[data-testid="summary-slot-card"]')).toBeNull();
});
it("does not mount plugin slots scoped to other entity types", async () => {
await render();

View File

@ -20,6 +20,7 @@ import { projectsApi } from "../api/projects";
import { routinesApi } from "../api/routines";
import { IssuesList } from "../components/IssuesList";
import { PageTabBar } from "../components/PageTabBar";
import { SummarySlotCard } from "../components/SummarySlotCard";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import {
@ -447,7 +448,7 @@ function ExecutionWorkspaceIssuesList({
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!companyId,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
// Event-sourced via LiveUpdatesProvider (issue 9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
@ -1409,14 +1410,25 @@ export function ExecutionWorkspaceDetail() {
</CardContent>
</Card>
) : activeTab === "issues" ? (
<ExecutionWorkspaceIssuesList
companyId={workspace.companyId}
workspace={workspace}
issues={linkedIssues}
isLoading={linkedIssuesQuery.isLoading}
error={linkedIssuesQuery.error as Error | null}
project={project}
/>
<div className="space-y-6">
{workspace.projectWorkspaceId ? (
<SummarySlotCard
companyId={workspace.companyId}
scopeKind="project_workspace"
scopeId={workspace.projectWorkspaceId}
title="Workspace summary"
description="Summarizer keeps the latest workspace status, next step, and operator-needed items here."
/>
) : null}
<ExecutionWorkspaceIssuesList
companyId={workspace.companyId}
workspace={workspace}
issues={linkedIssues}
isLoading={linkedIssuesQuery.isLoading}
error={linkedIssuesQuery.error as Error | null}
project={project}
/>
</div>
) : activePluginTab ? (
<PluginSlotMount
slot={activePluginTab.slot}

View File

@ -56,6 +56,8 @@ const SERVER_INFO_TOGGLE_SELECTOR =
const BUILT_IN_AGENTS_TOGGLE_SELECTOR =
'button[aria-label="Toggle built-in agents experimental setting"]';
const APPS_TOGGLE_SELECTOR = 'button[aria-label="Toggle apps experimental setting"]';
const SUMMARIES_TOGGLE_SELECTOR =
'button[aria-label="Toggle summaries experimental setting"]';
const AUTO_RECOVERY_TOGGLE_SELECTOR =
'button[aria-label="Toggle task graph liveness auto-recovery"]';
@ -72,6 +74,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableBuiltInAgents: false,
enableSummaries: false,
enableDecisions: false,
enableGoalsSidebarLink: false,
enableTaskWatchdogs: false,
@ -438,6 +441,26 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
expect(toggle?.getAttribute("aria-checked")).toBe("true");
});
it("renders and patches the Summaries experimental toggle", async () => {
await renderPage();
expect(container.textContent).toContain("Summaries");
expect(container.textContent).toContain("Show Summarizer-generated status slots");
const toggle = container.querySelector<HTMLButtonElement>(SUMMARIES_TOGGLE_SELECTOR);
expect(toggle?.getAttribute("aria-checked")).toBe("false");
await act(async () => {
toggle?.click();
});
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
enableSummaries: true,
});
expect(toggle?.getAttribute("aria-checked")).toBe("true");
});
it("renders and patches the Server Info Debug View experimental toggle", async () => {
await renderPage();

View File

@ -296,6 +296,7 @@ export function InstanceExperimentalSettings() {
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true;
const enableBuiltInAgents = experimentalQuery.data?.enableBuiltInAgents === true;
const enableSummaries = experimentalQuery.data?.enableSummaries === true;
const enableDecisions = experimentalQuery.data?.enableDecisions === true;
const enableGoalsSidebarLink = experimentalQuery.data?.enableGoalsSidebarLink === true;
const enableCases = experimentalQuery.data?.enableCases === true;
@ -508,6 +509,24 @@ export function InstanceExperimentalSettings() {
</div>
</Card>
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Summaries</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Show Summarizer-generated status slots on project and workspace pages, with on-demand refresh and
revision history. Existing summary data is kept when this is disabled.
</p>
</div>
<ToggleSwitch
checked={enableSummaries}
onCheckedChange={() => toggleMutation.mutate({ enableSummaries: !enableSummaries })}
disabled={toggleMutation.isPending}
aria-label="Toggle summaries experimental setting"
/>
</div>
</Card>
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">

View File

@ -32,6 +32,7 @@ const mockResourceMembershipsApi = vi.hoisted(() => ({
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockIssuesList = vi.hoisted(() => vi.fn());
const mockSummarySlotCard = vi.hoisted(() => vi.fn());
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
@ -81,6 +82,12 @@ vi.mock("../components/InlineEditor", () => ({
vi.mock("../components/ProjectWorkspacesContent", () => ({
ProjectWorkspacesContent: () => <div data-testid="project-workspaces" />,
}));
vi.mock("../components/SummarySlotCard", () => ({
SummarySlotCard: (props: unknown) => {
mockSummarySlotCard(props);
return <div data-testid="summary-slot-card">Project summary card</div>;
},
}));
vi.mock("../components/PageTabBar", () => ({
PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => (
<div>{items.map((item) => <button key={item.value}>{item.label}</button>)}</div>
@ -204,6 +211,16 @@ describe("ProjectDetail", () => {
});
expect(container.textContent).toContain("Managed by Missions");
expect(container.textContent).toContain("Project summary card");
expect(mockSummarySlotCard).toHaveBeenCalledWith(expect.objectContaining({
companyId: "company-1",
scopeKind: "project",
scopeId: "project-1",
title: "Project summary",
}));
const titleEditor = Array.from(container.querySelectorAll("span")).find((node) => node.textContent === "Managed Project");
const summaryCard = container.querySelector('[data-testid="summary-slot-card"]');
expect(titleEditor && summaryCard ? Boolean(titleEditor.compareDocumentPosition(summaryCard) & Node.DOCUMENT_POSITION_FOLLOWING) : false).toBe(true);
expect(container.textContent).toContain("Plugin operations");
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
projectId: "project-1",

View File

@ -24,6 +24,7 @@ import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
import { SummarySlotCard } from "../components/SummarySlotCard";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
@ -241,7 +242,7 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!companyId,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
// Event-sourced via LiveUpdatesProvider (issue 9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
@ -318,7 +319,7 @@ function ProjectPluginOperationsList({
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!companyId,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
// Event-sourced via LiveUpdatesProvider (issue 9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
@ -830,6 +831,14 @@ export function ProjectDetail() {
</div>
</div>
<SummarySlotCard
companyId={resolvedCompanyId}
scopeKind="project"
scopeId={project.id}
title="Project summary"
description="Summarizer keeps the latest project status, next step, and operator-needed items here."
/>
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="project"

View File

@ -17,6 +17,7 @@ const mockExecutionWorkspacesApi = vi.hoisted(() => ({
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn() }));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockSummarySlotCard = vi.hoisted(() => vi.fn());
vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi }));
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
@ -36,6 +37,12 @@ vi.mock("@/lib/router", () => ({
vi.mock("../components/IssuesQuicklook", () => ({
IssuesQuicklook: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("../components/SummarySlotCard", () => ({
SummarySlotCard: (props: unknown) => {
mockSummarySlotCard(props);
return <div data-testid="summary-slot-card">Workspaces summary card</div>;
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@ -176,6 +183,15 @@ describe("Workspaces", () => {
expect(mockInstanceSettingsApi.getExperimental).toHaveBeenCalled();
expect(mockExecutionWorkspacesApi.listOverview).toHaveBeenCalledWith("company-1", { offset: 0 });
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
expect(container.textContent).toContain("Workspaces summary card");
expect(mockSummarySlotCard).toHaveBeenCalledWith(expect.objectContaining({
companyId: "company-1",
scopeKind: "workspaces_overview",
title: "Workspace summary",
}));
const heading = Array.from(container.querySelectorAll("h2")).find((node) => node.textContent === "Workspaces");
const summaryCard = container.querySelector('[data-testid="summary-slot-card"]');
expect(heading && summaryCard ? Boolean(heading.compareDocumentPosition(summaryCard) & Node.DOCUMENT_POSITION_FOLLOWING) : false).toBe(true);
expect(container.textContent).toContain("Paperclip App");
expect(container.textContent).toContain("Workspace Alpha");
expect(container.textContent).toContain("PAP-11916");

View File

@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { instanceSettingsApi } from "../api/instanceSettings";
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
import { SummarySlotCard } from "../components/SummarySlotCard";
import { PageSkeleton } from "../components/PageSkeleton";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
@ -120,6 +121,13 @@ export function Workspaces() {
<h2 className="text-xl font-bold">Workspaces</h2>
</div>
<SummarySlotCard
companyId={selectedCompanyId}
scopeKind="workspaces_overview"
title="Workspace summary"
description="Summarizer tracks workspace activity, live services, and follow-up needs across projects."
/>
{groups.length === 0 ? (
<p className="text-sm text-muted-foreground">No workspace activity yet.</p>
) : (

View File

@ -7,7 +7,7 @@ import { EntityRow } from "@/components/EntityRow";
import { EmptyState } from "@/components/EmptyState";
import { InlineBanner } from "@/components/InlineBanner";
import { AgentStatusBadge } from "@/components/StatusBadge";
import { BuiltInAgentBadge, BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges";
import { BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges";
import { ConfigureBuiltInAgentModal } from "@/components/ConfigureBuiltInAgentModal";
import { BuiltInBundlePanel } from "@/components/BuiltInBundlePanel";
import {
@ -81,23 +81,22 @@ function RosterRow({
lifecycle?: "needs_setup" | "pending_approval";
status: string;
}) {
const cluster = (
const cluster = lifecycle ? (
<>
<BuiltInAgentBadge />
{lifecycle && <BuiltInLifecycleChip status={lifecycle} />}
<BuiltInLifecycleChip status={lifecycle} />
{lifecycle === "needs_setup" && (
<Button size="xs" variant="outline">Set up</Button>
)}
</>
);
) : null;
return (
<EntityRow
title={name}
titleClassName="w-56"
titlePriority
subtitle="General"
secondaryRow={<div className="xl:hidden flex flex-wrap items-center gap-1.5">{cluster}</div>}
meta={<div className="hidden xl:flex items-center gap-1.5">{cluster}</div>}
secondaryRow={cluster ? <div className="xl:hidden flex flex-wrap items-center gap-1.5">{cluster}</div> : undefined}
meta={cluster ? <div className="hidden xl:flex items-center gap-1.5">{cluster}</div> : undefined}
trailing={<AgentStatusBadge status={status} />}
/>
);
@ -131,7 +130,7 @@ export const SurfaceGallery: Story = {
<RosterRow name="Briefs Agent" lifecycle="pending_approval" status="idle" />
</div>
<p className="text-[11px] text-muted-foreground">
Resize below the <code>xl</code> breakpoint to see the badge/action
Resize below the <code>xl</code> breakpoint to see the lifecycle/action
cluster drop to a second line so the agent name never collapses
(PAP-12988).
</p>
@ -201,15 +200,11 @@ export const SurfaceGallery: Story = {
<div className="flex items-center gap-2 px-3 py-1.5 text-[13px]">
<span className="min-w-0 truncate">Briefs Agent</span>
<span className="ml-1 flex items-center gap-1">
<BuiltInAgentBadge compact />
<BuiltInLifecycleChip status="needs_setup" compact />
</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 text-[13px]">
<span className="min-w-0 truncate">Learning Agent</span>
<span className="ml-1 flex items-center gap-1">
<BuiltInAgentBadge compact />
</span>
</div>
</div>
</div>

View File

@ -0,0 +1,436 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
__liveUpdatesTestUtils,
type CompanyLiveEventHandler,
} from "@/context/LiveUpdatesProvider";
import type {
GetSummarySlotResponse,
ListSummarySlotRevisionsResponse,
SummarySlot,
SummarySlotDocument,
SummarySlotIssueRef,
SummarySlotRevision,
} from "@paperclipai/shared";
import { SummarySlotCard } from "@/components/SummarySlotCard";
import type { BuiltInAgentState } from "@/api/builtInAgents";
import { queryKeys } from "@/lib/queryKeys";
// QA fixtures for PAP-13939 — mirror the shapes exercised by
// SummarySlotCard.test.tsx so the browser render matches the unit coverage.
const COMPANY_ID = "company-1";
const SCOPE_KIND = "project" as const;
const SCOPE_ID = "project-1";
const SLOT_KEY = "header" as const;
const LATEST_BODY = [
"### Needs you",
"",
"- Approve the pricing plan — it has been waiting on you since yesterday.",
"- Answer QA's question about the login flow.",
"",
"### Since you were last here",
"",
"The team finished the search filters and shipped the mobile layout. Two tasks are still in review; nothing else is stuck.",
].join("\n");
const OLD_BODY = [
"### Needs you",
"",
"- Review the search filter PR before QA can start.",
].join("\n");
const MID_BODY = [
"### Needs you",
"",
"- Confirm whether the board wants mobile screenshots in the release note.",
"",
"### Since you were last here",
"",
"The Summarizer drafted a shorter update, but QA had not finished the login smoke yet.",
].join("\n");
const RECENT_BODY = [
"### Needs you",
"",
"- Answer QA's question about the login flow.",
"",
"### Since you were last here",
"",
"Search filters shipped and the mobile layout passed a first visual review.",
].join("\n");
function readySummarizer(): BuiltInAgentState {
return {
definition: {
key: "summarizer",
displayName: "Summarizer",
featureKeys: ["summarizer"],
shortPurpose: "Writes summaries",
defaultInstructions: "Summarize",
defaultRole: "Summarizer",
},
status: "ready",
agentId: "agent-summarizer",
agent: null,
pauseReason: null,
resources: [],
};
}
function needsSetupSummarizer(): BuiltInAgentState {
return { ...readySummarizer(), status: "needs_setup" };
}
function slot(overrides: Partial<SummarySlot> = {}): SummarySlot {
return {
id: "slot-1",
companyId: COMPANY_ID,
scopeKind: SCOPE_KIND,
scopeId: SCOPE_ID,
slotKey: SLOT_KEY,
documentId: null,
status: "idle",
failureReason: null,
generatingIssueId: null,
lastGeneratedAt: null,
lastGeneratedByAgentId: null,
lastModel: null,
createdAt: "2026-07-14T00:00:00.000Z",
updatedAt: "2026-07-14T16:12:00.000Z",
...overrides,
};
}
function summaryDocument(overrides: Partial<SummarySlotDocument> = {}): SummarySlotDocument {
return {
id: "doc-1",
companyId: COMPANY_ID,
title: "Project summary",
format: "markdown",
body: LATEST_BODY,
latestRevisionId: "rev-2",
latestRevisionNumber: 2,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: "agent-summarizer",
updatedByUserId: null,
createdAt: "2026-07-13T00:00:00.000Z",
updatedAt: "2026-07-14T16:12:00.000Z",
...overrides,
};
}
function issue(overrides: Partial<SummarySlotIssueRef> = {}): SummarySlotIssueRef {
return {
id: "issue-1",
identifier: "PAP-14000",
title: "Summarize project",
status: "in_progress",
...overrides,
};
}
function revision(overrides: Partial<SummarySlotRevision> = {}): SummarySlotRevision {
return {
id: "rev-1",
companyId: COMPANY_ID,
documentId: "doc-1",
revisionNumber: 1,
title: "Project summary",
format: "markdown",
body: OLD_BODY,
changeSummary: null,
createdByAgentId: "agent-summarizer",
createdByUserId: null,
createdByRunId: null,
createdAt: "2026-07-13T00:00:00.000Z",
...overrides,
};
}
interface SeedInput {
enableSummaries?: boolean;
agent?: BuiltInAgentState;
slotResponse?: GetSummarySlotResponse;
revisionsResponse?: ListSummarySlotRevisionsResponse;
}
function Seed({ seed, children }: { seed: SeedInput; children: ReactNode }) {
const queryClient = useQueryClient();
queryClient.setQueryData(queryKeys.instance.experimentalSettings, {
enableSummaries: seed.enableSummaries ?? true,
});
if (seed.agent) {
queryClient.setQueryData(queryKeys.builtInAgents.list(COMPANY_ID), [seed.agent]);
}
if (seed.slotResponse) {
queryClient.setQueryData(
queryKeys.summarySlots.detail(COMPANY_ID, SCOPE_KIND, SLOT_KEY, SCOPE_ID),
seed.slotResponse,
);
}
if (seed.revisionsResponse) {
queryClient.setQueryData(
queryKeys.summarySlots.revisions(COMPANY_ID, SCOPE_KIND, SLOT_KEY, SCOPE_ID),
seed.revisionsResponse,
);
}
return <>{children}</>;
}
function CardHarness({ seed, width }: { seed: SeedInput; width: number }) {
return (
<Seed seed={seed}>
<div style={{ width, maxWidth: "100%" }}>
<SummarySlotCard
companyId={COMPANY_ID}
scopeKind={SCOPE_KIND}
scopeId={SCOPE_ID}
slotKey={SLOT_KEY}
title="Project summary"
description="Summarizer keeps the latest project status, next step, and operator-needed items here."
/>
</div>
</Seed>
);
}
const { LiveEventSubscriptionContext, dispatchLiveEventToSubscribers } = __liveUpdatesTestUtils;
// Wraps the card in the shared live-event subscription and pushes a single
// heartbeat.run.progress event once the card has mounted, so the generating
// state renders its live status line (PAP-13984).
function LiveStatusHarness({ seed, width, message }: { seed: SeedInput; width: number; message: string }) {
const subscribersRef = useRef<Set<CompanyLiveEventHandler>>(new Set());
const subscription = useRef({
subscribe: (fn: CompanyLiveEventHandler) => {
subscribersRef.current.add(fn);
return () => {
subscribersRef.current.delete(fn);
};
},
});
useEffect(() => {
dispatchLiveEventToSubscribers(subscribersRef.current, COMPANY_ID, {
id: 1,
companyId: COMPANY_ID,
type: "heartbeat.run.progress",
createdAt: "2026-07-15T00:00:00.000Z",
payload: { issueId: "issue-1", message },
});
}, [message]);
return (
<LiveEventSubscriptionContext.Provider value={subscription.current}>
<CardHarness seed={seed} width={width} />
</LiveEventSubscriptionContext.Provider>
);
}
// Streams the summarize-status output protocol (STATUS lines + sentinel-wrapped
// draft) over the shared live-event socket so the card renders its token-streamed
// draft preview (PAP-13986). Learns the run id from a progress event first, then
// pushes the assistant `acpx.text_delta` records that carry the draft.
function StreamingDraftHarness({
seed,
width,
draftText,
sliceSize = 8,
}: {
seed: SeedInput;
width: number;
draftText: string;
sliceSize?: number;
}) {
const subscribersRef = useRef<Set<CompanyLiveEventHandler>>(new Set());
const subscription = useRef({
subscribe: (fn: CompanyLiveEventHandler) => {
subscribersRef.current.add(fn);
return () => {
subscribersRef.current.delete(fn);
};
},
});
useEffect(() => {
const runId = "run-summary-1";
dispatchLiveEventToSubscribers(subscribersRef.current, COMPANY_ID, {
id: 1,
companyId: COMPANY_ID,
type: "heartbeat.run.progress",
createdAt: "2026-07-15T00:00:00.000Z",
payload: { issueId: "issue-1", runId },
});
// Defer the token deltas one tick so the learned run id has committed before
// the log handler filters on it.
const timer = window.setTimeout(() => {
let seq = 1;
for (let i = 0; i < draftText.length; i += sliceSize) {
dispatchLiveEventToSubscribers(subscribersRef.current, COMPANY_ID, {
id: seq + 1,
companyId: COMPANY_ID,
type: "heartbeat.run.log",
createdAt: "2026-07-15T00:00:00.000Z",
payload: {
runId,
seq,
ts: "2026-07-15T00:00:00.000Z",
stream: "stdout",
chunk: JSON.stringify({
type: "acpx.text_delta",
text: draftText.slice(i, i + sliceSize),
channel: "output",
}),
},
});
seq += 1;
}
}, 30);
return () => window.clearTimeout(timer);
}, [draftText, sliceSize]);
return (
<LiveEventSubscriptionContext.Provider value={subscription.current}>
<CardHarness seed={seed} width={width} />
</LiveEventSubscriptionContext.Provider>
);
}
const STREAMING_DRAFT_PARTIAL = [
"STATUS: reviewing 14 open issues…",
"STATUS: writing the summary…",
"<<<SUMMARY-DRAFT>>>",
"### Needs you",
"",
"- Approve the pricing plan — it has been waiting since yesterday.",
"- Answer QA's question about the login flow.",
"",
"### Since you were last here",
"",
"The team finished the search filters and the mobile layout is now",
].join("\n");
const STREAMING_DRAFT_COMPLETE = [
STREAMING_DRAFT_PARTIAL,
" passing a first visual review. Nothing else is stuck.\n",
"<<<END-SUMMARY-DRAFT>>>\n",
].join("");
const DESKTOP = 640;
const MOBILE = 375;
const meta: Meta<typeof CardHarness> = {
title: "Summaries/SummarySlotCard",
component: CardHarness,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof CardHarness>;
const emptySeed: SeedInput = {
agent: readySummarizer(),
slotResponse: { slot: null, document: null, generatingIssue: null },
revisionsResponse: { slot: null, revisions: [] },
};
const setupSeed: SeedInput = {
agent: needsSetupSummarizer(),
slotResponse: { slot: null, document: null, generatingIssue: null },
};
const generatingSeed: SeedInput = {
agent: readySummarizer(),
slotResponse: {
slot: slot({ status: "generating", generatingIssueId: "issue-1" }),
document: null,
generatingIssue: issue({ status: "in_progress" }),
},
};
const generatedSeed: SeedInput = {
agent: readySummarizer(),
slotResponse: {
slot: slot({ documentId: "doc-1", lastModel: "claude-haiku" }),
document: summaryDocument(),
generatingIssue: null,
},
revisionsResponse: {
slot: slot({ documentId: "doc-1" }),
revisions: [revision({ id: "rev-2", revisionNumber: 2, body: LATEST_BODY })],
},
};
const historySeed: SeedInput = {
agent: readySummarizer(),
slotResponse: {
slot: slot({ documentId: "doc-1" }),
document: summaryDocument({ latestRevisionId: "rev-4", latestRevisionNumber: 4 }),
generatingIssue: null,
},
revisionsResponse: {
slot: slot({ documentId: "doc-1" }),
revisions: [
revision({ id: "rev-1", revisionNumber: 1, body: OLD_BODY, createdAt: "2026-07-08T11:02:00.000Z" }),
revision({ id: "rev-2", revisionNumber: 2, body: MID_BODY, createdAt: "2026-07-10T14:42:00.000Z" }),
revision({ id: "rev-3", revisionNumber: 3, body: RECENT_BODY, createdAt: "2026-07-13T09:25:00.000Z" }),
revision({ id: "rev-4", revisionNumber: 4, body: LATEST_BODY, createdAt: "2026-07-14T16:12:00.000Z" }),
],
},
};
const failedSeed: SeedInput = {
agent: readySummarizer(),
slotResponse: {
slot: slot({
status: "failed",
failureReason: "Summary generation task PAP-14000: Summarize project finished without writing a summary.",
generatingIssueId: "issue-1",
}),
document: null,
generatingIssue: issue({ status: "done" }),
},
};
export const Disabled: Story = {
args: { seed: { enableSummaries: false, agent: readySummarizer() }, width: DESKTOP },
};
export const SetupCta: Story = { args: { seed: setupSeed, width: DESKTOP } };
export const Empty: Story = { args: { seed: emptySeed, width: DESKTOP } };
export const Generating: Story = { args: { seed: generatingSeed, width: DESKTOP } };
export const GeneratingWithStatusLine: StoryObj<typeof LiveStatusHarness> = {
render: (args) => <LiveStatusHarness {...args} />,
args: {
seed: generatingSeed,
width: DESKTOP,
message: "Reviewing 14 open issues, drafting the “Needs you” section…",
},
};
// PAP-13986 — token-streamed draft rendering.
export const StreamingDraft: StoryObj<typeof StreamingDraftHarness> = {
render: (args) => <StreamingDraftHarness {...args} />,
args: { seed: generatingSeed, width: DESKTOP, draftText: STREAMING_DRAFT_PARTIAL },
};
export const StreamingDraftComplete: StoryObj<typeof StreamingDraftHarness> = {
render: (args) => <StreamingDraftHarness {...args} />,
args: { seed: generatingSeed, width: DESKTOP, draftText: STREAMING_DRAFT_COMPLETE },
};
// Finalize handoff: the authoritative revision has landed (Phase 1 invalidation)
// and replaced the streamed preview.
export const FinalizeHandoff: Story = { args: { seed: generatedSeed, width: DESKTOP } };
export const Generated: Story = { args: { seed: generatedSeed, width: DESKTOP } };
export const HistoryRevisions: Story = { args: { seed: historySeed, width: DESKTOP } };
export const FailedRetry: Story = { args: { seed: failedSeed, width: DESKTOP } };
export const EmptyMobile: Story = { args: { seed: emptySeed, width: MOBILE } };
export const GeneratedMobile: Story = { args: { seed: generatedSeed, width: MOBILE } };
export const HistoryRevisionsMobile: Story = { args: { seed: historySeed, width: MOBILE } };
export const SetupCtaMobile: Story = { args: { seed: setupSeed, width: MOBILE } };