Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
484a6c7
refactor(shared): extract task contracts and model policy
richardsolomou Jul 24, 2026
ec5fb80
fix(models): preserve GLM effort policy
richardsolomou Jul 24, 2026
fa2dda4
test(ui): wait for async content
richardsolomou Jul 24, 2026
0960bc3
test(ui): isolate plan approval presentation
richardsolomou Jul 24, 2026
3f608f2
fix(shared): Preserve canonical task artifacts
richardsolomou Jul 28, 2026
9c4235c
test(agent): match canonical model picker order
richardsolomou Jul 28, 2026
b4e9084
Merge main into shared cloud task foundations
richardsolomou Jul 29, 2026
b69d17d
refactor(api-client): extract cloud task transport
richardsolomou Jul 24, 2026
01aec6d
refactor(core): extract cloud task policies
richardsolomou Jul 24, 2026
53e5383
refactor(core): rename cloud task service as engine
richardsolomou Jul 24, 2026
2245a2e
refactor(core): extract portable cloud task engine
richardsolomou Jul 24, 2026
36f9b39
refactor(core): extract repository integration semantics
richardsolomou Jul 24, 2026
3003336
refactor(core): extract pending prompt recovery
richardsolomou Jul 24, 2026
b5204c8
refactor(core): extract plan approval presentation
richardsolomou Jul 24, 2026
7ccd259
refactor(core): extract permission option presentation
richardsolomou Jul 24, 2026
046422e
refactor(core): extract composer controls
richardsolomou Jul 24, 2026
37f1c0b
refactor(core): extract composer model policy
richardsolomou Jul 24, 2026
0a1c884
fix(core): prefer streamed plan content
richardsolomou Jul 24, 2026
e27b337
refactor(core): extract session presentation semantics
richardsolomou Jul 24, 2026
04a7f52
refactor(api-client): expose shared automation contracts
richardsolomou Jul 24, 2026
0b25fcd
refactor(core): extract inbox presentation semantics
richardsolomou Jul 24, 2026
afa5cb6
refactor(core): extract inbox activity presentation
richardsolomou Jul 24, 2026
a7e4e28
refactor(core): extract portability contracts
richardsolomou Jul 24, 2026
1f3ad6a
refactor(ui): reuse inbox identifier formatting
richardsolomou Jul 24, 2026
c6b211e
chore: merge main into MCP transport extraction
richardsolomou Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/api-client/src/posthog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,33 @@ describe("PostHogAPIClient", () => {
);
});

it.each([true, false])("forwards auto publish %s", async (autoPublish) => {
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);
const post = vi.fn().mockResolvedValue({
id: "task-123",
title: "Task",
description: "Task",
created_at: "2026-04-14T00:00:00Z",
updated_at: "2026-04-14T00:00:00Z",
origin_product: "user_created",
});
(client as unknown as { api: { post: typeof post } }).api = { post };

await client.runTaskInCloud("task-123", null, { autoPublish });

expect(post).toHaveBeenCalledWith(
"/api/projects/{project_id}/tasks/{id}/run/",
expect.objectContaining({
body: expect.objectContaining({ auto_publish: autoPublish }),
}),
);
});

it("rejects unsupported reasoning effort for cloud Codex runs", async () => {
const client = new PostHogAPIClient(
"http://localhost:8000",
Expand Down
2 changes: 1 addition & 1 deletion packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,7 @@ function buildCloudRunRequestBody(
if (options?.prAuthorshipMode) {
body.pr_authorship_mode = options.prAuthorshipMode;
}
if (options?.autoPublish) {
if (options?.autoPublish !== undefined) {
body.auto_publish = options.autoPublish;
}
if (options?.rtkEnabled === false) {
Expand Down
30 changes: 30 additions & 0 deletions packages/api-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,33 @@ export type McpAuthType = Schemas.MCPAuthTypeEnum;
export type McpRecommendedServer = Schemas.MCPServerTemplate;
export type McpServerInstallation = Schemas.MCPServerInstallation;
export type McpInstallationTool = Schemas.MCPServerInstallationTool;
export type McpOAuthRedirectResponse = Schemas.OAuthRedirectResponse;
export type McpInstallSource = "posthog" | "posthog-code" | "posthog-mobile";
export type McpInstallResponse =
| McpServerInstallation
| McpOAuthRedirectResponse;

export interface InstallCustomMcpServerOptions {
name: string;
url: string;
auth_type: McpAuthType;
api_key?: string;
description?: string;
client_id?: string;
client_secret?: string;
install_source?: McpInstallSource;
posthog_code_callback_url?: string;
}

export interface InstallMcpTemplateOptions {
template_id: string;
api_key?: string;
install_source?: McpInstallSource;
posthog_code_callback_url?: string;
}

export interface UpdateMcpServerInstallationOptions {
display_name?: string;
description?: string;
is_enabled?: boolean;
}
37 changes: 37 additions & 0 deletions packages/core/src/automations/automationTemplatePresentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { TaskAutomation } from "@posthog/shared";

export const SKILL_TEMPLATE_ID_PREFIX = "llm-skill:";

export function formatSkillTemplateId(skillName: string): string {
return `${SKILL_TEMPLATE_ID_PREFIX}${skillName.trim()}`;
}

export function parseSkillTemplateId(
templateId: string | null | undefined,
): string | null {
if (!templateId?.startsWith(SKILL_TEMPLATE_ID_PREFIX)) return null;
const skillName = templateId.slice(SKILL_TEMPLATE_ID_PREFIX.length).trim();
return skillName || null;
}

export interface AutomationTemplatePresentation {
templateName: string | null;
repositoryLabel: string | null;
contextLabel: string | null;
secondaryLabel: string;
}

export function getAutomationTemplatePresentation(
automation: Pick<TaskAutomation, "repository" | "template_id">,
): AutomationTemplatePresentation {
const repositoryLabel = automation.repository.trim() || null;
const skillName = parseSkillTemplateId(automation.template_id);
const contextLabel = skillName ? "Skill store" : null;
return {
templateName:
skillName ?? (automation.template_id ? "Template automation" : null),
repositoryLabel,
contextLabel,
secondaryLabel: repositoryLabel ?? contextLabel ?? "No repository context",
};
}
74 changes: 74 additions & 0 deletions packages/core/src/inbox/activityLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { AnySignalReportArtefact } from "@posthog/shared/domain-types";

export type ActivityArtefact = Extract<
AnySignalReportArtefact,
{ type: "commit" | "task_run" }
>;

export function selectActivityArtefacts(
artefacts: AnySignalReportArtefact[],
): ActivityArtefact[] {
return artefacts
.filter(
(artefact): artefact is ActivityArtefact =>
artefact.type === "commit" || artefact.type === "task_run",
)
.sort((left, right) => left.created_at.localeCompare(right.created_at));
}

export function shortSha(sha: string): string {
return sha.slice(0, 12);
}

const SIGNALS_TYPE_LABELS: Record<string, string> = {
research: "Research",
implementation: "Implementation",
repo_selection: "Repo selection",
};

export function humanizeIdentifier(value: string): string {
const spaced = value.replace(/[_-]+/g, " ").trim();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}

export function taskRunLabel(content: {
product: string;
type: string;
}): string {
return content.product === "signals"
? (SIGNALS_TYPE_LABELS[content.type] ?? humanizeIdentifier(content.type))
: humanizeIdentifier(content.type);
}

export function attributionLabel(artefact: {
created_by?: { first_name?: string; email: string } | null;
task_id?: string | null;
}): string | null {
if (artefact.created_by) {
return artefact.created_by.first_name?.trim() || artefact.created_by.email;
}
return artefact.task_id ? "agent" : null;
}

export type DiffLineKind = "add" | "del" | "hunk" | "context";

export interface DiffLine {
text: string;
kind: DiffLineKind;
}

export function parseDiffLines(diff: string): DiffLine[] {
return diff
.replace(/\n$/, "")
.split("\n")
.map((text) => {
if (text.startsWith("+") && !text.startsWith("+++")) {
return { text, kind: "add" as const };
}
if (text.startsWith("-") && !text.startsWith("---")) {
return { text, kind: "del" as const };
}
if (text.startsWith("@@")) return { text, kind: "hunk" as const };
return { text, kind: "context" as const };
});
}
27 changes: 20 additions & 7 deletions packages/core/src/inbox/engagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,16 @@ export function buildBulkActionEvents(
export interface InboxViewedFilterState {
sourceProductFilter: string[];
priorityFilter: string[];
searchQuery: string;
searchQuery?: string;
statusFilter?: readonly string[];
defaultStatusFilter?: readonly string[];
suggestedReviewerFilter?: string[];
/**
* True when the reviewer scope is the default ("For you"). False when the
* user has narrowed to a teammate or the whole project — treated as an
* active filter for `has_active_filters`.
*/
isDefaultScope: boolean;
isDefaultScope?: boolean;
}

export interface BuildInboxViewedInput {
Expand All @@ -192,7 +195,7 @@ export interface BuildInboxViewedInput {
/** Server-reported total of reports matching the active query — the headline inbox number. */
totalCount: number;
/** Tab badge counts shown in the v2 header (the numbers the user actually sees). */
tabCounts: { pulls: number; reports: number };
tabCounts?: { pulls: number; reports: number };
filters: InboxViewedFilterState;
}

Expand All @@ -207,7 +210,8 @@ export interface BuildInboxViewedInput {
export function buildInboxViewedProperties(
input: BuildInboxViewedInput,
): InboxViewedProperties {
const { visibleReports, totalCount, tabCounts, filters } = input;
const { visibleReports, totalCount, filters } = input;
const tabCounts = input.tabCounts ?? { pulls: 0, reports: totalCount };

const priorityCounts = { P0: 0, P1: 0, P2: 0, P3: 0, P4: 0, unknown: 0 };
const actionabilityCounts = {
Expand Down Expand Up @@ -237,19 +241,28 @@ export function buildInboxViewedProperties(
}
}

const statusFiltered =
filters.statusFilter !== undefined &&
filters.defaultStatusFilter !== undefined &&
(filters.statusFilter.length !== filters.defaultStatusFilter.length ||
filters.statusFilter.some(
(status) => !filters.defaultStatusFilter?.includes(status),
));
const hasActiveFilters =
filters.sourceProductFilter.length > 0 ||
filters.priorityFilter.length > 0 ||
filters.searchQuery.trim().length > 0 ||
!filters.isDefaultScope;
(filters.searchQuery?.trim().length ?? 0) > 0 ||
statusFiltered ||
(filters.suggestedReviewerFilter?.length ?? 0) > 0 ||
filters.isDefaultScope === false;

return {
report_count: visibleReports.length,
total_count: totalCount,
ready_count: readyCount,
has_active_filters: hasActiveFilters,
source_product_filter: filters.sourceProductFilter,
status_filter_count: 0,
status_filter_count: filters.statusFilter?.length ?? 0,
is_empty: totalCount === 0,
priority_p0_count: priorityCounts.P0,
priority_p1_count: priorityCounts.P1,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/inbox/reportMembership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ export function isDismissedReport(report: SignalReport): boolean {
return report.status === "suppressed" || report.status === "resolved";
}

export function isRestorableReport(
report: Pick<SignalReport, "status">,
): boolean {
return report.status === "suppressed";
}

export function getImmediatelyActionableReports(
reports: SignalReport[],
): SignalReport[] {
return reports.filter(
(report) =>
report.status === "ready" &&
report.actionability === "immediately_actionable" &&
!report.already_addressed,
);
}

export type InboxScope = "for-you" | "entire-project" | `teammate:${string}`;

export const INBOX_SCOPE_FOR_YOU: InboxScope = "for-you";
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/mcp-servers/presentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function isMcpOAuthRedirect(
response: object,
): response is { redirect_url: string } {
return (
"redirect_url" in response && typeof response.redirect_url === "string"
);
}

export function isStdioMcpServer(server: {
transport_type?: string | null;
}): boolean {
return server.transport_type === "stdio";
}
85 changes: 85 additions & 0 deletions packages/core/src/sessions/posthogExecDisplay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { parseMcpToolName } from "@posthog/shared";

const POSTHOG_SERVER_RE = /^(?:plugin_)?posthog(?:_[^_]+)*$/;
const POSTHOG_VERB_RE =
/^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/;
const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/;
const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/;

export interface PostHogExecDisplay {
label: string;
input?: string;
}

export function isPostHogExecTool(toolName: string): boolean {
const mcp = parseMcpToolName(toolName);
return !!mcp && mcp.tool === "exec" && POSTHOG_SERVER_RE.test(mcp.server);
}

export function getPostHogExecDisplay(
toolInput: unknown,
): PostHogExecDisplay | null {
if (!toolInput || typeof toolInput !== "object") return null;
const input = toolInput as { command?: unknown; input?: unknown };
if (typeof input.command !== "string") return null;
const match = input.command.match(POSTHOG_VERB_RE);
if (!match) return null;
const verb = match[1] as "tools" | "search" | "info" | "schema" | "call";
const rest = (match[2] ?? "").trim();
const explicitInput = readExplicitInput(input.input);

switch (verb) {
case "tools":
return { label: "List tools", input: undefined };
case "search":
return {
label: "Search tools",
input: explicitInput ?? (rest || undefined),
};
case "info":
return { label: rest ? `Read ${rest}` : "Read tool", input: undefined };
case "schema": {
const schema = rest.match(POSTHOG_TOOL_NAME_RE);
if (!schema) return { label: "Inspect schema", input: undefined };
const path = explicitInput ?? ((schema[2] ?? "").trim() || undefined);
return {
label: path
? `Inspect ${schema[1]}.${path}`
: `Inspect ${schema[1]} fields`,
input: undefined,
};
}
case "call": {
const call = rest.match(POSTHOG_CALL_BODY_RE);
if (!call) return null;
return {
label: call[1],
input: explicitInput ?? ((call[2] ?? "").trim() || undefined),
};
}
}
}

function readExplicitInput(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value === "string") return value.trim() || undefined;
try {
return JSON.stringify(value);
} catch {
return undefined;
}
}

export function formatPosthogExecBody(
input: string | undefined,
): string | undefined {
if (!input) return undefined;
try {
const parsed = JSON.parse(input);
if (parsed && typeof parsed === "object")
return JSON.stringify(parsed, null, 2);
} catch {
return input;
}
return input;
}
Loading
Loading