diff --git a/apps/code/src/renderer/di/bindings.ts b/apps/code/src/renderer/di/bindings.ts index f8556c970f..5bd2b7626d 100644 --- a/apps/code/src/renderer/di/bindings.ts +++ b/apps/code/src/renderer/di/bindings.ts @@ -13,6 +13,10 @@ import { type AutoresearchSessionClient, type AutoresearchStorageClient, } from "@posthog/core/autoresearch/identifiers"; +import { + CLOUD_TASK_CLIENT, + type CloudTaskClient, +} from "@posthog/core/cloud-task/cloudTaskClient"; import { CODE_REVIEW_WORKSPACE_CLIENT, REVERT_HUNK_SERVICE, @@ -76,8 +80,10 @@ import { import { PI_RUNNER } from "@posthog/core/pi-runtime/identifiers"; import type { PiRunner } from "@posthog/core/pi-runtime/piRunner"; import { - PI_SESSION_CLIENT, - type PiSessionClient, + LOCAL_PI_SESSION_FACTORY, + PI_SESSION_PROVIDER, + type PiSessionFactory, + type PiSessionProvider, } from "@posthog/core/pi-runtime/piSessionController"; import { type BundleLocalSkill, @@ -295,7 +301,9 @@ export interface RendererBindings { [ANALYTICS_TRACKER]: AnalyticsTracker; [TASK_CREATION_HOST]: ITaskCreationHost; [PI_RUNNER]: PiRunner; - [PI_SESSION_CLIENT]: PiSessionClient; + [PI_SESSION_PROVIDER]: PiSessionProvider; + [LOCAL_PI_SESSION_FACTORY]: PiSessionFactory; + [CLOUD_TASK_CLIENT]: CloudTaskClient; [TASK_CREATION_EFFECTS]: TaskCreationEffects; [RENDERER_TASK_SERVICE]: TaskService; [TASK_SERVICE]: TaskService; diff --git a/apps/code/src/renderer/di/container.ts b/apps/code/src/renderer/di/container.ts index 5d7d27ddf4..dba9224fd8 100644 --- a/apps/code/src/renderer/di/container.ts +++ b/apps/code/src/renderer/di/container.ts @@ -2,6 +2,10 @@ import "reflect-metadata"; import { useDevFlagsStore } from "@features/dev-toolbar/devFlagsStore"; import { TypedContainer } from "@inversifyjs/strongly-typed"; import type { TrpcRouter } from "@main/trpc/router"; +import { + CLOUD_TASK_CLIENT, + type CloudTaskClient, +} from "@posthog/core/cloud-task/cloudTaskClient"; import { CODE_REVIEW_WORKSPACE_CLIENT, REVERT_HUNK_SERVICE, @@ -35,7 +39,7 @@ import type { LocalMcpWorkspaceClient } from "@posthog/core/local-mcp/localMcpIm import { PI_RUNNER } from "@posthog/core/pi-runtime/identifiers"; import { piRuntimeModule } from "@posthog/core/pi-runtime/pi-runtime.module"; import type { PiRunner } from "@posthog/core/pi-runtime/piRunner"; -import { PI_SESSION_CLIENT } from "@posthog/core/pi-runtime/piSessionController"; +import { LOCAL_PI_SESSION_FACTORY } from "@posthog/core/pi-runtime/piSessionController"; import { CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL, CLOUD_ARTIFACT_READ_FILE_AS_BASE64, @@ -89,7 +93,9 @@ import { import { WorkspaceSetupService } from "@posthog/core/workspace/WorkspaceSetupService"; import { setRootContainer } from "@posthog/di/container"; import { HOST_TRPC_CLIENT } from "@posthog/host-router/client"; -import { TrpcPiSessionClient } from "@posthog/host-router/pi-session-client"; +import { TrpcCloudTaskClient } from "@posthog/host-router/cloud-task-client"; +import { TrpcPiRunner } from "@posthog/host-router/pi-runner"; +import { TrpcPiSessionFactory } from "@posthog/host-router/pi-session-factory"; import { BROWSER_TABS_CLIENT, type BrowserTabsClient, @@ -156,7 +162,6 @@ import { trpcClient } from "@renderer/trpc"; import { hostTrpcClient } from "@renderer/trpc/client"; import type { TRPCClient } from "@trpc/client"; import { hostLog, logger } from "@utils/logger"; -import { TrpcPiRunner } from "../platform-adapters/trpc-pi-runner"; import type { RendererBindings } from "./bindings"; import { TASK_SERVICE as RENDERER_TASK_SERVICE, TRPC_CLIENT } from "./tokens"; @@ -297,7 +302,8 @@ container // Bind services container.bind(TASK_CREATION_HOST).to(TrpcTaskCreationHost); container.bind(PI_RUNNER).to(TrpcPiRunner); -container.bind(PI_SESSION_CLIENT).to(TrpcPiSessionClient); +container.bind(LOCAL_PI_SESSION_FACTORY).to(TrpcPiSessionFactory); +container.bind(CLOUD_TASK_CLIENT).to(TrpcCloudTaskClient); container.load(piRuntimeModule); container.bind(TASK_CREATION_EFFECTS).toConstantValue(taskCreationEffects); container.bind(RENDERER_TASK_SERVICE).to(TaskService); diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 4013102faa..1e64ab8df4 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -1,3 +1,5 @@ +import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering"; +import type { SignalReport } from "@posthog/shared/domain-types"; import { useFocusEffect, useRouter } from "expo-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View } from "react-native"; @@ -20,12 +22,8 @@ import { decidedIds, useDismissedReportsStore, } from "@/features/inbox/stores/dismissedReportsStore"; -import { - DEFAULT_STATUS_FILTER, - useInboxFilterStore, -} from "@/features/inbox/stores/inboxFilterStore"; +import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore"; import { useInboxStore } from "@/features/inbox/stores/inboxStore"; -import type { SignalReport } from "@/features/inbox/types"; import { buildInboxViewedProperties } from "@/features/inbox/utils"; import { useIntegrations } from "@/features/tasks/hooks/useIntegrations"; import { ANALYTICS_EVENTS, useAnalytics } from "@/lib/analytics"; @@ -74,7 +72,7 @@ export default function InboxScreen() { statusFilter, suggestedReviewerFilter, priorityFilter, - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }), ); }, [ diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index 48fa3daab2..8ad989bfa9 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -1,4 +1,16 @@ import { Text } from "@components/text"; +import { + formatSignalReportSummaryMarkdown, + inboxStatusLabel, +} from "@posthog/core/inbox/reportPresentation"; +import { DISMISSAL_REASON_OPTIONS } from "@posthog/shared"; +import type { + ActionabilityJudgmentContent, + SignalFindingContent, + SignalReportPriority, + SignalReportStatus, + SuggestedReviewersArtefact, +} from "@posthog/shared/domain-types"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import * as Haptics from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -39,7 +51,6 @@ import { type ReviewerActionExtra, SuggestedReviewers, } from "@/features/inbox/components/SuggestedReviewers"; -import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants"; import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker"; import { useInboxReport, @@ -47,17 +58,6 @@ import { useInboxReportSignals, } from "@/features/inbox/hooks/useInboxReports"; import { useInboxStore } from "@/features/inbox/stores/inboxStore"; -import type { - ActionabilityJudgmentContent, - SignalFindingContent, - SignalReportPriority, - SignalReportStatus, - SuggestedReviewersArtefact, -} from "@/features/inbox/types"; -import { - formatSignalReportSummaryMarkdown, - inboxStatusLabel, -} from "@/features/inbox/utils"; import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; import { computeReportAgeHours, diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index fe73a847f3..31dc3d6e5d 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,10 +1,19 @@ import { Text } from "@components/text"; +import { DEFAULT_CLAUDE_EXECUTION_MODE } from "@posthog/core/sessions/executionModes"; import { countUserMessages, getSessionActivityPhase, } from "@posthog/core/sessions/sessionActivity"; import { isTaskRunning } from "@posthog/core/tasks/taskArchive"; -import type { Task } from "@posthog/shared"; +import { + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + type ExecutionMode, + getReasoningEffortOptions, + type SupportedReasoningEffort, + serializeCloudPrompt, + type Task, +} from "@posthog/shared"; import { useQueryClient } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -20,7 +29,6 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { FloatingBackButton } from "@/components/FloatingBackButton"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { runTaskInCloud } from "@/features/tasks/api"; import { CustomImageBadge } from "@/features/tasks/components/CustomImageBadge"; import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge"; @@ -28,16 +36,7 @@ import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; import { StopRunButton } from "@/features/tasks/components/StopRunButton"; import { TaskSessionView } from "@/features/tasks/components/TaskSessionView"; import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; -import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt"; import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; -import { - DEFAULT_EXECUTION_MODE, - DEFAULT_MODEL, - DEFAULT_REASONING, - type ExecutionMode, - modelSupportsReasoning, - type ReasoningEffort, -} from "@/features/tasks/composer/options"; import { QueuedMessagesDock } from "@/features/tasks/composer/QueuedMessagesDock"; import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer"; import { @@ -169,10 +168,10 @@ export default function TaskDetailScreen() { string | undefined >(); const composerMode: ExecutionMode = - composerConfig?.mode ?? DEFAULT_EXECUTION_MODE; - const composerModel = composerConfig?.model ?? DEFAULT_MODEL; - const composerReasoning: ReasoningEffort = - composerConfig?.reasoning ?? DEFAULT_REASONING; + composerConfig?.mode ?? DEFAULT_CLAUDE_EXECUTION_MODE; + const composerModel = composerConfig?.model ?? DEFAULT_GATEWAY_MODEL; + const composerReasoning: SupportedReasoningEffort = + composerConfig?.reasoning ?? DEFAULT_REASONING_EFFORT; const messagingMode = useMessagingMode(taskId); const queuedCount = useQueuedCount(taskId); @@ -315,16 +314,21 @@ export default function TaskDetailScreen() { ) : text; - const supportsReasoning = modelSupportsReasoning(composerModel); - const updatedTask = await runTaskInCloud(taskId, { - resumeFromRunId: task.latest_run?.id, - pendingUserMessage, - runtimeAdapter: "claude", - model: composerModel, - reasoningEffort: supportsReasoning ? composerReasoning : undefined, - initialPermissionMode: composerMode, - rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, - }); + const supportsReasoning = + getReasoningEffortOptions("claude", composerModel) !== null; + const updatedTask = await getPostHogApiClient().runTaskInCloud( + taskId, + undefined, + { + resumeFromRunId: task.latest_run?.id, + pendingUserMessage, + adapter: "claude", + model: composerModel, + reasoningLevel: supportsReasoning ? composerReasoning : undefined, + initialPermissionMode: composerMode, + rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, + }, + ); setTask(updatedTask); await connectToTask(updatedTask); updateTaskInCache(updatedTask); @@ -513,7 +517,7 @@ export default function TaskDetailScreen() { ); const handleReasoningChange = useCallback( - (value: ReasoningEffort) => { + (value: SupportedReasoningEffort) => { if (!taskId) return; setComposerConfig(taskId, { reasoning: value }); setConfigOption(taskId, "effort", value).catch(() => {}); @@ -566,10 +570,14 @@ export default function TaskDetailScreen() { setRetrying(true); disconnectFromTask(taskId); - const updatedTask = await runTaskInCloud(taskId, { - resumeFromRunId: task.latest_run?.id, - rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, - }); + const updatedTask = await getPostHogApiClient().runTaskInCloud( + taskId, + undefined, + { + resumeFromRunId: task.latest_run?.id, + rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, + }, + ); setTask(updatedTask); await connectToTask(updatedTask); updateTaskInCache(updatedTask); diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 012b206dcc..9df757f9de 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -1,4 +1,17 @@ import { Text } from "@components/text"; +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getAvailableModes, +} from "@posthog/core/sessions/executionModes"; +import { + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + type ExecutionMode, + getReasoningEffortOptions, + isSupportedReasoningEffort, + type SupportedReasoningEffort, + serializeCloudPrompt, +} from "@posthog/shared"; import { LinearGradient } from "expo-linear-gradient"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { @@ -15,7 +28,7 @@ import { Sparkle, StopIcon, } from "phosphor-react-native"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Pressable, @@ -30,13 +43,11 @@ import { import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; import { useVoiceRecording } from "@/features/chat"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { runTaskInCloud } from "@/features/tasks/api"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; import { AttachmentSheet } from "@/features/tasks/composer/attachments/AttachmentSheet"; import { AttachmentsBar } from "@/features/tasks/composer/attachments/AttachmentsBar"; import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; -import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt"; import { captureFromCamera, pickDocument, @@ -45,22 +56,15 @@ import { import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; import { DotBackground } from "@/features/tasks/composer/DotBackground"; import { - DEFAULT_EXECUTION_MODE, - DEFAULT_MODEL, - DEFAULT_REASONING, - EXECUTION_MODES, - type ExecutionMode, - MODELS, - modeLabel, - modelLabel, - modelSupportsReasoning, - REASONING_LEVELS, - type ReasoningEffort, - reasoningLabel, + getMobileModelOptions, + getModelConfigOption, + getModelLabel, + resolveAvailableModel, } from "@/features/tasks/composer/options"; import { Pill } from "@/features/tasks/composer/Pill"; import { RepositoryPickerInline } from "@/features/tasks/composer/RepositoryPickerInline"; import { SelectSheet } from "@/features/tasks/composer/SelectSheet"; +import { useCloudTaskConfigOptions } from "@/features/tasks/hooks/useCloudTaskConfigOptions"; import { useUserIntegrations } from "@/features/tasks/hooks/useUserIntegrations"; import { useWarmTask } from "@/features/tasks/hooks/useWarmTask"; import { pendingPromptRecoveryStoreApi } from "@/features/tasks/stores/pendingPromptRecoveryStore"; @@ -84,6 +88,7 @@ import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { toRgba, useThemeColors } from "@/lib/theme"; const log = logger.scope("task-create"); +const EXECUTION_MODES = getAvailableModes(); const SUGGESTIONS = [ "Create or update my CLAUDE.md file", @@ -99,6 +104,11 @@ function modeIcon(mode: ExecutionMode, color: string, size = 14) { return ; case "acceptEdits": return ; + case "bypassPermissions": + case "full-access": + return ; + case "read-only": + return ; case "auto": return ; } @@ -119,6 +129,9 @@ export default function NewTaskScreen() { const { insets, bottom } = useScreenInsets(); const keyboard = useReanimatedKeyboardAnimation(); const restingBottom = bottom("compact"); + const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude"); + const modelConfigOption = getModelConfigOption(configOptions); + const mobileModelOptions = getMobileModelOptions(modelConfigOption); const { error, hasGithubIntegration, @@ -182,22 +195,32 @@ export default function NewTaskScreen() { const prefs = usePreferencesStore.getState(); if (prefs.defaultInitialTaskMode === "last_used") { const last = prefs.lastNewTaskMode; - const isValidMode = EXECUTION_MODES.some((m) => m.value === last); + const isValidMode = EXECUTION_MODES.some((mode) => mode.id === last); if (isValidMode) return last as ExecutionMode; } - return DEFAULT_EXECUTION_MODE; + return DEFAULT_CLAUDE_EXECUTION_MODE; }); - const [model, setModel] = useState(DEFAULT_MODEL); - const [reasoning, setReasoning] = useState(() => { + const [model, setModel] = useState(DEFAULT_GATEWAY_MODEL); + const [reasoning, setReasoning] = useState(() => { const prefs = usePreferencesStore.getState(); - const isValidReasoning = (v: string): v is ReasoningEffort => - REASONING_LEVELS.some((r) => r.value === v); const desired = prefs.defaultReasoningEffort === "last_used" ? prefs.lastUsedReasoningEffort : prefs.defaultReasoningEffort; - return isValidReasoning(desired) ? desired : DEFAULT_REASONING; + return isSupportedReasoningEffort("claude", DEFAULT_GATEWAY_MODEL, desired) + ? desired + : DEFAULT_REASONING_EFFORT; }); + + useEffect(() => { + if (!hasLiveConfig) return; + const availableModel = resolveAvailableModel(modelConfigOption, model); + if (availableModel === model) return; + setModel(availableModel); + if (!isSupportedReasoningEffort("claude", availableModel, reasoning)) { + setReasoning(DEFAULT_REASONING_EFFORT); + } + }, [hasLiveConfig, model, modelConfigOption, reasoning]); const [creating, setCreating] = useState(false); const [repoSheetOpen, setRepoSheetOpen] = useState(false); const [modeSheetOpen, setModeSheetOpen] = useState(false); @@ -309,7 +332,8 @@ export default function NewTaskScreen() { ? `Attached: ${attachments[0].fileName}` : `Attached ${attachments.length} files`); - const task = await getPostHogApiClient().createTask({ + const client = getPostHogApiClient(); + const task = await client.createTask({ description: descriptionText, title: descriptionText.slice(0, 100), repository: selection.repository ?? undefined, @@ -334,7 +358,7 @@ export default function NewTaskScreen() { // Seed the per-task composer config with the mode/model/reasoning the // user picked here, so the task detail screen reflects them and every // subsequent run (resume-after-terminal) reuses the selected mode rather - // than falling back to DEFAULT_EXECUTION_MODE ("plan"). + // than falling back to the default plan mode. setComposerConfig(task.id, { mode, model, reasoning }); const pendingUserMessage = @@ -344,13 +368,14 @@ export default function NewTaskScreen() { ) : trimmedPrompt; - const supportsReasoning = modelSupportsReasoning(model); + const supportsReasoning = + getReasoningEffortOptions("claude", model) !== null; - await runTaskInCloud(task.id, { + await client.runTaskInCloud(task.id, undefined, { pendingUserMessage, - runtimeAdapter: "claude", + adapter: "claude", model, - reasoningEffort: supportsReasoning ? reasoning : undefined, + reasoningLevel: supportsReasoning ? reasoning : undefined, initialPermissionMode: mode, autoPublish: usePreferencesStore.getState().autoPublishCloudRuns, rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, @@ -386,8 +411,12 @@ export default function NewTaskScreen() { const hasContent = !!prompt.trim() || attachments.length > 0; const canSubmit = - hasContent && isRepositorySelectionComplete(selection) && !creating; - const showReasoningPill = modelSupportsReasoning(model); + hasLiveConfig && + hasContent && + isRepositorySelectionComplete(selection) && + !creating; + const reasoningOptions = getReasoningEffortOptions("claude", model) ?? []; + const showReasoningPill = reasoningOptions.length > 0; // Best-effort prewarm; failures are swallowed. `selection.integrationId` is // the GitHub installation id, not a PostHog integration id — the backend @@ -395,7 +424,7 @@ export default function NewTaskScreen() { useWarmTask({ repository: selection.repository, githubIntegrationId: selection.integrationId, - composerIsEmpty: !hasContent, + composerIsEmpty: !hasContent || !hasLiveConfig, runtimeAdapter: "claude", model, reasoningEffort: showReasoningPill ? reasoning : null, @@ -610,14 +639,17 @@ export default function NewTaskScreen() { ? themeColors.accent[11] : themeColors.gray[11], )} - label={modeLabel(mode)} + label={ + EXECUTION_MODES.find((option) => option.id === mode) + ?.name ?? mode + } accent={mode === "plan"} onPress={() => setModeSheetOpen(true)} /> } - label={modelLabel(model)} + label={getModelLabel(modelConfigOption, model)} onPress={() => setModelSheetOpen(true)} /> @@ -626,7 +658,11 @@ export default function NewTaskScreen() { icon={ } - label={reasoningLabel(reasoning)} + label={ + reasoningOptions.find( + (option) => option.value === reasoning, + )?.name ?? reasoning + } onPress={() => setReasoningSheetOpen(true)} /> ) : null} @@ -715,12 +751,12 @@ export default function NewTaskScreen() { }} onClose={() => setModeSheetOpen(false)} options={EXECUTION_MODES.map((executionMode) => ({ - value: executionMode.value, - label: executionMode.label, + value: executionMode.id, + label: executionMode.name, description: executionMode.description, icon: modeIcon( - executionMode.value, - executionMode.value === "plan" + executionMode.id as ExecutionMode, + executionMode.id === "plan" ? themeColors.accent[11] : themeColors.gray[11], 16, @@ -734,15 +770,16 @@ export default function NewTaskScreen() { value={model} onChange={(value) => { setModel(value); - if (!modelSupportsReasoning(value)) { - setReasoning(DEFAULT_REASONING); + if (!isSupportedReasoningEffort("claude", value, reasoning)) { + setReasoning(DEFAULT_REASONING_EFFORT); } }} onClose={() => setModelSheetOpen(false)} - options={MODELS.map((modelOption) => ({ + options={mobileModelOptions.map((modelOption) => ({ value: modelOption.value, label: modelOption.label, description: modelOption.description, + disabled: modelOption.disabled, icon: , }))} /> @@ -752,14 +789,14 @@ export default function NewTaskScreen() { title="Reasoning" value={reasoning} onChange={(value) => { - const next = value as ReasoningEffort; + const next = value as SupportedReasoningEffort; setReasoning(next); usePreferencesStore.getState().setLastUsedReasoningEffort(next); }} onClose={() => setReasoningSheetOpen(false)} - options={REASONING_LEVELS.map((reasoningLevel) => ({ + options={reasoningOptions.map((reasoningLevel) => ({ value: reasoningLevel.value, - label: reasoningLevel.label, + label: reasoningLevel.name, icon: , }))} /> diff --git a/apps/mobile/src/features/inbox/activityLog.test.ts b/apps/mobile/src/features/inbox/activityLog.test.ts index 8b791296be..8ba0a681fe 100644 --- a/apps/mobile/src/features/inbox/activityLog.test.ts +++ b/apps/mobile/src/features/inbox/activityLog.test.ts @@ -1,3 +1,4 @@ +import type { AnySignalReportArtefact } from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; import { attributionLabel, @@ -6,9 +7,8 @@ import { shortSha, taskRunLabel, } from "./activityLog"; -import type { ReportArtefact } from "./types"; -function commit(id: string, createdAt: string): ReportArtefact { +function commit(id: string, createdAt: string): AnySignalReportArtefact { return { id, type: "commit", @@ -22,7 +22,7 @@ function commit(id: string, createdAt: string): ReportArtefact { }; } -function taskRun(id: string, createdAt: string): ReportArtefact { +function taskRun(id: string, createdAt: string): AnySignalReportArtefact { return { id, type: "task_run", @@ -33,13 +33,13 @@ function taskRun(id: string, createdAt: string): ReportArtefact { describe("selectActivityArtefacts", () => { it("keeps only commit and task_run, sorted oldest-first", () => { - const artefacts: ReportArtefact[] = [ + const artefacts: AnySignalReportArtefact[] = [ taskRun("b", "2026-01-02T00:00:00Z"), { id: "x", type: "note", created_at: "2026-01-03T00:00:00Z", - content: {}, + content: { note: "" }, }, commit("a", "2026-01-01T00:00:00Z"), ]; @@ -51,12 +51,12 @@ describe("selectActivityArtefacts", () => { }); it("returns an empty list when there is no activity", () => { - const artefacts: ReportArtefact[] = [ + const artefacts: AnySignalReportArtefact[] = [ { id: "x", type: "note", created_at: "2026-01-01T00:00:00Z", - content: {}, + content: { note: "" }, }, ]; expect(selectActivityArtefacts(artefacts)).toEqual([]); diff --git a/apps/mobile/src/features/inbox/activityLog.ts b/apps/mobile/src/features/inbox/activityLog.ts index 053b84f04b..cb11aefa22 100644 --- a/apps/mobile/src/features/inbox/activityLog.ts +++ b/apps/mobile/src/features/inbox/activityLog.ts @@ -1,12 +1,12 @@ -import type { ReportArtefact } from "./types"; +import type { AnySignalReportArtefact } from "@posthog/shared/domain-types"; export type ActivityArtefact = Extract< - ReportArtefact, + AnySignalReportArtefact, { type: "commit" | "task_run" } >; export function selectActivityArtefacts( - artefacts: ReportArtefact[], + artefacts: AnySignalReportArtefact[], ): ActivityArtefact[] { return artefacts .filter( diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index 9b37b82725..1afbcb3c8b 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -1,343 +1,11 @@ -import { authedFetch, getBaseUrl, getProjectId, HttpError } from "@/lib/api"; -import { logger } from "@/lib/logger"; -import type { DismissalReasonOptionValue } from "./constants"; - -const log = logger.scope("inbox-api"); - -import type { - AvailableSuggestedReviewer, - AvailableSuggestedReviewersResponse, - CommitDiffResponse, - ReportArtefact, - SignalProcessingStateResponse, - SignalReport, - SignalReportArtefactsResponse, - SignalReportSignalsResponse, - SignalReportsQueryParams, - SignalReportsResponse, - SuggestedReviewerWriteEntry, -} from "./types"; - -export async function getSignalReports( - params?: SignalReportsQueryParams, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const url = new URL(`${baseUrl}/api/projects/${projectId}/signals/reports/`); - - if (params?.limit != null) { - url.searchParams.set("limit", String(params.limit)); - } - if (params?.offset != null) { - url.searchParams.set("offset", String(params.offset)); - } - if (params?.status) { - url.searchParams.set("status", params.status); - } - if (params?.ordering) { - url.searchParams.set("ordering", params.ordering); - } - if (params?.source_product) { - url.searchParams.set("source_product", params.source_product); - } - if (params?.suggested_reviewers) { - url.searchParams.set("suggested_reviewers", params.suggested_reviewers); - } - if (params?.priority) { - url.searchParams.set("priority", params.priority); - } - - const response = await authedFetch(url.toString()); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch signal reports", - ); - } - - const data = await response.json(); - return { - results: data.results ?? [], - count: data.count ?? data.results?.length ?? 0, - }; -} - -export async function getSignalReport( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/`, - ); - - if (response.status === 404 || response.status === 403) { - return null; - } - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch signal report", - ); - } - - return await response.json(); -} - -export async function getSignalProcessingState(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/processing_state/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch signal processing state", - ); - } - - return await response.json(); -} - -export async function getAvailableSuggestedReviewers( - query?: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const url = new URL( - `${baseUrl}/api/projects/${projectId}/signals/reports/available_reviewers/`, - ); - - if (query?.trim()) { - url.searchParams.set("query", query.trim()); - } - - const response = await authedFetch(url.toString()); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch available suggested reviewers", - ); - } - - // API returns a dict keyed by UUID: { "uuid": { name, email, github_login } } - const data = await response.json(); - const results = Object.entries(data) - .map(([uuid, value]) => { - if (typeof value !== "object" || value === null) return null; - const v = value as Record; - return { - uuid, - name: typeof v.name === "string" ? v.name : "", - email: typeof v.email === "string" ? v.email : "", - github_login: typeof v.github_login === "string" ? v.github_login : "", - }; - }) - .filter((r): r is AvailableSuggestedReviewer => r !== null); - - return { results, count: results.length }; -} - -export async function getSignalReportArtefacts( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/`, - ); - - if (!response.ok) { - const body = await response.text().catch(() => ""); - log.warn("Failed to fetch report artefacts", { - reportId, - status: response.status, - body: body.slice(0, 500), - }); - return { results: [], count: 0 }; - } - - const data = await response.json(); - const results: ReportArtefact[] = data.results ?? []; - return { results, count: data.count ?? results.length }; -} - -/** Fetch a commit artefact's diff against its parent (lazily, on demand). */ -export async function getCommitDiff( - reportId: string, - artefactId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/${artefactId}/diff/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Couldn’t load the diff", - ); - } - - const data = await response.json(); - return { - diff: typeof data.diff === "string" ? data.diff : "", - truncated: data.truncated === true, - }; -} - -/** Replace the content of a report artefact (full PUT, not a partial update). */ -export async function updateSignalReportArtefact( - reportId: string, - artefactId: string, - content: SuggestedReviewerWriteEntry[], -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/${artefactId}/`, - { - method: "PUT", - body: JSON.stringify({ content }), - }, - ); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new HttpError( - response.status, - response.statusText, - errorText || "Failed to update suggested reviewers", - ); - } -} - -export async function getSignalReportSignals( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/signals/`, - ); - - if (!response.ok) { - log.warn("Failed to fetch report signals", { - reportId, - status: response.status, - }); - return { signals: [] }; - } - - const data = await response.json(); - return { signals: data.signals ?? [] }; -} +import { extractRepoSelectionRepository } from "@posthog/core/inbox/artefacts"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; /** Resolve the repository associated with a signal report via its repo_selection artefact. */ export async function getReportRepository( reportId: string, ): Promise { - const { results } = await getSignalReportArtefacts(reportId); - const repoArtefact = results.find((a) => a.type === "repo_selection"); - if (!repoArtefact) return null; - - let parsed: unknown = repoArtefact.content; - if (typeof parsed === "string") { - try { - parsed = JSON.parse(parsed); - } catch { - return (parsed as string).toLowerCase(); - } - } - - if (typeof parsed === "object" && parsed !== null) { - const repo = - (parsed as Record).repository ?? - (parsed as Record).repo; - if (typeof repo === "string") return repo.toLowerCase(); - } - - return null; -} - -export interface DismissSignalReportInput { - reason: DismissalReasonOptionValue; - note?: string; -} - -export async function dismissSignalReport( - reportId: string, - input: DismissSignalReportInput, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/state/`, - { - method: "POST", - body: JSON.stringify({ - state: "suppressed", - dismissal_reason: input.reason, - ...(input.note?.trim() ? { dismissal_note: input.note.trim() } : {}), - }), - }, - ); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new HttpError( - response.status, - response.statusText, - errorText || "Failed to dismiss signal report", - ); - } - - return await response.json(); -} - -/** Re-queue a dismissed report into the inbox via the `potential` transition. */ -export async function restoreSignalReport( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/state/`, - { - method: "POST", - body: JSON.stringify({ state: "potential" }), - }, - ); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new HttpError( - response.status, - response.statusText, - errorText || "Failed to restore signal report", - ); - } - - return await response.json(); + const { results } = + await getPostHogApiClient().getSignalReportArtefacts(reportId); + return extractRepoSelectionRepository(results); } diff --git a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx index abe60b8a46..a0aed9bc98 100644 --- a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx +++ b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx @@ -1,4 +1,7 @@ import { Text } from "@components/text"; +import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation"; +import { dismissalReasonLabel } from "@posthog/shared"; +import type { SignalReport } from "@posthog/shared/domain-types"; import * as Haptics from "expo-haptics"; import { ArrowCounterClockwise, Tray } from "phosphor-react-native"; import { memo, useCallback, useEffect, useRef, useState } from "react"; @@ -11,13 +14,7 @@ import { } from "react-native"; import { useThemeColors } from "@/lib/theme"; import { useArchivedReports, useRestoreReport } from "../hooks/useInboxReports"; -import type { SignalReport } from "../types"; -import { - dismissalReasonLabel, - formatReportTimestamp, - inboxStatusLabel, - isRestorableReport, -} from "../utils"; +import { formatReportTimestamp, isRestorableReport } from "../utils"; interface ArchivedReportListProps { onReportPress?: (report: SignalReport) => void; diff --git a/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx b/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx index fb176374a6..19f4cc2510 100644 --- a/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx +++ b/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx @@ -1,11 +1,11 @@ import { Text } from "@components/text"; +import type { CommitContent } from "@posthog/shared/domain-types"; import { CaretDown, CaretRight } from "phosphor-react-native"; import { useState } from "react"; import { ActivityIndicator, Pressable, View } from "react-native"; import { useThemeColors } from "@/lib/theme"; import { shortSha } from "../activityLog"; import { useCommitDiff } from "../hooks/useInboxReports"; -import type { CommitContent } from "../types"; import { DiffBlock } from "./DiffBlock"; export function ArtefactCommit({ diff --git a/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx b/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx index bea2436c62..2c2b004a58 100644 --- a/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx +++ b/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx @@ -1,11 +1,11 @@ import { Text } from "@components/text"; +import type { TaskRunArtefactContent } from "@posthog/shared/domain-types"; import { useRouter } from "expo-router"; import { CaretRight } from "phosphor-react-native"; import { Pressable, View } from "react-native"; import { useTask } from "@/features/tasks"; import { useThemeColors } from "@/lib/theme"; import { taskRunLabel } from "../activityLog"; -import type { TaskRunArtefactContent } from "../types"; export function ArtefactTaskRun({ content, diff --git a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx index 65fc913399..56253e4a2c 100644 --- a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx +++ b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx @@ -1,4 +1,8 @@ import { Text } from "@components/text"; +import { + DISMISSAL_REASON_OPTIONS, + type DismissalReasonOptionValue, +} from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { Check } from "phosphor-react-native"; import { useEffect, useState } from "react"; @@ -14,10 +18,6 @@ import { } from "react-native"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; -import { - DISMISSAL_REASON_OPTIONS, - type DismissalReasonOptionValue, -} from "../constants"; import { useDismissReport } from "../hooks/useInboxReports"; export interface DismissReportResult { diff --git a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx index 2e30695576..ad1d1e013c 100644 --- a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx +++ b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx @@ -3,6 +3,10 @@ import { buildReviewerOptions, reviewerMatchesAvailable, } from "@posthog/core/inbox/artefacts"; +import type { + AvailableSuggestedReviewer, + SuggestedReviewer, +} from "@posthog/shared/domain-types"; import { MagnifyingGlass } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { @@ -17,7 +21,6 @@ import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports"; -import type { AvailableSuggestedReviewer, SuggestedReviewer } from "../types"; import { ReviewerOptionRow } from "./ReviewerOptionRow"; interface EditReviewersSheetProps { diff --git a/apps/mobile/src/features/inbox/components/FilterSheet.tsx b/apps/mobile/src/features/inbox/components/FilterSheet.tsx index 11ed58e5af..47a22b36d8 100644 --- a/apps/mobile/src/features/inbox/components/FilterSheet.tsx +++ b/apps/mobile/src/features/inbox/components/FilterSheet.tsx @@ -1,15 +1,13 @@ import { Text } from "@components/text"; -import { EXTERNAL_INBOX_SOURCES } from "@posthog/shared"; +import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering"; +import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation"; +import { EXTERNAL_INBOX_SOURCES, type SourceProduct } from "@posthog/shared"; +import type { SignalReportPriority } from "@posthog/shared/domain-types"; import { Check } from "phosphor-react-native"; import { Modal, Pressable, ScrollView, View } from "react-native"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; -import { - type SourceProduct, - useInboxFilterStore, -} from "../stores/inboxFilterStore"; -import type { SignalReportPriority, SignalReportStatus } from "../types"; -import { inboxStatusLabel } from "../utils"; +import { useInboxFilterStore } from "../stores/inboxFilterStore"; interface FilterSheetProps { visible: boolean; @@ -29,15 +27,6 @@ const SORT_OPTIONS: SortOption[] = [ { label: "Oldest first", field: "created_at", direction: "asc" }, ]; -const FILTERABLE_STATUSES: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "failed", - "candidate", - "potential", -]; - function useStatusDotColors(): Record { const themeColors = useThemeColors(); return { @@ -74,9 +63,11 @@ export const SOURCE_PRODUCT_OPTIONS: { value: SourceProduct; label: string }[] = { value: "session_replay", label: "Session replay" }, { value: "error_tracking", label: "Error tracking" }, { value: "llm_analytics", label: "AI observability" }, + { value: "github", label: "GitHub" }, + { value: "linear", label: "Linear" }, + { value: "zendesk", label: "Zendesk" }, { value: "conversations", label: "Conversations" }, { value: "signals_scout", label: "Scout" }, - { value: "health_checks", label: "Health checks" }, ...EXTERNAL_INBOX_SOURCES.map((source) => ({ value: source.product, label: source.label, @@ -141,7 +132,7 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) { const hasActiveFilters = sourceProductFilter.length > 0 || priorityFilter.length > 0 || - statusFilter.length < FILTERABLE_STATUSES.length; + statusFilter.length < INBOX_PIPELINE_STATUSES.length; return ( - {FILTERABLE_STATUSES.map((status) => ( + {INBOX_PIPELINE_STATUSES.map((status) => ( s.currentIndex); @@ -240,7 +245,8 @@ export function TinderView({ // 3. Create the task const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; - const task = await getPostHogApiClient().createTask({ + const client = getPostHogApiClient(); + const task = await client.createTask({ description: prompt, title: prompt.slice(0, 255), repository: match?.repository ?? repo ?? undefined, @@ -251,10 +257,10 @@ export function TinderView({ } as CreateTaskOptions); // 4. Run it - await runTaskInCloud(task.id, { + await client.runTaskInCloud(task.id, undefined, { pendingUserMessage: prompt, - runtimeAdapter: "claude", - model: DEFAULT_MODEL, + adapter: "claude", + model, initialPermissionMode: "plan", runSource: "signal_report", signalReportId: report.id, @@ -276,6 +282,7 @@ export function TinderView({ }, [ repositoryOptions, + model, showToastPending, showToastDone, acceptReport, @@ -489,7 +496,7 @@ export function TinderView({ setExpandedReport(null); }} className="h-16 w-16 items-center justify-center rounded-full border-2 border-status-success bg-status-success/10 active:bg-status-success/20" - disabled={creating} + disabled={creating || !hasLiveConfig} hitSlop={8} > {creating ? ( diff --git a/apps/mobile/src/features/inbox/constants.ts b/apps/mobile/src/features/inbox/constants.ts deleted file mode 100644 index f15aca7c5b..0000000000 --- a/apps/mobile/src/features/inbox/constants.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Reasons offered when the user dismisses a signal report. - * Mirrors apps/code/src/shared/dismissalReasons.ts. - */ -export const DISMISSAL_REASON_OPTIONS = [ - { - value: "already_fixed", - label: "Already fixed", - snoozesInsteadOfDismiss: true, - }, - { value: "report_unclear", label: "Report is unclear to me" }, - { value: "analysis_wrong", label: "Agent's analysis is wrong" }, - { value: "wontfix_intentional", label: "Won't fix — intentional behavior" }, - { - value: "wontfix_irrelevant", - label: "Won't fix — issue is real but insignificant", - }, - { value: "other", label: "Something else…" }, -] as const; - -export type DismissalReasonOptionValue = - (typeof DISMISSAL_REASON_OPTIONS)[number]["value"]; diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts index 2e970def6b..16cd3b1f64 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts @@ -9,8 +9,8 @@ vi.mock("posthog-react-native", () => ({ usePostHog: () => null, })); +import type { SignalReport } from "@posthog/shared/domain-types"; import { ANALYTICS_EVENTS, type Analytics } from "@/lib/analytics"; -import type { SignalReport } from "../types"; import { type InboxEngagementTracker, type UseInboxEngagementTrackerOptions, diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts index 67ac03e03a..323f03a938 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts @@ -1,3 +1,4 @@ +import type { SignalReport } from "@posthog/shared/domain-types"; import { useCallback, useEffect, useRef } from "react"; import { ANALYTICS_EVENTS, @@ -7,7 +8,6 @@ import { type InboxReportCloseMethod, type InboxReportOpenMethod, } from "@/lib/analytics"; -import type { SignalReport } from "../types"; interface OpenInfo { reportId: string; diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts index 407730a054..542886a407 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts @@ -1,3 +1,7 @@ +import type { + SignalReport, + SignalReportsResponse, +} from "@posthog/shared/domain-types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement } from "react"; import { act, create } from "react-test-renderer"; @@ -11,12 +15,13 @@ const getAvailableSuggestedReviewers = vi.fn(async (_query?: string) => ({ results: [], count: 0, })); -vi.mock("../api", () => ({ - getAvailableSuggestedReviewers: (query?: string) => - getAvailableSuggestedReviewers(query), +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + getAvailableSuggestedReviewers: (query?: string) => + getAvailableSuggestedReviewers(query), + }), })); -import type { SignalReport, SignalReportsResponse } from "../types"; import { getReportsNextPageParam, useAvailableSuggestedReviewers, diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts index ac37c738d1..e262300bf9 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts @@ -7,28 +7,7 @@ import { INBOX_DISMISSED_STATUS_FILTER, INBOX_REFETCH_INTERVAL_MS, } from "@posthog/core/inbox/reportFiltering"; -import { - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; -import { useMemo } from "react"; -import { useAuthStore } from "@/features/auth"; -import { - type DismissSignalReportInput, - dismissSignalReport, - getAvailableSuggestedReviewers, - getCommitDiff, - getSignalProcessingState, - getSignalReport, - getSignalReportArtefacts, - getSignalReportSignals, - getSignalReports, - restoreSignalReport, - updateSignalReportArtefact, -} from "../api"; -import { useInboxFilterStore } from "../stores/inboxFilterStore"; +import type { DismissalReasonOptionValue } from "@posthog/shared"; import type { AvailableSuggestedReviewersResponse, CommitDiffResponse, @@ -39,8 +18,19 @@ import type { SignalReportsQueryParams, SignalReportsResponse, SuggestedReviewer, + SuggestedReviewersArtefact, SuggestedReviewerWriteEntry, -} from "../types"; +} from "@posthog/shared/domain-types"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useAuthStore } from "@/features/auth"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; +import { useInboxFilterStore } from "../stores/inboxFilterStore"; import { isRestorableReport } from "../utils"; export const inboxKeys = { @@ -97,7 +87,7 @@ export function useInboxReports(options?: { enabled?: boolean }) { const query = useInfiniteQuery({ queryKey: inboxKeys.list(params), queryFn: ({ pageParam }) => - getSignalReports({ + getPostHogApiClient().getSignalReports({ ...params, limit: REPORTS_PAGE_SIZE, offset: pageParam, @@ -136,7 +126,7 @@ export function useArchivedReports(options?: { enabled?: boolean }) { const query = useQuery({ queryKey: inboxKeys.archived(params), - queryFn: () => getSignalReports(params), + queryFn: () => getPostHogApiClient().getSignalReports(params), enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), }); @@ -157,7 +147,7 @@ export function useInboxReport(reportId: string | null) { queryKey: inboxKeys.detail(reportId ?? ""), queryFn: () => { if (!reportId) throw new Error("reportId is required"); - return getSignalReport(reportId); + return getPostHogApiClient().getSignalReport(reportId); }, enabled: !!projectId && !!oauthAccessToken && !!reportId, }); @@ -168,7 +158,7 @@ export function useSignalProcessingState(options?: { enabled?: boolean }) { return useQuery({ queryKey: inboxKeys.processingState, - queryFn: () => getSignalProcessingState(), + queryFn: () => getPostHogApiClient().getSignalProcessingState(), enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), refetchInterval: INBOX_REFETCH_INTERVAL_MS, }); @@ -183,7 +173,8 @@ export function useAvailableSuggestedReviewers(options?: { return useQuery({ queryKey: [...inboxKeys.all, "available-reviewers", query] as const, - queryFn: () => getAvailableSuggestedReviewers(query || undefined), + queryFn: () => + getPostHogApiClient().getAvailableSuggestedReviewers(query || undefined), enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), staleTime: 5 * 60 * 1000, // Only poll the unfiltered list; search terms are transient and each one @@ -199,7 +190,7 @@ export function useInboxReportArtefacts(reportId: string | null) { queryKey: inboxKeys.artefacts(reportId ?? ""), queryFn: () => { if (!reportId) throw new Error("reportId is required"); - return getSignalReportArtefacts(reportId); + return getPostHogApiClient().getSignalReportArtefacts(reportId); }, enabled: !!projectId && !!oauthAccessToken && !!reportId, // The log is a live work record — agents append artefacts while a report @@ -218,7 +209,7 @@ export function useCommitDiff( return useQuery({ queryKey: inboxKeys.commitDiff(reportId, artefactId), - queryFn: () => getCommitDiff(reportId, artefactId), + queryFn: () => getPostHogApiClient().getCommitDiff(reportId, artefactId), // A commit's diff is immutable, so only fetch once expanded and never retry. enabled: enabled && !!projectId && !!oauthAccessToken, staleTime: 5 * 60_000, @@ -233,7 +224,7 @@ export function useInboxReportSignals(reportId: string | null) { queryKey: inboxKeys.signals(reportId ?? ""), queryFn: () => { if (!reportId) throw new Error("reportId is required"); - return getSignalReportSignals(reportId); + return getPostHogApiClient().getSignalReportSignals(reportId); }, enabled: !!projectId && !!oauthAccessToken && !!reportId, }); @@ -256,7 +247,9 @@ export function useUpdateSuggestedReviewers(reportId: string) { { previous: SignalReportArtefactsResponse | undefined } >({ mutationFn: ({ artefactId, content }) => - updateSignalReportArtefact(reportId, artefactId, content), + getPostHogApiClient() + .updateSignalReportArtefact(reportId, artefactId, content) + .then(() => undefined), onMutate: async ({ artefactId, optimisticReviewers }) => { await queryClient.cancelQueries({ queryKey }); const previous = @@ -264,12 +257,20 @@ export function useUpdateSuggestedReviewers(reportId: string) { if (previous) { queryClient.setQueryData(queryKey, { ...previous, - results: previous.results.map((artefact) => - artefact.id === artefactId && - artefact.type === "suggested_reviewers" - ? { ...artefact, content: optimisticReviewers } - : artefact, - ), + results: previous.results.map((artefact) => { + if ( + artefact.id === artefactId && + artefact.type === "suggested_reviewers" + ) { + const updatedArtefact: SuggestedReviewersArtefact = { + ...artefact, + type: "suggested_reviewers", + content: optimisticReviewers, + }; + return updatedArtefact; + } + return artefact; + }), }); } return { previous }; @@ -288,8 +289,17 @@ export function useUpdateSuggestedReviewers(reportId: string) { export function useDismissReport(reportId: string) { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (input) => dismissSignalReport(reportId, input), + return useMutation< + SignalReport, + Error, + { reason: DismissalReasonOptionValue; note?: string } + >({ + mutationFn: (input) => + getPostHogApiClient().updateSignalReportState(reportId, { + state: "suppressed", + dismissal_reason: input.reason, + ...(input.note?.trim() ? { dismissal_note: input.note.trim() } : {}), + }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: inboxKeys.detail(reportId) }); queryClient.invalidateQueries({ queryKey: inboxKeys.all }); @@ -305,11 +315,12 @@ export function useRestoreReport() { // report. return useMutation({ mutationFn: async (reportId) => { - const current = await getSignalReport(reportId); + const client = getPostHogApiClient(); + const current = await client.getSignalReport(reportId); if (current && !isRestorableReport(current)) { return false; } - await restoreSignalReport(reportId); + await client.updateSignalReportState(reportId, { state: "potential" }); return true; }, onSuccess: () => { diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts index 935407c640..3bfc12271b 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts @@ -1,3 +1,4 @@ +import type { SourceProduct } from "@posthog/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@react-native-async-storage/async-storage", () => ({ @@ -8,7 +9,7 @@ vi.mock("@react-native-async-storage/async-storage", () => ({ }, })); -import { type SourceProduct, useInboxFilterStore } from "./inboxFilterStore"; +import { useInboxFilterStore } from "./inboxFilterStore"; describe("inboxFilterStore", () => { beforeEach(() => { diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts index a0536417da..1f779d7dd7 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -1,13 +1,13 @@ import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering"; import type { SourceProduct } from "@posthog/shared"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; import type { SignalReportOrderingField, SignalReportPriority, SignalReportStatus, -} from "../types"; +} from "@posthog/shared/domain-types"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; type SortField = Extract< SignalReportOrderingField, @@ -16,12 +16,6 @@ type SortField = Extract< type SortDirection = "asc" | "desc"; -export type { SourceProduct }; - -export const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ - ...INBOX_PIPELINE_STATUSES, -]; - interface InboxFilterState { sortField: SortField; sortDirection: SortDirection; @@ -51,7 +45,7 @@ export const useInboxFilterStore = create()( (set) => ({ sortField: "priority", sortDirection: "asc", - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: [...INBOX_PIPELINE_STATUSES], sourceProductFilter: [], suggestedReviewerFilter: [], priorityFilter: [], @@ -100,7 +94,7 @@ export const useInboxFilterStore = create()( set({ priorityFilter: Array.from(new Set(priorities)) }), resetFilters: () => set({ - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: [...INBOX_PIPELINE_STATUSES], sourceProductFilter: [], suggestedReviewerFilter: [], priorityFilter: [], diff --git a/apps/mobile/src/features/inbox/stores/inboxStore.ts b/apps/mobile/src/features/inbox/stores/inboxStore.ts index 7bdb2ec206..32120c07a2 100644 --- a/apps/mobile/src/features/inbox/stores/inboxStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxStore.ts @@ -1,5 +1,5 @@ +import type { SignalReportOrderingField } from "@posthog/shared/domain-types"; import { create } from "zustand"; -import type { SignalReportOrderingField } from "../types"; type OrderDirection = "asc" | "desc"; diff --git a/apps/mobile/src/features/inbox/types.ts b/apps/mobile/src/features/inbox/types.ts deleted file mode 100644 index 248ab5f86b..0000000000 --- a/apps/mobile/src/features/inbox/types.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { DismissalReasonOptionValue } from "./constants"; - -export type SignalReportStatus = - | "potential" - | "candidate" - | "in_progress" - | "ready" - | "failed" - | "pending_input" - | "resolved" - | "suppressed" - | "deleted"; - -export type SignalReportPriority = "P0" | "P1" | "P2" | "P3" | "P4"; - -export type SignalReportActionability = - | "immediately_actionable" - | "requires_human_input" - | "not_actionable"; - -export interface SignalReport { - id: string; - title: string | null; - summary: string | null; - status: SignalReportStatus; - total_weight: number; - signal_count: number; - signals_at_run?: number; - created_at: string; - updated_at: string; - artefact_count: number; - priority?: SignalReportPriority | null; - actionability?: SignalReportActionability | null; - already_addressed?: boolean | null; - dismissal_reason?: DismissalReasonOptionValue | null; - dismissal_note?: string | null; - is_suggested_reviewer?: boolean; - source_products?: string[]; - implementation_pr_url?: string | null; -} - -export interface SignalReportsResponse { - results: SignalReport[]; - count: number; -} - -export type SignalReportOrderingField = - | "priority" - | "signal_count" - | "total_weight" - | "created_at" - | "updated_at"; - -export interface SignalReportsQueryParams { - limit?: number; - offset?: number; - status?: string; - ordering?: string; - source_product?: string; - suggested_reviewers?: string; - priority?: string; -} - -export interface SignalProcessingStateResponse { - paused_until: string | null; -} - -export interface AvailableSuggestedReviewer { - uuid: string; - name: string; - email: string; - github_login: string; -} - -export interface AvailableSuggestedReviewersResponse { - results: AvailableSuggestedReviewer[]; - count: number; -} - -export interface Signal { - signal_id: string; - content: string; - source_product: string; - source_type: string; - source_id: string; - weight: number; - timestamp: string; - extra: Record; -} - -export interface SignalFindingContent { - signal_id: string; - relevant_code_paths: string[]; - relevant_commit_hashes: Record; - data_queried: string; - verified: boolean; -} - -export interface PriorityJudgmentContent { - explanation: string; - priority: SignalReportPriority; -} - -export interface ActionabilityJudgmentContent { - explanation: string; - actionability: SignalReportActionability; - already_addressed: boolean; -} - -export interface SuggestedReviewerCommit { - sha: string; - url: string; - reason: string; -} - -export interface SuggestedReviewerUser { - id: number; - uuid: string; - email: string; - first_name: string; - last_name: string; -} - -export interface SuggestedReviewer { - github_login: string; - github_name: string | null; - relevant_commits: SuggestedReviewerCommit[]; - user: SuggestedReviewerUser | null; -} - -export interface SuggestedReviewersArtefact { - id: string; - type: "suggested_reviewers"; - created_at: string; - content: SuggestedReviewer[]; -} - -/** - * Write shape for replacing the suggested_reviewers artefact. The server - * canonicalizes to a lowercase `github_login`, with `user_uuid` winning when - * both are supplied. - */ -export interface SuggestedReviewerWriteEntry { - github_login?: string; - user_uuid?: string; - github_name?: string; -} - -export interface ArtefactUser { - uuid?: string; - email: string; - first_name?: string; - last_name?: string; -} - -export interface CommitContent { - repository: string; - branch: string; - commit_sha: string; - message: string; - note?: string | null; -} - -export interface TaskRunArtefactContent { - task_id: string; - product: string; - type: string; -} - -export interface CommitDiffResponse { - diff: string; - truncated: boolean; -} - -/** - * Fields shared by every artefact row. `created_by` / `task_id` carry - * attribution: at most one is set — `created_by` for user writes, `task_id` - * for agent writes, neither for system writes. - */ -interface BaseArtefact { - id: string; - created_at: string; - created_by?: ArtefactUser | null; - task_id?: string | null; -} - -export type ReportArtefact = - | (BaseArtefact & { - type: "priority_judgment"; - content: PriorityJudgmentContent; - }) - | (BaseArtefact & { - type: "actionability_judgment"; - content: ActionabilityJudgmentContent; - }) - | (BaseArtefact & { type: "signal_finding"; content: SignalFindingContent }) - | (BaseArtefact & { type: "commit"; content: CommitContent }) - | (BaseArtefact & { type: "task_run"; content: TaskRunArtefactContent }) - | (BaseArtefact & SuggestedReviewersArtefact) - | (BaseArtefact & { type: string; content: unknown }); - -export interface SignalReportArtefactsResponse { - results: ReportArtefact[]; - count: number; -} - -export interface SignalReportSignalsResponse { - signals: Signal[]; -} diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index ad56b19305..ce8b2b43af 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -1,9 +1,20 @@ +import { + buildArchiveListOrdering, + buildPriorityFilterParam, + buildSignalReportListOrdering, + INBOX_PIPELINE_STATUSES, +} from "@posthog/core/inbox/reportFiltering"; +import { formatSignalReportSummaryMarkdown } from "@posthog/core/inbox/reportPresentation"; +import { dismissalReasonLabel } from "@posthog/shared"; +import type { + Signal, + SignalReport, + SignalReportOrderingField, + SignalReportStatus, +} from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; -import type { Signal, SignalReport, SignalReportStatus } from "./types"; import { buildInboxViewedProperties, - dismissalReasonLabel, - formatSignalReportSummaryMarkdown, isRestorableReport, sourceLine, } from "./utils"; @@ -21,15 +32,6 @@ function signal(source_product: string, source_type: string): Signal { }; } -const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "failed", - "candidate", - "potential", -]; - function makeReport( partial: Partial & Pick, ): SignalReport { @@ -88,10 +90,10 @@ describe("buildInboxViewedProperties", () => { it("emits zero counts for an empty list", () => { const props = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(props).toMatchObject({ report_count: 0, @@ -137,10 +139,10 @@ describe("buildInboxViewedProperties", () => { const props = buildInboxViewedProperties(reports, 4, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(props.report_count).toBe(4); @@ -161,36 +163,36 @@ describe("buildInboxViewedProperties", () => { statusFilter: ["ready"], suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(narrowed.has_active_filters).toBe(true); expect(narrowed.status_filter_count).toBe(1); const sourced = buildInboxViewedProperties([], 0, { sourceProductFilter: ["error_tracking"], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(sourced.has_active_filters).toBe(true); expect(sourced.source_product_filter).toEqual(["error_tracking"]); const reviewer = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: ["uuid-1"], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(reviewer.has_active_filters).toBe(true); const prioritized = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: ["P0"], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(prioritized.has_active_filters).toBe(true); }); @@ -198,15 +200,89 @@ describe("buildInboxViewedProperties", () => { it("treats a reordered default status set as not filtered", () => { const props = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: [...DEFAULT_STATUS_FILTER].reverse(), + statusFilter: [...INBOX_PIPELINE_STATUSES].reverse(), suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(props.has_active_filters).toBe(false); }); }); +describe("buildSignalReportListOrdering", () => { + it.each([ + { + field: "priority" as SignalReportOrderingField, + direction: "desc" as const, + expected: "status,-priority,-created_at", + }, + { + field: "priority" as SignalReportOrderingField, + direction: "asc" as const, + expected: "status,priority,-created_at", + }, + { + field: "signal_count" as SignalReportOrderingField, + direction: "desc" as const, + expected: "status,-signal_count,priority", + }, + { + field: "total_weight" as SignalReportOrderingField, + direction: "asc" as const, + expected: "status,total_weight,priority", + }, + { + field: "created_at" as SignalReportOrderingField, + direction: "desc" as const, + expected: "status,-created_at,priority", + }, + { + field: "updated_at" as SignalReportOrderingField, + direction: "asc" as const, + expected: "status,updated_at,priority", + }, + ])( + "orders $field $direction as $expected", + ({ field, direction, expected }) => { + expect(buildSignalReportListOrdering(field, direction)).toBe(expected); + }, + ); +}); + +describe("buildPriorityFilterParam", () => { + it.each([ + { + name: "returns undefined for an empty selection", + input: [], + expected: undefined, + }, + { + name: "joins selected priorities with commas", + input: ["P0", "P2"] as const, + expected: "P0,P2", + }, + { + name: "dedupes repeated priorities", + input: ["P1", "P1", "P3"] as const, + expected: "P1,P3", + }, + ])("$name", ({ input, expected }) => { + expect(buildPriorityFilterParam([...input])).toBe(expected); + }); +}); + +describe("buildArchiveListOrdering", () => { + it.each([ + { direction: "desc" as const, expected: "-updated_at" }, + { direction: "asc" as const, expected: "updated_at" }, + ])( + "sorts by field without a status prefix ($direction)", + ({ direction, expected }) => { + expect(buildArchiveListOrdering("updated_at", direction)).toBe(expected); + }, + ); +}); + describe("isRestorableReport", () => { it.each([ { status: "suppressed" as SignalReportStatus, expected: true }, diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index b0040ddcef..13a8e4c80b 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -2,15 +2,14 @@ import { EXTERNAL_INBOX_SOURCE_BY_PRODUCT, type SourceProduct, } from "@posthog/shared"; -import { differenceInHours, format, formatDistanceToNow } from "date-fns"; -import type { InboxViewedProperties } from "@/lib/analytics"; -import { DISMISSAL_REASON_OPTIONS } from "./constants"; import type { Signal, SignalReport, SignalReportPriority, SignalReportStatus, -} from "./types"; +} from "@posthog/shared/domain-types"; +import { differenceInHours, format, formatDistanceToNow } from "date-fns"; +import type { InboxViewedProperties } from "@/lib/analytics"; const ERROR_TRACKING_TYPE_LABELS: Record = { issue_created: "New issue", @@ -46,34 +45,7 @@ export function sourceLine(signal: Signal): string { const warehouseSource = EXTERNAL_INBOX_SOURCE_BY_PRODUCT[source_product as SourceProduct]; const product = warehouseSource?.label ?? source_product.replace(/_/g, " "); - const type = source_type.replace(/_/g, " "); - return `${product} · ${type}`; -} - -const SIGNAL_SUMMARY_SECTION_HEADERS = [ - "What's happening", - "Root cause", - "How to resolve", -] as const; - -/** - * Inserts blank lines around signal report summary section headers so each - * label and its body render on their own line (agent output often packs them - * together, e.g. `**What's happening:** text **Root cause:** ...`). - */ -export function formatSignalReportSummaryMarkdown(content: string): string { - let result = content; - - for (const header of SIGNAL_SUMMARY_SECTION_HEADERS) { - const boldHeader = `\\*\\*${header}:\\*\\*`; - result = result.replace( - new RegExp(`([^\\n])\\s*(${boldHeader})`, "gi"), - "$1\n\n$2", - ); - result = result.replace(new RegExp(`(${boldHeader})\\s+`, "gi"), "$1\n\n"); - } - - return result; + return `${product} · ${source_type.replace(/_/g, " ")}`; } /** Relative time for the last day, absolute "MMM d" beyond it. */ @@ -93,38 +65,6 @@ export function isRestorableReport( return report.status === "suppressed"; } -/** Human label for a persisted dismissal reason, falling back to the raw code. */ -export function dismissalReasonLabel(value: string): string { - return ( - DISMISSAL_REASON_OPTIONS.find((o) => o.value === value)?.label ?? value - ); -} - -export function inboxStatusLabel(status: SignalReportStatus): string { - switch (status) { - case "ready": - return "Ready"; - case "resolved": - return "Resolved"; - case "pending_input": - return "Needs input"; - case "in_progress": - return "Researching"; - case "candidate": - return "Queued"; - case "potential": - return "Gathering"; - case "failed": - return "Failed"; - case "suppressed": - return "Suppressed"; - case "deleted": - return "Deleted"; - default: - return status; - } -} - /** * Returns only reports that are actionable for the tinder-like card deck: * ready, immediately actionable, not already addressed. @@ -140,11 +80,11 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] { interface InboxViewedFilterState { sourceProductFilter: string[]; - statusFilter: SignalReportStatus[]; + statusFilter: readonly SignalReportStatus[]; suggestedReviewerFilter: string[]; priorityFilter: SignalReportPriority[]; /** Default status filter as defined in the filter store, used to detect whether the user has narrowed it. */ - defaultStatusFilter: SignalReportStatus[]; + defaultStatusFilter: readonly SignalReportStatus[]; } /** diff --git a/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx b/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx index e953df43b9..6e39aead06 100644 --- a/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx +++ b/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx @@ -15,8 +15,8 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; -import type { RepositoryOption } from "@/features/tasks/types"; import { useThemeColors } from "@/lib/theme"; +import type { RepositoryOption } from "../types"; // Tuning for the nested (ScrollView) path's progressive mount. The first // chunk needs to cover the rows the user can actually see (~5 with the diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 4297268370..b61e6f732e 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -1,4 +1,16 @@ import { Text } from "@components/text"; +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getAvailableModes, +} from "@posthog/core/sessions/executionModes"; +import { + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + type ExecutionMode, + getReasoningEffortOptions, + isSupportedReasoningEffort, + type SupportedReasoningEffort, +} from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { ArrowUp, @@ -32,6 +44,7 @@ import { View, } from "react-native"; import { useVoiceRecording } from "@/features/chat"; +import { useCloudTaskConfigOptions } from "@/features/tasks/hooks/useCloudTaskConfigOptions"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; import type { MessagingMode } from "../stores/messagingModeStore"; @@ -44,18 +57,10 @@ import { } from "./attachments/pickers"; import type { PendingAttachment } from "./attachments/types"; import { - DEFAULT_EXECUTION_MODE, - DEFAULT_MODEL, - DEFAULT_REASONING, - EXECUTION_MODES, - type ExecutionMode, - MODELS, - modeLabel, - modelLabel, - modelSupportsReasoning, - REASONING_LEVELS, - type ReasoningEffort, - reasoningLabel, + getMobileModelOptions, + getModelConfigOption, + getModelLabel, + resolveAvailableModel, } from "./options"; import { Pill } from "./Pill"; import { SelectSheet } from "./SelectSheet"; @@ -66,6 +71,7 @@ import { } from "./submitComposerMessage"; const log = logger.scope("task-chat-composer"); +const EXECUTION_MODES = getAvailableModes(); interface TaskChatComposerProps { onSend: ( @@ -80,10 +86,10 @@ interface TaskChatComposerProps { /** Current pill values (persisted per-task by the caller). */ mode: ExecutionMode; model: string; - reasoning: ReasoningEffort; + reasoning: SupportedReasoningEffort; onModeChange: (mode: ExecutionMode) => void; onModelChange: (model: string) => void; - onReasoningChange: (reasoning: ReasoningEffort) => void; + onReasoningChange: (reasoning: SupportedReasoningEffort) => void; /** Steer vs Queue behaviour for messages sent while a turn is running. */ messagingMode: MessagingMode; queuedCount: number; @@ -103,6 +109,11 @@ function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode { return ; case "acceptEdits": return ; + case "bypassPermissions": + case "full-access": + return ; + case "read-only": + return ; case "auto": return ; } @@ -183,6 +194,9 @@ export function TaskChatComposer({ onCancelEdit, }: TaskChatComposerProps) { const themeColors = useThemeColors(); + const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude"); + const modelConfigOption = getModelConfigOption(configOptions); + const mobileModelOptions = getMobileModelOptions(modelConfigOption); const [message, setMessage] = useState(() => initialMessage ?? ""); const [attachments, setAttachments] = useState([]); const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); @@ -206,6 +220,23 @@ export function TaskChatComposer({ setAttachments(restoredDraft.attachments); }, [restoredDraft]); + useEffect(() => { + if (!hasLiveConfig) return; + const availableModel = resolveAvailableModel(modelConfigOption, model); + if (availableModel === model) return; + onModelChange(availableModel); + if (!isSupportedReasoningEffort("claude", availableModel, reasoning)) { + onReasoningChange(DEFAULT_REASONING_EFFORT); + } + }, [ + hasLiveConfig, + model, + modelConfigOption, + onModelChange, + onReasoningChange, + reasoning, + ]); + const appendTranscript = useCallback((transcript: string) => { setMessage((prev) => (prev ? `${prev} ${transcript}` : transcript)); }, []); @@ -220,7 +251,8 @@ export function TaskChatComposer({ const [modelSheetOpen, setModelSheetOpen] = useState(false); const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); - const showReasoningPill = modelSupportsReasoning(model); + const reasoningOptions = getReasoningEffortOptions("claude", model) ?? []; + const showReasoningPill = reasoningOptions.length > 0; const hasContent = !isComposerEmpty({ text: message, attachments }); const canSend = hasContent && !disabled && !isRecording; @@ -399,21 +431,28 @@ export function TaskChatComposer({ ? themeColors.accent[11] : themeColors.gray[11], )} - label={modeLabel(mode)} + label={ + EXECUTION_MODES.find((option) => option.id === mode) + ?.name ?? mode + } accent={mode === "plan"} onPress={() => setModeSheetOpen(true)} /> } - label={modelLabel(model)} + label={getModelLabel(modelConfigOption, model)} onPress={() => setModelSheetOpen(true)} /> {showReasoningPill ? ( } - label={reasoningLabel(reasoning)} + label={ + reasoningOptions.find( + (option) => option.value === reasoning, + )?.name ?? reasoning + } onPress={() => setReasoningSheetOpen(true)} /> ) : null} @@ -462,12 +501,12 @@ export function TaskChatComposer({ onChange={(v) => onModeChange(v as ExecutionMode)} onClose={() => setModeSheetOpen(false)} options={EXECUTION_MODES.map((m) => ({ - value: m.value, - label: m.label, + value: m.id, + label: m.name, description: m.description, icon: modeIcon( - m.value, - m.value === "plan" ? themeColors.accent[11] : themeColors.gray[11], + m.id as ExecutionMode, + m.id === "plan" ? themeColors.accent[11] : themeColors.gray[11], 16, ), }))} @@ -479,18 +518,16 @@ export function TaskChatComposer({ value={model} onChange={(v) => { onModelChange(v); - // If the new model doesn't support reasoning, drop the level so the - // payload stays consistent. Default reasoning re-applies when - // switching back to a reasoning-capable model. - if (!modelSupportsReasoning(v)) { - onReasoningChange(DEFAULT_REASONING); + if (!isSupportedReasoningEffort("claude", v, reasoning)) { + onReasoningChange(DEFAULT_REASONING_EFFORT); } }} onClose={() => setModelSheetOpen(false)} - options={MODELS.map((m) => ({ + options={mobileModelOptions.map((m) => ({ value: m.value, label: m.label, description: m.description, + disabled: m.disabled, icon: , }))} /> @@ -499,11 +536,11 @@ export function TaskChatComposer({ open={reasoningSheetOpen} title="Reasoning" value={reasoning} - onChange={(v) => onReasoningChange(v as ReasoningEffort)} + onChange={(v) => onReasoningChange(v as SupportedReasoningEffort)} onClose={() => setReasoningSheetOpen(false)} - options={REASONING_LEVELS.map((r) => ({ + options={reasoningOptions.map((r) => ({ value: r.value, - label: r.label, + label: r.name, icon: , }))} /> @@ -520,7 +557,7 @@ export function TaskChatComposer({ } export const TASK_CHAT_DEFAULTS = { - mode: DEFAULT_EXECUTION_MODE, - model: DEFAULT_MODEL, - reasoning: DEFAULT_REASONING, + mode: DEFAULT_CLAUDE_EXECUTION_MODE, + model: DEFAULT_GATEWAY_MODEL, + reasoning: DEFAULT_REASONING_EFFORT, } as const; diff --git a/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts b/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts deleted file mode 100644 index 35f885cdc7..0000000000 --- a/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CloudPromptBlock } from "./types"; - -/** - * Wire format prefix shared with `packages/shared/src/cloud-prompt.ts`. The - * backend's `deserializeCloudPrompt` looks for this prefix and decodes the - * trailing JSON as `{ blocks: ContentBlock[] }`. Plain-text prompts without - * attachments are sent as strings (no prefix) so chat echoes stay readable. - */ -export const CLOUD_PROMPT_PREFIX = "__twig_cloud_prompt_v1__:"; - -export function serializeCloudPrompt(blocks: CloudPromptBlock[]): string { - if (blocks.length === 1 && blocks[0].type === "text") { - return blocks[0].text.trim(); - } - return `${CLOUD_PROMPT_PREFIX}${JSON.stringify({ blocks })}`; -} diff --git a/apps/mobile/src/features/tasks/composer/options.test.ts b/apps/mobile/src/features/tasks/composer/options.test.ts index 75328ebf04..c155d51849 100644 --- a/apps/mobile/src/features/tasks/composer/options.test.ts +++ b/apps/mobile/src/features/tasks/composer/options.test.ts @@ -1,27 +1,68 @@ +import { + type CloudTaskConfigOption, + DEFAULT_GATEWAY_MODEL, + restrictedModelMeta, +} from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { - DEFAULT_MODEL, - DEFAULT_REASONING, - modelSupportsReasoning, - REASONING_LEVELS, + getMobileModelOptions, + getModelConfigOption, + getModelLabel, + resolveAvailableModel, } from "./options"; -describe("task composer options", () => { - it("uses an eligible non-premium default model", () => { - expect(DEFAULT_MODEL).toBe("claude-opus-4-8"); - expect(DEFAULT_MODEL).not.toContain("fable"); - }); +const modelOption: CloudTaskConfigOption = { + id: "model", + name: "Model", + type: "select", + currentValue: DEFAULT_GATEWAY_MODEL, + options: [ + { + value: DEFAULT_GATEWAY_MODEL, + name: "Claude Opus 4.8", + description: "Default", + }, + { + value: "claude-fable-5", + name: "Claude Fable 5", + _meta: restrictedModelMeta(), + }, + ], + category: "model", + description: "Choose a model", +}; - it("derives reasoning defaults and options from shared policy", () => { - expect(DEFAULT_REASONING).toBe("high"); - expect(REASONING_LEVELS.map((option) => option.value)).toEqual([ - "low", - "medium", - "high", - "xhigh", - "max", +describe("mobile cloud task model options", () => { + it("adapts live model options and disables restricted entries", () => { + expect(getMobileModelOptions(modelOption)).toEqual([ + { + value: DEFAULT_GATEWAY_MODEL, + label: "Claude Opus 4.8", + description: "Default", + disabled: false, + }, + { + value: "claude-fable-5", + label: "Claude Fable 5", + description: undefined, + disabled: true, + }, ]); - expect(modelSupportsReasoning("claude-opus-4-8")).toBe(true); - expect(modelSupportsReasoning("claude-haiku-4-5")).toBe(false); + }); + + it("falls back from restricted or missing selections", () => { + expect(resolveAvailableModel(modelOption, "claude-fable-5")).toBe( + DEFAULT_GATEWAY_MODEL, + ); + expect(resolveAvailableModel(modelOption, "missing-model")).toBe( + DEFAULT_GATEWAY_MODEL, + ); + }); + + it("reads the live model label and config option", () => { + expect(getModelConfigOption([modelOption])).toBe(modelOption); + expect(getModelLabel(modelOption, DEFAULT_GATEWAY_MODEL)).toBe( + "Claude Opus 4.8", + ); }); }); diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts index 572fff4a35..1db34b57a1 100644 --- a/apps/mobile/src/features/tasks/composer/options.ts +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -1,107 +1,56 @@ import { - DEFAULT_CLAUDE_EXECUTION_MODE, - getAvailableModes, -} from "@posthog/core/sessions/executionModes"; -import { - DEFAULT_GATEWAY_MODEL, - DEFAULT_REASONING_EFFORT, - defaultEligibleModel, - getReasoningEffortOptions, - type ExecutionMode as SharedExecutionMode, - type SupportedReasoningEffort, + type CloudTaskConfigOption, + isRestrictedModelOption, } from "@posthog/shared"; -export type ExecutionMode = Extract< - SharedExecutionMode, - "default" | "acceptEdits" | "plan" | "auto" ->; -export type ReasoningEffort = SupportedReasoningEffort; - -export const EXECUTION_MODES: { - value: ExecutionMode; - label: string; - description: string; -}[] = getAvailableModes() - .filter( - (mode): mode is typeof mode & { id: ExecutionMode } => - mode.id === "default" || - mode.id === "acceptEdits" || - mode.id === "plan" || - mode.id === "auto", - ) - .map((mode) => ({ - value: mode.id, - label: mode.name, - description: mode.description, - })); - -export interface ModelOption { +export interface MobileModelOption { value: string; label: string; description?: string; - supportsReasoning: boolean; + disabled: boolean; } -export const MODELS: ModelOption[] = [ - { - value: "claude-fable-5", - label: "Claude Fable 5", - description: "Newest, most capable", - supportsReasoning: true, - }, - { - value: "claude-opus-5", - label: "Claude Opus 5", - description: "Most capable, slower", - supportsReasoning: true, - }, - { - value: "claude-opus-4-8", - label: "Claude Opus 4.8", - description: "Previous Opus generation", - supportsReasoning: true, - }, - { - value: "claude-sonnet-5", - label: "Claude Sonnet 5", - description: "Balanced, fast", - supportsReasoning: true, - }, - { - value: "claude-sonnet-4-6", - label: "Claude Sonnet 4.6", - description: "Balanced", - supportsReasoning: true, - }, -]; - -export const DEFAULT_EXECUTION_MODE: ExecutionMode = - DEFAULT_CLAUDE_EXECUTION_MODE; -export const DEFAULT_MODEL = - defaultEligibleModel(DEFAULT_GATEWAY_MODEL) ?? - MODELS.find((model) => defaultEligibleModel(model.value))?.value ?? - DEFAULT_GATEWAY_MODEL; -export const DEFAULT_REASONING: ReasoningEffort = DEFAULT_REASONING_EFFORT; - -export const REASONING_LEVELS: { - value: ReasoningEffort; - label: string; -}[] = (getReasoningEffortOptions("claude", DEFAULT_MODEL) ?? []).map( - (option) => ({ value: option.value, label: option.name }), -); - -export function modelLabel(value: string): string { - return MODELS.find((m) => m.value === value)?.label ?? value; +export function getModelConfigOption( + configOptions: readonly CloudTaskConfigOption[], +): CloudTaskConfigOption { + const modelOption = configOptions.find( + (option) => option.category === "model", + ); + if (!modelOption) { + throw new Error("Cloud task model configuration is unavailable"); + } + return modelOption; } -export function modeLabel(value: ExecutionMode): string { - return EXECUTION_MODES.find((m) => m.value === value)?.label ?? value; +export function getMobileModelOptions( + modelOption: CloudTaskConfigOption, +): MobileModelOption[] { + return modelOption.options.map((option) => ({ + value: option.value, + label: option.name, + description: option.description, + disabled: isRestrictedModelOption(option._meta), + })); } -export function reasoningLabel(value: ReasoningEffort): string { - return REASONING_LEVELS.find((r) => r.value === value)?.label ?? value; +export function getModelLabel( + modelOption: CloudTaskConfigOption, + value: string, +): string { + return ( + modelOption.options.find((option) => option.value === value)?.name ?? value + ); } -export function modelSupportsReasoning(value: string): boolean { - return getReasoningEffortOptions("claude", value) !== null; +export function resolveAvailableModel( + modelOption: CloudTaskConfigOption, + value: string, +): string { + const selectedOption = modelOption.options.find( + (option) => option.value === value, + ); + if (selectedOption && !isRestrictedModelOption(selectedOption._meta)) { + return value; + } + return modelOption.currentValue; } diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts index 772d9a4f69..fb47817a8c 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts @@ -8,36 +8,28 @@ const { mockGetTaskAutomations, mockCreateTaskAutomation, mockUpdateTaskAutomation, - mockApiClient, } = vi.hoisted(() => ({ mockUseAuthStore: vi.fn(), mockGetTaskAutomations: vi.fn(), mockCreateTaskAutomation: vi.fn(), mockUpdateTaskAutomation: vi.fn(), - mockApiClient: { - listTaskAutomations: vi.fn(), - getTaskAutomation: vi.fn(), - createTaskAutomation: vi.fn(), - updateTaskAutomation: vi.fn(), - deleteTaskAutomation: vi.fn(), - runTaskAutomation: vi.fn(), - }, })); vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore, })); -vi.mock("../api", () => ({ runTaskInCloud: vi.fn() })); - vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => mockApiClient, + getPostHogApiClient: vi.fn(() => ({ + listTaskAutomations: mockGetTaskAutomations, + getTaskAutomation: vi.fn(), + createTaskAutomation: mockCreateTaskAutomation, + updateTaskAutomation: mockUpdateTaskAutomation, + deleteTaskAutomation: vi.fn(), + runTaskAutomation: vi.fn(), + })), })); -mockApiClient.listTaskAutomations = mockGetTaskAutomations; -mockApiClient.createTaskAutomation = mockCreateTaskAutomation; -mockApiClient.updateTaskAutomation = mockUpdateTaskAutomation; - import { automationKeys, getAutomationPollingInterval, diff --git a/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts new file mode 100644 index 0000000000..4b516f1445 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts @@ -0,0 +1,121 @@ +import { + type CloudTaskConfigOption, + DEFAULT_GATEWAY_MODEL, +} from "@posthog/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type PropsWithChildren } from "react"; +import { act, create } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockGetCloudTaskConfigOptions, mockUseAuthStore } = vi.hoisted(() => ({ + mockGetCloudTaskConfigOptions: vi.fn(), + mockUseAuthStore: vi.fn(), +})); + +vi.mock("posthog-react-native", () => ({ + useFeatureFlag: () => false, +})); + +vi.mock("@/features/auth", () => ({ + useAuthStore: mockUseAuthStore, +})); + +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + getCloudTaskConfigOptions: mockGetCloudTaskConfigOptions, + }), +})); + +import { getModelConfigOption } from "../composer/options"; +import { useCloudTaskConfigOptions } from "./useCloudTaskConfigOptions"; + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return createElement( + QueryClientProvider, + { client: queryClient }, + children, + ); + }; +} + +async function renderHook() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + let currentResult: ReturnType; + + function HookProbe() { + currentResult = useCloudTaskConfigOptions("claude"); + return null; + } + + const Wrapper = createWrapper(queryClient); + await act(async () => { + create(createElement(Wrapper, null, createElement(HookProbe))); + await Promise.resolve(); + }); + + return { + get current() { + return currentResult; + }, + }; +} + +async function waitForAssertion(assertion: () => void): Promise { + const timeoutAt = Date.now() + 2_000; + while (Date.now() < timeoutAt) { + try { + assertion(); + return; + } catch (error) { + await new Promise((resolve) => setTimeout(resolve, 10)); + if (Date.now() >= timeoutAt) throw error; + } + } +} + +describe("useCloudTaskConfigOptions", () => { + beforeEach(() => { + mockGetCloudTaskConfigOptions.mockReset(); + mockUseAuthStore.mockImplementation((selector) => + selector({ oauthAccessToken: "token" }), + ); + }); + + it("uses the authenticated live Claude catalog", async () => { + const liveOptions: CloudTaskConfigOption[] = [ + { + id: "model", + name: "Model", + type: "select", + currentValue: "claude-sonnet-5", + options: [{ value: "claude-sonnet-5", name: "Claude Sonnet 5" }], + category: "model", + description: "Choose a model", + }, + ]; + mockGetCloudTaskConfigOptions.mockResolvedValue(liveOptions); + + const result = await renderHook(); + await waitForAssertion(() => { + expect(result.current.configOptions).toEqual(liveOptions); + expect(result.current.hasLiveConfig).toBe(true); + }); + expect(mockGetCloudTaskConfigOptions).toHaveBeenCalledWith("claude"); + }); + + it("keeps the shared fallback when unauthenticated", async () => { + mockUseAuthStore.mockImplementation((selector) => + selector({ oauthAccessToken: null }), + ); + + const result = await renderHook(); + + expect( + getModelConfigOption(result.current.configOptions).currentValue, + ).toBe(DEFAULT_GATEWAY_MODEL); + expect(mockGetCloudTaskConfigOptions).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts new file mode 100644 index 0000000000..aaedf3375d --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts @@ -0,0 +1,52 @@ +import { + type Adapter, + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + GLM_MODEL_FLAG, + isGlmModelId, +} from "@posthog/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useFeatureFlag } from "posthog-react-native"; +import { useAuthStore } from "@/features/auth"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; + +export const cloudTaskConfigOptionKeys = { + all: ["cloud-task-config-options"] as const, + adapter: (adapter: Adapter) => + [...cloudTaskConfigOptionKeys.all, adapter] as const, +}; + +const fallbackOptionsByAdapter: Record = { + claude: buildCloudTaskConfigOptions([], "claude"), + codex: buildCloudTaskConfigOptions([], "codex"), +}; + +export function useCloudTaskConfigOptions(adapter: Adapter = "claude") { + const oauthAccessToken = useAuthStore((state) => state.oauthAccessToken); + const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); + const query = useQuery({ + queryKey: cloudTaskConfigOptionKeys.adapter(adapter), + queryFn: () => getPostHogApiClient().getCloudTaskConfigOptions(adapter), + enabled: !!oauthAccessToken, + staleTime: 5 * 60 * 1000, + }); + const configOptions = query.data ?? fallbackOptionsByAdapter[adapter]; + const visibleConfigOptions = glmEnabled + ? configOptions + : configOptions.map((option) => + option.category === "model" + ? { + ...option, + options: option.options.filter( + (model) => !isGlmModelId(model.value), + ), + } + : option, + ); + + return { + ...query, + configOptions: visibleConfigOptions, + hasLiveConfig: query.data !== undefined, + }; +} diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts index 45040a2df6..68394d2aba 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts @@ -15,10 +15,10 @@ vi.mock("@/features/auth", () => ({ })); vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => ({ + getPostHogApiClient: vi.fn(() => ({ getGithubRepositories: mockGetGithubRepositories, getIntegrations: mockGetIntegrations, - }), + })), })); import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; @@ -123,7 +123,7 @@ describe("useIntegrations", () => { }, ]); mockGetGithubRepositories - .mockResolvedValueOnce(["annika/mobile-app"]) + .mockResolvedValueOnce(["Annika/Mobile-App", ""]) .mockRejectedValueOnce(new Error("GitHub repos failed")); const queryClient = new QueryClient({ diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts index bf2aab3c77..25f909f6f9 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts @@ -1,34 +1,14 @@ +import { combineGithubRepositories } from "@posthog/core/integrations/repositories"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; -import type { Integration, RepositoryOption } from "../types"; -import { buildRepositoryOptions } from "../utils/repositorySelection"; - -/** Cheap content-equality check for repository option lists. Lets the cache - * write effect skip no-op updates, which is what kept retriggering renders - * before — `buildRepositoryOptions` always returns a fresh array, so the - * effect's dep array churned every render. */ -function repositoryOptionsEqual( - a: RepositoryOption[], - b: RepositoryOption[], -): boolean { - if (a === b) return true; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - const left = a[i]; - const right = b[i]; - if ( - left.integrationId !== right.integrationId || - left.repository !== right.repository || - left.integrationLabel !== right.integrationLabel - ) { - return false; - } - } - return true; -} +import { + buildRepositoryOptions, + repositoryLoadWarning, + repositoryOptionsEqual, +} from "../utils/repositorySelection"; export const integrationKeys = { all: ["integrations"] as const, @@ -60,26 +40,7 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { queryKey: integrationKeys.github(), queryFn: async () => { const data = await getPostHogApiClient().getIntegrations(); - return data.flatMap((integration): Integration[] => { - if ( - integration.kind !== "github" || - typeof integration.id !== "number" - ) { - return []; - } - - return [ - { - id: integration.id, - kind: integration.kind, - display_name: - typeof integration.display_name === "string" - ? integration.display_name - : undefined, - config: integration.config as Integration["config"], - }, - ]; - }); + return data.filter((i) => i.kind === "github"); }, enabled: enabled && !!projectId && !!oauthAccessToken, }); @@ -97,9 +58,11 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const results = await Promise.allSettled( githubIntegrations.map(async (integration) => ({ integrationId: integration.id, - repositories: await getPostHogApiClient().getGithubRepositories( - integration.id, - ), + repositories: ( + await getPostHogApiClient().getGithubRepositories(integration.id) + ) + .map((repository) => repository.toLowerCase()) + .filter(Boolean), })), ); @@ -117,12 +80,10 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { return { repositoriesByIntegration, - partialError: - failedCount === 0 - ? null - : failedCount === githubIntegrations.length - ? "Could not load GitHub repositories. Pull to retry." - : "Some GitHub repositories could not be loaded. Pull to retry.", + partialError: repositoryLoadWarning( + failedCount, + githubIntegrations.length, + ), }; }, enabled: enabled && githubIntegrations.length > 0, @@ -130,7 +91,19 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const repositoriesByIntegration = repositoriesQuery.data?.repositoriesByIntegration ?? {}; - const repositories = Object.values(repositoriesByIntegration).flat().sort(); + const repositories = Object.keys( + combineGithubRepositories( + githubIntegrations.map((integration) => ({ + data: { + integrationId: integration.id, + repos: repositoriesByIntegration[integration.id] ?? [], + }, + isPending: repositoriesQuery.isPending, + isError: false, + isRefetching: repositoriesQuery.isRefetching, + })), + ).repositoryMap, + ).sort(); // Memoize the derived options list keyed on the underlying query data so // its reference is stable across renders when the data hasn't actually diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts index 579ec4bce2..1395c283e8 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts @@ -27,18 +27,15 @@ vi.mock("@/lib/logger", () => { }; }); -vi.mock("../api", () => ({ - runTaskInCloud: vi.fn(), -})); - vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => ({ + getPostHogApiClient: vi.fn(() => ({ createTask: vi.fn(), deleteTask: vi.fn(), getTask: vi.fn(), getTasks: vi.fn(), + runTaskInCloud: vi.fn(), updateTask: vi.fn(), - }), + })), })); vi.mock("../stores/taskStore", () => ({ diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 95b5d4725f..862459708e 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore, useUserQuery } from "@/features/auth"; import { logger } from "@/lib/logger"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; -import { runTaskInCloud } from "../api"; import { useTaskStore } from "../stores/taskStore"; import type { CreateTaskOptions } from "../types"; @@ -136,13 +135,13 @@ export function useUpdateTask() { }: { taskId: string; updates: Partial; - }) => - getPostHogApiClient().updateTask( + }) => { + const client = getPostHogApiClient(); + return client.updateTask( taskId, - updates as Parameters< - ReturnType["updateTask"] - >[1], - ), + updates as Parameters[1], + ); + }, onSuccess: (updatedTask, { taskId }) => { // Update the detail cache immediately queryClient.setQueryData(taskKeys.detail(taskId), updatedTask); @@ -174,7 +173,8 @@ export function useRunTask() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (taskId: string) => runTaskInCloud(taskId), + mutationFn: (taskId: string) => + getPostHogApiClient().runTaskInCloud(taskId), onSuccess: (updatedTask, taskId) => { queryClient.setQueryData(taskKeys.detail(taskId), updatedTask); queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); diff --git a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts index 1ba6655cf2..c951950409 100644 --- a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts @@ -1,8 +1,13 @@ +import { combineUserGithubRepositories } from "@posthog/core/integrations/repositories"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; import type { RepositoryOption } from "../types"; +import { + buildUserRepositoryOptions, + repositoryLoadWarning, +} from "../utils/repositorySelection"; /** * User-scoped sibling of {@link useIntegrations}. Reads the authenticated @@ -29,20 +34,25 @@ interface UseUserIntegrationsOptions { enabled?: boolean; } -function integrationLabel(integration: { - installation_id: string; - account?: { name?: string | null } | null; -}): string { - return integration.account?.name ?? `GitHub ${integration.installation_id}`; -} - export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { const { enabled = true } = options; const { oauthAccessToken } = useAuthStore(); const integrationsQuery = useQuery({ queryKey: userIntegrationKeys.github(), - queryFn: () => getPostHogApiClient().getGithubUserIntegrations(), + queryFn: async () => { + const integrations = + await getPostHogApiClient().getGithubUserIntegrations(); + return integrations.map(({ account, ...integration }) => ({ + ...integration, + account: account + ? { + name: account.name ?? undefined, + type: account.type ?? undefined, + } + : undefined, + })); + }, enabled: enabled && !!oauthAccessToken, }); @@ -53,53 +63,57 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { integrations.map((i) => i.installation_id), ), queryFn: async () => { - const byInstallation: Record = {}; const results = await Promise.allSettled( integrations.map(async (integration) => ({ installationId: integration.installation_id, - repositories: await getPostHogApiClient().getGithubUserRepositories( - integration.installation_id, - ), + repositories: ( + await getPostHogApiClient().getGithubUserRepositories( + integration.installation_id, + ) + ) + .map((repository) => repository.toLowerCase()) + .filter(Boolean), })), ); - let failedCount = 0; - for (const result of results) { - if (result.status === "fulfilled") { - byInstallation[result.value.installationId] = - result.value.repositories; - } else { - failedCount += 1; - } - } + const combined = combineUserGithubRepositories( + results.map((result) => ({ + data: + result.status === "fulfilled" + ? { + userIntegrationId: + integrations.find( + (integration) => + integration.installation_id === + result.value.installationId, + )?.id ?? "", + installationId: result.value.installationId, + repos: result.value.repositories, + } + : undefined, + isPending: false, + isError: result.status === "rejected", + isRefetching: false, + })), + integrations.map((integration) => integration.installation_id), + ); return { - byInstallation, - partialError: - failedCount === 0 - ? null - : failedCount === integrations.length - ? "Could not load GitHub repositories. Pull to retry." - : "Some GitHub repositories could not be loaded. Pull to retry.", + byInstallation: combined.reposByInstallationId, + partialError: repositoryLoadWarning( + combined.failedInstallationIds.length, + integrations.length, + ), }; }, enabled: enabled && integrations.length > 0, }); const repositoryOptions = useMemo(() => { - const byInstallation = repositoriesQuery.data?.byInstallation ?? {}; - return integrations - .flatMap((integration) => { - const repositories = byInstallation[integration.installation_id] ?? []; - return repositories.map((repository) => ({ - // GitHub installation ids fit in a JS number; use it as the numeric - // key the picker/RepositoryOption already expect. - integrationId: Number(integration.installation_id), - integrationLabel: integrationLabel(integration), - repository, - })); - }) - .sort((left, right) => left.repository.localeCompare(right.repository)); + return buildUserRepositoryOptions( + integrations, + repositoriesQuery.data?.byInstallation ?? {}, + ); }, [integrations, repositoriesQuery.data]); /** Resolve the `UserIntegration` UUID for a selected installation id, to send diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx index 175a77c276..4985ffeaee 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx @@ -9,7 +9,9 @@ vi.mock("posthog-react-native", () => ({ useFeatureFlag: () => flagState.enabled, })); vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => ({ warmTask: mockWarmTask }), + getPostHogApiClient: vi.fn(() => ({ + warmTask: mockWarmTask, + })), })); vi.mock("@/lib/logger", () => { const mockLogger = { diff --git a/apps/mobile/src/features/tasks/index.ts b/apps/mobile/src/features/tasks/index.ts index 7da4db747e..2bcbcc35a7 100644 --- a/apps/mobile/src/features/tasks/index.ts +++ b/apps/mobile/src/features/tasks/index.ts @@ -24,9 +24,3 @@ export { useTaskStore } from "./stores/taskStore"; // Types export * from "./types"; - -// Utils -export { - convertStoredEntriesToEvents, - parseSessionLogs, -} from "./utils/parseSessionLogs"; diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 462589a58e..0c57cc6d1a 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,7 +1,9 @@ +import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents"; import { type CloudTaskUpdatePayload, isTerminalStatus, type StoredLogEntry, + serializeCloudPrompt, type Task, } from "@posthog/shared"; import * as Haptics from "expo-haptics"; @@ -18,7 +20,6 @@ import { sendCloudCommand, } from "../api"; import { buildCloudPromptBlocks } from "../composer/attachments/buildCloudPrompt"; -import { serializeCloudPrompt } from "../composer/attachments/cloudPrompt"; import type { PendingAttachment } from "../composer/attachments/types"; import { type WatchCloudTaskHandle, @@ -31,7 +32,6 @@ import type { SessionNotificationAttachment, TerminalStatus, } from "../types"; -import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs"; import { playbackRateForTaskDuration } from "../utils/playbackRate"; import { reinjectPromptAttachments } from "../utils/promptAttachments"; import { playCompletionSound } from "../utils/sounds"; @@ -991,7 +991,8 @@ export const useTaskSessionStore = create((set, get) => ({ ? update.newEntries : dedupAgainstLocalEchoes(update.newEntries, echoSet); - const events = convertStoredEntriesToEvents(dedupedEntries); + const events = + convertStoredEntriesToPortableSessionEvents(dedupedEntries); // Snapshots are S3-backed and replay user turns as text-only chunks; // reattach the images from the `session/prompt` entries in the same log. if (isSnapshot) { diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index 456eae37a1..39276a7eeb 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,11 +1,12 @@ +import type { TaskActivitySortMode } from "@posthog/core/tasks/taskActivity"; +import type { ExecutionMode, SupportedReasoningEffort } from "@posthog/shared"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import type { ExecutionMode, ReasoningEffort } from "../composer/options"; import type { RepositorySelection } from "../types"; export type OrganizeMode = "by-project" | "chronological"; -export type SortMode = "created" | "updated"; +export type SortMode = TaskActivitySortMode; const EMPTY_REPOSITORY_SELECTION: RepositorySelection = { integrationId: null, @@ -17,7 +18,7 @@ const EMPTY_REPOSITORY_SELECTION: RepositorySelection = { export interface TaskComposerConfig { mode?: ExecutionMode; model?: string; - reasoning?: ReasoningEffort; + reasoning?: SupportedReasoningEffort; } interface TaskUIState { diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 29a2754d7f..ad45e83576 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -1,14 +1,8 @@ import type { CloudPermissionOption, CloudTaskPermissionRequestUpdate, - StoredLogEntry as SharedStoredLogEntry, - TaskRunStatus, } from "@posthog/shared"; -export interface MobileStoredLogEntry extends SharedStoredLogEntry { - direction?: "client" | "agent"; -} - export interface SessionNotificationAttachment { kind: "image" | "document"; uri: string; @@ -16,51 +10,12 @@ export interface SessionNotificationAttachment { mimeType?: string; } -export interface SessionNotification { - update?: { - sessionUpdate?: string; - content?: { type: string; text: string }; - // Sidecar carrying user-uploaded attachments on user_message_chunk events. - // The wire format embeds the bytes themselves in a separate serialized - // cloud-prompt payload sent to the agent; this field exists only so the - // local feed can render the attachments alongside the echoed text. - attachments?: SessionNotificationAttachment[]; - title?: string; - toolCallId?: string; - status?: "pending" | "in_progress" | "completed" | "failed" | null; - rawInput?: Record; - rawOutput?: unknown; - entries?: PlanEntry[]; - _meta?: { - claudeCode?: { - toolName?: string; - parentToolCallId?: string; - }; - }; - }; -} - export interface PlanEntry { content: string; status: "pending" | "in_progress" | "completed" | "failed"; priority: string; } -export interface AcpMessage { - type: "acp_message"; - direction: "client" | "agent"; - ts: number; - message: unknown; -} - -export interface SessionUpdateEvent { - type: "session_update"; - ts: number; - notification: SessionNotification; -} - -export type SessionEvent = AcpMessage | SessionUpdateEvent; - export interface CloudPermissionResponseSelection { optionId: string; displayText: string; @@ -75,64 +30,6 @@ export interface CloudPendingPermissionRequest { response?: CloudPermissionResponseSelection; } -export interface TaskRunStateEvent { - type: "task_run_state"; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - error_message?: string | null; - branch?: string | null; - updated_at?: string | null; - completed_at?: string | null; -} - -export interface PermissionRequestEventData { - type: "permission_request"; - requestId: string; - toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; - options: CloudPermissionOption[]; -} - -export interface SseErrorEventData { - error: string; -} - -export function isTaskRunStateEvent(data: unknown): data is TaskRunStateEvent { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "task_run_state" - ); -} - -export function isPermissionRequestEvent( - data: unknown, -): data is PermissionRequestEventData { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "permission_request" && - typeof (data as { requestId?: string }).requestId === "string" - ); -} - -export function isKeepaliveEvent(data: unknown): boolean { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "keepalive" - ); -} - -export function isSseErrorEvent(data: unknown): data is SseErrorEventData { - return ( - typeof data === "object" && - data !== null && - "error" in data && - typeof (data as SseErrorEventData).error === "string" - ); -} - export interface Integration { id: number; kind: string; diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts deleted file mode 100644 index a6d512d59d..0000000000 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents"; -import type { MobileStoredLogEntry, SessionNotification } from "../types"; - -export interface ParsedSessionLogs { - notifications: SessionNotification[]; - rawEntries: MobileStoredLogEntry[]; -} - -export function parseSessionLogs(content: string): ParsedSessionLogs { - if (!content?.trim()) { - return { notifications: [], rawEntries: [] }; - } - - const notifications: SessionNotification[] = []; - const rawEntries: MobileStoredLogEntry[] = []; - - for (const line of content.trim().split("\n")) { - try { - const stored = JSON.parse(line) as MobileStoredLogEntry; - - const msg = stored.notification; - if (msg) { - const hasId = msg.id !== undefined; - const hasMethod = msg.method !== undefined; - const hasResult = msg.result !== undefined || msg.error !== undefined; - - if (hasId && hasMethod) { - stored.direction = "client"; - } else if (hasId && hasResult) { - stored.direction = "agent"; - } else if (hasMethod && !hasId) { - stored.direction = "agent"; - } - } - - rawEntries.push(stored); - - if ( - stored.type === "notification" && - stored.notification?.method === "session/update" && - stored.notification?.params - ) { - notifications.push(stored.notification.params as SessionNotification); - } - } catch { - // Skip malformed lines - } - } - - return { notifications, rawEntries }; -} - -export const convertStoredEntriesToEvents = - convertStoredEntriesToPortableSessionEvents; diff --git a/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts b/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts index 820ef73caf..b7177617e3 100644 --- a/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts +++ b/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts @@ -1,36 +1,31 @@ import { describe, expect, it } from "vitest"; import { buildRepositoryOptions, + buildUserRepositoryOptions, findRepositoryOption, isRepositorySelectionComplete, + repositoryLoadWarning, + repositoryOptionsEqual, toRepositorySelection, } from "./repositorySelection"; describe("repositorySelection", () => { const integrations = [ - { - id: 7, - kind: "github", - display_name: "Personal GitHub", - }, + { id: 7, kind: "github", display_name: "Personal GitHub" }, { id: 11, kind: "github", - config: { - account: { - login: "posthog", - }, - }, + config: { account: { login: "posthog" } }, }, ]; it("preserves integration identity for each repository option", () => { - const options = buildRepositoryOptions(integrations, { - 7: ["annika/mobile-app"], - 11: ["posthog/posthog", "posthog/code"], - }); - - expect(options).toEqual([ + expect( + buildRepositoryOptions(integrations, { + 7: ["annika/mobile-app"], + 11: ["posthog/posthog", "posthog/code"], + }), + ).toEqual([ { integrationId: 7, integrationLabel: "Personal GitHub", @@ -49,41 +44,81 @@ describe("repositorySelection", () => { ]); }); - it("finds the exact repository option when multiple integrations expose the same repository", () => { + it("finds an exact repository option", () => { const options = buildRepositoryOptions(integrations, { 7: ["posthog/posthog"], 11: ["posthog/posthog"], }); - const selected = findRepositoryOption(options, { + expect( + findRepositoryOption(options, { + integrationId: 11, + repository: "posthog/posthog", + }), + ).toEqual({ integrationId: 11, + integrationLabel: "posthog", repository: "posthog/posthog", }); + }); + + it.each([ + [{ installation_id: "42", account: { name: "PostHog" } }, "PostHog"], + [{ installation_id: "43" }, "GitHub 43"], + ])( + "builds user integration options with the expected label", + (integration, integrationLabel) => { + expect( + buildUserRepositoryOptions([integration], { + [integration.installation_id]: ["posthog/code"], + }), + ).toEqual([ + { + integrationId: Number(integration.installation_id), + integrationLabel, + repository: "posthog/code", + }, + ]); + }, + ); - expect(selected).toEqual({ + it("treats a changed label as a different repository option", () => { + const option = { integrationId: 11, integrationLabel: "posthog", - repository: "posthog/posthog", - }); + repository: "posthog/code", + }; + + expect( + repositoryOptionsEqual( + [option], + [{ ...option, integrationLabel: "PostHog GitHub" }], + ), + ).toBe(false); }); - it("converts an option into a reusable repository selection payload", () => { - const options = buildRepositoryOptions(integrations, { - 11: ["posthog/code"], - }); + it.each([ + [0, 2, null], + [1, 2, "Some GitHub repositories could not be loaded. Pull to retry."], + [2, 2, "Could not load GitHub repositories. Pull to retry."], + ])( + "maps repository failures to the expected warning", + (failedCount, totalCount, expected) => { + expect(repositoryLoadWarning(failedCount, totalCount)).toBe(expected); + }, + ); - const selection = toRepositorySelection(options[0] ?? null); + it("converts an option into a repository selection", () => { + const selection = toRepositorySelection({ + integrationId: 11, + integrationLabel: "posthog", + repository: "posthog/code", + }); expect(selection).toEqual({ integrationId: 11, repository: "posthog/code", }); expect(isRepositorySelectionComplete(selection)).toBe(true); - expect( - isRepositorySelectionComplete({ - integrationId: null, - repository: "posthog/code", - }), - ).toBe(false); }); }); diff --git a/apps/mobile/src/features/tasks/utils/repositorySelection.ts b/apps/mobile/src/features/tasks/utils/repositorySelection.ts index 9e7b2cc8a8..bff8441a6a 100644 --- a/apps/mobile/src/features/tasks/utils/repositorySelection.ts +++ b/apps/mobile/src/features/tasks/utils/repositorySelection.ts @@ -2,6 +2,7 @@ import type { Integration, RepositoryOption, RepositorySelection, + UserGithubIntegration, } from "../types"; function getIntegrationLabel(integration: Integration): string { @@ -17,26 +18,67 @@ export function buildRepositoryOptions( repositoriesByIntegration: Record, ): RepositoryOption[] { return integrations - .flatMap((integration) => { - const repositories = repositoriesByIntegration[integration.id] ?? []; - - return repositories.map((repository) => ({ + .flatMap((integration) => + (repositoriesByIntegration[integration.id] ?? []).map((repository) => ({ integrationId: integration.id, integrationLabel: getIntegrationLabel(integration), repository, - })); - }) + })), + ) + .sort((left, right) => left.repository.localeCompare(right.repository)); +} + +export function buildUserRepositoryOptions( + integrations: UserGithubIntegration[], + repositoriesByInstallation: Record, +): RepositoryOption[] { + return integrations + .flatMap((integration) => + (repositoriesByInstallation[integration.installation_id] ?? []).map( + (repository) => ({ + integrationId: Number(integration.installation_id), + integrationLabel: + integration.account?.name ?? + `GitHub ${integration.installation_id}`, + repository, + }), + ), + ) .sort((left, right) => left.repository.localeCompare(right.repository)); } +export function repositoryOptionsEqual( + left: RepositoryOption[], + right: RepositoryOption[], +): boolean { + return ( + left.length === right.length && + left.every((option, index) => { + const other = right[index]; + return ( + other?.integrationId === option.integrationId && + other.integrationLabel === option.integrationLabel && + other.repository === option.repository + ); + }) + ); +} + +export function repositoryLoadWarning( + failedCount: number, + totalCount: number, +): string | null { + if (failedCount === 0) return null; + return failedCount === totalCount + ? "Could not load GitHub repositories. Pull to retry." + : "Some GitHub repositories could not be loaded. Pull to retry."; +} + export function findRepositoryOption( options: RepositoryOption[], selection: RepositorySelection, ): RepositoryOption | null { - if (!selection.integrationId || !selection.repository) { - return null; - } - + if (!selection.integrationId || !selection.repository) return null; return ( options.find( (option) => diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index 3075d139ce..0db326eebe 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -29,6 +29,10 @@ import { canvasCoreModule } from "@posthog/core/canvas/canvas.module"; import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module"; import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task"; import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module"; +import { + CLOUD_TASK_CLIENT, + type CloudTaskClient, +} from "@posthog/core/cloud-task/cloudTaskClient"; import { CLOUD_TASK_AUTH, CLOUD_TASK_SERVICE, @@ -86,10 +90,14 @@ import { type GithubConnectClient as OnboardingGithubConnectContract, } from "@posthog/core/onboarding/identifiers"; import { onboardingModule } from "@posthog/core/onboarding/onboarding.module"; +import { PI_RUNNER } from "@posthog/core/pi-runtime/identifiers"; import { piRuntimeModule } from "@posthog/core/pi-runtime/pi-runtime.module"; +import type { PiRunner } from "@posthog/core/pi-runtime/piRunner"; import { - PI_SESSION_CLIENT, - type PiSessionClient, + LOCAL_PI_SESSION_FACTORY, + PI_SESSION_PROVIDER, + type PiSessionFactory, + type PiSessionProvider, } from "@posthog/core/pi-runtime/piSessionController"; import { type BundleLocalSkill, @@ -161,7 +169,9 @@ import { HOST_TRPC_CLIENT, type HostTrpcClient, } from "@posthog/host-router/client"; -import { TrpcPiSessionClient } from "@posthog/host-router/pi-session-client"; +import { TrpcCloudTaskClient } from "@posthog/host-router/cloud-task-client"; +import { TrpcPiRunner } from "@posthog/host-router/pi-runner"; +import { TrpcPiSessionFactory } from "@posthog/host-router/pi-session-factory"; import { ANALYTICS_SERVICE, type IAnalytics, @@ -314,7 +324,10 @@ import { hostTrpcClient } from "./web-trpc"; interface WebBindings { [HOST_TRPC_CLIENT]: HostTrpcClient; - [PI_SESSION_CLIENT]: PiSessionClient; + [PI_SESSION_PROVIDER]: PiSessionProvider; + [LOCAL_PI_SESSION_FACTORY]: PiSessionFactory; + [CLOUD_TASK_CLIENT]: CloudTaskClient; + [PI_RUNNER]: PiRunner; [ROOT_LOGGER]: RootLogger; [HOST_LOGGER]: HostLogger; [FEATURE_FLAGS]: FeatureFlags; @@ -395,7 +408,9 @@ export const container = new TypedContainer({ // Keystone: the same typed host client the renderer binds — served in-process // here (web-trpc.ts) instead of over Electron IPC. container.bind(HOST_TRPC_CLIENT).toConstantValue(hostTrpcClient); -container.bind(PI_SESSION_CLIENT).to(TrpcPiSessionClient); +container.bind(LOCAL_PI_SESSION_FACTORY).to(TrpcPiSessionFactory); +container.bind(CLOUD_TASK_CLIENT).to(TrpcCloudTaskClient); +container.bind(PI_RUNNER).to(TrpcPiRunner); container.load(piRuntimeModule); // Logger: web uses console; electron uses electron-log. Same RootLogger shape. diff --git a/apps/web/src/web-host-router.ts b/apps/web/src/web-host-router.ts index d562ba4cd1..07f148b42d 100644 --- a/apps/web/src/web-host-router.ts +++ b/apps/web/src/web-host-router.ts @@ -1,3 +1,7 @@ +import { fetchPosthogPiModelCatalog } from "@posthog/agent/pi/model-catalog"; +import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; +import type { AuthService } from "@posthog/core/auth/auth"; +import { AUTH_SERVICE } from "@posthog/core/auth/auth.module"; import { TEAM_SKILLS_SERVICE } from "@posthog/core/skills/identifiers"; import type { TeamSkillsService } from "@posthog/core/skills/teamSkillsService"; import { resolveService } from "@posthog/di/container"; @@ -134,6 +138,19 @@ const agentStubRouter = router({ }), // Called by resetSessionService() on logout/project switch. resetAll: publicProcedure.mutation(() => undefined), + getPiModelCatalog: publicProcedure + .input( + z.object({ apiHost: z.string(), region: z.enum(["us", "eu", "dev"]) }), + ) + .query(async ({ input }) => { + const auth = resolveService(AUTH_SERVICE); + const { accessToken } = await auth.getValidAccessToken(); + return fetchPosthogPiModelCatalog( + getLlmGatewayUrl(input.apiHost), + input.region, + accessToken, + ); + }), // Model/mode/effort options for the task-input preview + cloud run creation // (a cloud run requires a model). Real: fetched from the CORS-open PostHog LLM // gateway, same logic the desktop main process runs (see web-agent-config.ts). diff --git a/packages/agent/package.json b/packages/agent/package.json index fdf66665c4..c4e12b6365 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -32,6 +32,14 @@ "types": "./dist/pi/rpc-client.d.ts", "import": "./dist/pi/rpc-client.js" }, + "./pi/rpc-transport": { + "types": "./dist/pi/rpc-transport.d.ts", + "import": "./dist/pi/rpc-transport.js" + }, + "./pi/remote-rpc-client": { + "types": "./dist/pi/remote-rpc-client.d.ts", + "import": "./dist/pi/remote-rpc-client.js" + }, "./pi/conversation": { "types": "./dist/pi/conversation/translatePiConversation.d.ts", "import": "./dist/pi/conversation/translatePiConversation.js" @@ -44,6 +52,10 @@ "types": "./dist/pi/types.d.ts", "import": "./dist/pi/types.js" }, + "./pi/model-catalog": { + "types": "./dist/pi/model-catalog.d.ts", + "import": "./dist/pi/model-catalog.js" + }, "./pr-url-detector": { "types": "./dist/pr-url-detector.d.ts", "import": "./dist/pr-url-detector.js" @@ -114,7 +126,7 @@ } }, "bin": { - "agent-server": "./dist/server/bin.cjs" + "agent-server": "./dist/server/bin.js" }, "type": "module", "keywords": [ diff --git a/packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts b/packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts index 04610b6f00..41d5be45d5 100644 --- a/packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts @@ -84,6 +84,12 @@ function getModelConfigOption(response: { return response.configOptions?.find((opt) => opt.id === "model"); } +function getEffortConfigOption(response: { + configOptions?: Array<{ id: string; currentValue?: unknown }> | null; +}) { + return response.configOptions?.find((opt) => opt.id === "effort"); +} + // Real temp dirs: createSession validates cwd and SettingsManager reads // settings from disk; CLAUDE_CONFIG_DIR keeps both away from the real home. const cwd = mkdtempSync(path.join(os.tmpdir(), "claude-agent-test-cwd-")); @@ -358,6 +364,21 @@ describe("ClaudeAcpAgent session creation", () => { } }); + it("does not expose effort controls when a new session starts with Kimi K3", async () => { + const agent = makeAgent(); + + const response = await agent.newSession({ + cwd, + mcpServers: [], + _meta: { taskRunId: "run-kimi", model: "moonshotai/kimi-k3" }, + }); + + expect(getModelConfigOption(response)?.currentValue).toBe( + "moonshotai/kimi-k3", + ); + expect(getEffortConfigOption(response)).toBeUndefined(); + }); + // The timeout *message* (RequestError "... timed out after ...") is covered // by claude-agent.refresh.test.ts. Here we cover the leak fix on the // new-session and resume paths: any init failure must close the query so the diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 14974dd3c6..0a9842dee7 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -2252,6 +2252,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { settingsManager.getSettings().model, meta?.model, ]); + modelOptions.currentModelId = resolvedModelId; session.modelId = resolvedModelId; session.lastContextWindowSize = this.getContextWindowForModel(resolvedModelId); diff --git a/packages/agent/src/pi/conversation/translatePiConversation.test.ts b/packages/agent/src/pi/conversation/translatePiConversation.test.ts index a55458e0f8..4db19cb58e 100644 --- a/packages/agent/src/pi/conversation/translatePiConversation.test.ts +++ b/packages/agent/src/pi/conversation/translatePiConversation.test.ts @@ -1,5 +1,5 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createPiConversationTranslator } from "./translatePiConversation"; function assistant( @@ -99,7 +99,9 @@ describe("createPiConversationTranslator", () => { ).toEqual([]); }); - it("completes a turn using the latest runtime timestamp", () => { + it("completes a turn using the settlement time", () => { + vi.useFakeTimers(); + vi.setSystemTime(30); const translator = createPiConversationTranslator(); const laterMessage = assistant( [{ type: "text", text: "later" }], @@ -116,8 +118,9 @@ describe("createPiConversationTranslator", () => { translator.translateEvent({ type: "message_end", message: earlierMessage }); expect(translator.translateEvent({ type: "agent_settled" })).toEqual([ - { type: "turn_completed", timestamp: 20 }, + { type: "turn_completed", timestamp: 30 }, ]); + vi.useRealTimers(); }); it("translates retry lifecycle without rendering transient runtime errors", () => { @@ -237,6 +240,34 @@ describe("createPiConversationTranslator", () => { ]); }); + it("emits the completed compaction summary without reloading history", () => { + const translator = createPiConversationTranslator(); + + expect( + translator.translateEvent({ + type: "compaction_end", + reason: "manual", + result: { + summary: "Earlier work was compacted.", + firstKeptEntryId: "entry-1", + tokensBefore: 1000, + }, + aborted: false, + willRetry: false, + }), + ).toMatchObject([ + { + type: "runtime_status", + status: "compacting", + isComplete: true, + }, + { + type: "assistant_message_chunk", + content: { type: "text", text: "Earlier work was compacted." }, + }, + ]); + }); + it("translates compaction failures with their error", () => { const translator = createPiConversationTranslator(); @@ -282,6 +313,7 @@ describe("createPiConversationTranslator", () => { kind: "execute", status: "in_progress", rawInput: { command: "pwd" }, + origin: "user_shell", }, }, { @@ -291,6 +323,7 @@ describe("createPiConversationTranslator", () => { id: "pi-bash-20", status: "completed", rawOutput: "/tmp/project", + origin: "user_shell", content: [ { type: "content", @@ -302,6 +335,132 @@ describe("createPiConversationTranslator", () => { ]); }); + it("streams direct RPC bash output into one execute tool call", () => { + const translator = createPiConversationTranslator(); + const [started] = translator.beginDirectBash("printf hello"); + expect(started).toMatchObject({ + type: "tool_call_started", + toolCall: { + title: "printf hello", + status: "in_progress", + }, + }); + if (started?.type !== "tool_call_started") { + throw new Error("Expected a direct bash tool call"); + } + const toolCallId = started.toolCall.id; + + expect( + translator.translateEvent({ + type: "bash_execution_update", + id: "req_1", + delta: "hel", + }), + ).toMatchObject([ + { + type: "tool_call_updated", + toolCall: { + id: toolCallId, + content: [ + { + type: "content", + content: { type: "text", text: "hel" }, + }, + ], + }, + }, + ]); + + expect( + translator.translateEvent({ + type: "bash_execution_update", + id: "req_1", + delta: "lo", + }), + ).toMatchObject([ + { + type: "tool_call_updated", + toolCall: { + id: toolCallId, + content: [ + { + type: "content", + content: { type: "text", text: "hello" }, + }, + ], + }, + }, + ]); + + expect( + translator.completeDirectBash({ + output: "hello", + exitCode: 0, + cancelled: false, + }), + ).toMatchObject([ + { + type: "tool_call_updated", + toolCall: { + id: toolCallId, + status: "completed", + rawOutput: "hello", + content: [ + { + type: "content", + content: { type: "text", text: "hello" }, + }, + ], + }, + }, + ]); + }); + + it("throttles direct bash output by encoded byte size", () => { + const translator = createPiConversationTranslator(); + translator.beginDirectBash("unicode-output"); + + expect( + translator.translateEvent({ + type: "bash_execution_update", + id: "req_1", + delta: "🙂".repeat(1_024), + }), + ).toHaveLength(1); + expect( + translator.translateEvent({ + type: "bash_execution_update", + id: "req_1", + delta: "x", + }), + ).toEqual([]); + }); + + it("preserves streamed direct bash output when the command fails", () => { + const translator = createPiConversationTranslator(); + const [started] = translator.beginDirectBash("failing-command"); + if (started?.type !== "tool_call_started") { + throw new Error("Expected a direct bash tool call"); + } + + translator.translateEvent({ + type: "bash_execution_update", + id: "req_1", + delta: "partial output", + }); + + expect(translator.failDirectBash("transport failed")).toMatchObject([ + { + type: "tool_call_updated", + toolCall: { + id: started.toolCall.id, + status: "failed", + rawOutput: "partial output\n\ntransport failed", + }, + }, + ]); + }); + it("streams tool execution start, output updates, and completion", () => { const translator = createPiConversationTranslator(); const message = assistant( diff --git a/packages/agent/src/pi/conversation/translatePiConversation.ts b/packages/agent/src/pi/conversation/translatePiConversation.ts index b02cb82c58..745145623d 100644 --- a/packages/agent/src/pi/conversation/translatePiConversation.ts +++ b/packages/agent/src/pi/conversation/translatePiConversation.ts @@ -8,6 +8,8 @@ type AgentMessage = Extract< { type: "message_end" } >["message"]; +const utf8Encoder = new TextEncoder(); + function isMessage(message: AgentMessage): message is Message { return ( message.role === "user" || @@ -31,6 +33,7 @@ function customMessageEvents(message: AgentMessage): AgentConversationEvent[] { kind: "execute", status: "in_progress", rawInput: { command: message.command }, + origin: "user_shell", }, }, { @@ -40,6 +43,7 @@ function customMessageEvents(message: AgentMessage): AgentConversationEvent[] { id, status: failed ? "failed" : "completed", rawOutput: message.output, + origin: "user_shell", content: message.output ? [ { @@ -90,7 +94,16 @@ function isAssistantMessage( return message.role === "assistant"; } +export interface PiDirectBashResult { + cancelled: boolean; + exitCode: number | null; + output: string; +} + export interface PiConversationTranslator { + beginDirectBash(command: string): AgentConversationEvent[]; + completeDirectBash(result: PiDirectBashResult): AgentConversationEvent[]; + failDirectBash(message: string): AgentConversationEvent[]; translateHistoryMessage(message: AgentMessage): AgentConversationEvent[]; translateEvent(event: AgentSessionEvent): AgentConversationEvent[]; } @@ -102,6 +115,89 @@ export function createPiConversationTranslator(): PiConversationTranslator { let latestRuntimeTimestamp = 0; let latestConversationTimestamp = 0; let pendingRuntimeError: AgentConversationEvent | undefined; + let directBashSequence = 0; + let activeDirectBash: + | { + nextOutputBytes: number; + output: string; + outputBytes: number; + startedAt: number; + toolCallId: string; + } + | undefined; + + function beginDirectBash(command: string): AgentConversationEvent[] { + const startedAt = Date.now(); + const toolCallId = `pi-bash-live-${startedAt}-${++directBashSequence}`; + activeDirectBash = { + nextOutputBytes: 4_096, + output: "", + outputBytes: 0, + startedAt, + toolCallId, + }; + + return [ + { + type: "tool_call_started", + timestamp: startedAt, + toolCall: { + id: toolCallId, + title: command, + kind: "execute", + status: "in_progress", + rawInput: { command }, + origin: "user_shell", + }, + }, + ]; + } + + function finishDirectBash( + status: "completed" | "failed", + output: string, + ): AgentConversationEvent[] { + const directBash = activeDirectBash; + activeDirectBash = undefined; + if (!directBash) { + return []; + } + + return [ + { + type: "tool_call_updated", + timestamp: Date.now(), + toolCall: { + id: directBash.toolCallId, + status, + rawOutput: output, + origin: "user_shell", + content: output + ? [ + { + type: "content", + content: { type: "text", text: output }, + }, + ] + : [], + }, + }, + ]; + } + + function completeDirectBash( + result: PiDirectBashResult, + ): AgentConversationEvent[] { + const failed = result.cancelled || (result.exitCode ?? 0) !== 0; + return finishDirectBash(failed ? "failed" : "completed", result.output); + } + + function failDirectBash(message: string): AgentConversationEvent[] { + const output = [activeDirectBash?.output, message] + .filter(Boolean) + .join("\n\n"); + return finishDirectBash("failed", output); + } function translateHistoryMessage( message: AgentMessage, @@ -212,6 +308,54 @@ export function createPiConversationTranslator(): PiConversationTranslator { ); } + if (event.type === "bash_execution_update") { + const directBash = activeDirectBash; + if (!directBash) { + return []; + } + + directBash.output += event.delta; + directBash.outputBytes += utf8Encoder.encode(event.delta).byteLength; + if (directBash.outputBytes >= 4_096) { + if (directBash.outputBytes < directBash.nextOutputBytes) { + return []; + } + while (directBash.nextOutputBytes <= directBash.outputBytes) { + directBash.nextOutputBytes *= 2; + } + } + + return [ + { + type: "tool_call_updated", + timestamp: directBash.startedAt, + toolCall: { + id: directBash.toolCallId, + origin: "user_shell", + content: directBash.output + ? [ + { + type: "content", + content: { type: "text", text: directBash.output }, + }, + ] + : [], + }, + }, + ]; + } + + if (event.type === "queue_update") { + return [ + { + type: "queue_update", + timestamp: Date.now(), + steering: [...event.steering], + followUp: [...event.followUp], + }, + ]; + } + if (event.type === "message_end") { latestRuntimeTimestamp = Math.max( latestRuntimeTimestamp, @@ -322,27 +466,50 @@ export function createPiConversationTranslator(): PiConversationTranslator { ]; } - return [ + const timestamp = event.result?.summary + ? Math.max(Date.now(), latestConversationTimestamp + 1) + : latestConversationTimestamp; + latestConversationTimestamp = Math.max( + latestConversationTimestamp, + timestamp, + ); + const events: AgentConversationEvent[] = [ { type: "runtime_status", - timestamp: latestConversationTimestamp, + timestamp, status: "compacting", isComplete: true, }, ]; + if (event.result?.summary) { + events.push({ + type: "assistant_message_chunk", + timestamp, + content: { type: "text", text: event.result.summary }, + }); + } + + return events; } if (event.type === "agent_settled") { streamedAssistantTimestamps.clear(); - const timestamp = latestRuntimeTimestamp; + const timestamp = Math.max(Date.now(), latestRuntimeTimestamp); + const hadRuntimeActivity = latestRuntimeTimestamp > 0; latestRuntimeTimestamp = 0; - return timestamp > 0 ? [{ type: "turn_completed", timestamp }] : []; + return hadRuntimeActivity ? [{ type: "turn_completed", timestamp }] : []; } return []; } - return { translateHistoryMessage, translateEvent }; + return { + beginDirectBash, + completeDirectBash, + failDirectBash, + translateHistoryMessage, + translateEvent, + }; } diff --git a/packages/agent/src/pi/conversation/translatePiMessage.test.ts b/packages/agent/src/pi/conversation/translatePiMessage.test.ts index 0393331230..cad1a6b6cb 100644 --- a/packages/agent/src/pi/conversation/translatePiMessage.test.ts +++ b/packages/agent/src/pi/conversation/translatePiMessage.test.ts @@ -1,4 +1,8 @@ -import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai"; +import type { + AssistantMessage, + ToolResultMessage, + UserMessage, +} from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; import { createPiMessageTranslator } from "./translatePiMessage"; @@ -49,7 +53,12 @@ describe("createPiMessageTranslator", () => { role: "user", content: [ { type: "text", text: "first" }, - { type: "image", data: "abc", mimeType: "image/png" }, + { + type: "image", + data: "abc", + mimeType: "image/png", + fileName: "screenshot.png", + } as Exclude[number], ], timestamp: 0, }; @@ -61,7 +70,12 @@ describe("createPiMessageTranslator", () => { timestamp: 0, content: [ { type: "text", text: "first" }, - { type: "image", data: "abc", mimeType: "image/png" }, + { + type: "image", + data: "abc", + mimeType: "image/png", + fileName: "screenshot.png", + }, ], }, ]); @@ -110,4 +124,113 @@ describe("createPiMessageTranslator", () => { }, ]); }); + + it("provides generic rendered content for extension tool results", () => { + const translator = createPiMessageTranslator(); + const content: ToolResultMessage["content"] = [ + { type: "text", text: "Found " }, + { type: "text", text: "three matches" }, + ]; + const message: ToolResultMessage = { + role: "toolResult", + toolCallId: "extension-1", + toolName: "web_search", + content, + details: { resultCount: 3 }, + isError: false, + timestamp: 12, + }; + + expect(translator.translate(message)).toEqual([ + { + type: "tool_call_updated", + timestamp: 12, + toolCall: { + id: "extension-1", + status: "completed", + rawOutput: content, + content: [ + { + type: "content", + content: { type: "text", text: "Found three matches" }, + }, + ], + }, + }, + ]); + }); + + it("preserves images in generic extension tool results", () => { + const translator = createPiMessageTranslator(); + const content: ToolResultMessage["content"] = [ + { type: "image", data: "aW1hZ2U=", mimeType: "image/png" }, + ]; + const message: ToolResultMessage = { + role: "toolResult", + toolCallId: "extension-image", + toolName: "screenshot", + content, + isError: false, + timestamp: 12, + }; + + expect(translator.translate(message)).toMatchObject([ + { + type: "tool_call_updated", + toolCall: { + content: [ + { + type: "content", + content: { + type: "image", + data: "aW1hZ2U=", + mimeType: "image/png", + }, + }, + ], + }, + }, + ]); + }); + + it("keeps built-in tool translation and raw output", () => { + const translator = createPiMessageTranslator(); + const content: ToolResultMessage["content"] = [ + { type: "text", text: "file contents" }, + ]; + + translator.translateToolExecutionStart( + "read-1", + "read", + { path: "src/file.ts" }, + 1, + ); + + expect( + translator.translateToolExecutionEnd( + "read-1", + "read", + { content }, + false, + 2, + ), + ).toEqual([ + { + type: "tool_call_updated", + timestamp: 2, + toolCall: { + id: "read-1", + status: "completed", + rawOutput: content, + locations: [{ path: "src/file.ts" }], + content: [ + { + type: "content", + content: { type: "text", text: "file contents" }, + }, + ], + }, + }, + ]); + }); }); diff --git a/packages/agent/src/pi/conversation/translatePiMessage.ts b/packages/agent/src/pi/conversation/translatePiMessage.ts index 874dc06242..17f6ce1b07 100644 --- a/packages/agent/src/pi/conversation/translatePiMessage.ts +++ b/packages/agent/src/pi/conversation/translatePiMessage.ts @@ -7,6 +7,7 @@ import type { import type { AgentContent, AgentConversationEvent, + AgentToolCallContent, AgentToolCallStatus, } from "@posthog/shared"; import { type PiToolName, TOOL_KIND_BY_NAME } from "./toolKind"; @@ -43,11 +44,42 @@ function isPiToolName(name: string): name is PiToolName { return name in TOOL_KIND_BY_NAME; } +function toGenericToolContent( + resultContent: ToolResultMessage["content"], +): AgentToolCallContent[] | undefined { + const content: AgentToolCallContent[] = []; + let text = ""; + + const appendText = () => { + if (!text) { + return; + } + content.push({ type: "content", content: { type: "text", text } }); + text = ""; + }; + + for (const block of resultContent) { + if (block.type === "text") { + text += block.text; + continue; + } + const translated = toContent(block); + if (translated) { + appendText(); + content.push({ type: "content", content: translated }); + } + } + appendText(); + + return content.length > 0 ? content : undefined; +} + function toContent(block: { type: string; text?: string; data?: string; mimeType?: string; + fileName?: string; }): AgentContent | undefined { if (block.type === "text" && typeof block.text === "string") { return { type: "text", text: block.text }; @@ -58,7 +90,12 @@ function toContent(block: { typeof block.data === "string" && typeof block.mimeType === "string" ) { - return { type: "image", data: block.data, mimeType: block.mimeType }; + return { + type: "image", + data: block.data, + mimeType: block.mimeType, + ...(block.fileName ? { fileName: block.fileName } : {}), + }; } return undefined; @@ -214,6 +251,12 @@ export function createPiMessageTranslator(): PiMessageTranslator { if (output.locations) { toolCall.locations = output.locations; } + } else { + const content = toGenericToolContent(result.content); + + if (content) { + toolCall.content = content; + } } return [{ type: "tool_call_updated", timestamp, toolCall }]; diff --git a/packages/agent/src/pi/model-catalog.test.ts b/packages/agent/src/pi/model-catalog.test.ts new file mode 100644 index 0000000000..aad2ffed61 --- /dev/null +++ b/packages/agent/src/pi/model-catalog.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + fetchPosthogPiModelCatalog, + resolvePosthogPiModelCatalog, +} from "./model-catalog"; + +describe("resolvePosthogPiModelCatalog", () => { + afterEach(() => { + delete process.env.PI_OFFLINE; + vi.unstubAllGlobals(); + }); + + it("uses the PostHog provider model configuration for gateway models", () => { + const models = resolvePosthogPiModelCatalog( + [ + { + id: "claude-opus-4-8", + owned_by: "anthropic", + context_window: 1_000_000, + supports_vision: true, + allowed: true, + }, + { + id: "claude-haiku-4-5", + owned_by: "anthropic", + context_window: 200_000, + supports_vision: true, + allowed: true, + }, + { + id: "gpt-5.6-sol", + owned_by: "openai", + context_window: 1_000_000, + supports_vision: true, + allowed: false, + }, + ], + "us", + ); + + expect(models).toEqual([ + expect.objectContaining({ + provider: "posthog", + id: "claude-opus-4-8", + thinkingLevels: expect.arrayContaining(["off", "high", "xhigh"]), + }), + expect.objectContaining({ + provider: "posthog", + id: "claude-haiku-4-5", + }), + ]); + }); + + it("uses fallback models without fetching while offline", async () => { + process.env.PI_OFFLINE = "1"; + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + const models = await fetchPosthogPiModelCatalog( + "https://gateway.example.com", + "us", + ); + + expect(fetch).not.toHaveBeenCalled(); + expect(models.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/agent/src/pi/model-catalog.ts b/packages/agent/src/pi/model-catalog.ts new file mode 100644 index 0000000000..96b6bb45d5 --- /dev/null +++ b/packages/agent/src/pi/model-catalog.ts @@ -0,0 +1,50 @@ +import { + getSupportedThinkingLevels, + type ModelThinkingLevel, +} from "@earendil-works/pi-ai"; +import { + fetchPosthogGatewayModels, + type GatewayModel, + resolveModelConfigsFromGatewayModels, +} from "@posthog/harness/extensions/posthog-provider/models"; +import type { CloudRegion } from "@posthog/shared"; + +export interface PiModelCatalogEntry { + provider: "posthog"; + id: string; + name: string; + contextWindow: number; + thinkingLevels: ModelThinkingLevel[]; +} + +export function resolvePosthogPiModelCatalog( + gatewayModels: GatewayModel[], + region: CloudRegion, +): PiModelCatalogEntry[] { + return resolveModelConfigsFromGatewayModels(gatewayModels, region).map( + (model) => ({ + provider: "posthog", + id: model.id, + name: model.name, + contextWindow: model.contextWindow, + thinkingLevels: getSupportedThinkingLevels({ + ...model, + api: model.api ?? "anthropic-messages", + baseUrl: model.baseUrl ?? "", + provider: "posthog", + }), + }), + ); +} + +export async function fetchPosthogPiModelCatalog( + gatewayUrl: string, + region: CloudRegion, + apiKey?: string, +): Promise { + const models = + process.env.PI_OFFLINE || process.env.HARNESS_STATIC_MODELS + ? [] + : await fetchPosthogGatewayModels(gatewayUrl, apiKey); + return resolvePosthogPiModelCatalog(models, region); +} diff --git a/packages/agent/src/pi/queue-persistence.test.ts b/packages/agent/src/pi/queue-persistence.test.ts new file mode 100644 index 0000000000..656db83158 --- /dev/null +++ b/packages/agent/src/pi/queue-persistence.test.ts @@ -0,0 +1,48 @@ +import type { SessionEntry } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { + POSTHOG_PI_QUEUE_ENTRY_TYPE, + readPersistedPiQueue, +} from "./queue-persistence"; + +describe("readPersistedPiQueue", () => { + it("uses the latest valid persisted queue snapshot", () => { + const entries = [ + { + type: "custom", + customType: POSTHOG_PI_QUEUE_ENTRY_TYPE, + data: { steering: ["old"], followUp: [] }, + }, + { + type: "custom", + customType: POSTHOG_PI_QUEUE_ENTRY_TYPE, + data: { steering: ["new"], followUp: ["later"] }, + }, + ] as SessionEntry[]; + + expect(readPersistedPiQueue(entries)).toEqual({ + steering: ["new"], + followUp: ["later"], + }); + }); + + it("ignores malformed and unrelated custom entries", () => { + const entries = [ + { + type: "custom", + customType: POSTHOG_PI_QUEUE_ENTRY_TYPE, + data: { steering: [1], followUp: [] }, + }, + { + type: "custom", + customType: "other", + data: { steering: ["other"], followUp: [] }, + }, + ] as SessionEntry[]; + + expect(readPersistedPiQueue(entries)).toEqual({ + steering: [], + followUp: [], + }); + }); +}); diff --git a/packages/agent/src/pi/queue-persistence.ts b/packages/agent/src/pi/queue-persistence.ts new file mode 100644 index 0000000000..abf2854181 --- /dev/null +++ b/packages/agent/src/pi/queue-persistence.ts @@ -0,0 +1,30 @@ +import type { SessionEntry } from "@earendil-works/pi-coding-agent"; +import type { PiQueueSnapshot } from "./types"; + +export const POSTHOG_PI_QUEUE_ENTRY_TYPE = "posthog.pi.queue"; + +export function readPersistedPiQueue(entries: SessionEntry[]): PiQueueSnapshot { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if ( + entry?.type !== "custom" || + entry.customType !== POSTHOG_PI_QUEUE_ENTRY_TYPE + ) { + continue; + } + const data = entry.data as Partial | undefined; + if ( + Array.isArray(data?.steering) && + data.steering.every((message) => typeof message === "string") && + Array.isArray(data.followUp) && + data.followUp.every((message) => typeof message === "string") + ) { + return { + steering: [...data.steering], + followUp: [...data.followUp], + }; + } + } + + return { steering: [], followUp: [] }; +} diff --git a/packages/agent/src/pi/remote-rpc-client.ts b/packages/agent/src/pi/remote-rpc-client.ts new file mode 100644 index 0000000000..3a576b292d --- /dev/null +++ b/packages/agent/src/pi/remote-rpc-client.ts @@ -0,0 +1,164 @@ +import type { + RpcClient, + RpcCommand, + RpcResponse, +} from "@earendil-works/pi-coding-agent"; +import type { AgentConversationEvent } from "@posthog/shared"; +import { createPiConversationTranslator } from "./conversation/translatePiConversation"; +import { type PiRpcTransport, parsePiRpcResponse } from "./rpc-transport"; + +export type PiRemoteRpcClient = Pick< + RpcClient, + | "prompt" + | "steer" + | "followUp" + | "abort" + | "getState" + | "getSessionStats" + | "setModel" + | "getAvailableModels" + | "getAvailableThinkingLevels" + | "setThinkingLevel" + | "compact" + | "bash" + | "abortBash" + | "getEntries" + | "getCommands" +>; + +export async function getRemotePiConversation( + client: Pick, +): Promise { + const entries = await client.getEntries(); + const translator = createPiConversationTranslator(); + const events: AgentConversationEvent[] = []; + + for (const entry of entries.entries) { + if (entry.type === "message") { + const translated = translator.translateHistoryMessage(entry.message); + events.push( + ...translated.map((event, index) => ({ + ...event, + sourceId: `${entry.id}:${index}`, + })), + ); + } + } + + return events; +} + +export class RemotePiRpcClient implements PiRemoteRpcClient { + constructor(private readonly transport: PiRpcTransport) {} + + async prompt( + message: string, + images?: Parameters[1], + ): Promise { + await this.request({ type: "prompt", message, images }); + } + + async steer( + message: string, + images?: Parameters[1], + ): Promise { + await this.request({ type: "steer", message, images }); + } + + async followUp( + message: string, + images?: Parameters[1], + ): Promise { + await this.request({ type: "follow_up", message, images }); + } + + async abort(): Promise { + await this.request({ type: "abort" }); + } + + async getState(): ReturnType { + return this.data(await this.request({ type: "get_state" })); + } + + async getSessionStats(): ReturnType { + return this.data(await this.request({ type: "get_session_stats" })); + } + + async setModel( + provider: string, + modelId: string, + ): ReturnType { + return this.data( + await this.request({ type: "set_model", provider, modelId }), + ); + } + + async getAvailableModels(): ReturnType< + PiRemoteRpcClient["getAvailableModels"] + > { + const data = this.data<{ + models: Awaited>; + }>(await this.request({ type: "get_available_models" })); + return data.models; + } + + async getAvailableThinkingLevels(): ReturnType< + PiRemoteRpcClient["getAvailableThinkingLevels"] + > { + const data = this.data<{ + levels: Awaited< + ReturnType + >; + }>(await this.request({ type: "get_available_thinking_levels" })); + return data.levels; + } + + async setThinkingLevel( + level: Parameters[0], + ): Promise { + await this.request({ type: "set_thinking_level", level }); + } + + async compact( + customInstructions?: string, + ): ReturnType { + return this.data( + await this.request({ type: "compact", customInstructions }), + ); + } + + async bash(command: string): ReturnType { + return this.data(await this.request({ type: "bash", command })); + } + + async abortBash(): Promise { + await this.request({ type: "abort_bash" }); + } + + async getEntries( + since?: string, + ): ReturnType { + return this.data(await this.request({ type: "get_entries", since })); + } + + async getCommands(): ReturnType { + const data = this.data<{ + commands: Awaited>; + }>(await this.request({ type: "get_commands" })); + return data.commands; + } + + private async request(command: RpcCommand): Promise { + const identifiedCommand = command.id + ? command + : { ...command, id: globalThis.crypto.randomUUID() }; + return parsePiRpcResponse(await this.transport.request(identifiedCommand)); + } + + private data(response: RpcResponse): T { + if (!response.success) { + throw new Error(response.error); + } + return (response as unknown as { data: T }).data; + } +} diff --git a/packages/agent/src/pi/rpc-client.test.ts b/packages/agent/src/pi/rpc-client.test.ts index ff4dcc3c23..25db00d0a4 100644 --- a/packages/agent/src/pi/rpc-client.test.ts +++ b/packages/agent/src/pi/rpc-client.test.ts @@ -1,39 +1,9 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { RpcClient } from "@earendil-works/pi-coding-agent"; import { describe, expect, it } from "vitest"; -import { - createPiRpcClient, - getAvailableModelsWithThinkingLevels, - type PiRpcClient, -} from "./rpc-client"; - -describe("getAvailableModelsWithThinkingLevels", () => { - it("uses Pi's per-model capability map", async () => { - const client = { - getAvailableModels: async () => [ - { - provider: "openai", - id: "gpt-5.6", - contextWindow: 200000, - reasoning: true, - thinkingLevelMap: { - off: "none", - minimal: null, - xhigh: "xhigh", - max: "max", - }, - }, - ], - } as unknown as PiRpcClient; - - await expect(getAvailableModelsWithThinkingLevels(client)).resolves.toEqual( - [ - expect.objectContaining({ - thinkingLevels: ["off", "low", "medium", "high", "xhigh", "max"], - }), - ], - ); - }); -}); +import { createPiRpcClient } from "./rpc-client"; describe("createPiRpcClient", () => { it("does not put provider credentials in the child environment", () => { @@ -60,4 +30,45 @@ describe("createPiRpcClient", () => { .options.env, ).toBeUndefined(); }); + + it("uses the private host channel without changing Pi RPC", async () => { + const directory = await mkdtemp(join(tmpdir(), "pi-host-channel-")); + const hostPath = join(directory, "host.mjs"); + await writeFile( + hostPath, + ` +import { closeSync } from "node:fs"; + +closeSync(3); +process.stdin.resume(); +process.on("message", (request) => { + const data = request.method === "clear_queue" + ? { steering: ["cleared"], followUp: [] } + : { steering: ["queued"], followUp: ["later"] }; + process.send({ type: "posthog_pi_host_response", id: request.id, data }); +}); +`, + ); + const client = createPiRpcClient({ + cliPath: hostPath, + cwd: directory, + providerOptions: { apiKey: "proxy-key" }, + }); + + try { + await client.start(); + + await expect(client.getQueue()).resolves.toEqual({ + steering: ["queued"], + followUp: ["later"], + }); + await expect(client.clearQueue()).resolves.toEqual({ + steering: ["cleared"], + followUp: [], + }); + } finally { + await client.stop(); + await rm(directory, { recursive: true }); + } + }); }); diff --git a/packages/agent/src/pi/rpc-client.ts b/packages/agent/src/pi/rpc-client.ts index 1479a4d6af..068153bdff 100644 --- a/packages/agent/src/pi/rpc-client.ts +++ b/packages/agent/src/pi/rpc-client.ts @@ -1,20 +1,19 @@ import { type ChildProcess, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import type { Writable } from "node:stream"; import { StringDecoder } from "node:string_decoder"; import { fileURLToPath } from "node:url"; -import { - type Api, - getSupportedThinkingLevels, - type Model, -} from "@earendil-works/pi-ai"; import { RpcClient, type RpcClientOptions, } from "@earendil-works/pi-coding-agent"; import { safePiEnvironment } from "./rpc-environment"; -import type { PiModelOption, PiThinkingLevel } from "./types"; +import type { PiQueueSnapshot } from "./types"; -export type PiRpcClient = RpcClient; +export type PiRpcClient = RpcClient & { + getQueue(): Promise; + clearQueue(): Promise; +}; export interface PiRpcProviderOptions { region?: "us" | "eu" | "dev"; @@ -22,19 +21,6 @@ export interface PiRpcProviderOptions { baseUrl?: string; } -export async function getAvailableModelsWithThinkingLevels( - client: PiRpcClient, -): Promise { - const models = await client.getAvailableModels(); - - return models.map((model) => ({ - ...model, - thinkingLevels: getSupportedThinkingLevels( - model as unknown as Model, - ) as PiThinkingLevel[], - })); -} - type RpcClientProcessAccess = { process?: ChildProcess; }; @@ -52,6 +38,19 @@ interface RpcClientInternals { rejectPendingRequests(error: Error): void; } +interface PiHostRequest { + type: "posthog_pi_host_request"; + id: string; + method: "get_queue" | "clear_queue"; +} + +interface PiHostResponse { + type: "posthog_pi_host_response"; + id: string; + data?: PiQueueSnapshot; + error?: string; +} + function attachJsonlReader( stream: NodeJS.ReadableStream, onLine: (line: string) => void, @@ -73,6 +72,15 @@ function attachJsonlReader( } class SecurePiRpcClient extends RpcClient { + private readonly hostRequests = new Map< + string, + { + resolve: (snapshot: PiQueueSnapshot) => void; + reject: (error: Error) => void; + timeout: ReturnType; + } + >(); + constructor( private readonly secureOptions: RpcClientOptions, private readonly providerOptions: PiRpcProviderOptions, @@ -104,7 +112,7 @@ class SecurePiRpcClient extends RpcClient { { cwd: this.secureOptions.cwd, env: safePiEnvironment(process.env), - stdio: ["pipe", "pipe", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe", "pipe", "ipc"], }, ); internals.process = child; @@ -120,7 +128,9 @@ class SecurePiRpcClient extends RpcClient { const error = internals.createProcessExitError(code, signal); internals.exitError = error; internals.rejectPendingRequests(error); + this.rejectHostRequests(error); }); + child.on("message", (message: unknown) => this.handleHostResponse(message)); child.once("error", (error) => { if (internals.process !== child) { return; @@ -147,6 +157,7 @@ class SecurePiRpcClient extends RpcClient { } const bootstrapPipe = child.stdio[3] as Writable | null; + bootstrapPipe?.on("error", () => {}); bootstrapPipe?.end( JSON.stringify({ providerOptions: this.providerOptions }), ); @@ -159,6 +170,84 @@ class SecurePiRpcClient extends RpcClient { ); } } + + getQueue(): Promise { + return this.sendHostRequest("get_queue"); + } + + clearQueue(): Promise { + return this.sendHostRequest("clear_queue"); + } + + private sendHostRequest( + method: PiHostRequest["method"], + ): Promise { + const process = (this as unknown as RpcClientInternals).process; + if (!process?.connected) { + return Promise.reject(new Error("Pi RPC host is not connected")); + } + + const id = randomUUID(); + const request: PiHostRequest = { + type: "posthog_pi_host_request", + id, + method, + }; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.hostRequests.delete(id); + reject(new Error(`Pi RPC host request timed out: ${method}`)); + }, 10_000); + this.hostRequests.set(id, { resolve, reject, timeout }); + process.send?.(request, (error) => { + if (!error) { + return; + } + const pending = this.hostRequests.get(id); + if (pending) { + clearTimeout(pending.timeout); + this.hostRequests.delete(id); + } + reject(error); + }); + }); + } + + private handleHostResponse(message: unknown): void { + const response = message as Partial; + if ( + response.type !== "posthog_pi_host_response" || + typeof response.id !== "string" + ) { + return; + } + + const request = this.hostRequests.get(response.id); + if (!request) { + return; + } + this.hostRequests.delete(response.id); + clearTimeout(request.timeout); + + if (typeof response.error === "string") { + request.reject(new Error(response.error)); + return; + } + if (!response.data) { + request.reject(new Error("Pi RPC host returned an empty queue response")); + return; + } + request.resolve(response.data); + } + + private rejectHostRequests(error: Error): void { + for (const request of this.hostRequests.values()) { + clearTimeout(request.timeout); + request.reject(error); + } + this.hostRequests.clear(); + } } export function getPiRpcClientProcess( @@ -167,7 +256,10 @@ export function getPiRpcClientProcess( return (client as unknown as RpcClientProcessAccess).process ?? null; } -export type PiRpcClientOptions = Pick & { +export type PiRpcClientOptions = Pick< + RpcClientOptions, + "cliPath" | "cwd" | "model" +> & { sessionFile?: string; providerOptions: PiRpcProviderOptions; }; @@ -175,11 +267,14 @@ export type PiRpcClientOptions = Pick & { export function createPiRpcClient(options: PiRpcClientOptions): PiRpcClient { const { sessionFile, providerOptions, ...rpcOptions } = options; const args = sessionFile ? ["--session-file", sessionFile] : []; + const cliPath = + rpcOptions.cliPath ?? + fileURLToPath(new URL("./rpc-host.js", import.meta.url)); return new SecurePiRpcClient( { ...rpcOptions, args, - cliPath: fileURLToPath(new URL("./rpc-host.js", import.meta.url)), + cliPath, provider: "posthog", }, providerOptions, diff --git a/packages/agent/src/pi/rpc-host.ts b/packages/agent/src/pi/rpc-host.ts index 0ce15c5e68..9516177b4e 100644 --- a/packages/agent/src/pi/rpc-host.ts +++ b/packages/agent/src/pi/rpc-host.ts @@ -2,12 +2,22 @@ import { readFileSync } from "node:fs"; import { SessionManager } from "@earendil-works/pi-coding-agent"; import { createHarnessRuntime, runRpcMode } from "@posthog/harness"; import type { PosthogProviderOptions } from "@posthog/harness/extensions/posthog-provider/provider"; +import { + POSTHOG_PI_QUEUE_ENTRY_TYPE, + readPersistedPiQueue, +} from "./queue-persistence"; import { sanitizePiHostEnvironment } from "./rpc-environment"; interface PiRpcBootstrap { providerOptions?: PosthogProviderOptions; } +interface PiHostRequest { + type: "posthog_pi_host_request"; + id: string; + method: "get_queue" | "clear_queue"; +} + function argumentValue(name: string): string | undefined { const index = process.argv.indexOf(name); return index === -1 ? undefined : process.argv[index + 1]; @@ -24,20 +34,76 @@ const cwd = process.cwd(); const sessionFile = argumentValue("--session-file"); const sessionManager = sessionFile ? SessionManager.open(sessionFile, undefined, cwd) - : undefined; + : SessionManager.create(cwd); const runtime = await createHarnessRuntime({ cwd, sessionManager, ...providerOptions, }); +const persistedQueue = readPersistedPiQueue(sessionManager.getEntries()); +for (const message of persistedQueue.steering) { + await runtime.session.steer(message); +} +for (const message of persistedQueue.followUp) { + await runtime.session.followUp(message); +} +runtime.session.subscribe((event) => { + if (event.type !== "queue_update") { + return; + } + runtime.session.sessionManager.appendCustomEntry( + POSTHOG_PI_QUEUE_ENTRY_TYPE, + { + steering: [...event.steering], + followUp: [...event.followUp], + }, + ); +}); + const requestedModel = argumentValue("--model")?.replace(/^posthog\//, ""); if (requestedModel) { - const model = runtime.services.modelRegistry.find("posthog", requestedModel); + const model = runtime.services.modelRuntime.getModel( + "posthog", + requestedModel, + ); if (!model) { throw new Error(`PostHog model not found: ${requestedModel}`); } await runtime.session.setModel(model); } +process.on("message", (message: unknown) => { + const request = message as Partial; + if ( + request.type !== "posthog_pi_host_request" || + typeof request.id !== "string" || + (request.method !== "get_queue" && request.method !== "clear_queue") + ) { + return; + } + + try { + const session = runtime.session; + const data = + request.method === "clear_queue" + ? session.clearQueue() + : { + steering: [...session.getSteeringMessages()], + followUp: [...session.getFollowUpMessages()], + }; + process.send?.({ + type: "posthog_pi_host_response", + id: request.id, + data, + }); + } catch (error) { + process.send?.({ + type: "posthog_pi_host_response", + id: request.id, + error: error instanceof Error ? error.message : String(error), + }); + } +}); + await runRpcMode(runtime); diff --git a/packages/agent/src/pi/rpc-transport.test.ts b/packages/agent/src/pi/rpc-transport.test.ts new file mode 100644 index 0000000000..5ea9b03c03 --- /dev/null +++ b/packages/agent/src/pi/rpc-transport.test.ts @@ -0,0 +1,73 @@ +import type { RpcCommand, RpcResponse } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; +import { RemotePiRpcClient } from "./remote-rpc-client"; +import { piRpcCommandSchema } from "./rpc-transport"; + +function response(command: RpcCommand, data?: unknown): RpcResponse { + return { + type: "response" as const, + command: command.type, + success: true as const, + ...(data === undefined ? {} : { data }), + } as RpcResponse; +} + +describe("RemotePiRpcClient", () => { + it("uses Pi's native methods to encode commands over an injected transport", async () => { + const request = vi.fn(async (command: RpcCommand) => { + if (command.type === "compact") { + return response(command, { + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 100, + }); + } + if (command.type === "get_available_thinking_levels") { + return response(command, { levels: ["off", "high", "xhigh"] }); + } + if (command.type === "get_session_stats") { + return response(command, { + sessionId: "session-1", + totalMessages: 2, + tokens: { total: 120 }, + cost: 0.01, + }); + } + return response(command); + }); + const client = new RemotePiRpcClient({ request }); + + const compaction = await client.compact("retain decisions"); + const thinkingLevels = await client.getAvailableThinkingLevels(); + const stats = await client.getSessionStats(); + + expect(request).toHaveBeenNthCalledWith(1, { + id: expect.any(String), + type: "compact", + customInstructions: "retain decisions", + }); + expect(request).toHaveBeenNthCalledWith(2, { + id: expect.any(String), + type: "get_available_thinking_levels", + }); + expect(request).toHaveBeenNthCalledWith(3, { + id: expect.any(String), + type: "get_session_stats", + }); + expect(compaction.summary).toBe("summary"); + expect(thinkingLevels).toEqual(["off", "high", "xhigh"]); + expect(stats).toMatchObject({ tokens: { total: 120 }, cost: 0.01 }); + }); + + it("rejects malformed responses from every transport", async () => { + const client = new RemotePiRpcClient({ + request: vi.fn(async () => ({ type: "not-a-response" })), + }); + + await expect(client.getState()).rejects.toThrow(); + }); + + it("requires a native command type at the transport boundary", () => { + expect(() => piRpcCommandSchema.parse({ mode: "invalid" })).toThrow(); + }); +}); diff --git a/packages/agent/src/pi/rpc-transport.ts b/packages/agent/src/pi/rpc-transport.ts new file mode 100644 index 0000000000..37b2f86710 --- /dev/null +++ b/packages/agent/src/pi/rpc-transport.ts @@ -0,0 +1,61 @@ +import type { + AgentSessionEvent, + RpcClient, + RpcCommand, + RpcResponse, +} from "@earendil-works/pi-coding-agent"; +import { z } from "zod/v4"; + +export type { RpcCommand, RpcResponse } from "@earendil-works/pi-coding-agent"; + +export const piRpcCommandSchema = z + .object({ + id: z.string().optional(), + type: z.string().min(1), + }) + .loose() + .transform((command) => command as RpcCommand); + +export const piRpcResponseSchema = z.discriminatedUnion("success", [ + z + .object({ + id: z.string().optional(), + type: z.literal("response"), + command: z.string(), + success: z.literal(true), + data: z.unknown().optional(), + }) + .loose(), + z + .object({ + id: z.string().optional(), + type: z.literal("response"), + command: z.string(), + success: z.literal(false), + error: z.string(), + }) + .loose(), +]); + +export function parsePiRpcResponse(value: unknown): RpcResponse { + return piRpcResponseSchema.parse(value) as RpcResponse; +} + +export interface PiRpcTransport { + request(command: RpcCommand): Promise; + onEvent?(listener: (event: AgentSessionEvent) => void): () => void; + start?(): Promise; + stop?(): Promise; +} + +interface RpcClientInternals { + send(command: RpcCommand): Promise; +} + +export function sendPiRpcCommand( + client: RpcClient, + command: RpcCommand, +): Promise { + const internals = client as unknown as RpcClientInternals; + return internals.send(command); +} diff --git a/packages/agent/src/pi/runtime.test.ts b/packages/agent/src/pi/runtime.test.ts index b9cf4656de..e1c82972fa 100644 --- a/packages/agent/src/pi/runtime.test.ts +++ b/packages/agent/src/pi/runtime.test.ts @@ -1,9 +1,7 @@ -import type { AssistantMessage } from "@earendil-works/pi-ai"; -import type { - AgentSessionEvent, - RpcClient, -} from "@earendil-works/pi-coding-agent"; +import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai"; +import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; +import type { PiRpcClient } from "./rpc-client"; import { PiRuntime } from "./runtime"; function assistant(text: string): AssistantMessage { @@ -26,51 +24,238 @@ function assistant(text: string): AssistantMessage { }; } -function createClient(messages: AssistantMessage[] = []) { +function createClient() { let listener: (event: AgentSessionEvent) => void = () => {}; + const send = vi.fn(); const client = { onEvent: vi.fn((nextListener) => { listener = nextListener; return () => {}; }), - getEntries: vi.fn(async () => ({ - entries: messages.map((message, index) => ({ - type: "message" as const, - id: `entry-${index}`, - parentId: null, - timestamp: new Date().toISOString(), - message, - })), - })), - } as unknown as RpcClient; - - return { client, emit: (event: AgentSessionEvent) => listener(event) }; + send, + getQueue: vi.fn(async () => ({ steering: [], followUp: [] })), + clearQueue: vi.fn(async () => ({ steering: [], followUp: [] })), + } as unknown as PiRpcClient; + + return { + client, + emit: (event: AgentSessionEvent) => listener(event), + send, + }; } describe("PiRuntime", () => { - it("normalizes live Pi events before forwarding them", () => { + it("streams and completes direct bash from one RPC operation", async () => { + const { client, emit, send } = createClient(); + const runtime = new PiRuntime(client); + const conversationListener = vi.fn(); + runtime.onConversationEvent(conversationListener); + send.mockImplementation(async () => { + emit({ type: "bash_execution_update", id: "req_1", delta: "one\n" }); + emit({ type: "bash_execution_update", id: "req_1", delta: "two\n" }); + return { + type: "response", + command: "bash", + success: true, + data: { + output: "one\ntwo\n", + exitCode: 0, + cancelled: false, + truncated: false, + }, + }; + }); + + await runtime.sendCommand({ type: "bash", command: "print-lines" }); + + const events = conversationListener.mock.calls.map(([event]) => event); + expect(events).toHaveLength(4); + expect(events[0]).toMatchObject({ + type: "tool_call_started", + toolCall: { title: "print-lines", status: "in_progress" }, + }); + const toolCallId = events[0].toolCall.id; + expect(events[2]).toMatchObject({ + type: "tool_call_updated", + toolCall: { + id: toolCallId, + content: [ + { + type: "content", + content: { type: "text", text: "one\ntwo\n" }, + }, + ], + }, + }); + expect(events[3]).toMatchObject({ + type: "tool_call_updated", + toolCall: { id: toolCallId, status: "completed" }, + }); + }); + + it("uses the native command id for the echoed user message", async () => { + const { client, emit, send } = createClient(); + const runtime = new PiRuntime(client); + const conversationListener = vi.fn(); + runtime.onConversationEvent(conversationListener); + const message: UserMessage = { + role: "user", + content: "hello", + timestamp: 1, + }; + send.mockImplementation(async () => { + emit({ type: "message_end", message }); + return { + id: "message-1", + type: "response", + command: "prompt", + success: true, + }; + }); + + await runtime.sendCommand({ + id: "message-1", + type: "prompt", + message: "hello", + }); + + expect(conversationListener).toHaveBeenCalledWith( + expect.objectContaining({ + type: "user_message", + id: "message-1", + }), + ); + }); + + it("does not apply an extension command id to a later user message", async () => { + const { client, emit, send } = createClient(); + const runtime = new PiRuntime(client); + const conversationListener = vi.fn(); + runtime.onConversationEvent(conversationListener); + send.mockImplementation(async (command: { message?: string }) => { + if (command.message === "next") { + emit({ + type: "message_end", + message: { role: "user", content: "next", timestamp: 1 }, + }); + } + return { + type: "response", + command: "prompt", + success: true, + }; + }); + + await runtime.sendCommand({ + id: "extension-id", + type: "prompt", + message: "/extension", + }); + await runtime.sendCommand({ + id: "message-id", + type: "prompt", + message: "next", + }); + + expect(conversationListener).toHaveBeenCalledWith( + expect.objectContaining({ + type: "user_message", + id: "message-id", + }), + ); + }); + + it("drops cleared queued message ids before matching later messages", async () => { + const { client, emit, send } = createClient(); + const runtime = new PiRuntime(client); + const conversationListener = vi.fn(); + runtime.onConversationEvent(conversationListener); + send.mockResolvedValue({ + type: "response", + command: "steer", + success: true, + }); + + await runtime.sendCommand({ + id: "cleared-id", + type: "steer", + message: "continue", + }); + runtime.clearPendingQueuedUserMessages(); + send.mockImplementationOnce(async () => { + emit({ + type: "message_end", + message: { role: "user", content: "continue", timestamp: 1 }, + }); + return { type: "response", command: "prompt", success: true }; + }); + await runtime.sendCommand({ + id: "current-id", + type: "prompt", + message: "continue", + }); + + expect(conversationListener).toHaveBeenCalledWith( + expect.objectContaining({ type: "user_message", id: "current-id" }), + ); + }); + + it("rejects concurrent direct bash commands", async () => { + const { client, send } = createClient(); + const runtime = new PiRuntime(client); + let resolveBash: (value: unknown) => void = () => {}; + send.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveBash = resolve; + }), + ); + + const first = runtime.sendCommand({ type: "bash", command: "sleep 1" }); + await expect( + runtime.sendCommand({ type: "bash", command: "pwd" }), + ).rejects.toThrow("already running"); + resolveBash({ + type: "response", + command: "bash", + success: true, + data: { output: "", exitCode: 0, cancelled: false }, + }); + await first; + }); + + it("forwards native queue snapshots", () => { const { client, emit } = createClient(); const runtime = new PiRuntime(client); const conversationListener = vi.fn(); runtime.onConversationEvent(conversationListener); - emit({ type: "message_end", message: assistant("hello") }); + emit({ + type: "queue_update", + steering: ["fix this"], + followUp: ["then summarize"], + }); expect(conversationListener).toHaveBeenCalledWith({ - type: "assistant_message_chunk", - timestamp: 1, - content: { type: "text", text: "hello" }, + type: "queue_update", + timestamp: expect.any(Number), + steering: ["fix this"], + followUp: ["then summarize"], }); }); - it("normalizes persisted conversation history", async () => { - const { client } = createClient([assistant("history")]); + it("normalizes live Pi events before forwarding them", () => { + const { client, emit } = createClient(); const runtime = new PiRuntime(client); + const conversationListener = vi.fn(); + runtime.onConversationEvent(conversationListener); - await expect(runtime.conversation()).resolves.toContainEqual({ + emit({ type: "message_end", message: assistant("hello") }); + + expect(conversationListener).toHaveBeenCalledWith({ type: "assistant_message_chunk", timestamp: 1, - content: { type: "text", text: "history" }, + content: { type: "text", text: "hello" }, }); }); }); diff --git a/packages/agent/src/pi/runtime.ts b/packages/agent/src/pi/runtime.ts index 102c517fcb..31a2a776a3 100644 --- a/packages/agent/src/pi/runtime.ts +++ b/packages/agent/src/pi/runtime.ts @@ -1,17 +1,16 @@ -import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; +import type { + AgentSessionEvent, + RpcCommand, + RpcResponse, +} from "@earendil-works/pi-coding-agent"; import type { AgentConversationEvent } from "@posthog/shared"; import { createPiConversationTranslator, type PiConversationTranslator, + type PiDirectBashResult, } from "./conversation/translatePiConversation"; -import { - createPiRpcClient, - getAvailableModelsWithThinkingLevels, - getPiRpcClientProcess, - type PiRpcClient, - type PiRpcClientOptions, -} from "./rpc-client"; -import type { PiModelOption } from "./types"; +import { getPiRpcClientProcess, type PiRpcClient } from "./rpc-client"; +import { sendPiRpcCommand } from "./rpc-transport"; export class PiRuntime { readonly client: PiRpcClient; @@ -23,6 +22,12 @@ export class PiRuntime { private readonly conversationListeners = new Set< (event: AgentConversationEvent) => void >(); + private readonly pendingUserMessages: Array<{ + id: string; + message: string; + type: "prompt" | "steer" | "follow_up"; + }> = []; + private directBashActive = false; constructor(client: PiRpcClient) { this.client = client; @@ -46,22 +51,66 @@ export class PiRuntime { return () => this.conversationListeners.delete(listener); } - availableModels(): Promise { - return getAvailableModelsWithThinkingLevels(this.client); - } - - async conversation(): Promise { - const entries = await this.client.getEntries(); - const translator = createPiConversationTranslator(); - const events: AgentConversationEvent[] = []; + async sendCommand(command: RpcCommand): Promise { + const isUserMessage = + command.type === "prompt" || + command.type === "steer" || + command.type === "follow_up"; + if (isUserMessage && command.id) { + this.pendingUserMessages.push({ + id: command.id, + message: command.message, + type: command.type, + }); + } + if (command.type !== "bash") { + try { + const response = await sendPiRpcCommand(this.client, command); + if (!response.success && isUserMessage && command.id) { + this.removePendingUserMessageId(command.id); + } + return response; + } catch (error) { + if (isUserMessage && command.id) { + this.removePendingUserMessageId(command.id); + } + throw error; + } + } - for (const entry of entries.entries) { - if (entry.type === "message") { - events.push(...translator.translateHistoryMessage(entry.message)); + if (this.directBashActive) { + throw new Error("A Pi bash command is already running"); + } + this.directBashActive = true; + this.emitConversationEvents( + this.translator.beginDirectBash(command.command), + ); + try { + const response = await sendPiRpcCommand(this.client, command); + if (response.success) { + const result = (response as { data: PiDirectBashResult }).data; + this.emitConversationEvents(this.translator.completeDirectBash(result)); + } else { + this.emitConversationEvents( + this.translator.failDirectBash(response.error), + ); } + return response; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emitConversationEvents(this.translator.failDirectBash(message)); + throw error; + } finally { + this.directBashActive = false; } + } - return events; + clearPendingQueuedUserMessages(): void { + for (let index = this.pendingUserMessages.length - 1; index >= 0; index--) { + if (this.pendingUserMessages[index]?.type !== "prompt") { + this.pendingUserMessages.splice(index, 1); + } + } } private handleEvent(event: AgentSessionEvent): void { @@ -71,13 +120,37 @@ export class PiRuntime { const conversationEvents = this.translator.translateEvent(event); for (const conversationEvent of conversationEvents) { - for (const listener of this.conversationListeners) { - listener(conversationEvent); + if (conversationEvent.type === "user_message") { + const text = conversationEvent.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join(""); + const pendingIndex = this.pendingUserMessages.findIndex( + (pending) => pending.message === text, + ); + if (pendingIndex >= 0) { + const [pending] = this.pendingUserMessages.splice(pendingIndex, 1); + conversationEvent.id = pending.id; + } } } + this.emitConversationEvents(conversationEvents); + } + + private removePendingUserMessageId(messageId: string): void { + const index = this.pendingUserMessages.findIndex( + (pending) => pending.id === messageId, + ); + if (index >= 0) { + this.pendingUserMessages.splice(index, 1); + } } -} -export function createPiRuntime(options: PiRpcClientOptions): PiRuntime { - return new PiRuntime(createPiRpcClient(options)); + private emitConversationEvents(events: AgentConversationEvent[]): void { + for (const event of events) { + for (const listener of this.conversationListeners) { + listener(event); + } + } + } } diff --git a/packages/agent/src/pi/types.ts b/packages/agent/src/pi/types.ts index 30443c7596..1771097372 100644 --- a/packages/agent/src/pi/types.ts +++ b/packages/agent/src/pi/types.ts @@ -1,4 +1,4 @@ -import type { QueueMode, ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { RpcClient, RpcSessionState, @@ -11,7 +11,6 @@ function exhaustiveValues() { } export type PiThinkingLevel = ThinkingLevel; -export type PiQueueMode = QueueMode; export const PI_THINKING_LEVELS = exhaustiveValues()([ "off", @@ -23,18 +22,14 @@ export const PI_THINKING_LEVELS = exhaustiveValues()([ "max", ]); -export const PI_QUEUE_MODES = exhaustiveValues()([ - "all", - "one-at-a-time", -]); - export type PiNativeModelInfo = Awaited< ReturnType >[number]; -export type PiModelOption = PiNativeModelInfo & { - thinkingLevels: PiThinkingLevel[]; -}; +export interface PiPersistedSessionConfig { + model: { provider: string; id: string } | null; + thinkingLevel: PiThinkingLevel; +} export type PiCommand = Awaited>[number]; @@ -42,4 +37,9 @@ export type PiSessionStatus = Omit & { model?: Pick, "provider" | "id">; }; +export interface PiQueueSnapshot { + steering: string[]; + followUp: string[]; +} + export type PiSessionStats = Awaited>; diff --git a/packages/agent/src/posthog-api.test.ts b/packages/agent/src/posthog-api.test.ts index 651f539b3c..cbc600ebf4 100644 --- a/packages/agent/src/posthog-api.test.ts +++ b/packages/agent/src/posthog-api.test.ts @@ -127,6 +127,124 @@ describe("PostHogAPIClient", () => { }, ); + it("loads and atomically replaces the durable task session", async () => { + const client = new PostHogAPIClient({ + apiUrl: "https://app.posthog.com", + getApiKey: vi.fn().mockResolvedValue("token"), + projectId: 7, + }); + const content = '{"type":"session"}\n'; + const access = { + id: "session-1", + download_url: "https://storage.example/session.jsonl", + content_sha256: "old-hash", + }; + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue(access), + }) + .mockResolvedValueOnce({ + ok: true, + text: vi.fn().mockResolvedValue(content), + }) + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ + id: "session-1", + content_sha256: "new-hash", + }), + }); + + const storage = await client.getTaskSession("task-1", "run-1"); + await expect(client.downloadTaskSession(storage)).resolves.toBe(content); + await expect( + client.syncTaskSession( + "task-1", + "run-1", + "sandbox-1", + "old-hash", + content, + "task-run-token", + ), + ).resolves.toBe("new-hash"); + + expect(mockFetch).toHaveBeenLastCalledWith( + "https://app.posthog.com/api/projects/7/tasks/task-1/runs/run-1/task_session_sync/", + expect.objectContaining({ method: "POST", body: content }), + ); + const request = mockFetch.mock.calls.at(-1)?.[1] as RequestInit; + const headers = request.headers as Headers; + expect(headers.get("Content-Type")).toBe("application/octet-stream"); + expect(headers.get("If-Match")).toBe('"old-hash"'); + expect(headers.get("X-Sandbox-ID")).toBe("sandbox-1"); + expect(headers.get("X-Task-Run-Token")).toBe("task-run-token"); + }); + + it("treats a task session without stored JSONL as empty", async () => { + const client = new PostHogAPIClient({ + apiUrl: "https://app.posthog.com", + getApiKey: vi.fn().mockResolvedValue("token"), + projectId: 7, + }); + + await expect( + client.downloadTaskSession({ + id: "session-1", + download_url: null, + content_sha256: null, + }), + ).resolves.toBe(""); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("treats a missing stored task session object as empty", async () => { + const client = new PostHogAPIClient({ + apiUrl: "https://app.posthog.com", + getApiKey: vi.fn().mockResolvedValue("token"), + projectId: 7, + }); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + }); + + await expect( + client.downloadTaskSession({ + id: "session-1", + download_url: "https://storage.example/missing.jsonl", + content_sha256: "old-hash", + }), + ).resolves.toBe(""); + }); + + it("surfaces an uncertain task session replacement without retrying", async () => { + const client = new PostHogAPIClient({ + apiUrl: "https://app.posthog.com", + getApiKey: vi.fn().mockResolvedValue("token"), + projectId: 7, + }); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 504, + statusText: "Gateway Timeout", + text: vi.fn().mockResolvedValue("Gateway Timeout"), + }); + + await expect( + client.syncTaskSession( + "task-1", + "run-1", + "sandbox-1", + null, + '{"type":"session"}\n', + "task-run-token", + ), + ).rejects.toThrow("Failed to sync task session: [504] Gateway Timeout"); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + it("returns only the artifacts created by the current upload request", async () => { const client = new PostHogAPIClient({ apiUrl: "https://app.posthog.com", diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index b3596fe424..99b2574ff0 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -1,3 +1,4 @@ +import type { StoredLogEntry } from "@posthog/shared"; import packageJson from "../package.json" with { type: "json" }; import type { ArtifactType, @@ -40,6 +41,12 @@ export interface PreparedTaskArtifactUpload { presigned_post: { url: string; fields: Record }; } +export interface TaskSessionStorageAccess { + id: string; + download_url: string | null; + content_sha256: string | null; +} + export interface TaskArtifactFinalizeUploadPayload { id: string; name: string; @@ -98,7 +105,9 @@ export class PostHogAPIClient { "Authorization", `Bearer ${await this.resolveApiKey(forceRefresh)}`, ); - headers.set("Content-Type", "application/json"); + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } headers.set("User-Agent", this.config.userAgent ?? DEFAULT_USER_AGENT); return headers; } @@ -210,10 +219,71 @@ export class PostHogAPIClient { ); } + async getTaskSession( + taskId: string, + runId: string, + ): Promise { + const teamId = this.getTeamId(); + return this.apiRequest( + `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/task_session/`, + ); + } + + async downloadTaskSession(access: TaskSessionStorageAccess): Promise { + if (!access.download_url) { + return ""; + } + const response = await fetch(access.download_url, { + signal: AbortSignal.timeout(30_000), + }); + if (response.status === 404) { + return ""; + } + if (!response.ok) { + throw new Error( + `Failed to download task session: [${response.status}] ${response.statusText}`, + ); + } + return response.text(); + } + + async syncTaskSession( + taskId: string, + runId: string, + sandboxId: string, + expectedContentSha256: string | null, + content: string, + taskRunToken: string, + ): Promise { + const teamId = this.getTeamId(); + const response = await this.performRequestWithRetry( + `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/task_session_sync/`, + { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + "If-Match": `"${expectedContentSha256 ?? "none"}"`, + "X-Sandbox-ID": sandboxId, + "X-Task-Run-Token": taskRunToken, + }, + body: content, + signal: AbortSignal.timeout(30_000), + }, + ); + if (!response.ok) { + const error = await response.text().catch(() => response.statusText); + throw new Error( + `Failed to sync task session: [${response.status}] ${error}`, + ); + } + const result = (await response.json()) as { content_sha256: string }; + return result.content_sha256; + } + async appendTaskRunLog( taskId: string, runId: string, - entries: StoredEntry[], + entries: (StoredEntry | StoredLogEntry)[], ): Promise { const teamId = this.getTeamId(); return this.apiRequest( diff --git a/packages/agent/src/server/bin.ts b/packages/agent/src/server/bin.ts index 6b5ac2e188..2c7e22d16b 100644 --- a/packages/agent/src/server/bin.ts +++ b/packages/agent/src/server/bin.ts @@ -1,15 +1,18 @@ #!/usr/bin/env node +import { fileURLToPath } from "node:url"; import { Command } from "commander"; import { z } from "zod/v4"; import { isSupportedReasoningEffort } from "../adapters/reasoning-effort"; import { DEFAULT_POSTHOG_EXEC_PERMISSION_REGEX_SOURCE } from "../posthog-exec-permission"; import { AgentServer } from "./agent-server"; +import { PiAgentServer } from "./pi-agent-server"; import { claudeCodeConfigSchema, mcpServersSchema, posthogExecPermissionRegexSchema, relayMcpServerNamesSchema, } from "./schemas"; +import type { AgentServerConfig } from "./types"; const envSchema = z.object({ JWT_PUBLIC_KEY: z @@ -33,13 +36,16 @@ const envSchema = z.object({ }) .regex(/^\d+$/, "POSTHOG_PROJECT_ID must be a numeric string") .transform((val) => parseInt(val, 10)), + POSTHOG_AGENT_RUNTIME: z.enum(["acp", "pi"]).optional(), + POSTHOG_SANDBOX_ID: z.string().min(1).optional(), POSTHOG_CODE_RUNTIME_ADAPTER: z.enum(["claude", "codex"]).optional(), POSTHOG_CODE_MODEL: z.string().optional(), POSTHOG_CODE_REASONING_EFFORT: z - .enum(["low", "medium", "high", "xhigh", "max"]) + .enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]) .optional(), POSTHOG_AGENT_STATE_DIR: z.string().startsWith("/").optional(), POSTHOG_TASK_RUN_EVENT_INGEST_TOKEN: z.string().min(1).optional(), + POSTHOG_TASK_RUN_SESSION_TOKEN: z.string().min(1).optional(), // Base URL for the event-ingest POST only; falls back to POSTHOG_API_URL when unset. POSTHOG_TASK_RUN_EVENT_INGEST_URL: z.url().optional(), POSTHOG_TASK_RUN_EVENT_INGEST_STREAM_WINDOW_MS: z @@ -176,6 +182,7 @@ program // bodies can't leak it. Defense in depth, not a boundary: same-UID // processes can still read the container's initial env via /proc. delete process.env.POSTHOG_AGENT_OTEL_LOGS_TOKEN; + delete process.env.POSTHOG_TASK_RUN_SESSION_TOKEN; const mode = options.mode === "background" ? "background" : "interactive"; const createPr = parseBooleanOption(options.createPr, "--createPr"); @@ -227,11 +234,12 @@ program ); } - const server = new AgentServer({ + const serverConfig: AgentServerConfig = { port: parseInt(options.port, 10), agentStateDir: env.POSTHOG_AGENT_STATE_DIR, jwtPublicKey: env.JWT_PUBLIC_KEY, eventIngestToken: env.POSTHOG_TASK_RUN_EVENT_INGEST_TOKEN, + taskRunSessionToken: env.POSTHOG_TASK_RUN_SESSION_TOKEN, eventIngestBaseUrl: env.POSTHOG_TASK_RUN_EVENT_INGEST_URL, eventIngestStreamWindowMs: env.POSTHOG_TASK_RUN_EVENT_INGEST_STREAM_WINDOW_MS, @@ -248,6 +256,7 @@ program mode, taskId: options.taskId, runId: options.runId, + sandboxId: env.POSTHOG_SANDBOX_ID, createPr, autoPublish, mcpServers, @@ -256,10 +265,17 @@ program baseBranch: options.baseBranch, claudeCode, allowedDomains, + piRpcHostPath: fileURLToPath( + new URL("../pi/rpc-host.js", import.meta.url), + ), runtimeAdapter: env.POSTHOG_CODE_RUNTIME_ADAPTER, model: env.POSTHOG_CODE_MODEL, reasoningEffort: env.POSTHOG_CODE_REASONING_EFFORT, - }); + }; + const server = + env.POSTHOG_AGENT_RUNTIME === "pi" + ? new PiAgentServer(serverConfig) + : new AgentServer(serverConfig); process.on("SIGINT", async () => { await server.stop(); diff --git a/packages/agent/src/server/event-stream-sender.ts b/packages/agent/src/server/event-stream-sender.ts index 05b17fbedd..a4f30b4cf0 100644 --- a/packages/agent/src/server/event-stream-sender.ts +++ b/packages/agent/src/server/event-stream-sender.ts @@ -103,6 +103,7 @@ export class TaskRunEventStreamSender { config.logger.info("Event ingest target resolved", { ingestUrl: this.ingestUrl, routedToProxy: usingProxy, + persistentUpload: !usingProxy || config.keepProxyStreamOpen === true, }); this.maxBufferedEvents = config.maxBufferedEvents ?? DEFAULT_MAX_BUFFERED_EVENTS; @@ -478,6 +479,12 @@ export class TaskRunEventStreamSender { await this.applyIngestResponse(response, "Event ingest stream"); this.sequenceSynced = true; + this.config.logger.debug("Task run event ingest stream delivered", { + durationMs: Date.now() - stream.startedAtMs, + sentBytes: stream.sentBytes, + sentEvents: stream.sentEvents, + sentThroughSeq: stream.sentThroughSeq, + }); } private async abortActiveStream(): Promise { diff --git a/packages/agent/src/server/pi-agent-server.test.ts b/packages/agent/src/server/pi-agent-server.test.ts new file mode 100644 index 0000000000..c185666512 --- /dev/null +++ b/packages/agent/src/server/pi-agent-server.test.ts @@ -0,0 +1,476 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { PiAgentServer } from "./pi-agent-server"; +import type { AgentServerConfig } from "./types"; + +function config(overrides: Partial = {}): AgentServerConfig { + return { + port: 0, + jwtPublicKey: "public-key", + apiUrl: "https://us.posthog.com", + apiKey: "token", + projectId: 1, + mode: "interactive", + taskId: "task-1", + runId: "run-1", + sandboxId: "sandbox-1", + taskRunSessionToken: "task-run-token", + ...overrides, + }; +} + +describe("PiAgentServer", () => { + it.each([ + ["task", { task_id: "task-2", run_id: "run-1", team_id: 1 }], + ["run", { task_id: "task-1", run_id: "run-2", team_id: 1 }], + ["team", { task_id: "task-1", run_id: "run-1", team_id: 2 }], + ])("rejects a token for a different %s", (_field, identity) => { + const server = new PiAgentServer(config()) as unknown as { + assertConfiguredRun(payload: Record): void; + }; + + expect(() => + server.assertConfiguredRun({ + ...identity, + user_id: 1, + distinct_id: "user-1", + mode: "interactive", + }), + ).toThrow("Token does not match the configured task run"); + }); + + it("persists translated Pi events at the turn boundary", async () => { + const appendTaskRunLog = vi.fn(async () => ({})); + const server = new PiAgentServer(config()) as unknown as { + posthogAPI: { appendTaskRunLog: typeof appendTaskRunLog }; + handleEvent(event: Record): void; + logFlushQueue: Promise; + }; + server.posthogAPI.appendTaskRunLog = appendTaskRunLog; + + server.handleEvent({ + type: "user_message", + timestamp: 1, + content: [{ type: "text", text: "hello" }], + }); + server.handleEvent({ type: "turn_completed", timestamp: 2 }); + await server.logFlushQueue; + + expect(appendTaskRunLog).toHaveBeenCalledWith("task-1", "run-1", [ + { + id: expect.any(String), + type: "pi_event", + timestamp: expect.any(String), + event: { + type: "user_message", + timestamp: 1, + content: [{ type: "text", text: "hello" }], + sourceId: expect.any(String), + }, + }, + { + id: expect.any(String), + type: "pi_event", + timestamp: expect.any(String), + event: { + type: "turn_completed", + timestamp: 2, + sourceId: expect.any(String), + }, + }, + ]); + }); + + it("bounds events retained while no SSE client is connected", () => { + const server = new PiAgentServer(config()) as unknown as { + broadcast(event: Record): void; + pendingEvents: Record[]; + }; + + for (let index = 0; index < 1_100; index++) { + server.broadcast({ type: "test", index }); + } + + expect(server.pendingEvents).toHaveLength(1_000); + expect(server.pendingEvents[0]).toEqual({ type: "test", index: 100 }); + }); + + it("coalesces replay and log buffers for repeated tool updates", () => { + const server = new PiAgentServer(config()) as unknown as { + broadcast(event: Record): void; + pendingEvents: Record[]; + pendingLogEntries: Array<{ event?: Record }>; + }; + + server.broadcast({ + type: "pi_event", + event: { + type: "tool_call_updated", + timestamp: 1, + toolCall: { id: "tool-1", content: [{ type: "content" }] }, + }, + }); + server.broadcast({ + type: "pi_event", + event: { + type: "tool_call_updated", + timestamp: 2, + toolCall: { id: "tool-1", status: "completed" }, + }, + }); + + expect(server.pendingEvents).toHaveLength(1); + expect(server.pendingLogEntries).toHaveLength(1); + expect(server.pendingLogEntries[0]?.event).toMatchObject({ + timestamp: 2, + toolCall: { + id: "tool-1", + status: "completed", + content: [{ type: "content" }], + }, + }); + }); + + it("flushes long-running conversation logs in bounded batches", async () => { + const appendTaskRunLog = vi.fn( + async (_taskId: string, _runId: string, _entries: unknown[]) => ({}), + ); + const server = new PiAgentServer(config()) as unknown as { + posthogAPI: { appendTaskRunLog: typeof appendTaskRunLog }; + handleEvent(event: Record): void; + logFlushQueue: Promise; + }; + server.posthogAPI.appendTaskRunLog = appendTaskRunLog; + + for (let index = 0; index < 100; index++) { + server.handleEvent({ + type: "assistant_message_chunk", + timestamp: index, + content: { type: "text", text: String(index) }, + }); + } + await server.logFlushQueue; + + expect(appendTaskRunLog).toHaveBeenCalledOnce(); + expect(appendTaskRunLog.mock.calls[0]?.[2]).toHaveLength(100); + }); + + it("uses the durable message id for an idle native Pi prompt", async () => { + const sendCommand = vi.fn( + async (_command: Record) => ({}), + ); + const server = new PiAgentServer(config()) as unknown as { + session: unknown; + executeCommand( + method: string, + params: Record, + ): Promise; + }; + server.session = { + runtime: { + client: { + getState: vi.fn(async () => ({ isStreaming: false })), + }, + sendCommand, + }, + }; + + await server.executeCommand("user_message", { + content: "hello", + messageId: "message-1", + }); + + expect(sendCommand).toHaveBeenCalledWith({ + id: "message-1", + type: "prompt", + message: "hello", + images: [], + }); + }); + + it("hydrates cloud artifacts into native Pi prompt inputs", async () => { + const repositoryPath = await mkdtemp(join(tmpdir(), "pi-attachments-")); + const sendCommand = vi.fn( + async (_command: Record) => ({}), + ); + const downloadArtifact = vi + .fn() + .mockResolvedValueOnce(Buffer.from("notes")) + .mockResolvedValueOnce(Buffer.from("image")); + const server = new PiAgentServer(config({ repositoryPath })) as unknown as { + posthogAPI: { downloadArtifact: typeof downloadArtifact }; + session: unknown; + executeCommand( + method: string, + params: Record, + ): Promise; + }; + server.posthogAPI.downloadArtifact = downloadArtifact; + server.session = { + runtime: { + client: { + getState: vi.fn(async () => ({ isStreaming: false })), + }, + sendCommand, + }, + }; + + await server.executeCommand("user_message", { + content: "Read these", + artifacts: [ + { + id: "file-1", + name: "notes.txt", + type: "user_attachment", + content_type: "text/plain", + storage_path: "artifacts/notes.txt", + }, + { + id: "image-1", + name: "image.png", + type: "user_attachment", + content_type: "image/png", + storage_path: "artifacts/image.png", + }, + ], + }); + + const command = sendCommand.mock.calls[0][0]; + const filePath = join( + repositoryPath, + ".posthog", + "attachments", + "file-1-notes.txt", + ); + expect(command.message).toContain(filePath); + await expect(readFile(filePath, "utf8")).resolves.toBe("notes"); + expect(command.images).toEqual([ + { + type: "image", + data: Buffer.from("image").toString("base64"), + mimeType: "image/png", + fileName: "image.png", + }, + ]); + + await rm(repositoryPath, { recursive: true }); + }); + + it("allows a failed user-message delivery to be retried", async () => { + const sendCommand = vi + .fn() + .mockRejectedValueOnce(new Error("delivery failed")) + .mockResolvedValueOnce(undefined); + const server = new PiAgentServer(config()) as unknown as { + session: unknown; + executeCommand( + method: string, + params: Record, + ): Promise; + }; + server.session = { + runtime: { + client: { + getState: vi.fn(async () => ({ isStreaming: false })), + }, + sendCommand, + }, + }; + const params = { content: "hello", messageId: "message-1" }; + + await expect(server.executeCommand("user_message", params)).rejects.toThrow( + "delivery failed", + ); + await expect( + server.executeCommand("user_message", params), + ).resolves.toBeUndefined(); + + expect(sendCommand).toHaveBeenCalledTimes(2); + }); + + it("does not install an SSE controller canceled during initialization", async () => { + let finishInitialization: (() => void) | undefined; + const initializationGate = new Promise((resolve) => { + finishInitialization = resolve; + }); + const controller = { send: vi.fn(), close: vi.fn() }; + const payload = { task_id: "task-1", run_id: "run-1" }; + type TestController = typeof controller; + type TestPayload = typeof payload; + const server = new PiAgentServer(config()) as unknown as { + session: { + payload: TestPayload; + sseController: TestController | null; + } | null; + createSession(sessionPayload: TestPayload): Promise; + initializeSession( + sessionPayload: TestPayload, + sseController: TestController, + ): Promise; + cancelSseController(sseController: TestController): void; + }; + server.createSession = vi.fn(async (sessionPayload) => { + await initializationGate; + server.session = { payload: sessionPayload, sseController: null }; + }); + + const initialization = server.initializeSession(payload, controller); + server.cancelSseController(controller); + finishInitialization?.(); + await initialization; + + expect(server.session?.sseController).toBeNull(); + expect(controller.send).not.toHaveBeenCalled(); + }); + + it("preserves a replacement SSE controller when the old stream cancels", () => { + const oldController = { send: vi.fn(), close: vi.fn() }; + const replacementController = { send: vi.fn(), close: vi.fn() }; + const server = new PiAgentServer(config()) as unknown as { + session: { sseController: typeof replacementController } | null; + cancelSseController(controller: typeof oldController): void; + }; + server.session = { sseController: replacementController }; + + server.cancelSseController(oldController); + + expect(server.session?.sseController).toBe(replacementController); + + server.cancelSseController(replacementController); + + expect(server.session?.sseController).toBeNull(); + }); + + it("forwards native Pi RPC commands through the runtime", async () => { + const sendCommand = vi.fn(async () => ({ + type: "response", + command: "set_follow_up_mode", + success: true, + })); + const server = new PiAgentServer(config()) as unknown as { + session: unknown; + executeCommand( + method: string, + params: Record, + ): Promise; + }; + server.session = { runtime: { client: {}, sendCommand } }; + const command = { + type: "set_follow_up_mode", + mode: "one-at-a-time", + }; + + const response = await server.executeCommand("pi/rpc", { command }); + + expect(sendCommand).toHaveBeenCalledWith(command); + expect(response).toEqual({ + type: "response", + command: "set_follow_up_mode", + success: true, + }); + }); + + it.each([ + ["queue_get", "getQueue"], + ["queue_clear", "clearQueue"], + ] as const)( + "forwards %s through the private Pi host API", + async (method, operation) => { + const queue = { + steering: ["fix this"], + followUp: ["then summarize"], + }; + const client = { + getQueue: vi.fn(async () => queue), + clearQueue: vi.fn(async () => queue), + }; + const clearPendingQueuedUserMessages = vi.fn(); + const server = new PiAgentServer(config()) as unknown as { + session: unknown; + executeCommand( + method: string, + params: Record, + ): Promise; + }; + server.session = { + runtime: { client, clearPendingQueuedUserMessages }, + }; + + await expect(server.executeCommand(method, {})).resolves.toEqual(queue); + expect(client[operation]).toHaveBeenCalledOnce(); + expect(clearPendingQueuedUserMessages).toHaveBeenCalledTimes( + method === "queue_clear" ? 1 : 0, + ); + }, + ); + + it("waits for Pi to create the native session file before syncing", async () => { + const directory = await mkdtemp(join(tmpdir(), "pi-session-sync-")); + const syncTaskSession = vi.fn(async () => "content-hash"); + const server = new PiAgentServer(config()) as unknown as { + sessionFile: string; + posthogAPI: { syncTaskSession: typeof syncTaskSession }; + syncTaskSession(): Promise; + }; + server.sessionFile = join(directory, "not-created.jsonl"); + server.posthogAPI = { syncTaskSession }; + + await server.syncTaskSession(); + + expect(syncTaskSession).not.toHaveBeenCalled(); + await rm(directory, { recursive: true }); + }); + + it("syncs changed native session JSONL to durable task storage", async () => { + const directory = await mkdtemp(join(tmpdir(), "pi-session-sync-")); + const sessionFile = join(directory, "session.jsonl"); + const content = '{"type":"session"}\n'; + await writeFile(sessionFile, content); + const syncTaskSession = vi.fn(async () => "content-hash"); + const server = new PiAgentServer(config()) as unknown as { + sessionFile: string; + posthogAPI: { syncTaskSession: typeof syncTaskSession }; + syncTaskSession(): Promise; + }; + server.sessionFile = sessionFile; + server.posthogAPI = { syncTaskSession }; + + await server.syncTaskSession(); + await server.syncTaskSession(); + + expect(syncTaskSession).toHaveBeenCalledOnce(); + expect(syncTaskSession).toHaveBeenCalledWith( + "task-1", + "run-1", + "sandbox-1", + null, + content, + "task-run-token", + ); + await rm(directory, { recursive: true }); + }); + + it("publishes runtime-neutral Pi conversation events", () => { + const send = vi.fn(); + const server = new PiAgentServer(config()) as unknown as { + session: unknown; + handleEvent(event: unknown): void; + }; + server.session = { sseController: { send } }; + + server.handleEvent({ + type: "assistant_message_chunk", + timestamp: 1, + content: { type: "text", text: "hello" }, + }); + + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + type: "pi_event", + event: expect.objectContaining({ type: "assistant_message_chunk" }), + }), + ); + }); +}); diff --git a/packages/agent/src/server/pi-agent-server.ts b/packages/agent/src/server/pi-agent-server.ts new file mode 100644 index 0000000000..a218fe5d7c --- /dev/null +++ b/packages/agent/src/server/pi-agent-server.ts @@ -0,0 +1,861 @@ +import { randomUUID } from "node:crypto"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import type { ServerType } from "@hono/node-server"; +import { serve } from "@hono/node-server"; +import type { + AgentConversationEvent, + StoredLogEntry, + TaskRunArtifact, +} from "@posthog/shared"; +import { Hono } from "hono"; +import { z } from "zod/v4"; +import { createPiRpcClient, type PiRpcClient } from "../pi/rpc-client"; +import { piRpcCommandSchema, type RpcCommand } from "../pi/rpc-transport"; +import { PiRuntime } from "../pi/runtime"; +import { PostHogAPIClient } from "../posthog-api"; +import { resolveLlmGatewayUrl } from "../utils/gateway"; +import { Logger } from "../utils/logger"; +import { TaskRunEventStreamSender } from "./event-stream-sender"; +import { type JwtPayload, JwtValidationError, validateJwt } from "./jwt"; +import { jsonRpcRequestSchema } from "./schemas"; +import type { AgentServerConfig } from "./types"; + +interface SseController { + send(data: unknown): void; + close(): void; +} + +interface PiCloudSession { + payload: JwtPayload; + runtime: PiRuntime; + sseController: SseController | null; + unsubscribe: () => void; +} + +const emptySchema = z.object({}); +const MAX_PENDING_EVENTS = 1_000; +const MAX_PENDING_LOG_ENTRIES = 10_000; +const LOG_FLUSH_ENTRY_COUNT = 100; + +const userMessageCommandSchema = z + .object({ + content: z.string().min(1).optional(), + artifacts: z.array(z.record(z.string(), z.unknown())).optional(), + messageId: z.string().min(1).optional(), + steer: z.boolean().optional(), + }) + .refine( + (params) => params.content || (params.artifacts?.length ?? 0) > 0, + "Either content or artifacts are required", + ); + +const commandSchemas = { + user_message: userMessageCommandSchema, + cancel: emptySchema, + queue_get: emptySchema, + queue_clear: emptySchema, + "pi/rpc": z.object({ command: piRpcCommandSchema }), +} as const; + +type PiCommandMethod = keyof typeof commandSchemas; + +function updatedToolCallId( + event: AgentConversationEvent | undefined, +): string | undefined { + return event?.type === "tool_call_updated" ? event.toolCall.id : undefined; +} + +function mergeToolCallUpdate( + previous: AgentConversationEvent | undefined, + next: AgentConversationEvent | undefined, +): AgentConversationEvent | undefined { + if ( + previous?.type !== "tool_call_updated" || + next?.type !== "tool_call_updated" || + previous.toolCall.id !== next.toolCall.id + ) { + return next; + } + return { + ...next, + toolCall: { ...previous.toolCall, ...next.toolCall }, + }; +} + +export class PiAgentServer { + private readonly app: Hono; + private readonly logger = new Logger({ + debug: true, + prefix: "[PiAgentServer]", + }); + private readonly posthogAPI: PostHogAPIClient; + private readonly eventStreamSender: TaskRunEventStreamSender | null; + private server: ServerType | null = null; + private session: PiCloudSession | null = null; + private initializationPromise: Promise | null = null; + private pendingEvents: Record[] = []; + private sessionReadyBootMs?: number; + private sessionInitMs?: number; + private sessionFile: string | null = null; + private lastSyncedSessionContent = ""; + private sessionContentSha256: string | null = null; + private sessionSyncQueue: Promise = Promise.resolve(); + private settledPersistenceQueue: Promise = Promise.resolve(); + private pendingLogEntries: StoredLogEntry[] = []; + private logFlushQueue: Promise = Promise.resolve(); + private logFlushActive = false; + private logFlushRequested = false; + private readonly canceledSseControllers = new WeakSet(); + + constructor(private readonly config: AgentServerConfig) { + this.posthogAPI = new PostHogAPIClient({ + apiUrl: config.apiUrl, + projectId: config.projectId, + getApiKey: () => config.apiKey, + userAgent: `posthog/pi-cloud`, + }); + this.eventStreamSender = config.eventIngestToken + ? new TaskRunEventStreamSender({ + apiUrl: config.apiUrl, + eventIngestBaseUrl: config.eventIngestBaseUrl, + keepProxyStreamOpen: config.eventIngestKeepStreamOpen, + projectId: config.projectId, + taskId: config.taskId, + runId: config.runId, + token: config.eventIngestToken, + logger: this.logger.child("EventIngest"), + streamWindowMs: config.eventIngestStreamWindowMs, + }) + : null; + this.app = this.createApp(); + } + + async start(): Promise { + await new Promise((resolve) => { + this.server = serve( + { fetch: this.app.fetch, port: this.config.port }, + () => resolve(), + ); + }); + + const payload: JwtPayload = { + task_id: this.config.taskId, + run_id: this.config.runId, + team_id: this.config.projectId, + user_id: 0, + distinct_id: "pi-agent-server", + mode: this.config.mode, + }; + await this.initializeSession(payload, null); + } + + async stop(): Promise { + const session = this.session; + if (session) { + await session.runtime.client + .abort() + .catch((error) => + this.logger.debug( + "Failed to abort Pi session during shutdown", + error, + ), + ); + await session.runtime.client + .waitForIdle(5_000) + .catch((error) => + this.logger.debug( + "Pi session did not become idle during shutdown", + error, + ), + ); + await this.settledPersistenceQueue.catch((error) => + this.logger.error("Failed to persist settled Pi turn", error), + ); + await this.syncTaskSession().catch((error) => + this.logger.error("Failed to sync Pi session during shutdown", error), + ); + session.unsubscribe(); + await session.runtime.client + .stop() + .catch((error) => + this.logger.error("Failed to stop Pi client during shutdown", error), + ); + } + this.session = null; + await this.flushConversationLog().catch((error) => + this.logger.error("Failed to persist Pi events during shutdown", error), + ); + await this.eventStreamSender?.stop(); + this.server?.close(); + this.server = null; + } + + async reportFatalError(error: unknown): Promise { + const message = error instanceof Error ? error.message : String(error); + this.broadcast({ + type: "pi_event", + timestamp: new Date().toISOString(), + event: { + type: "runtime_error", + timestamp: Date.now(), + errorType: "agent_server_crash", + message, + } satisfies AgentConversationEvent, + }); + await this.settledPersistenceQueue.catch((syncError) => + this.logger.error( + "Failed to persist settled Pi turn after crash", + syncError, + ), + ); + await this.syncTaskSession().catch((syncError) => + this.logger.error("Failed to sync crashed Pi session", syncError), + ); + await this.flushConversationLog().catch((syncError) => + this.logger.error("Failed to persist crashed Pi events", syncError), + ); + await this.posthogAPI + .updateTaskRun(this.config.taskId, this.config.runId, { + status: "failed", + error_message: `Pi agent server crashed: ${message}`, + }) + .catch((updateError) => + this.logger.error( + "Failed to mark crashed Pi run as failed", + updateError, + ), + ); + await this.eventStreamSender?.stop(); + } + + private createApp(): Hono { + const app = new Hono(); + + app.get("/health", (context) => + context.json({ + status: "ok", + hasSession: this.session !== null, + bootMs: this.sessionReadyBootMs, + sessionInitMs: this.sessionInitMs, + }), + ); + + app.get("/events", async (context) => { + let payload: JwtPayload; + try { + payload = this.authenticate(context.req.header.bind(context.req)); + } catch (error) { + return context.json( + { error: error instanceof Error ? error.message : "Invalid token" }, + 401, + ); + } + + const encoder = new TextEncoder(); + let keepalive: ReturnType | null = null; + let sseController: SseController | null = null; + const stream = new ReadableStream({ + start: async (controller) => { + sseController = { + send: (data) => + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(data)}\n\n`), + ), + close: () => controller.close(), + }; + keepalive = setInterval(() => { + controller.enqueue(encoder.encode(": keepalive\n\n")); + }, 25_000); + await this.initializeSession(payload, sseController); + if (this.session?.sseController !== sseController) { + return; + } + this.replayPendingEvents(); + sseController.send({ type: "connected", run_id: payload.run_id }); + }, + cancel: () => { + if (keepalive) { + clearInterval(keepalive); + } + this.cancelSseController(sseController); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + }); + + app.post("/command", async (context) => { + let payload: JwtPayload; + try { + payload = this.authenticate(context.req.header.bind(context.req)); + } catch (error) { + return context.json( + { error: error instanceof Error ? error.message : "Invalid token" }, + 401, + ); + } + if (!this.session || this.session.payload.run_id !== payload.run_id) { + return context.json({ error: "No active session for this run" }, 400); + } + + const request = jsonRpcRequestSchema.safeParse( + await context.req.json().catch(() => null), + ); + if (!request.success) { + return context.json({ error: "Invalid JSON-RPC request" }, 400); + } + + const method = request.data.method as PiCommandMethod; + const schema = commandSchemas[method]; + if (!schema) { + return context.json({ + jsonrpc: "2.0", + id: request.data.id, + error: { + code: -32601, + message: `Unknown method: ${request.data.method}`, + }, + }); + } + const params = schema.safeParse(request.data.params ?? {}); + if (!params.success) { + return context.json({ + jsonrpc: "2.0", + id: request.data.id, + error: { code: -32602, message: params.error.message }, + }); + } + if (method === "pi/rpc") { + const command = (params.data as { command: RpcCommand }).command; + if (!command.id || command.id !== request.data.id) { + return context.json({ + jsonrpc: "2.0", + id: request.data.id, + error: { + code: -32602, + message: "Pi command id must match the JSON-RPC request id", + }, + }); + } + } + + try { + const result = await this.executeCommand( + method, + params.data as Record, + ); + return context.json({ jsonrpc: "2.0", id: request.data.id, result }); + } catch (error) { + return context.json({ + jsonrpc: "2.0", + id: request.data.id, + error: { + code: -32000, + message: error instanceof Error ? error.message : "Unknown error", + }, + }); + } + }); + + return app; + } + + private async initializeSession( + payload: JwtPayload, + sseController: SseController | null, + ): Promise { + if (this.session?.payload.run_id === payload.run_id) { + this.installSseController(sseController); + return; + } + if (this.initializationPromise) { + await this.initializationPromise; + this.installSseController(sseController); + return; + } + + const initializationPromise = this.createSession(payload); + this.initializationPromise = initializationPromise; + try { + await initializationPromise; + } finally { + if (this.initializationPromise === initializationPromise) { + this.initializationPromise = null; + } + } + this.installSseController(sseController); + } + + private async createSession(payload: JwtPayload): Promise { + const startedAt = Date.now(); + await this.waitForRepoReady(); + const cwd = this.config.repositoryPath ?? "/tmp/workspace"; + if (!this.config.sandboxId) { + throw new Error("Pi task session persistence requires a sandbox ID"); + } + const sessionStorage = await this.posthogAPI.getTaskSession( + payload.task_id, + payload.run_id, + ); + const persistedSessionContent = + await this.posthogAPI.downloadTaskSession(sessionStorage); + const restoredSessionFile = persistedSessionContent + ? join("/tmp", "posthog-pi-sessions", sessionStorage.id, "session.jsonl") + : undefined; + if (restoredSessionFile) { + await mkdir(dirname(restoredSessionFile), { recursive: true }); + await writeFile(restoredSessionFile, persistedSessionContent, "utf8"); + } + this.lastSyncedSessionContent = persistedSessionContent; + this.sessionContentSha256 = sessionStorage.content_sha256; + + const client = createPiRpcClient({ + cliPath: this.config.piRpcHostPath, + cwd, + model: this.config.model, + sessionFile: restoredSessionFile, + providerOptions: { + apiKey: this.config.apiKey, + baseUrl: resolveLlmGatewayUrl( + process.env.LLM_GATEWAY_URL, + this.config.apiUrl, + ), + }, + }); + const runtime = new PiRuntime(client); + const unsubscribeConversation = runtime.onConversationEvent((event) => + this.handleEvent(event), + ); + const unsubscribeRuntime = runtime.onRuntimeEvent((event) => { + if (event.type === "agent_settled") { + this.settledPersistenceQueue = this.settledPersistenceQueue + .then(() => this.persistSettledTurn()) + .catch((error) => { + this.logger.error("Failed to persist settled Pi turn", error); + }); + } + }); + await client.start(); + if (this.config.reasoningEffort) { + await client.setThinkingLevel(this.config.reasoningEffort); + } + const runtimeState = await client.getState(); + this.sessionFile = runtimeState.sessionFile ?? restoredSessionFile ?? null; + const unsubscribe = () => { + unsubscribeConversation(); + unsubscribeRuntime(); + }; + + this.session = { payload, runtime, sseController: null, unsubscribe }; + this.sessionReadyBootMs = Math.round(process.uptime() * 1000); + this.sessionInitMs = Date.now() - startedAt; + await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, { + status: "in_progress", + }); + this.broadcast({ + type: "pi_run_started", + timestamp: new Date().toISOString(), + taskId: payload.task_id, + runId: payload.run_id, + }); + } + + private handleEvent(event: AgentConversationEvent): void { + const id = randomUUID(); + this.broadcast({ + id, + type: "pi_event", + timestamp: new Date().toISOString(), + event: { ...event, sourceId: id }, + }); + if (event.type === "queue_update") { + void this.syncTaskSession().catch((error) => + this.logger.error("Failed to persist Pi queue state", error), + ); + } + } + + private async executeCommand( + method: PiCommandMethod, + params: Record, + ): Promise { + const runtime = this.session?.runtime; + if (!runtime) { + throw new Error("No active Pi runtime"); + } + const client = runtime.client; + switch (method) { + case "user_message": + return this.deliverUserMessage(runtime, params); + case "cancel": + return client.abort(); + case "queue_get": + return client.getQueue(); + case "queue_clear": { + const queue = await client.clearQueue(); + runtime.clearPendingQueuedUserMessages(); + return queue; + } + case "pi/rpc": + return runtime.sendCommand(params.command as RpcCommand); + } + } + + private async deliverUserMessage( + runtime: PiRuntime, + params: Record, + ): Promise { + const artifacts = Array.isArray(params.artifacts) + ? (params.artifacts as TaskRunArtifact[]) + : []; + const message = await this.prepareUserMessage( + typeof params.content === "string" ? params.content : "", + artifacts, + ); + return this.dispatchUserMessage( + runtime, + message.content, + message.images, + typeof params.messageId === "string" ? params.messageId : randomUUID(), + params.steer === true, + ); + } + + private async prepareUserMessage( + content: string, + artifacts: TaskRunArtifact[], + ): Promise<{ + content: string; + images: Parameters[1]; + }> { + const images: NonNullable[1]> = []; + const filePaths: string[] = []; + const attachmentDirectory = join( + this.config.repositoryPath ?? "/tmp/workspace", + ".posthog", + "attachments", + ); + + for (const artifact of artifacts) { + if (!artifact.storage_path) { + continue; + } + const data = await this.posthogAPI.downloadArtifact( + this.config.taskId, + this.config.runId, + artifact.storage_path, + ); + if (!data) { + throw new Error(`Failed to download attachment: ${artifact.name}`); + } + + const mimeType = artifact.content_type ?? "application/octet-stream"; + if (mimeType.startsWith("image/")) { + images.push({ + type: "image", + data: Buffer.from(data).toString("base64"), + mimeType, + fileName: artifact.name, + } as (typeof images)[number]); + continue; + } + + await mkdir(attachmentDirectory, { recursive: true }); + const fileName = `${artifact.id}-${basename(artifact.name)}`; + const filePath = join(attachmentDirectory, fileName); + await writeFile(filePath, Buffer.from(data)); + filePaths.push(filePath); + } + + const attachmentText = filePaths.length + ? `Attached files:\n${filePaths.map((filePath) => `- ${filePath}`).join("\n")}` + : ""; + return { + content: [content, attachmentText].filter(Boolean).join("\n\n"), + images, + }; + } + + private async dispatchUserMessage( + runtime: PiRuntime, + content: string, + images: Parameters[1], + id: string, + steer: boolean, + ): Promise { + const state = await runtime.client.getState(); + if (state.isStreaming && steer) { + return runtime.sendCommand({ + id, + type: "steer", + message: content, + images, + }); + } + if (state.isStreaming) { + return runtime.sendCommand({ + id, + type: "follow_up", + message: content, + images, + }); + } + return runtime.sendCommand({ + id, + type: "prompt", + message: content, + images, + }); + } + + private installSseController(sseController: SseController | null): void { + if (sseController && !this.canceledSseControllers.has(sseController)) { + if (this.session) { + this.session.sseController = sseController; + } + } + } + + private cancelSseController(sseController: SseController | null): void { + if (!sseController) { + return; + } + this.canceledSseControllers.add(sseController); + if (this.session?.sseController === sseController) { + this.session.sseController = null; + } + } + + private async persistSettledTurn(): Promise { + await Promise.all([this.syncTaskSession(), this.flushConversationLog()]); + } + + private syncTaskSession(): Promise { + const sync = this.sessionSyncQueue.then(async () => { + if (!this.sessionFile) { + return; + } + + let content: string; + try { + content = await readFile(this.sessionFile, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + if (content === this.lastSyncedSessionContent) { + return; + } + + if (!this.config.sandboxId || !this.config.taskRunSessionToken) { + throw new Error( + "Pi task session persistence requires sandbox credentials", + ); + } + this.sessionContentSha256 = await this.posthogAPI.syncTaskSession( + this.config.taskId, + this.config.runId, + this.config.sandboxId, + this.sessionContentSha256, + content, + this.config.taskRunSessionToken, + ); + this.lastSyncedSessionContent = content; + }); + this.sessionSyncQueue = sync.catch(() => undefined); + return sync; + } + + private broadcast(event: Record): void { + if (event.type === "pi_event" || event.type === "pi_run_started") { + const logEntry: StoredLogEntry = { + id: typeof event.id === "string" ? event.id : undefined, + type: event.type, + timestamp: + typeof event.timestamp === "string" ? event.timestamp : undefined, + event: + event.type === "pi_event" + ? (event.event as AgentConversationEvent) + : undefined, + }; + const toolCallId = updatedToolCallId(logEntry.event); + const pendingLogIndex = toolCallId + ? this.pendingLogEntries.findLastIndex( + (entry) => updatedToolCallId(entry.event) === toolCallId, + ) + : -1; + if (pendingLogIndex >= 0) { + const previous = this.pendingLogEntries[pendingLogIndex]; + this.pendingLogEntries[pendingLogIndex] = { + ...logEntry, + event: mergeToolCallUpdate(previous?.event, logEntry.event), + }; + } else { + this.pendingLogEntries.push(logEntry); + } + if (this.pendingLogEntries.length > MAX_PENDING_LOG_ENTRIES) { + this.pendingLogEntries.splice( + 0, + this.pendingLogEntries.length - MAX_PENDING_LOG_ENTRIES, + ); + } + if ( + event.type === "pi_run_started" || + this.pendingLogEntries.length >= LOG_FLUSH_ENTRY_COUNT || + (event.event as { type?: string } | undefined)?.type === + "turn_completed" + ) { + void this.flushConversationLog().catch((error) => + this.logger.error("Failed to persist Pi conversation events", error), + ); + } + } + + this.eventStreamSender?.enqueue(event); + if (this.session?.sseController) { + this.session.sseController.send(event); + } else { + const toolCallId = updatedToolCallId( + event.type === "pi_event" + ? (event.event as AgentConversationEvent) + : undefined, + ); + const pendingEventIndex = toolCallId + ? this.pendingEvents.findLastIndex( + (pending) => + pending.type === "pi_event" && + updatedToolCallId( + pending.event as AgentConversationEvent | undefined, + ) === toolCallId, + ) + : -1; + if (pendingEventIndex >= 0) { + const previous = this.pendingEvents[pendingEventIndex]; + this.pendingEvents[pendingEventIndex] = { + ...event, + event: mergeToolCallUpdate( + previous?.event as AgentConversationEvent | undefined, + event.event as AgentConversationEvent | undefined, + ), + }; + } else { + this.pendingEvents.push(event); + } + if (this.pendingEvents.length > MAX_PENDING_EVENTS) { + this.pendingEvents.splice( + 0, + this.pendingEvents.length - MAX_PENDING_EVENTS, + ); + } + } + } + + private flushConversationLog(): Promise { + if (this.logFlushActive) { + this.logFlushRequested = true; + return this.logFlushQueue; + } + if (this.pendingLogEntries.length === 0) { + return this.logFlushQueue; + } + + this.logFlushActive = true; + const flush = (async () => { + do { + this.logFlushRequested = false; + const entries = this.pendingLogEntries; + this.pendingLogEntries = []; + if (entries.length === 0) { + return; + } + try { + await this.posthogAPI.appendTaskRunLog( + this.config.taskId, + this.config.runId, + entries, + ); + } catch (error) { + this.pendingLogEntries = [ + ...entries, + ...this.pendingLogEntries, + ].slice(-MAX_PENDING_LOG_ENTRIES); + throw error; + } + } while ( + this.logFlushRequested || + this.pendingLogEntries.length >= LOG_FLUSH_ENTRY_COUNT + ); + })().finally(() => { + this.logFlushActive = false; + }); + + this.logFlushQueue = flush.catch(() => undefined); + return flush; + } + + private replayPendingEvents(): void { + const controller = this.session?.sseController; + if (!controller) { + return; + } + const events = this.pendingEvents; + this.pendingEvents = []; + for (const event of events) { + controller.send(event); + } + } + + private authenticate( + getHeader: (name: string) => string | undefined, + ): JwtPayload { + const authHeader = getHeader("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + throw new JwtValidationError( + "Missing authorization header", + "invalid_token", + ); + } + const payload = validateJwt(authHeader.slice(7), this.config.jwtPublicKey); + this.assertConfiguredRun(payload); + return payload; + } + + private assertConfiguredRun(payload: JwtPayload): void { + if ( + payload.task_id !== this.config.taskId || + payload.run_id !== this.config.runId || + payload.team_id !== this.config.projectId + ) { + throw new JwtValidationError( + "Token does not match the configured task run", + "invalid_token", + ); + } + } + + private async waitForRepoReady(): Promise { + const path = this.config.repoReadyFile; + if (!path) { + return; + } + const deadline = Date.now() + 10 * 60_000; + while (Date.now() < deadline) { + try { + await access(path); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw new Error(`Repository readiness file was not created: ${path}`); + } +} diff --git a/packages/agent/src/server/types.ts b/packages/agent/src/server/types.ts index dc2471dedb..6d596aa8ad 100644 --- a/packages/agent/src/server/types.ts +++ b/packages/agent/src/server/types.ts @@ -20,6 +20,7 @@ export interface AgentServerConfig { projectId: number; jwtPublicKey: string; // RS256 public key for JWT verification eventIngestToken?: string; + taskRunSessionToken?: string; // Base URL for the event-ingest POST only; falls back to apiUrl when unset. eventIngestBaseUrl?: string; eventIngestStreamWindowMs?: number; @@ -33,6 +34,7 @@ export interface AgentServerConfig { mode: AgentMode; taskId: string; runId: string; + sandboxId?: string; createPr?: boolean; // User-opted auto-publish: push and open a draft PR on completion even for // manual (non-automated-origin) cloud runs. createPr=false still wins. @@ -53,8 +55,16 @@ export interface AgentServerConfig { baseBranch?: string; claudeCode?: ClaudeCodeConfig; allowedDomains?: string[]; + piRpcHostPath?: string; runtimeAdapter?: Adapter; model?: string; - reasoningEffort?: "low" | "medium" | "high" | "xhigh" | "max"; + reasoningEffort?: + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; resolveRtkSavings?: () => Promise; } diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index 9315c383da..55bfd03fea 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -104,6 +104,21 @@ const sharedOptions = { }; export default defineConfig([ + { + entry: { + "pi/rpc-transport": "src/pi/rpc-transport.ts", + "pi/remote-rpc-client": "src/pi/remote-rpc-client.ts", + }, + format: ["esm"], + dts: false, + clean: false, + sourcemap: true, + splitting: false, + outDir: "dist", + target: "es2022", + platform: "browser", + external: ["@earendil-works/pi-ai", "@posthog/shared", "zod"], + }, { entry: [ "src/index.ts", @@ -117,6 +132,7 @@ export default defineConfig([ "src/pi/rpc-client.ts", "src/pi/runtime.ts", "src/pi/types.ts", + "src/pi/model-catalog.ts", "src/pi/conversation/translatePiConversation.ts", "src/resume.ts", "src/types.ts", @@ -134,6 +150,7 @@ export default defineConfig([ "src/execution-mode.ts", "src/server/schemas.ts", "src/server/agent-server.ts", + "src/server/bin.ts", ], format: ["esm"], dts: false, @@ -166,13 +183,6 @@ export default defineConfig([ } }, }, - { - entry: { "server/bin": "src/server/bin.ts" }, - format: ["cjs"], - dts: false, - clean: false, - ...sharedOptions, - }, { entry: { "pi/rpc-host": "src/pi/rpc-host.ts" }, format: ["esm"], diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index cc25844ea1..a8b384b892 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -640,6 +640,42 @@ describe("PostHogAPIClient", () => { ); }); + it("loads native task session storage access", async () => { + const storage = { + id: "session-1", + download_url: "https://storage.example/session.jsonl", + content_sha256: "hash", + }; + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => storage, + }); + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { + baseUrl: "http://localhost:8000", + fetcher: { fetch }, + }; + + await expect( + client.getTaskSessionStorageAccess("task-123", "run-123"), + ).resolves.toEqual(storage); + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: "get", + path: "/api/projects/123/tasks/task-123/runs/run-123/task_session/", + }), + ); + }); + it("maps the permission mode per adapter when creating task runs", async () => { const fetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index c596c81a60..64b506c107 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -173,6 +173,12 @@ export interface TaskRunSessionLogsResult { complete: boolean; } +export interface TaskSessionStorageAccess { + id: string; + download_url: string | null; + content_sha256: string | null; +} + /** Thrown when the backend rejects a cloud run with a 429 usage-limit error. */ export class CloudUsageLimitError extends Error { limitType: UsageLimitType; @@ -641,6 +647,7 @@ export interface FinalizedTaskArtifactUpload { export interface CloudRunOptions { adapter?: Adapter; + piRuntime?: boolean; model?: string; reasoningLevel?: string; sandboxEnvironmentId?: string; @@ -739,35 +746,35 @@ function buildCloudRunRequestBody( } if (options?.adapter) { body.runtime_adapter = options.adapter; - if (options.model) { - body.model = options.model; - } - if (options.reasoningLevel) { - if (!options.model) { - throw new Error( - "A cloud reasoning level requires a model to be selected.", - ); - } - if ( - !isSupportedReasoningEffort( - options.adapter, - options.model, - options.reasoningLevel, - ) - ) { - throw new Error( - `Reasoning effort '${options.reasoningLevel}' is not supported for ${options.adapter} model '${options.model}'.`, - ); - } - body.reasoning_effort = options.reasoningLevel; + } + if (options?.model && (options.adapter || options.piRuntime)) { + body.model = options.model; + } + if (options?.reasoningLevel && (options.adapter || options.piRuntime)) { + if (!options.model) { + throw new Error( + "A cloud reasoning level requires a model to be selected.", + ); } - // The API rejects initial_permission_mode without runtime_adapter and validates it per adapter. - if (options.initialPermissionMode) { - body.initial_permission_mode = resolveCloudInitialPermissionMode( + if ( + options.adapter && + !isSupportedReasoningEffort( options.adapter, - options.initialPermissionMode, + options.model, + options.reasoningLevel, + ) + ) { + throw new Error( + `Reasoning effort '${options.reasoningLevel}' is not supported for ${options.adapter} model '${options.model}'.`, ); } + body.reasoning_effort = options.reasoningLevel; + } + if (options?.adapter && options.initialPermissionMode) { + body.initial_permission_mode = resolveCloudInitialPermissionMode( + options.adapter, + options.initialPermissionMode, + ); } if (options?.resumeFromRunId) { body.resume_from_run_id = options.resumeFromRunId; @@ -3255,6 +3262,29 @@ export class PostHogAPIClient { return data.url; } + async getTaskSessionStorageAccess( + taskId: string, + runId: string, + ): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/task_session/`, + ); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/task_session/`, + }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`Failed to load task session: ${response.statusText}`); + } + + return (await response.json()) as TaskSessionStorageAccess; + } + async resumeRunInCloud(taskId: string, runId: string): Promise { const teamId = await this.getTeamId(); const url = new URL( diff --git a/packages/core/src/cloud-task/cloud-task-engine.ts b/packages/core/src/cloud-task/cloud-task-engine.ts index 006b0ab6d2..61072978f6 100644 --- a/packages/core/src/cloud-task/cloud-task-engine.ts +++ b/packages/core/src/cloud-task/cloud-task-engine.ts @@ -715,6 +715,10 @@ export class CloudTaskEngine extends TypedEventEmitter { } } + getCloudContext(): Promise<{ apiHost: string; teamId: number } | null> { + return this.auth.getCloudContext(); + } + watch(input: WatchInput): void { const key = watcherKey(input.taskId, input.runId); @@ -850,7 +854,7 @@ export class CloudTaskEngine extends TypedEventEmitter { jsonrpc: "2.0", method: input.method, params: input.params ?? {}, - id: `posthog-code-${Date.now()}`, + id: input.id ?? globalThis.crypto.randomUUID(), }; try { @@ -860,6 +864,7 @@ export class CloudTaskEngine extends TypedEventEmitter { "Content-Type": "application/json", }, body: JSON.stringify(body), + signal: AbortSignal.timeout(5 * 60_000), }); if (!response.ok) { @@ -886,7 +891,13 @@ export class CloudTaskEngine extends TypedEventEmitter { status: response.status, error: errorMessage, }); - return { success: false, error: errorMessage }; + const retryable = [400, 502, 503, 504].includes(response.status); + return { + success: false, + error: errorMessage, + status: response.status, + retryable, + }; } const data = (await response.json()) as { @@ -921,7 +932,7 @@ export class CloudTaskEngine extends TypedEventEmitter { method: input.method, error: errorMessage, }); - return { success: false, error: errorMessage }; + return { success: false, error: errorMessage, retryable: true }; } } diff --git a/packages/core/src/cloud-task/cloud-task-types.ts b/packages/core/src/cloud-task/cloud-task-types.ts deleted file mode 100644 index a2cd0c377e..0000000000 --- a/packages/core/src/cloud-task/cloud-task-types.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { StoredLogEntry, TaskRunStatus } from "@posthog/shared"; - -interface CloudTaskUpdateBase { - taskId: string; - runId: string; -} - -export interface CloudTaskLogsUpdate extends CloudTaskUpdateBase { - kind: "logs"; - newEntries: StoredLogEntry[]; - totalEntryCount: number; -} - -export interface CloudTaskStatusUpdate extends CloudTaskUpdateBase { - kind: "status"; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - errorMessage?: string | null; - branch?: string | null; - sandboxAlive?: boolean | null; -} - -export interface CloudTaskSnapshotUpdate extends CloudTaskUpdateBase { - kind: "snapshot"; - newEntries: StoredLogEntry[]; - totalEntryCount: number; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - errorMessage?: string | null; - branch?: string | null; - sandboxAlive?: boolean | null; -} - -export interface CloudTaskErrorUpdate extends CloudTaskUpdateBase { - kind: "error"; - errorTitle: string; - errorMessage: string; - retryable: boolean; -} - -export interface CloudPermissionOption { - kind: string; - optionId: string; - name: string; - _meta?: Record; -} - -export interface CloudTaskPermissionRequestUpdate extends CloudTaskUpdateBase { - kind: "permission_request"; - requestId: string; - toolCall: { - toolCallId: string; - title: string; - kind: string; - content?: unknown[]; - rawInput?: Record; - _meta?: Record; - }; - options: CloudPermissionOption[]; -} - -export type CloudTaskUpdatePayload = - | CloudTaskLogsUpdate - | CloudTaskStatusUpdate - | CloudTaskSnapshotUpdate - | CloudTaskErrorUpdate - | CloudTaskPermissionRequestUpdate; diff --git a/packages/core/src/cloud-task/cloudTaskClient.ts b/packages/core/src/cloud-task/cloudTaskClient.ts new file mode 100644 index 0000000000..053cab9c0f --- /dev/null +++ b/packages/core/src/cloud-task/cloudTaskClient.ts @@ -0,0 +1,24 @@ +import type { CloudTaskUpdatePayload } from "@posthog/shared/domain-types"; +import type { SendCommandInput, SendCommandOutput } from "./schemas"; + +export const CLOUD_TASK_CLIENT = Symbol.for("posthog.cloudTask.client"); + +export interface CloudTaskClient { + getContext(): Promise<{ apiHost: string; teamId: number } | null>; + watch(input: { + taskId: string; + runId: string; + apiHost: string; + teamId: number; + }): Promise; + unwatch(taskId: string, runId: string): Promise; + retry(taskId: string, runId: string): Promise; + subscribe( + taskId: string, + runId: string, + onUpdate: (update: CloudTaskUpdatePayload) => void, + onError: (error: unknown) => void, + onStarted: () => void, + ): () => void; + sendCommand(input: SendCommandInput): Promise; +} diff --git a/packages/core/src/cloud-task/schemas.ts b/packages/core/src/cloud-task/schemas.ts index b8c03eb202..908c213168 100644 --- a/packages/core/src/cloud-task/schemas.ts +++ b/packages/core/src/cloud-task/schemas.ts @@ -8,8 +8,20 @@ export { TERMINAL_STATUSES, } from "@posthog/shared"; +export const cloudContextOutput = z + .object({ apiHost: z.string(), teamId: z.number() }) + .nullable(); + // --- Events --- +export const progressNotificationParams = z.object({ + step: z.string().min(1), + status: z.enum(["in_progress", "completed", "failed"]), + label: z.string().min(1), + group: z.string().min(1), + detail: z.string().optional(), +}); + export const CloudTaskEvent = { Update: "cloud-task-update", } as const; @@ -47,6 +59,7 @@ export const onUpdateInput = z.object({ export const sendCommandInput = z.object({ taskId: z.string(), + id: z.string().optional(), runId: z.string(), apiHost: z.string(), teamId: z.number(), @@ -57,6 +70,9 @@ export const sendCommandInput = z.object({ "permission_response", "set_config_option", "mcp_response", + "pi/rpc", + "queue_get", + "queue_clear", ]), params: z.record(z.string(), z.unknown()).optional(), }); @@ -76,6 +92,8 @@ export const sendCommandOutput = z.object({ success: z.boolean(), result: z.unknown().optional(), error: z.string().optional(), + status: z.number().optional(), + retryable: z.boolean().optional(), }); export type SendCommandOutput = z.infer; diff --git a/packages/core/src/inbox/reportMembership.ts b/packages/core/src/inbox/reportMembership.ts index e31621fba3..5ad2188929 100644 --- a/packages/core/src/inbox/reportMembership.ts +++ b/packages/core/src/inbox/reportMembership.ts @@ -141,6 +141,22 @@ export function isInboxDetailPath(pathname: string): boolean { return INBOX_DETAIL_PATH_RE.test(pathname); } +/** Which tab a list pathname belongs to; anything unrecognised reads as Pulls. */ +export function inboxTabFromPath(pathname: string): InboxTabKey { + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.reports)) return "reports"; + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.runs)) return "runs"; + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.dismissed)) return "dismissed"; + return "pulls"; +} + +/** + * Whether the reviewer-scope control means anything on this tab: Runs is + * unscoped and the Archive is a terminal list, so neither filters by reviewer. + */ +export function inboxScopeApplies(tab: InboxTabKey): boolean { + return tab !== "runs" && tab !== "dismissed"; +} + /** * PR tab membership: Responder shipped a draft PR and it is `ready` for review. * PRs that have already been merged/closed (`resolved`) or are still running diff --git a/packages/core/src/pi-runtime/cloudPiSessionClient.test.ts b/packages/core/src/pi-runtime/cloudPiSessionClient.test.ts new file mode 100644 index 0000000000..14ff92b77e --- /dev/null +++ b/packages/core/src/pi-runtime/cloudPiSessionClient.test.ts @@ -0,0 +1,563 @@ +import type { TaskService } from "@posthog/core/task-detail/taskService"; +import type { AgentConversationEvent } from "@posthog/shared"; +import type { CloudTaskUpdatePayload } from "@posthog/shared/domain-types"; +import { describe, expect, it, vi } from "vitest"; +import type { CloudTaskClient } from "../cloud-task/cloudTaskClient"; +import { CloudPiSessionClient } from "./cloudPiSessionClient"; +import { + PiSessionController, + type PiSessionProvider, +} from "./piSessionController"; + +function createCloudTaskClient(autoStart = true) { + let onUpdate: (update: CloudTaskUpdatePayload) => void = () => {}; + let onError: (error: unknown) => void = () => {}; + let onStarted: () => void = () => {}; + const unsubscribe = vi.fn(); + const client: CloudTaskClient = { + getContext: vi.fn(async () => null), + watch: vi.fn(async () => {}), + unwatch: vi.fn(async () => {}), + retry: vi.fn(async () => {}), + subscribe: vi.fn((_taskId, _runId, handler, errorHandler, started) => { + onUpdate = handler; + onError = errorHandler; + onStarted = started; + if (autoStart) { + onStarted(); + } + return unsubscribe; + }), + sendCommand: vi.fn(async () => ({ success: false })), + }; + + return { + client, + startSubscription: () => onStarted(), + sendUpdate: (update: CloudTaskUpdatePayload) => onUpdate(update), + sendError: (error: unknown) => onError(error), + unsubscribe, + }; +} + +function context(status: "queued" | "in_progress" | "completed") { + return { + taskId: "task-1", + runId: "run-1", + runStatus: status, + apiHost: "https://us.posthog.com", + teamId: 1, + }; +} + +const snapshotEvent: AgentConversationEvent = { + type: "assistant_message_chunk", + timestamp: 1, + content: { type: "text", text: "durable response" }, +}; + +describe("CloudPiSessionClient", () => { + it("waits for the native Pi readiness event before startup RPC commands", async () => { + const cloud = createCloudTaskClient(); + vi.mocked(cloud.client.sendCommand).mockResolvedValue({ + success: true, + result: { + type: "response", + command: "get_state", + success: true, + data: { isStreaming: true }, + }, + }); + const session = new CloudPiSessionClient( + cloud.client, + context("in_progress"), + ); + session.onConversationEvent(vi.fn(), vi.fn()); + + const state = session.client.getState(); + expect(cloud.client.sendCommand).not.toHaveBeenCalled(); + + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "logs", + newEntries: [{ type: "pi_run_started" }], + totalEntryCount: 1, + }); + + await expect(state).resolves.toMatchObject({ isStreaming: true }); + expect(cloud.client.sendCommand).toHaveBeenCalledOnce(); + }); + + it("does not fail while a sandbox takes longer than 30 seconds to boot", async () => { + vi.useFakeTimers(); + try { + const cloud = createCloudTaskClient(); + vi.mocked(cloud.client.sendCommand).mockResolvedValue({ + success: true, + result: { + type: "response", + command: "get_state", + success: true, + data: { isStreaming: false }, + }, + }); + const session = new CloudPiSessionClient( + cloud.client, + context("in_progress"), + ); + session.onConversationEvent(vi.fn(), vi.fn()); + + const state = session.client.getState(); + await vi.advanceTimersByTimeAsync(60_000); + expect(cloud.client.sendCommand).not.toHaveBeenCalled(); + + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "logs", + newEntries: [{ type: "pi_run_started" }], + totalEntryCount: 1, + }); + + await expect(state).resolves.toMatchObject({ isStreaming: false }); + } finally { + vi.useRealTimers(); + } + }); + + it("waits for a fresh start when a queued resume snapshot contains an old start", async () => { + const cloud = createCloudTaskClient(); + vi.mocked(cloud.client.sendCommand).mockResolvedValue({ + success: true, + result: { + type: "response", + command: "get_state", + success: true, + data: { isStreaming: false }, + }, + }); + const session = new CloudPiSessionClient(cloud.client, context("queued")); + session.onConversationEvent(vi.fn(), vi.fn()); + + const state = session.client.getState(); + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + status: "queued", + newEntries: [{ type: "pi_run_started" }], + totalEntryCount: 1, + }); + + expect(cloud.client.sendCommand).not.toHaveBeenCalled(); + + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "logs", + newEntries: [{ type: "pi_run_started" }], + totalEntryCount: 2, + }); + + await expect(state).resolves.toMatchObject({ isStreaming: false }); + expect(cloud.client.sendCommand).toHaveBeenCalledOnce(); + }); + + it("waits for subscription readiness before watching and only unsubscribes on cleanup", async () => { + const cloud = createCloudTaskClient(false); + vi.mocked(cloud.client.watch).mockImplementation(async () => { + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + status: "completed", + newEntries: [{ type: "pi_event", event: snapshotEvent }], + totalEntryCount: 1, + }); + }); + const session = new CloudPiSessionClient( + cloud.client, + context("completed"), + ); + + const cleanup = session.onConversationEvent(vi.fn(), vi.fn()); + const conversation = session.getConversation(); + expect(cloud.client.watch).not.toHaveBeenCalled(); + + cloud.startSubscription(); + + await expect(conversation).resolves.toEqual([ + expect.objectContaining(snapshotEvent), + ]); + expect(cloud.client.watch).toHaveBeenCalledTimes(1); + + cleanup(); + expect(cloud.unsubscribe).toHaveBeenCalledTimes(1); + expect(cloud.client.unwatch).not.toHaveBeenCalled(); + }); + + it("rejects terminal history when the update subscription fails", async () => { + const cloud = createCloudTaskClient(); + const session = new CloudPiSessionClient( + cloud.client, + context("completed"), + ); + const onError = vi.fn(); + session.onConversationEvent(vi.fn(), onError); + + const conversation = session.getConversation(); + const error = new Error("subscription failed"); + cloud.sendError(error); + + await expect(conversation).rejects.toThrow("subscription failed"); + expect(onError).toHaveBeenCalledWith(error); + }); + + it("streams provisioning progress before the Pi runtime is ready", () => { + const cloud = createCloudTaskClient(); + const session = new CloudPiSessionClient( + cloud.client, + context("in_progress"), + ); + const events: AgentConversationEvent[] = []; + session.onConversationEvent((event) => events.push(event), vi.fn()); + + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "logs", + newEntries: [ + { + type: "notification", + timestamp: "2026-07-23T12:00:00.000Z", + notification: { + method: "_posthog/progress", + params: { + step: "sandbox", + status: "in_progress", + label: "Setting up sandbox", + group: "setup:run-1", + }, + }, + }, + ], + totalEntryCount: 1, + }); + + expect(events).toEqual([ + expect.objectContaining({ + type: "progress", + timestamp: Date.parse("2026-07-23T12:00:00.000Z"), + step: "sandbox", + status: "in_progress", + label: "Setting up sandbox", + group: "setup:run-1", + }), + ]); + expect(cloud.client.sendCommand).not.toHaveBeenCalled(); + }); + + it("normalizes legacy direct bash events at the cloud boundary", async () => { + const cloud = createCloudTaskClient(); + const session = new CloudPiSessionClient( + cloud.client, + context("completed"), + ); + session.onConversationEvent(vi.fn(), vi.fn()); + + const conversation = session.getConversation(); + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + status: "completed", + newEntries: [ + { + type: "pi_event", + event: { + type: "tool_call_updated", + timestamp: 1, + toolCall: { id: "pi-bash-1", status: "completed" }, + }, + }, + ], + totalEntryCount: 1, + }); + + await expect(conversation).resolves.toEqual([ + expect.objectContaining({ + type: "tool_call_updated", + toolCall: expect.objectContaining({ origin: "user_shell" }), + }), + ]); + }); + + it("serves persisted native config while the cloud runtime is cold", async () => { + const cloud = createCloudTaskClient(); + const session = new CloudPiSessionClient(cloud.client, { + ...context("completed"), + persistedConfig: { + model: { provider: "posthog", id: "claude-opus-4-8" }, + thinkingLevel: "high", + }, + }); + + await expect(session.client.getState()).resolves.toMatchObject({ + thinkingLevel: "high", + }); + expect(session.persistedConfig).toEqual({ + model: { provider: "posthog", id: "claude-opus-4-8" }, + thinkingLevel: "high", + }); + }); + + it("loads terminal history from the cloud snapshot without sandbox RPC", async () => { + const cloud = createCloudTaskClient(); + const session = new CloudPiSessionClient( + cloud.client, + context("completed"), + ); + const events: AgentConversationEvent[] = []; + session.onConversationEvent((event) => events.push(event), vi.fn()); + + const conversation = session.getConversation(); + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + status: "completed", + newEntries: [{ type: "pi_event", event: snapshotEvent }], + totalEntryCount: 1, + }); + + await expect(conversation).resolves.toEqual([ + expect.objectContaining(snapshotEvent), + ]); + expect(session.resumeRequired).toBe(true); + await expect(session.health()).resolves.toEqual({ state: "cold" }); + await expect(session.client.getState()).resolves.toMatchObject({ + isStreaming: false, + }); + await expect(session.client.getAvailableModels()).resolves.toEqual([]); + await expect(session.client.getCommands()).resolves.toEqual([]); + expect(events).toEqual([ + expect.objectContaining(snapshotEvent), + expect.objectContaining({ type: "turn_completed" }), + ]); + expect(cloud.client.sendCommand).not.toHaveBeenCalled(); + }); + + it("does not install streaming state after a terminal snapshot arrives during controller load", async () => { + const cloud = createCloudTaskClient(); + let resolveState: (result: { + success: true; + result: Record; + }) => void = () => {}; + const state = new Promise<{ + success: true; + result: Record; + }>((resolve) => { + resolveState = resolve; + }); + vi.mocked(cloud.client.sendCommand).mockImplementation(async (input) => { + if (input.method === "queue_get") { + return { + success: true, + result: { steering: [], followUp: [] }, + }; + } + const command = input.params?.command as { type: string }; + if (command.type === "get_state") { + return state; + } + return { success: false }; + }); + const session = new CloudPiSessionClient( + cloud.client, + context("in_progress"), + ); + const provider: PiSessionProvider = { + get: vi.fn(async () => session), + }; + const controller = new PiSessionController(provider, {} as TaskService); + + const connection = controller.connect("task-1"); + await vi.waitFor(() => { + expect(cloud.client.subscribe).toHaveBeenCalledTimes(1); + }); + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "logs", + newEntries: [{ type: "pi_run_started" }], + totalEntryCount: 1, + }); + await vi.waitFor(() => { + expect(cloud.client.sendCommand).toHaveBeenCalledTimes(3); + }); + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + status: "completed", + newEntries: [{ type: "pi_event", event: snapshotEvent }], + totalEntryCount: 1, + }); + resolveState({ + success: true, + result: { + type: "response", + command: "get_state", + success: true, + data: { + thinkingLevel: "off", + isStreaming: true, + isCompacting: false, + steeringMode: "all", + followUpMode: "all", + sessionId: "run-1", + autoCompactionEnabled: true, + messageCount: 1, + pendingMessageCount: 0, + }, + }, + }); + + await connection; + + const controllerSession = controller.store.getState().sessions["task-1"]; + expect(controllerSession.events).toContainEqual( + expect.objectContaining(snapshotEvent), + ); + expect(controllerSession.status).toMatchObject({ isStreaming: false }); + }); + + it("switches to terminal state when the run finishes during an RPC", async () => { + const cloud = createCloudTaskClient(); + const session = new CloudPiSessionClient( + cloud.client, + context("in_progress"), + ); + session.onConversationEvent(vi.fn(), vi.fn()); + vi.mocked(cloud.client.sendCommand).mockImplementation(async () => { + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + status: "completed", + newEntries: [{ type: "pi_event", event: snapshotEvent }], + totalEntryCount: 1, + }); + return { success: false, retryable: true }; + }); + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "logs", + newEntries: [{ type: "pi_run_started" }], + totalEntryCount: 1, + }); + + await expect(session.client.getState()).resolves.toMatchObject({ + isStreaming: false, + }); + await expect(session.getConversation()).resolves.toEqual([ + expect.objectContaining(snapshotEvent), + ]); + expect(cloud.client.sendCommand).toHaveBeenCalledTimes(1); + }); + + it("keeps sessions compatible with queue-unaware cloud runtimes", async () => { + const cloud = createCloudTaskClient(); + vi.mocked(cloud.client.sendCommand).mockResolvedValue({ + success: false, + error: "Unknown method: queue_get", + }); + const session = new CloudPiSessionClient( + cloud.client, + context("in_progress"), + ); + session.onConversationEvent(vi.fn(), vi.fn()); + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "logs", + newEntries: [{ type: "pi_run_started" }], + totalEntryCount: 1, + }); + + await expect(session.getQueue()).resolves.toEqual({ + steering: [], + followUp: [], + }); + }); + + it("preserves structured backend failure details", () => { + const cloud = createCloudTaskClient(); + const session = new CloudPiSessionClient( + cloud.client, + context("in_progress"), + ); + const onError = vi.fn(); + session.onConversationEvent(vi.fn(), onError); + + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "status", + status: "failed", + errorMessage: "Sandbox image does not support Pi", + }); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Cloud run failed", + message: "Sandbox image does not support Pi", + retryable: true, + }), + ); + }); + + it("processes reconnect snapshots and clears streaming on terminal status", async () => { + const cloud = createCloudTaskClient(); + const session = new CloudPiSessionClient( + cloud.client, + context("in_progress"), + ); + const events: AgentConversationEvent[] = []; + session.onConversationEvent((event) => events.push(event), vi.fn()); + + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + status: "in_progress", + newEntries: [{ type: "pi_event", event: snapshotEvent }], + totalEntryCount: 1, + }); + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + status: "in_progress", + newEntries: [{ type: "pi_event", event: snapshotEvent }], + totalEntryCount: 1, + }); + cloud.sendUpdate({ + taskId: "task-1", + runId: "run-1", + kind: "status", + status: "failed", + }); + + expect(events).toEqual([ + expect.objectContaining(snapshotEvent), + expect.objectContaining({ type: "turn_completed" }), + ]); + await expect(session.client.abort()).rejects.toThrow( + "Cloud task run run-1 is failed", + ); + expect(cloud.client.sendCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/pi-runtime/cloudPiSessionClient.ts b/packages/core/src/pi-runtime/cloudPiSessionClient.ts new file mode 100644 index 0000000000..238d508101 --- /dev/null +++ b/packages/core/src/pi-runtime/cloudPiSessionClient.ts @@ -0,0 +1,526 @@ +import { + type PiRemoteRpcClient, + RemotePiRpcClient, +} from "@posthog/agent/pi/remote-rpc-client"; +import type { RpcCommand } from "@posthog/agent/pi/rpc-transport"; +import type { + PiPersistedSessionConfig, + PiQueueSnapshot, +} from "@posthog/agent/pi/types"; +import type { + AgentConversationEvent, + PiRuntimeHealth, + StoredLogEntry, + TaskRunStatus, +} from "@posthog/shared"; +import type { CloudTaskUpdatePayload } from "@posthog/shared/domain-types"; +import type { CloudTaskClient } from "../cloud-task/cloudTaskClient"; +import { + isTerminalStatus, + progressNotificationParams, +} from "../cloud-task/schemas"; +import type { PiSession } from "./piSessionController"; + +function createTerminalPiRpcClient( + runId: string, + getRunStatus: () => TaskRunStatus, + persistedConfig?: PiPersistedSessionConfig | null, +): PiRemoteRpcClient { + const rejectCommand = async (): Promise => { + throw new Error(`Cloud task run ${runId} is ${getRunStatus()}`); + }; + + return { + prompt: rejectCommand, + steer: rejectCommand, + followUp: rejectCommand, + abort: rejectCommand, + getState: async () => ({ + isStreaming: false, + isCompacting: false, + thinkingLevel: persistedConfig?.thinkingLevel ?? "off", + steeringMode: "all", + followUpMode: "all", + sessionId: runId, + autoCompactionEnabled: true, + messageCount: 0, + pendingMessageCount: 0, + }), + getSessionStats: rejectCommand, + setModel: rejectCommand, + getAvailableModels: async () => [], + getAvailableThinkingLevels: async () => [], + setThinkingLevel: rejectCommand, + compact: rejectCommand, + bash: rejectCommand, + abortBash: rejectCommand, + getEntries: async () => ({ entries: [], leafId: null }), + getCommands: async () => [], + }; +} + +export interface CloudPiSessionContext { + taskId: string; + runId: string; + runStatus: TaskRunStatus; + apiHost: string; + teamId: number; + persistedConfig?: PiPersistedSessionConfig | null; +} + +export class CloudPiSessionClient implements PiSession { + private readonly liveClient: PiRemoteRpcClient; + private readonly terminalClient: PiRemoteRpcClient; + private runStatus: TaskRunStatus; + private snapshotEvents: AgentConversationEvent[] = []; + private snapshotReady = false; + private resolveSnapshot: () => void = () => {}; + private rejectSnapshot: (error: unknown) => void = () => {}; + private readonly snapshotReceived = new Promise((resolve, reject) => { + this.resolveSnapshot = resolve; + this.rejectSnapshot = reject; + }); + private runtimeReady = false; + private resolveRuntimeReady: () => void = () => {}; + private rejectRuntimeReady: (error: unknown) => void = () => {}; + private readonly runtimeReadyReceived = new Promise( + (resolve, reject) => { + this.resolveRuntimeReady = resolve; + this.rejectRuntimeReady = reject; + }, + ); + private terminalEventSent = false; + private resolveTerminalStatus: () => void = () => {}; + private readonly terminalStatusReceived = new Promise((resolve) => { + this.resolveTerminalStatus = resolve; + }); + + constructor( + private readonly cloudTaskClient: CloudTaskClient, + private readonly context: CloudPiSessionContext, + ) { + this.runStatus = context.runStatus; + if (isTerminalStatus(this.runStatus)) { + this.resolveTerminalStatus(); + } + void this.snapshotReceived.catch(() => {}); + void this.runtimeReadyReceived.catch(() => {}); + this.liveClient = new RemotePiRpcClient({ + request: (command) => this.request(command), + }); + this.terminalClient = createTerminalPiRpcClient( + context.runId, + () => this.runStatus, + context.persistedConfig, + ); + } + + get client(): PiRemoteRpcClient { + return isTerminalStatus(this.runStatus) + ? this.terminalClient + : this.liveClient; + } + + get resumeRequired(): boolean { + return isTerminalStatus(this.runStatus); + } + + get taskRunId(): string { + return this.context.runId; + } + + get persistedConfig(): PiPersistedSessionConfig | null | undefined { + return this.context.persistedConfig; + } + + get cloudStatus(): TaskRunStatus { + return this.runStatus; + } + + async retry(): Promise { + await this.cloudTaskClient.retry(this.context.taskId, this.context.runId); + } + + async getQueue(): Promise { + try { + return await this.requestQueue("queue_get"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes("Unknown method: queue_get") || + message.includes("queue_get is not supported") + ) { + return { steering: [], followUp: [] }; + } + throw error; + } + } + + clearQueue(): Promise { + return this.requestQueue("queue_clear"); + } + + async sendUserMessage( + type: "prompt" | "steer" | "follow_up", + message: string, + artifactIds: string[], + id: string = globalThis.crypto.randomUUID(), + ): Promise { + await this.waitForRuntimeReady(); + const result = await this.cloudTaskClient.sendCommand({ + taskId: this.context.taskId, + runId: this.context.runId, + apiHost: this.context.apiHost, + teamId: this.context.teamId, + id, + method: "user_message", + params: { + content: message, + artifact_ids: artifactIds, + steer: type === "steer", + }, + }); + if (!result.success) { + throw new Error(result.error ?? `Pi RPC command failed: ${type}`); + } + } + + health(): Promise { + if (this.runStatus === "in_progress") { + return Promise.resolve({ state: "streaming" }); + } + if (isTerminalStatus(this.runStatus)) { + return Promise.resolve({ state: "cold" }); + } + return Promise.resolve({ state: "starting" }); + } + + async getConversation(): Promise { + await this.snapshotReceived; + return this.snapshotEvents; + } + + onConversationEvent( + onEvent: (event: AgentConversationEvent) => void, + onError: (error: unknown) => void, + onCloudStatus?: (status: TaskRunStatus) => void, + ): () => void { + let active = true; + const unsubscribe = this.cloudTaskClient.subscribe( + this.context.taskId, + this.context.runId, + (update) => this.handleUpdate(update, onEvent, onError, onCloudStatus), + (error) => { + this.rejectRuntimeReady(error); + if (!this.snapshotReady || isTerminalStatus(this.runStatus)) { + this.rejectSnapshot(error); + } + onError(error); + }, + () => { + if (!active) { + return; + } + + void this.cloudTaskClient + .watch({ + taskId: this.context.taskId, + runId: this.context.runId, + apiHost: this.context.apiHost, + teamId: this.context.teamId, + }) + .catch((error) => { + this.rejectRuntimeReady(error); + if (!this.snapshotReady || isTerminalStatus(this.runStatus)) { + this.rejectSnapshot(error); + } + onError(error); + }); + }, + ); + + return () => { + active = false; + unsubscribe(); + }; + } + + private handleUpdate( + update: CloudTaskUpdatePayload, + onEvent: (event: AgentConversationEvent) => void, + onError: (error: unknown) => void, + onCloudStatus?: (status: TaskRunStatus) => void, + ): void { + const snapshotCanProveReadiness = + update.kind === "snapshot" && update.status === "in_progress"; + const hasCurrentReadinessEvent = + (update.kind === "logs" || snapshotCanProveReadiness) && + update.newEntries.some((entry) => entry.type === "pi_run_started"); + if (hasCurrentReadinessEvent) { + this.markRuntimeReady(); + } + + if (update.kind === "error") { + const error = Object.assign(new Error(update.errorMessage), { + title: update.errorTitle, + retryable: update.retryable, + }); + this.rejectRuntimeReady(error); + if (!this.snapshotReady || isTerminalStatus(this.runStatus)) { + this.rejectSnapshot(error); + } + onError(error); + return; + } + + if (update.kind === "snapshot") { + const events = this.getConversationEvents(update.newEntries, 0); + const previousSourceIds = new Set( + this.snapshotEvents.flatMap((event) => + event.sourceId ? [event.sourceId] : [], + ), + ); + + this.snapshotEvents = events; + this.markSnapshotReady(); + for (const event of events) { + if (!event.sourceId || !previousSourceIds.has(event.sourceId)) { + onEvent(event); + } + } + } else if (update.kind === "logs") { + const firstEntryIndex = update.totalEntryCount - update.newEntries.length; + const events = this.getConversationEvents( + update.newEntries, + firstEntryIndex, + ); + const existingSourceIds = new Set( + this.snapshotEvents.flatMap((event) => + event.sourceId ? [event.sourceId] : [], + ), + ); + const newEvents = events.filter( + (event) => !event.sourceId || !existingSourceIds.has(event.sourceId), + ); + this.snapshotEvents = [...this.snapshotEvents, ...newEvents]; + this.markSnapshotReady(); + for (const event of newEvents) { + onEvent(event); + } + } + + if ( + (update.kind === "snapshot" || update.kind === "status") && + update.status + ) { + this.runStatus = update.status; + onCloudStatus?.(update.status); + } + + if (isTerminalStatus(this.runStatus)) { + this.resolveTerminalStatus(); + if (!this.terminalEventSent) { + this.terminalEventSent = true; + onEvent({ type: "turn_completed", timestamp: Date.now() }); + } + } + + if ( + this.runStatus === "failed" && + (update.kind === "snapshot" || update.kind === "status") && + update.errorMessage + ) { + onError( + Object.assign(new Error(update.errorMessage), { + title: "Cloud run failed", + retryable: true, + }), + ); + } + } + + private getConversationEvents( + entries: StoredLogEntry[], + firstEntryIndex: number, + ): AgentConversationEvent[] { + const events: AgentConversationEvent[] = []; + for (const [index, entry] of entries.entries()) { + const sourceId = + entry.id ?? `${this.context.runId}:log:${firstEntryIndex + index}`; + if (entry.type === "pi_event" && entry.event) { + events.push({ + ...this.normalizeLegacyEvent(entry.event), + sourceId, + }); + continue; + } + + const progress = this.getProgressEvent(entry); + if (progress) { + events.push({ ...progress, sourceId }); + } + } + return events; + } + + private normalizeLegacyEvent( + event: AgentConversationEvent, + ): AgentConversationEvent { + if ( + event.type === "tool_call_started" && + event.toolCall.origin === undefined && + event.toolCall.id.startsWith("pi-bash-") + ) { + return { + ...event, + toolCall: { ...event.toolCall, origin: "user_shell" }, + }; + } + if ( + event.type === "tool_call_updated" && + event.toolCall.origin === undefined && + event.toolCall.id.startsWith("pi-bash-") + ) { + return { + ...event, + toolCall: { ...event.toolCall, origin: "user_shell" }, + }; + } + return event; + } + + private getProgressEvent( + entry: StoredLogEntry, + ): AgentConversationEvent | null { + if ( + entry.notification?.method !== "_posthog/progress" && + entry.notification?.method !== "__posthog/progress" + ) { + return null; + } + + const params = progressNotificationParams.safeParse( + entry.notification.params, + ); + const timestamp = Date.parse(entry.timestamp ?? ""); + if (!params.success || Number.isNaN(timestamp)) { + return null; + } + + return { + type: "progress", + timestamp, + ...params.data, + }; + } + + private async requestQueue( + method: "queue_get" | "queue_clear", + ): Promise { + await this.waitForRuntimeReady(); + if (isTerminalStatus(this.runStatus)) { + return { steering: [], followUp: [] }; + } + const result = await this.cloudTaskClient.sendCommand({ + taskId: this.context.taskId, + runId: this.context.runId, + apiHost: this.context.apiHost, + teamId: this.context.teamId, + id: globalThis.crypto.randomUUID(), + method, + params: {}, + }); + if (!result.success) { + throw new Error(result.error ?? `Pi queue command failed: ${method}`); + } + return result.result as PiQueueSnapshot; + } + + private async request(command: RpcCommand): Promise { + await this.waitForRuntimeReady(); + if (isTerminalStatus(this.runStatus)) { + throw new Error( + `Cloud task run ${this.context.runId} is ${this.runStatus}`, + ); + } + + if (!command.id) { + throw new Error(`Pi RPC command is missing an id: ${command.type}`); + } + + const isUserMessage = + command.type === "prompt" || + command.type === "steer" || + command.type === "follow_up"; + if (isUserMessage) { + await this.sendUserMessage(command.type, command.message, [], command.id); + return { + id: command.id, + type: "response", + command: command.type, + success: true, + }; + } + + const result = await this.cloudTaskClient.sendCommand({ + taskId: this.context.taskId, + runId: this.context.runId, + apiHost: this.context.apiHost, + teamId: this.context.teamId, + id: command.id, + method: "pi/rpc", + params: { command }, + }); + if (isTerminalStatus(this.runStatus) && command.type === "get_state") { + return { + id: command.id, + type: "response", + command: "get_state", + success: true, + data: { + isStreaming: false, + isCompacting: false, + thinkingLevel: this.context.persistedConfig?.thinkingLevel ?? "off", + steeringMode: "all", + followUpMode: "all", + sessionId: this.context.runId, + autoCompactionEnabled: true, + messageCount: this.snapshotEvents.length, + pendingMessageCount: 0, + }, + }; + } + if (!result.success) { + throw new Error(result.error ?? `Pi RPC command failed: ${command.type}`); + } + + return result.result; + } + + private markSnapshotReady(): void { + if (this.snapshotReady) { + return; + } + this.snapshotReady = true; + this.resolveSnapshot(); + } + + private markRuntimeReady(): void { + if (this.runtimeReady) { + return; + } + this.runtimeReady = true; + this.resolveRuntimeReady(); + } + + private async waitForRuntimeReady(): Promise { + if (this.runtimeReady || isTerminalStatus(this.runStatus)) { + return; + } + + await Promise.race([ + this.runtimeReadyReceived, + this.terminalStatusReceived, + ]); + } +} diff --git a/packages/core/src/pi-runtime/pi-runtime.module.ts b/packages/core/src/pi-runtime/pi-runtime.module.ts index 4fd1d3a7f1..10c46b4906 100644 --- a/packages/core/src/pi-runtime/pi-runtime.module.ts +++ b/packages/core/src/pi-runtime/pi-runtime.module.ts @@ -1,7 +1,12 @@ import { ContainerModule } from "inversify"; import { PI_SESSION_CONTROLLER } from "./identifiers"; -import { PiSessionController } from "./piSessionController"; +import { + PI_SESSION_PROVIDER, + PiSessionController, +} from "./piSessionController"; +import { RoutingPiSessionProvider } from "./piSessionProvider"; export const piRuntimeModule = new ContainerModule(({ bind }) => { + bind(PI_SESSION_PROVIDER).to(RoutingPiSessionProvider).inSingletonScope(); bind(PI_SESSION_CONTROLLER).to(PiSessionController).inSingletonScope(); }); diff --git a/packages/core/src/pi-runtime/piRunner.ts b/packages/core/src/pi-runtime/piRunner.ts index b174fb95fd..40fb9574a8 100644 --- a/packages/core/src/pi-runtime/piRunner.ts +++ b/packages/core/src/pi-runtime/piRunner.ts @@ -1,8 +1,11 @@ +import type { PiThinkingLevel } from "@posthog/agent/pi/types"; + export interface PiRunInput { taskId: string; cwd: string; prompt: string; model?: string; + thinkingLevel?: PiThinkingLevel; } export interface PiResumeInput { diff --git a/packages/core/src/pi-runtime/piSessionController.test.ts b/packages/core/src/pi-runtime/piSessionController.test.ts index 1c771e0d46..897e1799ee 100644 --- a/packages/core/src/pi-runtime/piSessionController.test.ts +++ b/packages/core/src/pi-runtime/piSessionController.test.ts @@ -1,25 +1,31 @@ +import type { PiRemoteRpcClient } from "@posthog/agent/pi/remote-rpc-client"; +import type { AuthService } from "@posthog/core/auth/auth"; import type { TaskService } from "@posthog/core/task-detail/taskService"; import type { AgentConversationEvent } from "@posthog/shared"; import { describe, expect, it, vi } from "vitest"; import { - type PiSessionClient, + PiOperationError, + type PiSession, PiSessionController, + type PiSessionProvider, } from "./piSessionController"; function createController( - client = createClient(), + session = createSession(), taskService = { openTask: vi.fn(async () => ({ success: true })), } as unknown as TaskService, + authService?: AuthService, ): PiSessionController { - return new PiSessionController(client, taskService); + const provider: PiSessionProvider = { + get: vi.fn(async () => session), + }; + return new PiSessionController(provider, taskService, authService); } -function createClient(): PiSessionClient { - return { - health: vi.fn(async () => ({ state: "idle" as const })), - conversation: vi.fn(async () => []), - status: vi.fn(async () => ({ +function createSession(): PiSession { + const client = { + getState: vi.fn(async () => ({ thinkingLevel: "off" as const, isStreaming: false, isCompacting: false, @@ -30,20 +36,45 @@ function createClient(): PiSessionClient { messageCount: 0, pendingMessageCount: 0, })), - availableModels: vi.fn(async () => []), - commands: vi.fn(async () => []), - subscribe: vi.fn(() => () => {}), + getSessionStats: vi.fn(async () => ({ + sessionFile: undefined, + sessionId: "session-1", + userMessages: 0, + assistantMessages: 0, + toolCalls: 0, + toolResults: 0, + totalMessages: 0, + tokens: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + cost: 0, + contextUsage: undefined, + })), + getAvailableModels: vi.fn(async () => []), + getAvailableThinkingLevels: vi.fn(async () => ["off" as const]), + getCommands: vi.fn(async () => []), prompt: vi.fn(async () => {}), steer: vi.fn(async () => {}), followUp: vi.fn(async () => {}), compact: vi.fn(async () => undefined), - setModel: vi.fn(async (_taskId, provider, id) => ({ provider, id })), + setModel: vi.fn(async (provider, id) => ({ provider, id })), setThinkingLevel: vi.fn(async () => {}), - setSteeringMode: vi.fn(async () => {}), - setFollowUpMode: vi.fn(async () => {}), bash: vi.fn(async () => undefined), abort: vi.fn(async () => {}), abortBash: vi.fn(async () => {}), + } as unknown as PiRemoteRpcClient; + + return { + client, + health: vi.fn(async () => ({ state: "idle" as const })), + getConversation: vi.fn(async () => []), + getQueue: vi.fn(async () => ({ steering: [], followUp: [] })), + clearQueue: vi.fn(async () => ({ steering: [], followUp: [] })), + onConversationEvent: vi.fn(() => () => {}), }; } @@ -80,66 +111,903 @@ describe("PiSessionController", () => { streaming: false, mode: "steer" as const, method: "prompt" as const, - expectedArgs: ["task-1", "hello"], + expectedArgs: ["hello"], }, { text: "hello", streaming: true, mode: "steer" as const, method: "steer" as const, - expectedArgs: ["task-1", "hello"], + expectedArgs: ["hello"], }, { text: "hello", streaming: true, mode: "queue" as const, method: "followUp" as const, - expectedArgs: ["task-1", "hello"], + expectedArgs: ["hello"], }, { text: "/compact keep details", streaming: false, mode: "steer" as const, method: "compact" as const, - expectedArgs: ["task-1", "keep details"], + expectedArgs: ["keep details"], }, ])("routes submissions through $method", async (input) => { - const client = createClient(); + const client = createSession(); const controller = createController(client); await controller.submit("task-1", input.text, input.streaming, input.mode); - expect(client[input.method]).toHaveBeenCalledWith(...input.expectedArgs); + expect(client.client[input.method]).toHaveBeenCalledWith( + ...input.expectedArgs, + ); + expect(client.getConversation).not.toHaveBeenCalled(); + }); + + it("uploads cloud follow-up attachments before sending the native message", async () => { + const session = createSession(); + session.sendUserMessage = vi.fn(async () => {}); + const prepareCloudPiMessage = vi.fn(async () => ({ + content: "Read this\n\nAttached files:\n- /tmp/cloud/input.txt", + artifactIds: ["artifact-1"], + })); + const controller = createController(session, { + prepareCloudPiMessage, + } as unknown as TaskService); + + await controller.connect("task-1", "run-1"); + await controller.submit( + "task-1", + 'Read this ', + false, + "steer", + ); + + expect(prepareCloudPiMessage).toHaveBeenCalledWith( + "task-1", + "run-1", + 'Read this ', + ); + expect(session.sendUserMessage).toHaveBeenCalledWith( + "prompt", + "Read this\n\nAttached files:\n- /tmp/cloud/input.txt", + ["artifact-1"], + expect.any(String), + ); + const messageId = vi.mocked(session.sendUserMessage).mock.calls[0][3]; + expect( + controller.store.getState().sessions["task-1"].events, + ).toContainEqual( + expect.objectContaining({ type: "user_message", id: messageId }), + ); + }); + + it("waits for cloud authentication restoration before sending", async () => { + let authStatus: "restoring" | "authenticated" = "restoring"; + let onStateChange: (state: { status: "authenticated" }) => void = () => {}; + const authService = { + getState: vi.fn(() => ({ status: authStatus })), + on: vi.fn((_event, handler) => { + onStateChange = handler; + }), + off: vi.fn(), + } as unknown as AuthService; + const session = createSession(); + session.sendUserMessage = vi.fn(async () => {}); + const controller = createController( + session, + { + prepareCloudPiMessage: vi.fn(async () => ({ + content: "hello", + artifactIds: [], + })), + } as unknown as TaskService, + authService, + ); + + await controller.connect("task-1", "run-1"); + const submission = controller.submit("task-1", "hello", false, "steer"); + await vi.waitFor(() => { + expect(controller.store.getState().sessions["task-1"].authRestoring).toBe( + true, + ); + }); + expect(session.sendUserMessage).not.toHaveBeenCalled(); + await expect( + controller.submit("task-1", "second", false, "steer"), + ).rejects.toMatchObject({ + failure: { + kind: "authentication", + recoveryPrompt: "second", + }, + }); + + authStatus = "authenticated"; + onStateChange({ status: "authenticated" }); + await submission; + + expect(session.sendUserMessage).toHaveBeenCalledOnce(); + expect(controller.store.getState().sessions["task-1"].authRestoring).toBe( + false, + ); + }); + + it("cancels auth-held submissions on disconnect and preserves the prompt", async () => { + const authService = { + getState: vi.fn(() => ({ status: "restoring" })), + on: vi.fn(), + off: vi.fn(), + } as unknown as AuthService; + const session = createSession(); + session.sendUserMessage = vi.fn(async () => {}); + const controller = createController( + session, + {} as TaskService, + authService, + ); + + await controller.connect("task-1", "run-1"); + const submission = controller.submit( + "task-1", + "do not lose this", + false, + "steer", + ); + await vi.waitFor(() => { + expect(controller.store.getState().sessions["task-1"].authRestoring).toBe( + true, + ); + }); + + controller.disconnect("task-1"); + + await expect(submission).rejects.toBeInstanceOf(PiOperationError); + expect(session.sendUserMessage).not.toHaveBeenCalled(); + expect(controller.store.getState().sessions["task-1"].error).toMatchObject({ + scope: "operation", + kind: "authentication", + recoveryPrompt: "do not lose this", + }); + }); + + it("classifies usage limits without failing the session", async () => { + const session = createSession(); + session.sendUserMessage = vi.fn(async () => { + throw new Error("Rate limit exceeded: User burst rate limit exceeded"); + }); + const controller = createController(session, { + prepareCloudPiMessage: vi.fn(async () => ({ + content: "hello", + artifactIds: [], + })), + } as unknown as TaskService); + + await controller.connect("task-1", "run-1"); + await expect( + controller.submit("task-1", "hello", false, "steer"), + ).rejects.toBeInstanceOf(PiOperationError); + + expect(controller.store.getState().sessions["task-1"]).toMatchObject({ + connectionState: "connected", + error: { + scope: "operation", + kind: "usage_limit", + title: "Usage limit reached", + limitCause: "org_limit", + }, + }); + }); + + it("classifies streamed transient provider errors as retryable", async () => { + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + const controller = createController(session); + + await controller.connect("task-1"); + onEvent({ + type: "runtime_error", + timestamp: 1, + errorType: "upstream_timeout", + message: "API Error: request timed out", + }); + + expect(controller.store.getState().sessions["task-1"].error).toMatchObject({ + scope: "operation", + kind: "transient", + title: "Provider temporarily unavailable", + retryable: true, + }); + expect(controller.store.getState().sessions["task-1"].connectionState).toBe( + "connected", + ); + }); + + it("keeps fatal runtime errors in a retryable disconnected state", async () => { + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + const controller = createController(session); + + await controller.connect("task-1"); + onEvent({ + type: "runtime_error", + timestamp: 1, + errorType: "agent_error", + message: "process exited unexpectedly", + }); + + expect(controller.store.getState().sessions["task-1"]).toMatchObject({ + connectionState: "disconnected", + error: { + scope: "connection", + kind: "fatal_session", + title: "Failed to send message", + retryable: true, + }, + }); + }); + + it("uses action-specific model errors", async () => { + const session = createSession(); + vi.mocked(session.client.setModel).mockRejectedValue( + new Error("Model is unavailable"), + ); + const controller = createController(session); + + await controller.connect("task-1"); + await expect( + controller.setModel("task-1", { provider: "posthog", id: "missing" }), + ).rejects.toBeInstanceOf(PiOperationError); + + expect(controller.store.getState().sessions["task-1"].error).toMatchObject({ + scope: "operation", + kind: "unknown", + title: "Failed to change Pi model", + message: "Model is unavailable", + }); + }); + + it("surfaces compaction failure details and resets compacting state", async () => { + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + const controller = createController(session); + + await controller.connect("task-1"); + onEvent({ + type: "runtime_status", + timestamp: 1, + status: "compacting", + }); + onEvent({ + type: "runtime_status", + timestamp: 2, + status: "compacting_failed", + error: "Summary request timed out", + }); + + expect(controller.store.getState().sessions["task-1"]).toMatchObject({ + status: { isCompacting: false }, + error: { + scope: "operation", + title: "Failed to compact Pi context", + message: "Summary request timed out", + }, + }); + }); + + it("allows only one queued message and keeps it out of the transcript", async () => { + const session = createSession(); + session.sendUserMessage = vi.fn(async () => {}); + vi.mocked(session.getQueue) + .mockResolvedValueOnce({ steering: [], followUp: [] }) + .mockResolvedValue({ steering: [], followUp: ["first"] }); + const controller = createController(session, { + prepareCloudPiMessage: vi.fn(async (_taskId, _runId, content) => ({ + content, + artifactIds: [], + })), + } as unknown as TaskService); + + await controller.connect("task-1", "run-1"); + await controller.submit("task-1", "first", true, "queue"); + + expect(controller.store.getState().sessions["task-1"]).toMatchObject({ + events: [], + queue: { steering: [], followUp: ["first"] }, + }); + await expect( + controller.submit("task-1", "second", true, "queue"), + ).rejects.toThrow("Pi already has a queued message"); + expect(session.sendUserMessage).toHaveBeenCalledOnce(); + }); + + it("clears the optimistic queue when Pi accepted the message as a prompt", async () => { + const session = createSession(); + session.sendUserMessage = vi.fn(async () => {}); + const controller = createController(session, { + prepareCloudPiMessage: vi.fn(async () => ({ + content: "continue", + artifactIds: [], + })), + } as unknown as TaskService); + + await controller.connect("task-1", "run-1"); + await controller.submit("task-1", "continue", true, "queue"); + + expect(controller.store.getState().sessions["task-1"].queue).toEqual({ + steering: [], + followUp: [], + }); + }); + + it("marks a submitted turn as streaming while the command starts", async () => { + let resolveSend: () => void = () => {}; + const sending = new Promise((resolve) => { + resolveSend = resolve; + }); + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + session.sendUserMessage = vi.fn(() => sending); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + const controller = createController(session, { + prepareCloudPiMessage: vi.fn(async () => ({ + content: "hello", + artifactIds: [], + })), + } as unknown as TaskService); + + await controller.connect("task-1", "run-1"); + const submission = controller.submit("task-1", "hello", false, "steer"); + + await vi.waitFor(() => { + expect( + controller.store.getState().sessions["task-1"].status, + ).toMatchObject({ isStreaming: true }); + }); + + resolveSend(); + await submission; + expect(controller.store.getState().sessions["task-1"].status).toMatchObject( + { isStreaming: true }, + ); + + onEvent({ type: "turn_completed", timestamp: 2 }); + + expect(controller.store.getState().sessions["task-1"].status).toMatchObject( + { isStreaming: false }, + ); + }); + + it("restores a native queue after retry replaces the runtime", async () => { + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + session.retry = vi.fn(async () => {}); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + const controller = createController(session); + + await controller.connect("task-1", "run-1"); + onEvent({ + type: "queue_update", + timestamp: 1, + steering: ["fix this"], + followUp: ["then summarize"], + }); + + await controller.retry("task-1"); + + expect(session.client.prompt).toHaveBeenCalledWith("fix this"); + expect(session.client.followUp).toHaveBeenCalledWith("then summarize"); + }); + + it("does not replay an already restored prompt after a later queue failure", async () => { + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + session.retry = vi.fn(async () => {}); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + vi.mocked(session.client.followUp).mockRejectedValueOnce( + new Error("queue unavailable"), + ); + const controller = createController(session); + + await controller.connect("task-1", "run-1"); + onEvent({ + type: "queue_update", + timestamp: 1, + steering: ["fix this"], + followUp: ["then summarize"], + }); + + await expect(controller.retry("task-1")).rejects.toThrow( + "queue unavailable", + ); + await controller.retry("task-1"); + + expect(session.client.prompt).toHaveBeenCalledTimes(1); + }); + + it("does not restore a captured queue after the task disconnects", async () => { + let resolveRetry: () => void = () => {}; + const retrying = new Promise((resolve) => { + resolveRetry = resolve; + }); + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + session.retry = vi.fn(() => retrying); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + const controller = createController(session); + + await controller.connect("task-1", "run-1"); + onEvent({ + type: "queue_update", + timestamp: 1, + steering: ["already handled"], + followUp: [], + }); + const retry = controller.retry("task-1"); + await vi.waitFor(() => expect(session.retry).toHaveBeenCalledOnce()); + + controller.disconnect("task-1"); + resolveRetry(); + await retry; + + expect(session.client.prompt).not.toHaveBeenCalled(); + }); + + it("retries a cloud session without discarding its transcript", async () => { + const initialEvent: AgentConversationEvent = { + type: "assistant_message_chunk", + timestamp: 1, + content: { type: "text", text: "existing work" }, + }; + const session = createSession(); + session.retry = vi.fn(async () => {}); + vi.mocked(session.getConversation).mockResolvedValue([initialEvent]); + const controller = createController(session); + + await controller.connect("task-1", "run-1"); + controller.store.setState((state) => ({ + sessions: { + ...state.sessions, + "task-1": { + ...state.sessions["task-1"], + connectionState: "disconnected", + error: { + id: "connection-error", + scope: "connection", + kind: "unknown", + title: "Connection failed", + message: "stream dropped", + retryable: true, + limitCause: null, + }, + }, + }, + })); + + await controller.retry("task-1"); + + expect(session.retry).toHaveBeenCalledOnce(); + expect(controller.store.getState().sessions["task-1"]).toMatchObject({ + connectionState: "connected", + events: [initialEvent], + error: undefined, + }); + }); + + it("does not retry disconnected cloud sessions after their view unmounts", async () => { + const session = createSession(); + session.retry = vi.fn(async () => {}); + const controller = createController(session); + + await controller.connect("task-1", "run-1"); + controller.store.setState((state) => ({ + sessions: { + ...state.sessions, + "task-1": { + ...state.sessions["task-1"], + connectionState: "disconnected", + cloudStatus: "in_progress", + error: { + id: "connection-error", + scope: "connection", + kind: "unknown", + title: "Connection failed", + message: "stream dropped", + retryable: true, + limitCause: null, + }, + }, + }, + })); + controller.disconnect("task-1"); + + controller.retryUnhealthyCloudSessions(); + + expect(session.retry).not.toHaveBeenCalled(); + }); + + it("deduplicates concurrent retry requests", async () => { + let resolveRetry: () => void = () => {}; + const session = createSession(); + session.retry = vi.fn( + () => + new Promise((resolve) => { + resolveRetry = resolve; + }), + ); + const controller = createController(session); + + await controller.connect("task-1", "run-1"); + controller.store.setState((state) => ({ + sessions: { + ...state.sessions, + "task-1": { + ...state.sessions["task-1"], + connectionState: "disconnected", + }, + }, + })); + + const first = controller.retry("task-1"); + const second = controller.retry("task-1"); + await vi.waitFor(() => expect(session.retry).toHaveBeenCalledOnce()); + resolveRetry(); + await Promise.all([first, second]); + }); + + it("uses the live bash operation without reloading native history", async () => { + const session = createSession(); + const controller = createController(session); + + await controller.bash("task-1", "printf hello"); + + expect(session.client.bash).toHaveBeenCalledWith("printf hello"); + expect(session.getConversation).not.toHaveBeenCalled(); + }); + + it("does not mark direct bash events as assistant streaming", async () => { + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + const controller = createController(session); + + await controller.connect("task-1", "run-1"); + onEvent({ + type: "tool_call_started", + timestamp: 1, + toolCall: { + id: "pi-bash-live-1-1", + title: "printf hello", + kind: "execute", + status: "in_progress", + rawInput: { command: "printf hello" }, + origin: "user_shell", + }, + }); + onEvent({ + type: "tool_call_updated", + timestamp: 2, + toolCall: { + id: "pi-bash-live-1-1", + status: "completed", + origin: "user_shell", + }, + }); + + expect( + controller.store.getState().sessions["task-1"].status?.isStreaming, + ).toBe(false); + }); + + it("hydrates cold model controls from persisted native config", async () => { + const session = { + ...createSession(), + persistedConfig: { + model: { provider: "posthog", id: "claude-opus-4-8" }, + thinkingLevel: "high" as const, + }, + }; + vi.mocked(session.client.getState).mockResolvedValue({ + thinkingLevel: "high", + isStreaming: false, + isCompacting: false, + steeringMode: "all", + followUpMode: "all", + sessionId: "session-1", + autoCompactionEnabled: true, + messageCount: 0, + pendingMessageCount: 0, + }); + vi.mocked(session.client.getAvailableModels).mockResolvedValue([]); + vi.mocked(session.client.getAvailableThinkingLevels).mockResolvedValue([]); + const controller = createController(session); + + await controller.connect("task-1", "run-1"); + + expect(controller.store.getState().sessions["task-1"]).toMatchObject({ + status: { + model: { provider: "posthog", id: "claude-opus-4-8" }, + thinkingLevel: "high", + }, + models: [{ provider: "posthog", id: "claude-opus-4-8" }], + thinkingLevels: ["high"], + modelsLoaded: true, + thinkingLevelsLoaded: true, + }); + }); + + it("keeps persisted controls when the old sandbox is unavailable", async () => { + const session = { + ...createSession(), + taskRunId: "run-1", + persistedConfig: { + model: { provider: "posthog", id: "claude-opus-4-8" }, + thinkingLevel: "high" as const, + }, + }; + vi.mocked(session.client.getState).mockRejectedValue( + new Error("No active sandbox for this task run"), + ); + const controller = createController(session); + + await expect(controller.connect("task-1", "run-1")).rejects.toThrow( + "No active sandbox", + ); + + expect(controller.store.getState().sessions["task-1"]).toMatchObject({ + status: { + model: { provider: "posthog", id: "claude-opus-4-8" }, + thinkingLevel: "high", + }, + models: [{ provider: "posthog", id: "claude-opus-4-8" }], + thinkingLevels: ["high"], + }); + }); + + it("resumes a terminal cloud run only when a message is submitted", async () => { + const terminalSession = { + ...createSession(), + resumeRequired: true, + taskRunId: "run-1", + }; + const resumedSession = createSession(); + const provider = { + get: vi + .fn() + .mockResolvedValueOnce(terminalSession) + .mockResolvedValue(resumedSession), + } as PiSessionProvider; + const resumeCloudPiRun = vi.fn(async () => ({ id: "run-1" })); + const taskService = { resumeCloudPiRun } as unknown as TaskService; + const controller = new PiSessionController(provider, taskService); + + await controller.connect("task-1"); + + expect(resumeCloudPiRun).not.toHaveBeenCalled(); + + await controller.submit("task-1", "continue", false, "steer"); + + expect(resumeCloudPiRun).toHaveBeenCalledWith("task-1", "run-1"); + expect(resumedSession.client.prompt).toHaveBeenCalledWith("continue"); + }); + + it("resumes and retries a message when the prior sandbox is gone", async () => { + const staleSession = { + ...createSession(), + taskRunId: "run-1", + sendUserMessage: vi.fn(async () => { + throw new Error("No active sandbox for this task run"); + }), + }; + const resumedSession = { + ...createSession(), + sendUserMessage: vi.fn(async () => {}), + }; + const provider = { + get: vi + .fn() + .mockResolvedValueOnce(staleSession) + .mockResolvedValue(resumedSession), + } as PiSessionProvider; + const resumeCloudPiRun = vi.fn(async () => ({ id: "run-2" })); + const taskService = { + prepareCloudPiMessage: vi.fn(async () => ({ + content: "continue", + artifactIds: [], + })), + resumeCloudPiRun, + } as unknown as TaskService; + const controller = new PiSessionController(provider, taskService); + + await controller.connect("task-1"); + await controller.submit("task-1", "continue", false, "steer"); + + expect(resumeCloudPiRun).toHaveBeenCalledWith("task-1", "run-1"); + expect(resumedSession.sendUserMessage).toHaveBeenCalledWith( + "prompt", + "continue", + [], + expect.any(String), + ); + }); + + it("keeps a connected transcript usable when a command fails", async () => { + const initialEvent: AgentConversationEvent = { + type: "user_message", + id: "message-1", + timestamp: 1, + content: [{ type: "text", text: "hello" }], + }; + const session = createSession(); + vi.mocked(session.getConversation).mockResolvedValue([initialEvent]); + vi.mocked(session.client.prompt).mockRejectedValue( + new Error("temporary command failure"), + ); + const controller = createController(session); + + await controller.connect("task-1"); + await expect( + controller.submit("task-1", "retry me", false, "steer"), + ).rejects.toThrow("temporary command failure"); + + expect(controller.store.getState().sessions["task-1"]).toMatchObject({ + connectionState: "connected", + events: [initialEvent], + error: { + scope: "operation", + title: "Failed to send message", + message: "temporary command failure", + }, + }); + }); + + it("owns and releases the bound session lifetime", async () => { + const session = createSession(); + const provider: PiSessionProvider = { + get: vi.fn(async () => session), + }; + const controller = new PiSessionController(provider, {} as TaskService); + + await controller.ensureConnected("task-1"); + await controller.setThinkingLevel("task-1", "high"); + + expect(provider.get).toHaveBeenCalledOnce(); + + controller.disconnect("task-1"); + await controller.ensureConnected("task-1"); + + expect(provider.get).toHaveBeenCalledTimes(2); }); it("opens cold tasks before connecting", async () => { - const client = createClient(); + const client = createSession(); vi.mocked(client.health).mockResolvedValue({ state: "cold" }); const openTask = vi.fn(async () => ({ success: true })); const taskService = { openTask } as unknown as TaskService; const controller = createController(client, taskService); - await controller.ensureConnected("task-1"); + await controller.ensureConnected("task-1", "run-1"); - expect(openTask).toHaveBeenCalledWith("task-1"); + expect(openTask).toHaveBeenCalledWith("task-1", "run-1"); expect(controller.store.getState().sessions["task-1"]).toMatchObject({ connectionState: "connected", }); }); - it("makes the transcript available before model discovery finishes", async () => { + it("refreshes native thinking levels after changing models", async () => { + const session = createSession(); + const client = session.client; + vi.mocked(client.getState).mockResolvedValue({ + thinkingLevel: "high", + isStreaming: false, + isCompacting: false, + steeringMode: "all", + followUpMode: "all", + sessionId: "session-1", + autoCompactionEnabled: true, + messageCount: 0, + pendingMessageCount: 0, + model: { + provider: "posthog", + id: "model-2", + name: "Model 2", + api: "anthropic-messages", + baseUrl: "https://example.com", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_000, + }, + }); + vi.mocked(client.getAvailableModels).mockResolvedValue([ + { + provider: "posthog", + id: "model-1", + contextWindow: 100_000, + reasoning: true, + }, + { + provider: "posthog", + id: "model-2", + contextWindow: 200_000, + reasoning: true, + }, + ]); + vi.mocked(client.getAvailableThinkingLevels).mockResolvedValue([ + "off", + "low", + "medium", + "high", + "xhigh", + ]); + const controller = createController(session); + await controller.ensureConnected("task-1"); + + await controller.setModel("task-1", { + provider: "posthog", + id: "model-2", + }); + + const state = controller.store.getState().sessions["task-1"]; + expect(state?.models).toEqual([ + expect.objectContaining({ id: "model-1" }), + expect.objectContaining({ id: "model-2" }), + ]); + expect(state?.thinkingLevels).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + ]); + }); + + it("loads status, models, and thinking levels independently", async () => { let resolveModels: (models: []) => void = () => {}; const models = new Promise<[]>((resolve) => { resolveModels = resolve; }); + let resolveThinkingLevels: (levels: ["off", "high"]) => void = () => {}; + const thinkingLevels = new Promise<["off", "high"]>((resolve) => { + resolveThinkingLevels = resolve; + }); const initialEvent: AgentConversationEvent = { type: "assistant_thought_chunk", timestamp: 1, content: { type: "text", text: "working" }, }; - const client = createClient(); - vi.mocked(client.conversation).mockResolvedValue([initialEvent]); - vi.mocked(client.status).mockResolvedValue({ + const client = createSession(); + vi.mocked(client.getConversation).mockResolvedValue([initialEvent]); + vi.mocked(client.client.getState).mockResolvedValue({ thinkingLevel: "high", isStreaming: true, isCompacting: false, @@ -150,7 +1018,10 @@ describe("PiSessionController", () => { messageCount: 1, pendingMessageCount: 0, }); - vi.mocked(client.availableModels).mockReturnValue(models); + vi.mocked(client.client.getAvailableModels).mockReturnValue(models); + vi.mocked(client.client.getAvailableThinkingLevels).mockReturnValue( + thinkingLevels, + ); const controller = createController(client); const connection = controller.connect("task-1"); @@ -159,11 +1030,269 @@ describe("PiSessionController", () => { expect(controller.store.getState().sessions["task-1"]).toMatchObject({ events: [initialEvent], status: { isStreaming: true }, + modelsLoaded: false, + thinkingLevelsLoaded: false, + }); + }); + + resolveThinkingLevels(["off", "high"]); + await vi.waitFor(() => { + expect(controller.store.getState().sessions["task-1"]).toMatchObject({ + modelsLoaded: false, + thinkingLevels: ["off", "high"], + thinkingLevelsLoaded: true, }); }); resolveModels([]); await connection; + expect(controller.store.getState().sessions["task-1"]).toMatchObject({ + models: [], + modelsLoaded: true, + thinkingLevelsLoaded: true, + }); + }); + + it("reconciles structurally equal live events included in native history", async () => { + const nativeEvent: AgentConversationEvent = { + type: "user_message", + id: "native-message-id", + timestamp: 1, + content: [{ type: "text", text: "hello" }], + sourceId: "pi-entry-1:0", + }; + const liveEvent: AgentConversationEvent = { + ...nativeEvent, + id: "live-message-id", + content: [{ type: "text", text: "hello" }], + }; + let resolveConversation: (events: AgentConversationEvent[]) => void = + () => {}; + const conversation = new Promise((resolve) => { + resolveConversation = resolve; + }); + let onEvent: (event: AgentConversationEvent) => void = () => {}; + let subscribed = false; + const session = createSession(); + vi.mocked(session.getConversation).mockReturnValue(conversation); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + subscribed = true; + return () => {}; + }); + const controller = createController(session); + + const connection = controller.connect("task-1"); + await vi.waitFor(() => expect(subscribed).toBe(true)); + onEvent(liveEvent); + resolveConversation([nativeEvent]); + await connection; + + expect(controller.store.getState().sessions["task-1"].events).toEqual([ + nativeEvent, + ]); + }); + + it("does not briefly duplicate retained events during reconnect snapshots", async () => { + const retainedEvent: AgentConversationEvent = { + type: "assistant_message_chunk", + timestamp: 1, + content: { type: "text", text: "retained" }, + sourceId: "pi-entry-1:0", + }; + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + const controller = createController(session); + + await controller.connect("task-1", "run-1"); + onEvent(retainedEvent); + controller.disconnect("task-1"); + + let resolveConversation: (events: AgentConversationEvent[]) => void = + () => {}; + vi.mocked(session.getConversation).mockReturnValue( + new Promise((resolve) => { + resolveConversation = resolve; + }), + ); + const reconnect = controller.connect("task-1", "run-1"); + await vi.waitFor(() => + expect(session.onConversationEvent).toHaveBeenCalledTimes(2), + ); + onEvent(retainedEvent); + + expect(controller.store.getState().sessions["task-1"].events).toEqual([ + retainedEvent, + ]); + + resolveConversation([retainedEvent]); + await reconnect; + }); + + it("does not append streamed assistant text already present in native history", async () => { + const nativeEvent: AgentConversationEvent = { + type: "assistant_message_chunk", + timestamp: 1, + content: { type: "text", text: "hello world" }, + sourceId: "pi-entry-1:0", + }; + const liveEvent: AgentConversationEvent = { + type: "assistant_message_chunk", + timestamp: 1, + content: { type: "text", text: "world" }, + sourceId: "pi-entry-1:0", + }; + let resolveConversation: (events: AgentConversationEvent[]) => void = + () => {}; + const conversation = new Promise((resolve) => { + resolveConversation = resolve; + }); + let onEvent: (event: AgentConversationEvent) => void = () => {}; + let subscribed = false; + const session = createSession(); + vi.mocked(session.getConversation).mockReturnValue(conversation); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + subscribed = true; + return () => {}; + }); + const controller = createController(session); + + const connection = controller.connect("task-1"); + await vi.waitFor(() => expect(subscribed).toBe(true)); + onEvent(liveEvent); + resolveConversation([nativeEvent]); + await connection; + + expect(controller.store.getState().sessions["task-1"].events).toEqual([ + nativeEvent, + ]); + }); + + it("drops retained live events when reconnecting after disconnect", async () => { + const liveEvent: AgentConversationEvent = { + type: "assistant_message_chunk", + timestamp: 1, + content: { type: "text", text: "stale" }, + }; + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + const controller = createController(session); + + await controller.connect("task-1"); + onEvent(liveEvent); + controller.disconnect("task-1"); + await controller.connect("task-1"); + + expect(controller.store.getState().sessions["task-1"].events).toEqual([]); + }); + + it("tracks native queue updates without adding them to the transcript", async () => { + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + const controller = createController(session); + + await controller.connect("task-1"); + onEvent({ + type: "queue_update", + timestamp: 1, + steering: ["fix this"], + followUp: ["then summarize"], + }); + + expect(controller.store.getState().sessions["task-1"]).toMatchObject({ + events: [], + queue: { + steering: ["fix this"], + followUp: ["then summarize"], + }, + status: { pendingMessageCount: 2 }, + }); + }); + + it("clears the native queue and returns its contents for editing", async () => { + const session = createSession(); + vi.mocked(session.clearQueue).mockResolvedValue({ + steering: ["fix this"], + followUp: ["then summarize"], + }); + const controller = createController(session); + + await controller.connect("task-1"); + const queue = await controller.clearQueue("task-1"); + + expect(queue).toEqual({ + steering: ["fix this"], + followUp: ["then summarize"], + }); + expect(controller.store.getState().sessions["task-1"].queue).toEqual({ + steering: [], + followUp: [], + }); + }); + + it("uses live turn completion without reloading native history", async () => { + const turnCompleted: AgentConversationEvent = { + type: "turn_completed", + timestamp: 1, + }; + let onEvent: (event: AgentConversationEvent) => void = () => {}; + const session = createSession(); + vi.mocked(session.onConversationEvent).mockImplementation((handler) => { + onEvent = handler; + return () => {}; + }); + const controller = createController(session); + + await controller.connect("task-1"); + vi.mocked(session.client.getSessionStats).mockResolvedValueOnce({ + sessionFile: undefined, + sessionId: "session-1", + userMessages: 1, + assistantMessages: 1, + toolCalls: 0, + toolResults: 0, + totalMessages: 2, + tokens: { + input: 1_000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + total: 1_500, + }, + cost: 0.03, + contextUsage: { + tokens: 12_000, + contextWindow: 100_000, + percent: 12, + }, + }); + onEvent(turnCompleted); + + await vi.waitFor(() => { + expect( + controller.store.getState().sessions["task-1"].stats, + ).toMatchObject({ + cost: 0.03, + contextUsage: { tokens: 12_000, contextWindow: 100_000 }, + }); + }); + expect(session.getConversation).toHaveBeenCalledOnce(); + expect(controller.store.getState().sessions["task-1"].events).toEqual([ + turnCompleted, + ]); }); it("loads session state and appends normalized runtime events", async () => { @@ -178,9 +1307,9 @@ describe("PiSessionController", () => { status: "compacting", }; let onEvent: (event: AgentConversationEvent) => void = () => {}; - const client = createClient(); - vi.mocked(client.conversation).mockResolvedValue([initialEvent]); - vi.mocked(client.subscribe).mockImplementation((_taskId, handler) => { + const client = createSession(); + vi.mocked(client.getConversation).mockResolvedValue([initialEvent]); + vi.mocked(client.onConversationEvent).mockImplementation((handler) => { onEvent = handler; return () => {}; }); diff --git a/packages/core/src/pi-runtime/piSessionController.ts b/packages/core/src/pi-runtime/piSessionController.ts index 6ad5ef873e..f2b202350d 100644 --- a/packages/core/src/pi-runtime/piSessionController.ts +++ b/packages/core/src/pi-runtime/piSessionController.ts @@ -1,78 +1,142 @@ +import type { PiRemoteRpcClient } from "@posthog/agent/pi/remote-rpc-client"; import type { - PiCommand, - PiModelOption, - PiQueueMode, - PiSessionStatus, + PiNativeModelInfo, + PiPersistedSessionConfig, + PiQueueSnapshot, PiThinkingLevel, } from "@posthog/agent/pi/types"; -import type { - AgentConversationEvent, - PiMessagingMode, - PiRuntimeHealth, +import { + type AgentConversationEvent, + classifyPromptFailure, + type PiMessagingMode, + type PiRuntimeHealth, + type PromptFailure, + type TaskRunStatus, } from "@posthog/shared"; -import { inject, injectable } from "inversify"; +import { inject, injectable, optional } from "inversify"; +import type { AuthService } from "../auth/auth"; +import { AUTH_SERVICE } from "../auth/auth.module"; +import { AuthServiceEvent } from "../auth/schemas"; import { parseCommandLine } from "../message-editor/commands"; import { TASK_SERVICE, type TaskService } from "../task-detail/taskService"; import { createEmptyPiControllerSession, createPiSessionStore, type PiControllerSessionState, + type PiSessionError, type PiSessionStore, } from "./piSessionStore"; export type { - PiModelOption, - PiQueueMode, + PiNativeModelInfo, + PiQueueSnapshot, PiThinkingLevel, } from "@posthog/agent/pi/types"; -export const PI_SESSION_CLIENT = Symbol.for("posthog.pi.sessionClient"); +export type PiModelSelection = Pick; -export interface PiSessionClient { - health(taskId: string): Promise; - conversation(taskId: string): Promise; - status(taskId: string): Promise; - availableModels(taskId: string): Promise; - commands(taskId: string): Promise; - subscribe( - taskId: string, +export const PI_SESSION_PROVIDER = Symbol.for("posthog.pi.sessionProvider"); +export const LOCAL_PI_SESSION_FACTORY = Symbol.for( + "posthog.pi.localSessionFactory", +); + +export interface PiSession { + client: PiRemoteRpcClient; + readonly resumeRequired?: boolean; + readonly cloudStatus?: TaskRunStatus; + readonly taskRunId?: string; + readonly persistedConfig?: PiPersistedSessionConfig | null; + retry?(): Promise; + getQueue(): Promise; + clearQueue(): Promise; + sendUserMessage?( + type: "prompt" | "steer" | "follow_up", + message: string, + artifactIds: string[], + id: string, + ): Promise; + health(): Promise; + getConversation(): Promise; + onConversationEvent( onEvent: (event: AgentConversationEvent) => void, onError: (error: unknown) => void, + onCloudStatus?: (status: TaskRunStatus) => void, ): () => void; - prompt(taskId: string, prompt: string): Promise; - steer(taskId: string, message: string): Promise; - followUp(taskId: string, message: string): Promise; - compact(taskId: string, customInstructions?: string): Promise; - setModel( - taskId: string, - provider: string, - modelId: string, - ): Promise<{ provider: string; id: string }>; - setThinkingLevel(taskId: string, level: PiThinkingLevel): Promise; - setSteeringMode(taskId: string, mode: PiQueueMode): Promise; - setFollowUpMode(taskId: string, mode: PiQueueMode): Promise; - bash(taskId: string, command: string): Promise; - abort(taskId: string): Promise; - abortBash(taskId: string): Promise; } +export interface PiSessionFactory { + get(taskId: string, taskRunId?: string): Promise; + readSessionConfig?( + downloadUrl: string, + ): Promise; +} + +export type PiSessionProvider = PiSessionFactory; + export type PiSubmitResult = "prompt" | "steer" | "followUp" | "compact"; +type PiOperation = + | "prompt" + | "compact" + | "model" + | "thinking" + | "bash" + | "cancel" + | "queue" + | "retry" + | "restart"; + +export class PiOperationError extends Error { + constructor(readonly failure: PiSessionError) { + super(failure.message); + this.name = "PiOperationError"; + } +} + +function normalizeSessionError(error: unknown): { + title: string; + message: string; + retryable: boolean; +} { + const value = error as { + title?: unknown; + message?: unknown; + retryable?: unknown; + }; + return { + title: typeof value?.title === "string" ? value.title : "Connection failed", + message: typeof value?.message === "string" ? value.message : String(error), + retryable: value?.retryable !== false, + }; +} + @injectable() export class PiSessionController { readonly store: PiSessionStore = createPiSessionStore(); + private readonly sessions = new Map>(); private readonly subscriptions = new Map void>(); private readonly liveEvents = new Map(); private readonly connections = new Map>(); private readonly readiness = new Map>(); + private readonly sessionVersions = new Map(); + private readonly queueRevisions = new Map(); + private readonly queuesToRestore = new Map(); + private readonly cancelAuthRestoration = new Map void>(); + private readonly taskRunIds = new Map(); + private readonly activeTaskIds = new Set(); constructor( - @inject(PI_SESSION_CLIENT) private readonly client: PiSessionClient, + @inject(PI_SESSION_PROVIDER) private readonly provider: PiSessionProvider, @inject(TASK_SERVICE) private readonly taskService: TaskService, + @inject(AUTH_SERVICE) + @optional() + private readonly authService?: AuthService, ) {} - ensureConnected(taskId: string): Promise { + ensureConnected(taskId: string, taskRunId?: string): Promise { + this.activeTaskIds.add(taskId); + this.bindTaskRun(taskId, taskRunId); this.ensureSubscription(taskId); const existing = this.readiness.get(taskId); @@ -84,25 +148,34 @@ export class PiSessionController { connectionState: "connecting", error: undefined, }); + const connectedSessionVersion = this.getSessionVersion(taskId); const readiness = this.ensureConnectedInternal(taskId) .then(() => { - this.updateSession(taskId, { connectionState: "connected" }); + if (this.getSessionVersion(taskId) === connectedSessionVersion) { + this.updateSession(taskId, { + connectionState: "connected", + error: undefined, + }); + } }) .catch((error) => { - this.updateSession(taskId, { - connectionState: "failed", - error: error instanceof Error ? error.message : String(error), - }); + if (this.getSessionVersion(taskId) === connectedSessionVersion) { + this.applySessionError(taskId, error); + } throw error; }) .finally(() => { - this.readiness.delete(taskId); + if (this.readiness.get(taskId) === readiness) { + this.readiness.delete(taskId); + } }); this.readiness.set(taskId, readiness); return readiness; } - connect(taskId: string): Promise { + connect(taskId: string, taskRunId?: string): Promise { + this.activeTaskIds.add(taskId); + this.bindTaskRun(taskId, taskRunId); this.ensureSubscription(taskId); const existing = this.connections.get(taskId); @@ -113,15 +186,97 @@ export class PiSessionController { this.updateSession(taskId, { error: undefined }); const connection = this.loadSession(taskId).finally(() => { - this.connections.delete(taskId); + if (this.connections.get(taskId) === connection) { + this.connections.delete(taskId); + } }); this.connections.set(taskId, connection); return connection; } disconnect(taskId: string): void { - this.subscriptions.get(taskId)?.(); - this.subscriptions.delete(taskId); + this.cancelAuthRestoration.get(taskId)?.(); + this.resetTransport(taskId); + this.taskRunIds.delete(taskId); + this.liveEvents.delete(taskId); + this.queueRevisions.delete(taskId); + this.queuesToRestore.delete(taskId); + this.activeTaskIds.delete(taskId); + } + + async retry(taskId: string): Promise { + if (this.getSession(taskId).connectionState === "connecting") { + return; + } + this.updateSession(taskId, { + connectionState: "connecting", + error: undefined, + }); + const taskRunId = this.taskRunIds.get(taskId); + this.captureQueueForRestore(taskId); + try { + const session = await this.getPiSession(taskId); + await session.retry?.(); + this.resetTransport(taskId); + await this.ensureConnected(taskId, taskRunId); + } catch (error) { + throw this.recordOperationFailure(taskId, "retry", error); + } + } + + async clearQueue(taskId: string): Promise { + try { + const session = await this.getPiSession(taskId); + const queue = await session.clearQueue(); + this.queuesToRestore.delete(taskId); + this.applyQueue(taskId, { steering: [], followUp: [] }); + return queue; + } catch (error) { + throw this.recordOperationFailure(taskId, "queue", error); + } + } + + async restart(taskId: string): Promise { + if (this.getSession(taskId).connectionState === "connecting") { + return; + } + const taskRunId = this.taskRunIds.get(taskId); + if (!taskRunId) { + await this.retry(taskId); + return; + } + + this.updateSession(taskId, { + connectionState: "connecting", + error: undefined, + }); + this.captureQueueForRestore(taskId); + try { + const resumedRun = await this.taskService.resumeCloudPiRun( + taskId, + taskRunId, + ); + this.resetTransport(taskId); + await this.ensureConnected(taskId, resumedRun.id); + } catch (error) { + throw this.recordOperationFailure(taskId, "restart", error); + } + } + + retryUnhealthyCloudSessions(): void { + for (const [taskId, session] of Object.entries( + this.store.getState().sessions, + )) { + if ( + this.activeTaskIds.has(taskId) && + session.cloudStatus !== undefined && + session.error?.retryable && + (session.connectionState === "disconnected" || + session.connectionState === "error") + ) { + void this.retry(taskId).catch(() => {}); + } + } } getSubmitAction( @@ -150,83 +305,208 @@ export class PiSessionController { const message = text.trim(); const action = this.getSubmitAction(message, isStreaming, messagingMode); - try { - if (action === "compact") { + const currentSession = await this.getPiSession(taskId); + const submissionSessionVersion = this.getSessionVersion(taskId); + if (this.getSession(taskId).authRestoring) { + throw this.recordOperationFailure( + taskId, + "prompt", + new Error("Authentication required while the session restores"), + undefined, + message, + ); + } + if (currentSession.sendUserMessage) { + try { + await this.waitForAuthRestoration(taskId); + if (this.getSessionVersion(taskId) !== submissionSessionVersion) { + throw new Error( + "Authentication required; submission cancelled after session changed", + ); + } + } catch (error) { + throw this.recordOperationFailure( + taskId, + "prompt", + error, + undefined, + message, + ); + } + } + const controllerSession = this.getSession(taskId); + if (controllerSession.error?.scope === "operation") { + this.updateSession(taskId, { error: undefined }); + } + const wasStreaming = controllerSession.status?.isStreaming ?? false; + const queuesMessage = action === "steer" || action === "followUp"; + const queuedMessageCount = + controllerSession.queue.steering.length + + controllerSession.queue.followUp.length; + if (queuesMessage && queuedMessageCount > 0) { + throw new Error("Pi already has a queued message"); + } + const refreshAfterSubmit = + action === "compact" || + this.isExtensionCommand(controllerSession, message); + if (action === "compact") { + try { + const session = await this.getWritablePiSession(taskId); const command = parseCommandLine(message); const customInstructions = command?.args?.trim() || undefined; - await this.client.compact(taskId, customInstructions); - await this.refreshConversation(taskId); - } else if (action === "prompt") { - await this.client.prompt(taskId, message); - } else if (action === "steer") { - await this.client.steer(taskId, message); - } else { - await this.client.followUp(taskId, message); + await session.client.compact(customInstructions); + } catch (error) { + throw this.recordOperationFailure(taskId, "compact", error); } + } else { + const commandType = action === "followUp" ? "follow_up" : action; + const messageId = currentSession.sendUserMessage + ? globalThis.crypto.randomUUID() + : undefined; + const hasOptimisticTranscriptMessage = Boolean( + messageId && action === "prompt", + ); + if (messageId && hasOptimisticTranscriptMessage) { + this.appendOptimisticUserMessage(taskId, messageId, message); + } + if (queuesMessage) { + this.applyQueue(taskId, { + steering: action === "steer" ? [message] : [], + followUp: action === "followUp" ? [message] : [], + }); + } + this.markTurnPending(taskId); + if (currentSession.resumeRequired) { + this.updateSession(taskId, { connectionState: "connecting" }); + } + + try { + const session = await this.getWritablePiSession(taskId); + this.markTurnPending(taskId); + if (session.sendUserMessage && messageId) { + const taskRunId = this.taskRunIds.get(taskId); + const prepared = taskRunId + ? await this.taskService.prepareCloudPiMessage( + taskId, + taskRunId, + message, + ) + : { content: message, artifactIds: [] }; + await this.sendCloudUserMessage( + taskId, + session, + commandType, + prepared.content, + prepared.artifactIds, + messageId, + ); + } else if (action === "prompt") { + await session.client.prompt(message); + } else if (action === "steer") { + await session.client.steer(message); + } else { + await session.client.followUp(message); + } + if (queuesMessage) { + await this.refreshQueue(taskId, session); + } + } catch (error) { + if (messageId && hasOptimisticTranscriptMessage) { + this.removeUserMessage(taskId, messageId); + } + if (queuesMessage) { + this.applyQueue(taskId, controllerSession.queue); + } + this.setTurnStreaming(taskId, wasStreaming); + const operation = queuesMessage ? "queue" : "prompt"; + throw this.recordOperationFailure(taskId, operation, error); + } + } + if (refreshAfterSubmit) { await this.refreshStatus(taskId); - return action; - } catch (error) { - this.updateSession(taskId, { - error: error instanceof Error ? error.message : String(error), - }); - throw error; } + return action; } - async setModel(taskId: string, model: PiModelOption): Promise { - await this.client.setModel(taskId, model.provider, model.id); - await this.refreshStatus(taskId); + async setModel(taskId: string, model: PiModelSelection): Promise { + try { + const session = await this.getPiSession(taskId); + await session.client.setModel(model.provider, model.id); + await this.refreshStatus(taskId); + await this.refreshStats(taskId); + const thinkingLevels = await session.client.getAvailableThinkingLevels(); + this.updateSession(taskId, { + thinkingLevels, + thinkingLevelsLoaded: true, + }); + } catch (error) { + throw this.recordOperationFailure(taskId, "model", error); + } } async setThinkingLevel( taskId: string, level: PiThinkingLevel, ): Promise { - await this.client.setThinkingLevel(taskId, level); - await this.refreshStatus(taskId); - } - - async setQueueMode( - taskId: string, - messagingMode: PiMessagingMode, - queueMode: PiQueueMode, - ): Promise { - if (messagingMode === "steer") { - await this.client.setSteeringMode(taskId, queueMode); - } else { - await this.client.setFollowUpMode(taskId, queueMode); + try { + const session = await this.getPiSession(taskId); + await session.client.setThinkingLevel(level); + await this.refreshStatus(taskId); + } catch (error) { + throw this.recordOperationFailure(taskId, "thinking", error); } - await this.refreshStatus(taskId); } async bash(taskId: string, command: string): Promise { this.updateSession(taskId, { isBashRunning: true }); try { - await this.client.bash(taskId, command); - await this.refreshConversation(taskId); + const session = await this.getPiSession(taskId); + await session.client.bash(command); + } catch (error) { + throw this.recordOperationFailure(taskId, "bash", error); } finally { this.updateSession(taskId, { isBashRunning: false }); } } async abort(taskId: string): Promise { - await this.client.abort(taskId); - await this.refreshStatus(taskId); + try { + const session = await this.getPiSession(taskId); + await session.client.abort(); + await this.refreshStatus(taskId); + } catch (error) { + throw this.recordOperationFailure(taskId, "cancel", error); + } } async abortBash(taskId: string): Promise { - await this.client.abortBash(taskId); - this.updateSession(taskId, { isBashRunning: false }); + try { + const session = await this.getPiSession(taskId); + await session.client.abortBash(); + this.updateSession(taskId, { isBashRunning: false }); + } catch (error) { + throw this.recordOperationFailure(taskId, "cancel", error); + } } private async ensureConnectedInternal(taskId: string): Promise { - const health = await this.client.health(taskId); + const session = await this.getPiSession(taskId); + const health = await session.health(); if (health.state === "cold") { - const result = await this.taskService.openTask(taskId); + const taskRunId = this.taskRunIds.get(taskId); + const result = taskRunId + ? await this.taskService.openTask(taskId, taskRunId) + : await this.taskService.openTask(taskId); if (!result.success) { throw new Error(result.error); } + + this.subscriptions.get(taskId)?.(); + this.subscriptions.delete(taskId); + this.sessions.delete(taskId); + this.connections.delete(taskId); + this.ensureSubscription(taskId); } await this.connect(taskId); @@ -237,100 +517,693 @@ export class PiSessionController { return; } - const unsubscribe = this.client.subscribe( - taskId, - (event) => this.handleEvent(taskId, event), - (error) => { - this.updateSession(taskId, { - error: error instanceof Error ? error.message : String(error), - }); - }, - ); - this.subscriptions.set(taskId, unsubscribe); + let disposed = false; + let unsubscribe: (() => void) | undefined; + void this.getPiSession(taskId) + .then((session) => { + if (disposed) { + return; + } + this.applyPersistedConfig(taskId, session); + this.updateSession(taskId, { cloudStatus: session.cloudStatus }); + unsubscribe = session.onConversationEvent( + (event) => this.handleEvent(taskId, event), + (error) => this.applySessionError(taskId, error), + (cloudStatus) => this.updateSession(taskId, { cloudStatus }), + ); + }) + .catch((error) => this.applySessionError(taskId, error)); + this.subscriptions.set(taskId, () => { + disposed = true; + unsubscribe?.(); + }); + } + + private applyPersistedConfig(taskId: string, session: PiSession): void { + const config = session.persistedConfig; + if (!config) { + return; + } + + const current = this.getSession(taskId); + const status = current.status + ? { + ...current.status, + model: config.model ?? undefined, + thinkingLevel: config.thinkingLevel, + } + : { + isStreaming: false, + isCompacting: false, + thinkingLevel: config.thinkingLevel, + model: config.model ?? undefined, + steeringMode: "all" as const, + followUpMode: "all" as const, + sessionId: session.taskRunId ?? taskId, + autoCompactionEnabled: true, + messageCount: current.events.length, + pendingMessageCount: 0, + }; + this.updateSession(taskId, { + status, + models: config.model ? [config.model] : [], + modelsLoaded: true, + thinkingLevels: [config.thinkingLevel], + thinkingLevelsLoaded: true, + }); } private async loadSession(taskId: string): Promise { + const connectedSessionVersion = this.getSessionVersion(taskId); try { - const [events, status] = await Promise.all([ - this.client.conversation(taskId), - this.client.status(taskId), + const session = await this.getPiSession(taskId); + const queueRevision = this.queueRevisions.get(taskId) ?? 0; + const retainedStats = this.getSession(taskId).stats; + const [events, status, queue, stats] = await Promise.all([ + session.getConversation(), + session.client.getState(), + session.getQueue(), + session.client.getSessionStats().catch(() => retainedStats), ]); - const liveEvents = status.isStreaming - ? (this.liveEvents.get(taskId) ?? []) - : []; + if (this.getSessionVersion(taskId) !== connectedSessionVersion) { + return; + } + const currentSession = this.getSession(taskId); - this.liveEvents.set(taskId, liveEvents); + const conversationEvents = events.filter( + (event) => event.type !== "queue_update", + ); + const liveEvents = this.liveEvents.get(taskId) ?? []; + const newLiveEvents = this.reconcileLiveEvents( + conversationEvents, + liveEvents, + ); + this.liveEvents.set(taskId, newLiveEvents); + const historyUserMessageIds = new Set( + conversationEvents.flatMap((event) => + event.type === "user_message" ? [event.id] : [], + ), + ); + const optimisticEvents = currentSession.events.filter( + (event) => + event.sourceId?.startsWith("optimistic:") && + (event.type !== "user_message" || + !historyUserMessageIds.has(event.id)), + ); + const reconciledEvents = [ + ...conversationEvents, + ...newLiveEvents, + ...optimisticEvents, + ]; + const resolvedQueue = + (this.queueRevisions.get(taskId) ?? 0) === queueRevision + ? queue + : currentSession.queue; + const resolvedStatus = { + ...status, + model: status.model + ? { provider: status.model.provider, id: status.model.id } + : (session.persistedConfig?.model ?? undefined), + pendingMessageCount: + resolvedQueue.steering.length + resolvedQueue.followUp.length, + }; + this.setSession(taskId, { connectionState: "connected", - events: [...events, ...liveEvents], - status, + events: reconciledEvents, + status: resolvedStatus, + stats, models: currentSession.models, + modelsLoaded: currentSession.modelsLoaded, + thinkingLevels: currentSession.thinkingLevels, + thinkingLevelsLoaded: currentSession.thinkingLevelsLoaded, commands: currentSession.commands, + queue: resolvedQueue, + error: + currentSession.error?.scope === "operation" + ? currentSession.error + : undefined, + authRestoring: currentSession.authRestoring, isBashRunning: false, - error: undefined, }); - const [models, commands] = await Promise.all([ - this.client.availableModels(taskId), - this.client.commands(taskId), + await this.restoreQueueIfNeeded(taskId, session, resolvedStatus); + + await Promise.all([ + session.client.getAvailableModels().then((models) => { + if (this.getSessionVersion(taskId) === connectedSessionVersion) { + const persistedModel = session.persistedConfig?.model; + this.updateSession(taskId, { + models: + models.length > 0 + ? models + : persistedModel + ? [persistedModel] + : [], + modelsLoaded: true, + }); + } + }), + session.client.getAvailableThinkingLevels().then((thinkingLevels) => { + if (this.getSessionVersion(taskId) === connectedSessionVersion) { + const persistedThinkingLevel = + session.persistedConfig?.thinkingLevel; + this.updateSession(taskId, { + thinkingLevels: + thinkingLevels.length > 0 + ? thinkingLevels + : persistedThinkingLevel + ? [persistedThinkingLevel] + : [], + thinkingLevelsLoaded: true, + }); + } + }), + session.client.getCommands().then((commands) => { + if (this.getSessionVersion(taskId) === connectedSessionVersion) { + this.updateSession(taskId, { commands }); + } + }), ]); - this.updateSession(taskId, { models, commands }); } catch (error) { - this.updateSession(taskId, { - error: error instanceof Error ? error.message : String(error), - }); + if (this.getSessionVersion(taskId) === connectedSessionVersion) { + this.applySessionError(taskId, error); + } throw error; } } private handleEvent(taskId: string, event: AgentConversationEvent): void { + if (event.type === "queue_update") { + const queue = { + steering: event.steering, + followUp: event.followUp, + }; + this.applyQueue(taskId, queue); + return; + } + + const session = this.getSession(taskId); + if ( + event.sourceId && + session.events.some((existing) => existing.sourceId === event.sourceId) + ) { + return; + } + + if (event.type === "runtime_error") { + this.recordOperationFailure( + taskId, + "prompt", + new Error(event.message), + event.errorType, + ); + } + const liveEvents = [...(this.liveEvents.get(taskId) ?? []), event]; this.liveEvents.set(taskId, liveEvents); - const session = this.getSession(taskId); let status = session.status; if (status && event.type === "runtime_status") { if (event.status === "compacting") { status = { ...status, isCompacting: !event.isComplete }; } else if (event.status === "compacting_failed") { status = { ...status, isCompacting: false }; + this.recordOperationFailure( + taskId, + "compact", + new Error(event.error ?? event.message ?? "Compaction failed"), + ); } } + const isDirectBashEvent = + (event.type === "tool_call_started" || + event.type === "tool_call_updated") && + event.toolCall.origin === "user_shell"; + const hasTurnActivity = + !isDirectBashEvent && + (event.type === "assistant_message_chunk" || + event.type === "assistant_thought_chunk" || + event.type === "tool_call_started" || + event.type === "tool_call_updated"); + if (status && hasTurnActivity) { + status = { ...status, isStreaming: true }; + } if (status && event.type === "turn_completed") { status = { ...status, isStreaming: false }; } + const existingUserMessageIndex = + event.type === "user_message" + ? session.events.findIndex( + (candidate) => + candidate.type === "user_message" && candidate.id === event.id, + ) + : -1; + const events = [...session.events]; + if (existingUserMessageIndex >= 0) { + events[existingUserMessageIndex] = event; + } else { + events.push(event); + } + + const latestSession = this.getSession(taskId); + const preserveConnectionError = + event.type === "runtime_error" && + latestSession.error?.scope === "connection"; + const preserveOperationError = latestSession.error?.scope === "operation"; this.updateSession(taskId, { - events: [...session.events, event], + connectionState: + event.type === "progress" || preserveConnectionError + ? latestSession.connectionState + : "connected", + events, status, + error: + preserveConnectionError || preserveOperationError + ? latestSession.error + : undefined, }); if (event.type === "turn_completed") { - const capturedCount = liveEvents.length; - void this.refreshConversation(taskId, capturedCount); + void this.refreshStats(taskId); + } + } + + private async refreshStats(taskId: string): Promise { + const sessionVersion = this.getSessionVersion(taskId); + try { + const session = await this.getPiSession(taskId); + const stats = await session.client.getSessionStats(); + if (this.getSessionVersion(taskId) === sessionVersion) { + this.updateSession(taskId, { stats }); + } + } catch { + return; + } + } + + private reconcileLiveEvents( + historyEvents: AgentConversationEvent[], + liveEvents: AgentConversationEvent[], + ): AgentConversationEvent[] { + const historySourceIds = new Set( + historyEvents.flatMap((event) => + event.sourceId ? [event.sourceId] : [], + ), + ); + return liveEvents.filter( + (event) => !event.sourceId || !historySourceIds.has(event.sourceId), + ); + } + + acknowledgeOperationFailure(taskId: string, failureId: string): void { + const session = this.getSession(taskId); + if ( + session.error?.scope === "operation" && + session.error.id === failureId + ) { + this.updateSession(taskId, { error: undefined }); + } + } + + private async waitForAuthRestoration(taskId: string): Promise { + if ( + !this.authService || + this.authService.getState().status !== "restoring" + ) { + return; + } + + this.updateSession(taskId, { authRestoring: true }); + try { + await new Promise((resolve, reject) => { + const cleanup = () => { + this.authService?.off( + AuthServiceEvent.StateChanged, + handleStateChange, + ); + this.cancelAuthRestoration.delete(taskId); + }; + const handleStateChange = ( + state: ReturnType, + ) => { + if (state.status === "restoring") { + return; + } + cleanup(); + if (state.status === "authenticated") { + resolve(); + } else { + reject(new Error("Authentication required for cloud commands")); + } + }; + this.cancelAuthRestoration.set(taskId, () => { + cleanup(); + reject( + new Error( + "Authentication required; submission cancelled while restoring", + ), + ); + }); + this.authService?.on(AuthServiceEvent.StateChanged, handleStateChange); + if (this.authService) { + handleStateChange(this.authService.getState()); + } + }); + } finally { + this.cancelAuthRestoration.delete(taskId); + this.updateSession(taskId, { authRestoring: false }); + } + } + + private recordOperationFailure( + taskId: string, + operation: PiOperation, + error: unknown, + errorType?: string, + recoveryPrompt?: string, + ): PiOperationError { + const details = (error as { data?: { details?: string } })?.data?.details; + const classified = classifyPromptFailure(error, details, errorType); + const retryable = + classified.retryable || + ((operation === "retry" || operation === "restart") && + classified.kind === "unknown"); + const scope = + classified.kind === "fatal_session" || + operation === "retry" || + operation === "restart" + ? "connection" + : "operation"; + const failure: PiSessionError = { + id: globalThis.crypto.randomUUID(), + scope, + kind: classified.kind, + title: this.errorTitleForOperation(operation, classified), + message: classified.message, + retryable, + limitCause: classified.limitCause, + recoveryPrompt, + }; + this.updateSession(taskId, { + error: failure, + ...(scope === "connection" + ? { + connectionState: retryable ? "disconnected" : "error", + } + : {}), + }); + return new PiOperationError(failure); + } + + private errorTitleForOperation( + operation: PiOperation, + failure: PromptFailure, + ): string { + if (failure.kind === "usage_limit") { + return "Usage limit reached"; + } + if (failure.kind === "transient") { + return "Provider temporarily unavailable"; + } + if (failure.kind === "authentication") { + return "Authentication required"; + } + const titles: Record = { + prompt: "Failed to send message", + compact: "Failed to compact Pi context", + model: "Failed to change Pi model", + thinking: "Failed to change Pi thinking level", + bash: "Failed to run Pi bash command", + cancel: "Failed to stop Pi", + queue: "Failed to update queued message", + retry: "Failed to reconnect to Pi", + restart: "Failed to restart Pi", + }; + return titles[operation]; + } + + private captureQueueForRestore(taskId: string): void { + const queue = this.getSession(taskId).queue; + if (queue.steering.length === 0 && queue.followUp.length === 0) { + return; + } + this.queuesToRestore.set(taskId, { + steering: [...queue.steering], + followUp: [...queue.followUp], + }); + } + + private async restoreQueueIfNeeded( + taskId: string, + session: PiSession, + status: NonNullable, + ): Promise { + const queue = this.getSession(taskId).queue; + const queueToRestore = this.queuesToRestore.get(taskId); + if (!queueToRestore) { + return; + } + if (queue.steering.length > 0 || queue.followUp.length > 0) { + this.queuesToRestore.delete(taskId); + return; + } + + const messages = [ + ...queueToRestore.steering.map((content) => ({ + content, + mode: "steer" as const, + })), + ...queueToRestore.followUp.map((content) => ({ + content, + mode: "follow_up" as const, + })), + ]; + this.queuesToRestore.delete(taskId); + if (!status.isStreaming) { + const first = messages.shift(); + if (first) { + await session.client.prompt(first.content); + } + } + + for (const message of messages) { + if (message.mode === "steer") { + await session.client.steer(message.content); + } else { + await session.client.followUp(message.content); + } } } - private async refreshConversation( + private async refreshQueue( taskId: string, - capturedLiveCount?: number, + session: PiSession, ): Promise { - const events = await this.client.conversation(taskId); - const liveEvents = this.liveEvents.get(taskId) ?? []; - const remainingEvents = - capturedLiveCount === undefined - ? [] - : liveEvents.slice(capturedLiveCount); - this.liveEvents.set(taskId, remainingEvents); + try { + const queue = await session.getQueue(); + this.applyQueue(taskId, queue); + } catch { + return; + } + } + + private applyQueue(taskId: string, queue: PiQueueSnapshot): void { + this.queueRevisions.set(taskId, (this.queueRevisions.get(taskId) ?? 0) + 1); this.updateSession(taskId, { - events: [...events, ...remainingEvents], + queue, + status: this.withPendingMessageCount(taskId, queue), + }); + } + + private withPendingMessageCount( + taskId: string, + queue: PiQueueSnapshot, + ): PiControllerSessionState["status"] { + const status = this.getSession(taskId).status; + if (!status) { + return undefined; + } + return { + ...status, + pendingMessageCount: queue.steering.length + queue.followUp.length, + }; + } + + private isExtensionCommand( + session: PiControllerSessionState, + message: string, + ): boolean { + const command = parseCommandLine(message); + if (!command) { + return false; + } + return session.commands.some( + (available) => + available.name === command.name && available.source === "extension", + ); + } + + private markTurnPending(taskId: string): void { + this.setTurnStreaming(taskId, true); + } + + private setTurnStreaming(taskId: string, isStreaming: boolean): void { + const session = this.getSession(taskId); + if (!session.status) { + return; + } + + this.updateSession(taskId, { + status: { ...session.status, isStreaming }, + }); + } + + private appendOptimisticUserMessage( + taskId: string, + messageId: string, + content: string, + ): void { + const session = this.getSession(taskId); + this.updateSession(taskId, { + events: [ + ...session.events, + { + type: "user_message", + id: messageId, + sourceId: `optimistic:${messageId}`, + timestamp: Date.now(), + content: [{ type: "text", text: content }], + }, + ], + }); + } + + private removeUserMessage(taskId: string, messageId: string): void { + const session = this.getSession(taskId); + this.updateSession(taskId, { + events: session.events.filter( + (event) => event.type !== "user_message" || event.id !== messageId, + ), }); } private async refreshStatus(taskId: string): Promise { - const status = await this.client.status(taskId); + const session = await this.getPiSession(taskId); + const status = await session.client.getState(); this.updateSession(taskId, { status }); } + private async sendCloudUserMessage( + taskId: string, + session: PiSession, + type: "prompt" | "steer" | "follow_up", + content: string, + artifactIds: string[], + messageId: string, + ): Promise { + if (!session.sendUserMessage) { + throw new Error("Cloud Pi session cannot send messages"); + } + + try { + await session.sendUserMessage(type, content, artifactIds, messageId); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const taskRunId = this.taskRunIds.get(taskId) ?? session.taskRunId; + if (!taskRunId || !message.includes("No active sandbox")) { + throw error; + } + + const resumedRun = await this.taskService.resumeCloudPiRun( + taskId, + taskRunId, + ); + this.resetTransport(taskId); + await this.ensureConnected(taskId, resumedRun.id); + const resumedSession = await this.getPiSession(taskId); + if (!resumedSession.sendUserMessage) { + throw new Error("Resumed cloud Pi session cannot send messages"); + } + await resumedSession.sendUserMessage( + type, + content, + artifactIds, + messageId, + ); + } + } + + private async getWritablePiSession(taskId: string): Promise { + const session = await this.getPiSession(taskId); + const taskRunId = this.taskRunIds.get(taskId) ?? session.taskRunId; + if (!session.resumeRequired || !taskRunId) { + return session; + } + + const resumedRun = await this.taskService.resumeCloudPiRun( + taskId, + taskRunId, + ); + this.disconnect(taskId); + await this.ensureConnected(taskId, resumedRun.id); + return this.getPiSession(taskId); + } + + private bindTaskRun(taskId: string, taskRunId?: string): void { + const currentTaskRunId = this.taskRunIds.get(taskId); + if (!taskRunId || currentTaskRunId === taskRunId) { + return; + } + + if (currentTaskRunId) { + this.resetTransport(taskId); + this.liveEvents.delete(taskId); + } + this.taskRunIds.set(taskId, taskRunId); + } + + private resetTransport(taskId: string): void { + this.advanceSessionVersion(taskId); + this.subscriptions.get(taskId)?.(); + this.subscriptions.delete(taskId); + this.sessions.delete(taskId); + this.connections.delete(taskId); + this.readiness.delete(taskId); + } + + private getPiSession(taskId: string): Promise { + const existing = this.sessions.get(taskId); + if (existing) { + return existing; + } + + const session = this.provider + .get(taskId, this.taskRunIds.get(taskId)) + .then((resolved) => { + if (resolved.taskRunId && !this.taskRunIds.has(taskId)) { + this.taskRunIds.set(taskId, resolved.taskRunId); + } + return resolved; + }); + this.sessions.set(taskId, session); + void session.catch(() => { + if (this.sessions.get(taskId) === session) { + this.sessions.delete(taskId); + } + }); + return session; + } + + private getSessionVersion(taskId: string): number { + return this.sessionVersions.get(taskId) ?? 0; + } + + private advanceSessionVersion(taskId: string): void { + this.sessionVersions.set(taskId, this.getSessionVersion(taskId) + 1); + } + private getSession(taskId: string): PiControllerSessionState { return ( this.store.getState().sessions[taskId] ?? createEmptyPiControllerSession() @@ -343,6 +1216,23 @@ export class PiSessionController { })); } + private applySessionError(taskId: string, error: unknown): void { + const failure = normalizeSessionError(error); + const classified = classifyPromptFailure(error); + this.updateSession(taskId, { + connectionState: failure.retryable ? "disconnected" : "error", + error: { + id: globalThis.crypto.randomUUID(), + scope: "connection", + kind: classified.kind, + title: failure.title, + message: failure.message, + retryable: failure.retryable, + limitCause: classified.limitCause, + }, + }); + } + private updateSession( taskId: string, update: Partial, diff --git a/packages/core/src/pi-runtime/piSessionProvider.test.ts b/packages/core/src/pi-runtime/piSessionProvider.test.ts new file mode 100644 index 0000000000..eca455d0e8 --- /dev/null +++ b/packages/core/src/pi-runtime/piSessionProvider.test.ts @@ -0,0 +1,207 @@ +import type { PiRemoteRpcClient } from "@posthog/agent/pi/remote-rpc-client"; +import { describe, expect, it, vi } from "vitest"; +import type { CloudTaskClient } from "../cloud-task/cloudTaskClient"; +import type { TaskService } from "../task-detail/taskService"; +import type { PiSession, PiSessionFactory } from "./piSessionController"; +import { RoutingPiSessionProvider } from "./piSessionProvider"; + +function localSession(): PiSession { + const client = { + getState: vi.fn(async () => ({ isStreaming: false })), + getAvailableModels: vi.fn(async () => []), + getCommands: vi.fn(async () => []), + prompt: vi.fn(async () => {}), + steer: vi.fn(async () => {}), + followUp: vi.fn(async () => {}), + compact: vi.fn(async () => undefined), + setModel: vi.fn(async () => ({ provider: "posthog", id: "model" })), + setThinkingLevel: vi.fn(async () => {}), + bash: vi.fn(async () => undefined), + abort: vi.fn(async () => {}), + abortBash: vi.fn(async () => {}), + } as unknown as PiRemoteRpcClient; + + return { + client, + health: vi.fn(async () => ({ state: "idle" as const })), + getConversation: vi.fn(async () => []), + getQueue: vi.fn(async () => ({ steering: [], followUp: [] })), + clearQueue: vi.fn(async () => ({ steering: [], followUp: [] })), + onConversationEvent: vi.fn(() => () => {}), + }; +} + +function localFactory(session: PiSession): PiSessionFactory { + return { + get: vi.fn(async () => session), + }; +} + +function cloudTaskClient(): CloudTaskClient { + return { + getContext: vi.fn(async () => ({ + apiHost: "https://us.posthog.com", + teamId: 1, + })), + watch: vi.fn(async () => {}), + unwatch: vi.fn(async () => {}), + retry: vi.fn(async () => {}), + subscribe: vi.fn((taskId, runId, onUpdate) => { + onUpdate({ + taskId, + runId, + kind: "logs", + newEntries: [{ type: "pi_run_started" }], + totalEntryCount: 1, + }); + return () => {}; + }), + sendCommand: vi.fn(async (input) => ({ + success: true, + result: { + type: "response", + command: input.params?.command + ? (input.params.command as { type: string }).type + : "unknown", + success: true, + }, + })), + }; +} + +function taskService(environment: "local" | "cloud"): TaskService { + return { + getCloudPiTaskSessionStorage: vi.fn(async () => null), + getTask: vi.fn(async () => ({ + id: "task-1", + runtime: "pi", + latest_run: + environment === "cloud" + ? { id: "run-1", environment: "cloud", status: "in_progress" } + : null, + })), + } as unknown as TaskService; +} + +describe("RoutingPiSessionProvider", () => { + it("returns a cloud session bound to its task run", async () => { + const local = localSession(); + const cloudTasks = cloudTaskClient(); + const provider = new RoutingPiSessionProvider( + localFactory(local), + cloudTasks, + taskService("cloud"), + ); + + const session = await provider.get("task-1"); + session.onConversationEvent(vi.fn(), vi.fn()); + await session.client.steer("change direction"); + + expect(cloudTasks.sendCommand).toHaveBeenCalledWith({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://us.posthog.com", + teamId: 1, + id: expect.any(String), + method: "user_message", + params: { + content: "change direction", + artifact_ids: [], + steer: true, + }, + }); + expect(local.client.steer).not.toHaveBeenCalled(); + }); + + it("reads persisted configuration through Pi's session reader", async () => { + const local = localSession(); + const localSessions = { + ...localFactory(local), + readSessionConfig: vi.fn(async () => ({ + model: { provider: "posthog", id: "claude-opus-4-8" }, + thinkingLevel: "high" as const, + })), + }; + const provider = new RoutingPiSessionProvider( + localSessions, + cloudTaskClient(), + { + ...taskService("cloud"), + getCloudPiTaskSessionStorage: vi.fn(async () => ({ + id: "session-1", + download_url: "https://storage.example/session.jsonl", + content_sha256: "hash", + })), + } as unknown as TaskService, + ); + + const session = await provider.get("task-1"); + + expect(localSessions.readSessionConfig).toHaveBeenCalledWith( + "https://storage.example/session.jsonl", + ); + expect(session.persistedConfig).toEqual({ + model: { provider: "posthog", id: "claude-opus-4-8" }, + thinkingLevel: "high", + }); + }); + + it("binds explicit historical runs and invalidates changed run context", async () => { + const local = localSession(); + const cloudTasks = cloudTaskClient(); + const getTask = vi.fn(async (_taskId: string, taskRunId?: string) => ({ + id: "task-1", + runtime: "pi" as const, + latest_run: { + id: taskRunId ?? "run-latest", + environment: "cloud" as const, + status: "in_progress" as const, + }, + })); + const provider = new RoutingPiSessionProvider( + localFactory(local), + cloudTasks, + { + getTask, + getCloudPiTaskSessionStorage: vi.fn(async () => null), + } as unknown as TaskService, + ); + + const historical = await provider.get("task-1", "run-old"); + historical.onConversationEvent(vi.fn(), vi.fn()); + await historical.client.abort(); + const replacement = await provider.get("task-1", "run-new"); + replacement.onConversationEvent(vi.fn(), vi.fn()); + await replacement.client.abort(); + + expect(getTask).toHaveBeenNthCalledWith(1, "task-1", "run-old"); + expect(getTask).toHaveBeenNthCalledWith(2, "task-1", "run-new"); + expect(cloudTasks.sendCommand).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ runId: "run-old" }), + ); + expect(cloudTasks.sendCommand).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ runId: "run-new" }), + ); + }); + + it("delegates local session lifetime to the controller", async () => { + const local = localSession(); + const localSessions = localFactory(local); + const cloudTasks = cloudTaskClient(); + const tasks = taskService("local"); + const provider = new RoutingPiSessionProvider( + localSessions, + cloudTasks, + tasks, + ); + + const first = await provider.get("task-1"); + const second = await provider.get("task-1"); + + expect(first).toBe(local); + expect(second).toBe(local); + expect(localSessions.get).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/src/pi-runtime/piSessionProvider.ts b/packages/core/src/pi-runtime/piSessionProvider.ts new file mode 100644 index 0000000000..b5dc0c9ae5 --- /dev/null +++ b/packages/core/src/pi-runtime/piSessionProvider.ts @@ -0,0 +1,68 @@ +import { inject, injectable } from "inversify"; +import { + CLOUD_TASK_CLIENT, + type CloudTaskClient, +} from "../cloud-task/cloudTaskClient"; +import { TASK_SERVICE, type TaskService } from "../task-detail/taskService"; +import { + CloudPiSessionClient, + type CloudPiSessionContext, +} from "./cloudPiSessionClient"; +import { + LOCAL_PI_SESSION_FACTORY, + type PiSession, + type PiSessionFactory, + type PiSessionProvider, +} from "./piSessionController"; + +@injectable() +export class RoutingPiSessionProvider implements PiSessionProvider { + constructor( + @inject(LOCAL_PI_SESSION_FACTORY) + private readonly localFactory: PiSessionFactory, + @inject(CLOUD_TASK_CLIENT) + private readonly cloudTaskClient: CloudTaskClient, + @inject(TASK_SERVICE) + private readonly taskService: TaskService, + ) {} + + async get(taskId: string, taskRunId?: string): Promise { + const cloudContext = await this.resolveCloudContext(taskId, taskRunId); + if (cloudContext) { + return new CloudPiSessionClient(this.cloudTaskClient, cloudContext); + } + return this.localFactory.get(taskId); + } + + private async resolveCloudContext( + taskId: string, + taskRunId?: string, + ): Promise { + const [task, context] = await Promise.all([ + this.taskService.getTask(taskId, taskRunId), + this.cloudTaskClient.getContext(), + ]); + const run = task.latest_run; + if (!context || !run || run.environment !== "cloud") { + return null; + } + + const storage = await this.taskService + .getCloudPiTaskSessionStorage(taskId, run.id) + .catch(() => null); + const persistedConfig = + storage?.download_url && this.localFactory.readSessionConfig + ? await this.localFactory + .readSessionConfig(storage.download_url) + .catch(() => null) + : null; + + return { + taskId, + runId: run.id, + runStatus: run.status, + persistedConfig, + ...context, + }; + } +} diff --git a/packages/core/src/pi-runtime/piSessionStore.ts b/packages/core/src/pi-runtime/piSessionStore.ts index 1b4519a59f..a1dd61b0e1 100644 --- a/packages/core/src/pi-runtime/piSessionStore.ts +++ b/packages/core/src/pi-runtime/piSessionStore.ts @@ -1,18 +1,45 @@ import type { PiCommand, - PiModelOption, + PiNativeModelInfo, + PiQueueSnapshot, + PiSessionStats, PiSessionStatus, + PiThinkingLevel, } from "@posthog/agent/pi/types"; -import type { AgentConversationEvent } from "@posthog/shared"; +import type { + AgentConversationEvent, + GatewayLimitCause, + PromptFailureKind, + SessionStatus, + TaskRunStatus, +} from "@posthog/shared"; import { createStore, type StoreApi } from "zustand/vanilla"; +export interface PiSessionError { + id: string; + scope: "connection" | "operation"; + kind: PromptFailureKind; + title: string; + message: string; + retryable: boolean; + limitCause: GatewayLimitCause | null; + recoveryPrompt?: string; +} + export interface PiControllerSessionState { - connectionState: "connecting" | "connected" | "failed"; + connectionState: SessionStatus; events: AgentConversationEvent[]; - models: PiModelOption[]; + models: Array>; + modelsLoaded: boolean; + thinkingLevels: PiThinkingLevel[]; + thinkingLevelsLoaded: boolean; commands: PiCommand[]; + queue: PiQueueSnapshot; status?: PiSessionStatus; - error?: string; + stats?: PiSessionStats; + cloudStatus?: TaskRunStatus; + error?: PiSessionError; + authRestoring: boolean; isBashRunning: boolean; } @@ -31,7 +58,12 @@ export function createEmptyPiControllerSession(): PiControllerSessionState { connectionState: "connecting", events: [], models: [], + modelsLoaded: false, + thinkingLevels: [], + thinkingLevelsLoaded: false, commands: [], + queue: { steering: [], followUp: [] }, + authRestoring: false, isBashRunning: false, }; } diff --git a/packages/core/src/pi-runtime/piSessionUsage.test.ts b/packages/core/src/pi-runtime/piSessionUsage.test.ts new file mode 100644 index 0000000000..d25f30bc4f --- /dev/null +++ b/packages/core/src/pi-runtime/piSessionUsage.test.ts @@ -0,0 +1,55 @@ +import type { PiSessionStats } from "@posthog/agent/pi/types"; +import { describe, expect, it } from "vitest"; +import { toPiContextUsage } from "./piSessionUsage"; + +function stats( + contextUsage: PiSessionStats["contextUsage"], + cost = 0, +): PiSessionStats { + return { + sessionFile: undefined, + sessionId: "session-1", + userMessages: 1, + assistantMessages: 1, + toolCalls: 0, + toolResults: 0, + totalMessages: 2, + tokens: { + input: 1_000, + output: 500, + cacheRead: 100, + cacheWrite: 50, + total: 1_650, + }, + cost, + contextUsage, + }; +} + +describe("toPiContextUsage", () => { + it("maps native Pi context and cost statistics to shared context usage", () => { + expect( + toPiContextUsage( + stats( + { tokens: 38_323, contextWindow: 1_000_000, percent: 3.8323 }, + 0.42, + ), + ), + ).toEqual({ + used: 38_323, + size: 1_000_000, + percentage: 4, + cost: { amount: 0.42, currency: "USD" }, + breakdown: null, + breakdownAvailable: false, + }); + }); + + it("hides context usage while Pi cannot estimate it", () => { + expect( + toPiContextUsage( + stats({ tokens: null, contextWindow: 100_000, percent: null }), + ), + ).toBeNull(); + }); +}); diff --git a/packages/core/src/pi-runtime/piSessionUsage.ts b/packages/core/src/pi-runtime/piSessionUsage.ts new file mode 100644 index 0000000000..87036a9bbf --- /dev/null +++ b/packages/core/src/pi-runtime/piSessionUsage.ts @@ -0,0 +1,25 @@ +import type { PiSessionStats } from "@posthog/agent/pi/types"; +import type { ContextUsage } from "../sessions/contextUsage"; + +export function toPiContextUsage( + stats: PiSessionStats | undefined, +): ContextUsage | null { + const usage = stats?.contextUsage; + if (!usage || usage.tokens === null) { + return null; + } + + return { + used: usage.tokens, + size: usage.contextWindow, + percentage: Math.round( + usage.percent ?? + (usage.contextWindow > 0 + ? (usage.tokens / usage.contextWindow) * 100 + : 0), + ), + cost: stats.cost > 0 ? { amount: stats.cost, currency: "USD" } : null, + breakdown: null, + breakdownAvailable: false, + }; +} diff --git a/packages/core/src/sessions/contextUsage.ts b/packages/core/src/sessions/contextUsage.ts index df3f4001e3..97d5d9ed11 100644 --- a/packages/core/src/sessions/contextUsage.ts +++ b/packages/core/src/sessions/contextUsage.ts @@ -18,6 +18,7 @@ export interface ContextUsage { /** Cumulative estimated session cost, summed across turns; `null` if none reported (e.g. codex). */ cost: { amount: number; currency: string } | null; breakdown: ContextBreakdown | null; + breakdownAvailable?: boolean; } type ContextUsageAggregate = Omit; diff --git a/packages/core/src/sessions/promptContent.test.ts b/packages/core/src/sessions/promptContent.test.ts index 6cfe9dee15..6ab8eebc31 100644 --- a/packages/core/src/sessions/promptContent.test.ts +++ b/packages/core/src/sessions/promptContent.test.ts @@ -71,6 +71,29 @@ describe("promptContent", () => { ]); }); + it("extracts inline Pi images as previewable attachments", () => { + const result = extractPromptDisplayContent([ + { type: "text", text: "what is in this image?" }, + { + type: "image", + data: "aW1hZ2U=", + mimeType: "image/png", + fileName: "screenshot.png", + } as Parameters[0][number], + ]); + + expect(result).toEqual({ + text: "what is in this image?", + attachments: [ + { + id: expect.stringMatching(/^inline-image:/), + label: "screenshot.png", + previewUrl: "data:image/png;base64,aW1hZ2U=", + }, + ], + }); + }); + it("does not mark ordinary file URIs as cloud artifacts", () => { const fileUri = "file:///tmp/screenshot.png"; diff --git a/packages/core/src/sessions/promptContent.ts b/packages/core/src/sessions/promptContent.ts index fb19f6296b..1f17d95605 100644 --- a/packages/core/src/sessions/promptContent.ts +++ b/packages/core/src/sessions/promptContent.ts @@ -23,6 +23,7 @@ export function makeAttachmentUri(filePath: string): string { export interface AttachmentRef { id: string; label: string; + previewUrl?: string; cloudArtifact?: CloudArtifactRef; } @@ -99,12 +100,35 @@ function getBlockAttachmentRef(block: ContentBlock): AttachmentRef | null { } if (block.type === "image") { - const uri = block.uri; - if (!uri) { + const image = block as typeof block & { + data?: string; + fileName?: string; + mimeType?: string; + uri?: string; + }; + if (image.uri) { + return parseAttachmentUri(image.uri) ?? parseFileUri(image.uri); + } + if (!image.data || !image.mimeType) { return null; } - return parseAttachmentUri(uri) ?? parseFileUri(uri); + const extensionByMimeType: Record = { + "image/gif": "gif", + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + }; + const extension = extensionByMimeType[image.mimeType]; + if (!extension) { + return null; + } + const id = `inline-image:${hashAttachmentPath(`${image.mimeType}:${image.data}`)}`; + return { + id, + label: image.fileName || `image.${extension}`, + previewUrl: `data:${image.mimeType};base64,${image.data}`, + }; } if (block.type === "resource_link") { diff --git a/packages/core/src/task-detail/piTaskCreator.ts b/packages/core/src/task-detail/piTaskCreator.ts deleted file mode 100644 index b453b6f5aa..0000000000 --- a/packages/core/src/task-detail/piTaskCreator.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { - Saga, - type SagaLogger, - type TaskCreationInput, - type TaskCreationOutput, - type Workspace, -} from "@posthog/shared"; -import type { Task } from "@posthog/shared/domain-types"; -import type { PiRunner } from "../pi-runtime/piRunner"; -import type { TaskCreationApiClient } from "./taskCreationApiClient"; -import type { ITaskCreationHost } from "./taskCreationHost"; -import { resolveTaskRepository } from "./taskRepository"; - -export interface PiTaskCreatorDeps { - posthogClient: TaskCreationApiClient; - host: ITaskCreationHost; - piRunner: PiRunner; - onTaskReady?: (output: TaskCreationOutput) => void; -} - -export class PiTaskCreator extends Saga { - readonly sagaName = "PiTaskCreator"; - - constructor( - private readonly deps: PiTaskCreatorDeps, - logger?: SagaLogger, - ) { - super(logger); - } - - protected async execute( - input: TaskCreationInput, - ): Promise { - if (input.workspaceMode === "cloud") { - throw new Error("Pi tasks are only supported in local workspaces"); - } - - const task = await this.createTask(input); - const repoPath = input.repoPath; - let workspace: Workspace | null = null; - - if (repoPath) { - workspace = await this.createWorkspace(task, repoPath, input); - } else if (input.allowNoRepo) { - workspace = await this.createScratchWorkspace(task); - } - - if (!workspace) { - throw new Error("Pi tasks require a workspace or scratch directory"); - } - - const cwd = workspace.worktreePath ?? workspace.folderPath; - const additionalDirectories = (input.additionalDirectories ?? []).filter( - (path) => path && path !== input.repoPath, - ); - if (additionalDirectories.length > 0) { - await this.step({ - name: "additional_directories", - execute: async () => { - await Promise.all( - additionalDirectories.map((path) => - this.deps.host.addAdditionalDirectory({ taskId: task.id, path }), - ), - ); - return { taskId: task.id, paths: additionalDirectories }; - }, - rollback: async ({ taskId, paths }) => { - await Promise.all( - paths.map((path) => - this.deps.host.removeAdditionalDirectory({ taskId, path }), - ), - ); - }, - }); - } - - await this.step({ - name: "pi_session", - execute: async () => { - await this.deps.piRunner.create({ - taskId: task.id, - cwd, - prompt: input.content ?? "", - model: input.model, - }); - return { taskId: task.id }; - }, - rollback: async ({ taskId }) => this.deps.piRunner.stop(taskId), - }); - - this.deps.onTaskReady?.({ task, workspace }); - return { task, workspace }; - } - - private async createTask(input: TaskCreationInput): Promise { - const repository = await resolveTaskRepository( - input, - this.deps.host, - this.log, - ); - - return this.step({ - name: "task_creation", - execute: async () => - (await this.deps.posthogClient.createTask({ - description: input.content ?? "", - repository: repository ?? undefined, - origin_product: input.signalReportId - ? "signal_report" - : "user_created", - signal_report: input.signalReportId ?? undefined, - channel: input.channelId ?? undefined, - runtime: "pi", - })) as unknown as Task, - rollback: async (task) => this.deps.posthogClient.deleteTask(task.id), - }); - } - - private async createWorkspace( - task: Task, - repoPath: string, - input: TaskCreationInput, - ): Promise { - const folder = await this.deps.host.getFolders().then(async (folders) => { - const existing = folders.find((candidate) => candidate.path === repoPath); - return existing ?? this.deps.host.addFolder({ folderPath: repoPath }); - }); - const workspaceInfo = await this.step({ - name: "workspace_creation", - execute: () => - this.deps.host.createWorkspace({ - taskId: task.id, - mainRepoPath: repoPath, - folderId: folder.id, - folderPath: repoPath, - mode: input.workspaceMode ?? "local", - branch: input.branch ?? undefined, - allowRemoteBranchCheckout: input.allowRemoteBranchCheckout, - reuseExistingWorktree: input.reuseExistingWorktree, - }), - rollback: () => - this.deps.host.deleteWorkspace({ - taskId: task.id, - mainRepoPath: repoPath, - }), - }); - - const workspaceMode = input.workspaceMode ?? "local"; - const worktree = workspaceInfo.worktree; - if (workspaceMode === "worktree" && !worktree) { - throw new Error("Pi worktree creation did not return a worktree"); - } - if (worktree) { - return { - taskId: task.id, - folderId: folder.id, - folderPath: repoPath, - mode: workspaceMode, - worktreePath: worktree.worktreePath, - worktreeName: worktree.worktreeName, - branchName: worktree.branchName, - baseBranch: worktree.baseBranch, - linkedBranch: workspaceInfo.linkedBranch, - createdAt: worktree.createdAt, - }; - } - - return { - taskId: task.id, - folderId: folder.id, - folderPath: repoPath, - mode: "local", - worktreePath: null, - worktreeName: null, - branchName: workspaceInfo.branchName, - baseBranch: input.branch ?? null, - linkedBranch: workspaceInfo.linkedBranch, - createdAt: new Date().toISOString(), - }; - } - - private async createScratchWorkspace(task: Task): Promise { - const folderPath = await this.deps.host.ensureScratchDir(task.id); - return { - taskId: task.id, - folderId: "", - folderPath, - mode: "local", - worktreePath: null, - worktreeName: null, - branchName: null, - baseBranch: null, - linkedBranch: null, - createdAt: new Date().toISOString(), - }; - } -} diff --git a/packages/core/src/task-detail/taskCreationApiClient.ts b/packages/core/src/task-detail/taskCreationApiClient.ts index 9a24ccd2e8..428189f9b0 100644 --- a/packages/core/src/task-detail/taskCreationApiClient.ts +++ b/packages/core/src/task-detail/taskCreationApiClient.ts @@ -1,3 +1,4 @@ +import type { TaskSessionStorageAccess } from "@posthog/api-client/posthog-client"; import type { Adapter, CloudMcpServerImport, @@ -12,6 +13,7 @@ export interface CreateTaskRunClientOptions { mode?: "interactive" | "background"; branch?: string | null; adapter?: Adapter; + piRuntime?: boolean; model?: string; reasoningLevel?: string; sandboxEnvironmentId?: string; @@ -45,4 +47,9 @@ export interface TaskCreationApiClient { runId: string, options?: StartTaskRunClientOptions, ): Promise; + getTaskSessionStorageAccess( + taskId: string, + runId: string, + ): Promise; + resumeRunInCloud(taskId: string, runId: string): Promise; } diff --git a/packages/core/src/task-detail/taskCreationEffects.ts b/packages/core/src/task-detail/taskCreationEffects.ts index 60981a8d4b..cce2d882ce 100644 --- a/packages/core/src/task-detail/taskCreationEffects.ts +++ b/packages/core/src/task-detail/taskCreationEffects.ts @@ -1,4 +1,5 @@ import type { TaskCreationInput, TaskCreationOutput } from "@posthog/shared"; +import type { TaskRun } from "@posthog/shared/domain-types"; /** * Host-side reactions to a successful task-creation: optimistic workspace @@ -9,4 +10,5 @@ import type { TaskCreationInput, TaskCreationOutput } from "@posthog/shared"; export interface TaskCreationEffects { onWorkspaceCreated(output: TaskCreationOutput): void; onCreateSuccess(output: TaskCreationOutput, input?: TaskCreationInput): void; + onRunResumed(taskId: string, run: TaskRun): void; } diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index 2e04f0daff..f4893f95a4 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -10,8 +10,6 @@ const mockHost = vi.hoisted(() => ({ getAuthenticatedClient: vi.fn(), getTaskDirectory: vi.fn(), ensureScratchDir: vi.fn(), - startPiSession: vi.fn(), - stopPiSession: vi.fn(), getWorkspace: vi.fn(), createWorkspace: vi.fn(), deleteWorkspace: vi.fn(), @@ -38,12 +36,17 @@ const mockHost = vi.hoisted(() => ({ linkTaskBranch: vi.fn(), })); -import { PiTaskCreator } from "./piTaskCreator"; import { TaskCreationSaga } from "./taskCreationSaga"; import { buildWorktreeAdoptionInput } from "./taskInput"; const host = mockHost as unknown as ITaskCreationHost; +const piRunner = { + create: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + stop: vi.fn(async () => {}), +}; + const sessionService = { connectToTask: vi.fn(), disconnectFromTask: vi.fn(), @@ -99,6 +102,7 @@ function makeSaga( } as never, host, sessionService, + piRunner, track: vi.fn(), ...extra, }); @@ -258,6 +262,7 @@ describe("TaskCreationSaga", () => { } as never, host, sessionService, + piRunner, track: vi.fn(), }); @@ -308,6 +313,7 @@ describe("TaskCreationSaga", () => { } as never, host, sessionService, + piRunner, track: vi.fn(), }); @@ -350,23 +356,14 @@ describe("TaskCreationSaga", () => { it("starts a Pi session without creating an ACP session", async () => { const createdTask = createTask({ repository: undefined }); const createTaskRequest = vi.fn().mockResolvedValue(createdTask); - const saga = new PiTaskCreator({ - posthogClient: { - createTask: createTaskRequest, - deleteTask: vi.fn(), - } as never, - host, - piRunner: { - create: mockHost.startPiSession, - stop: mockHost.stopPiSession, - } as never, - }); + const saga = makeSaga({ createTask: createTaskRequest }); const result = await saga.run({ content: "Draft a launch email", workspaceMode: "local", runtime: "pi", model: "claude-sonnet", + reasoningLevel: "medium", allowNoRepo: true, }); @@ -374,13 +371,98 @@ describe("TaskCreationSaga", () => { expect(createTaskRequest).toHaveBeenCalledWith( expect.objectContaining({ runtime: "pi" }), ); - expect(mockHost.startPiSession).toHaveBeenCalledWith({ + expect(piRunner.create).toHaveBeenCalledWith({ taskId: "task-123", cwd: "/tmp/scratch/task-123", prompt: "Draft a launch email", model: "claude-sonnet", + thinkingLevel: "medium", }); expect(sessionService.connectToTask).not.toHaveBeenCalled(); + expect(sessionService.markTaskCreationInFlight).not.toHaveBeenCalled(); + }); + + it("uploads cloud Pi attachments before starting the run", async () => { + const createdTask = createTask({ repository: "posthog/posthog" }); + const startedTask = createTask({ latest_run: createRun(), runtime: "pi" }); + const createTaskRun = vi.fn().mockResolvedValue(createRun()); + const startTaskRun = vi.fn().mockResolvedValue(startedTask); + mockHost.getCloudPromptTransport.mockReturnValue({ + filePaths: ["/tmp/input.txt"], + skillBundles: [], + messageText: "Read this", + promptText: "Read this\n\nAttached files: input.txt", + }); + mockHost.uploadRunAttachments.mockResolvedValue(["artifact-1"]); + const saga = makeSaga({ + createTask: vi.fn().mockResolvedValue(createdTask), + createTaskRun, + startTaskRun, + }); + + const result = await saga.run({ + content: "Read this", + filePaths: ["/tmp/input.txt"], + repository: "posthog/posthog", + workspaceMode: "cloud", + runtime: "pi", + }); + + expect(result.success).toBe(true); + expect(mockHost.uploadRunAttachments).toHaveBeenCalledWith( + expect.anything(), + "task-123", + "run-123", + ["/tmp/input.txt"], + [], + ); + expect(startTaskRun).toHaveBeenCalledWith("task-123", "run-123", { + pendingUserMessage: "Read this", + pendingUserArtifactIds: ["artifact-1"], + }); + }); + + it("starts a cloud Pi run without creating a local runtime", async () => { + const createdTask = createTask({ repository: "posthog/posthog" }); + const startedTask = createTask({ latest_run: createRun(), runtime: "pi" }); + const createTaskRun = vi.fn().mockResolvedValue(createRun()); + const startTaskRun = vi.fn().mockResolvedValue(startedTask); + const saga = makeSaga({ + createTask: vi.fn().mockResolvedValue(createdTask), + createTaskRun, + startTaskRun, + }); + + const result = await saga.run({ + content: "Fix the cloud build", + repository: "posthog/posthog", + workspaceMode: "cloud", + runtime: "pi", + branch: "main", + adapter: "codex", + model: "gpt-5.4", + reasoningLevel: "high", + }); + + expect(result.success).toBe(true); + expect(createTaskRun).toHaveBeenCalledWith( + "task-123", + expect.objectContaining({ + environment: "cloud", + mode: "interactive", + branch: "main", + adapter: undefined, + piRuntime: true, + model: undefined, + reasoningLevel: undefined, + initialPermissionMode: undefined, + }), + ); + expect(startTaskRun).toHaveBeenCalledWith("task-123", "run-123", { + pendingUserMessage: "Fix the cloud build", + pendingUserArtifactIds: undefined, + }); + expect(piRunner.create).not.toHaveBeenCalled(); }); it("uploads initial cloud attachments before starting the run", async () => { diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 59ebd86710..a2f384cb1b 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -1,3 +1,4 @@ +import { PI_THINKING_LEVELS } from "@posthog/agent/pi/types"; import { buildChannelContextBlock, buildChannelContextText, @@ -18,6 +19,7 @@ import { } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; +import type { PiRunner } from "../pi-runtime/piRunner"; import type { TaskCreationApiClient } from "./taskCreationApiClient"; import type { CloudPromptTransport, @@ -30,6 +32,7 @@ export interface TaskCreationDeps { posthogClient: TaskCreationApiClient; host: ITaskCreationHost; sessionService: SessionService; + piRunner: PiRunner; onTaskReady?: (output: TaskCreationOutput) => void; track: (event: string, props?: Record) => void; } @@ -87,15 +90,18 @@ export class TaskCreationSaga extends Saga< input: TaskCreationInput, ): Promise { const taskId = input.taskId; + const isPiRuntime = input.runtime === "pi"; const folderPromise = !taskId && input.repoPath ? this.resolveFolder(input.repoPath) : undefined; - const importedClaude = await this.importClaudeSession(input); + const importedClaude = isPiRuntime + ? undefined + : await this.importClaudeSession(input); const warmPayload = - !taskId && input.workspaceMode === "cloud" + !isPiRuntime && !taskId && input.workspaceMode === "cloud" ? await this.prepareWarmActivation(input) : null; @@ -105,9 +111,9 @@ export class TaskCreationSaga extends Saga< ) : await this.createTask(input, warmPayload); - // Session reconcile auto-recovers run-less local tasks; mark this one as - // mid-creation so the recovery doesn't race the agent_session step below. - this.deps.sessionService.markTaskCreationInFlight(task.id); + if (!isPiRuntime) { + this.deps.sessionService.markTaskCreationInFlight(task.id); + } if (importedClaude && input.repoPath) { await this.recordClaudeImport(input, importedClaude, task.id); @@ -387,23 +393,23 @@ export class TaskCreationSaga extends Saga< // the optimistic placeholder would show the bare task description with // no CONTEXT.md / personalization chip. Hand the augmented message to // the session service so it seeds the placeholder right away. - if (augmented && pendingUserMessage) { + if (!isPiRuntime && augmented && pendingUserMessage) { this.deps.sessionService.rememberInitialCloudPrompt( task.id, pendingUserMessage, ); } - // A cloud run always needs an explicit runtime adapter — the API rejects - // `initial_permission_mode` unless `runtime_adapter` is set. Callers that don't pick one - // (e.g. canvas generation) default to claude, matching the local-connect default below. - const cloudAdapter = input.adapter ?? "claude"; + const cloudAdapter = isPiRuntime + ? undefined + : (input.adapter ?? "claude"); const taskRun = await this.deps.posthogClient.createTaskRun(task.id, { environment: "cloud", mode: "interactive", branch, adapter: cloudAdapter, - model: input.model, - reasoningLevel: input.reasoningLevel, + ...(isPiRuntime ? { piRuntime: true } : {}), + model: isPiRuntime ? undefined : input.model, + reasoningLevel: isPiRuntime ? undefined : input.reasoningLevel, sandboxEnvironmentId: input.sandboxEnvironmentId, customImageId: input.customImageId, prAuthorshipMode, @@ -413,15 +419,16 @@ export class TaskCreationSaga extends Saga< signalReportId: input.signalReportId, importedMcpServers: input.importedMcpServers, relayedMcpServers: input.relayedMcpServers, - initialPermissionMode: - input.executionMode ?? - (cloudAdapter === "codex" ? "auto" : "plan"), + initialPermissionMode: cloudAdapter + ? (input.executionMode ?? + (cloudAdapter === "codex" ? "auto" : "plan")) + : undefined, }); if (!taskRun?.id) { throw new Error("Failed to create cloud run"); } - if (input.relayedMcpServers?.length) { + if (!isPiRuntime && input.relayedMcpServers?.length) { // Best-effort: relay designation failing must not fail creation — // the run still works, minus desktop-relayed servers. await this.deps.sessionService @@ -489,7 +496,7 @@ export class TaskCreationSaga extends Saga< if (shouldConnect) { const initialPrompt = - !input.taskId && input.content + !isPiRuntime && !input.taskId && input.content ? await this.readOnlyStep("build_prompt_blocks", () => buildPromptBlocks( input.content ?? "", @@ -514,6 +521,21 @@ export class TaskCreationSaga extends Saga< await this.step({ name: "agent_session", execute: async () => { + if (isPiRuntime) { + const thinkingLevel = PI_THINKING_LEVELS.find( + (level) => level === input.reasoningLevel, + ); + + await this.deps.piRunner.create({ + taskId: task.id, + cwd: agentCwd ?? "", + prompt: input.content ?? "", + model: input.model, + thinkingLevel, + }); + return { taskId: task.id }; + } + const connectParams: ConnectParams = { task, repoPath: agentCwd ?? "", @@ -534,6 +556,10 @@ export class TaskCreationSaga extends Saga< return { taskId: task.id }; }, rollback: async ({ taskId }) => { + if (isPiRuntime) { + await this.deps.piRunner.stop(taskId); + return; + } this.log.info("Rolling back: disconnecting agent session", { taskId, }); @@ -699,17 +725,18 @@ export class TaskCreationSaga extends Saga< augmented, }; - const lease = input.repository - ? this.deps.host.takeWarmTaskLease({ - repository: input.repository, - branch: input.branch ?? null, - runtimeAdapter: input.adapter ?? null, - model: input.model ?? null, - reasoningEffort: input.reasoningLevel ?? null, - sandboxEnvironmentId: input.sandboxEnvironmentId ?? null, - customImageId: input.customImageId ?? null, - }) - : null; + const lease = + input.repository && input.runtime !== "pi" + ? this.deps.host.takeWarmTaskLease({ + repository: input.repository, + branch: input.branch ?? null, + runtimeAdapter: input.adapter ?? null, + model: input.model ?? null, + reasoningEffort: input.reasoningLevel ?? null, + sandboxEnvironmentId: input.sandboxEnvironmentId ?? null, + customImageId: input.customImageId ?? null, + }) + : null; const requiresConfiguredWarm = Boolean( input.sandboxEnvironmentId || input.customImageId, @@ -761,6 +788,8 @@ export class TaskCreationSaga extends Saga< name: "task_creation", execute: async () => { const description = input.taskDescription ?? input.content ?? ""; + const canActivateWarmRun = + input.runtime !== "pi" && !warmPayload?.suppressWarmReuse; const result = await this.deps.posthogClient.createTask({ description, repository: repository ?? undefined, @@ -780,36 +809,46 @@ export class TaskCreationSaga extends Saga< // The server associates the task with the report and records the implementation // task_run artefact — no relationship label is sent (associations are unlabelled). branch: - input.workspaceMode === "cloud" && !warmPayload?.suppressWarmReuse + input.workspaceMode === "cloud" && canActivateWarmRun ? (input.branch ?? null) : undefined, runtime_adapter: - input.workspaceMode === "cloud" + input.workspaceMode === "cloud" && + canActivateWarmRun && + input.runtime !== "pi" ? (input.adapter ?? null) : undefined, model: - input.workspaceMode === "cloud" ? (input.model ?? null) : undefined, + input.workspaceMode === "cloud" && + canActivateWarmRun && + input.runtime !== "pi" + ? (input.model ?? null) + : undefined, reasoning_effort: - input.workspaceMode === "cloud" + input.workspaceMode === "cloud" && + canActivateWarmRun && + input.runtime !== "pi" ? (input.reasoningLevel ?? null) : undefined, sandbox_environment_id: - input.workspaceMode === "cloud" && !warmPayload?.suppressWarmReuse + input.workspaceMode === "cloud" && canActivateWarmRun ? input.sandboxEnvironmentId : undefined, custom_image_id: - input.workspaceMode === "cloud" && !warmPayload?.suppressWarmReuse + input.workspaceMode === "cloud" && canActivateWarmRun ? input.customImageId : undefined, signal_report: input.signalReportId ?? undefined, channel: input.channelId ?? undefined, - runtime: "acp", + runtime: input.runtime ?? "acp", pending_user_message: warmPayload?.pendingUserMessage, pending_user_artifact_ids: warmPayload?.pendingUserArtifactIds, // If creation activates a pre-warmed run, this is the only request // that can carry the choice — the saga skips run creation entirely. auto_publish: - input.workspaceMode === "cloud" && input.cloudAutoPublish + input.workspaceMode === "cloud" && + canActivateWarmRun && + input.cloudAutoPublish ? true : undefined, }); diff --git a/packages/core/src/task-detail/taskService.test.ts b/packages/core/src/task-detail/taskService.test.ts index 7883f3f4b5..9b147bd059 100644 --- a/packages/core/src/task-detail/taskService.test.ts +++ b/packages/core/src/task-detail/taskService.test.ts @@ -54,6 +54,83 @@ function makeService(): TaskService { return new TaskService(host, sessionService, effects, piRunner, rootLogger); } +describe("TaskService.openTask", () => { + it("opens a completed cloud Pi run without resuming it", async () => { + const completedRun = { + id: "run-1", + environment: "cloud", + status: "completed", + }; + const api = { + getTask: vi.fn(async () => ({ + id: "task-1", + runtime: "pi", + latest_run: completedRun, + })), + getTaskRun: vi.fn(async () => completedRun), + resumeRunInCloud: vi.fn(), + }; + const workspace = { folderPath: "/repo" }; + const host = { + getAuthenticatedClient: vi.fn(async () => api), + getWorkspace: vi.fn(async () => workspace), + } as unknown as ITaskCreationHost; + const piRunner = { + create: vi.fn(), + resume: vi.fn(), + stop: vi.fn(), + } as unknown as PiRunner; + const service = new TaskService( + host, + {} as SessionService, + {} as TaskCreationEffects, + piRunner, + rootLogger, + ); + + const result = await service.openTask("task-1", "run-1"); + + expect(result.success).toBe(true); + expect(api.resumeRunInCloud).not.toHaveBeenCalled(); + expect(piRunner.resume).not.toHaveBeenCalled(); + if (result.success) { + expect(result.data.task.latest_run).toBe(completedRun); + expect(result.data.workspace).toBe(workspace); + } + }); +}); + +describe("TaskService.resumeCloudPiRun", () => { + it("publishes the resumed run to host state", async () => { + const run = { + id: "run-2", + task_id: "task-1", + environment: "cloud", + status: "queued", + }; + const api = { + resumeRunInCloud: vi.fn(async () => run), + }; + const effects = { + onRunResumed: vi.fn(), + } as unknown as TaskCreationEffects; + const service = new TaskService( + { + getAuthenticatedClient: vi.fn(async () => api), + } as unknown as ITaskCreationHost, + {} as SessionService, + effects, + {} as PiRunner, + rootLogger, + ); + + await expect(service.resumeCloudPiRun("task-1", "run-1")).resolves.toBe( + run, + ); + expect(effects.onRunResumed).toHaveBeenCalledWith("task-1", run); + }); +}); + describe("TaskService.createTask validation", () => { it("rejects an input with neither content nor a taskDescription", async () => { const result = await makeService().createTask({ diff --git a/packages/core/src/task-detail/taskService.ts b/packages/core/src/task-detail/taskService.ts index 73ea84990f..52c76216a6 100644 --- a/packages/core/src/task-detail/taskService.ts +++ b/packages/core/src/task-detail/taskService.ts @@ -1,4 +1,7 @@ -import { CLOUD_USAGE_LIMIT_ERROR_MESSAGE } from "@posthog/api-client/posthog-client"; +import { + CLOUD_USAGE_LIMIT_ERROR_MESSAGE, + type TaskSessionStorageAccess, +} from "@posthog/api-client/posthog-client"; import { SESSION_SERVICE, type SessionService, @@ -9,12 +12,12 @@ import type { TaskCreationInput, TaskCreationOutput, } from "@posthog/shared"; -import type { Task } from "@posthog/shared/domain-types"; +import type { Task, TaskRun } from "@posthog/shared/domain-types"; import { inject, injectable } from "inversify"; +import { extractFilePaths, xmlToContent } from "../message-editor/content"; import { PI_RUNNER } from "../pi-runtime/identifiers"; import type { PiRunner } from "../pi-runtime/piRunner"; import { TASK_CREATION_EFFECTS, TASK_CREATION_HOST } from "./identifiers"; -import { PiTaskCreator } from "./piTaskCreator"; import type { TaskCreationEffects } from "./taskCreationEffects"; import type { ITaskCreationHost } from "./taskCreationHost"; import { TaskCreationSaga } from "./taskCreationSaga"; @@ -55,6 +58,45 @@ export class TaskService { private readonly log: ReturnType; + async prepareCloudPiMessage( + taskId: string, + runId: string, + serializedContent: string, + ): Promise<{ content: string; artifactIds: string[] }> { + const editorContent = xmlToContent(serializedContent); + const filePaths = extractFilePaths(editorContent); + const resolvedContent = + await this.host.resolveLocalSkillCommandPrompt(serializedContent); + const transport = this.host.getCloudPromptTransport( + resolvedContent, + filePaths, + ); + const hasAttachments = + transport.filePaths.length > 0 || transport.skillBundles.length > 0; + if (!hasAttachments) { + return { + content: transport.messageText ?? transport.promptText, + artifactIds: [], + }; + } + + const client = await this.host.getAuthenticatedClient(); + if (!client) { + throw new Error("Not authenticated"); + } + const artifactIds = await this.host.uploadRunAttachments( + client, + taskId, + runId, + transport.filePaths, + transport.skillBundles, + ); + return { + content: transport.messageText ?? transport.promptText, + artifactIds, + }; + } + public async createTask( input: TaskCreationInput, onTaskReady?: (output: TaskCreationOutput) => void, @@ -108,31 +150,18 @@ export class TaskService { } } - let result: CreateTaskResult; - if (input.runtime === "pi") { - const creator = new PiTaskCreator( - { - posthogClient, - host: this.host, - piRunner: this.piRunner, - onTaskReady, - }, - this.log, - ); - result = await creator.run(input); - } else { - const creator = new TaskCreationSaga( - { - posthogClient, - host: this.host, - sessionService: this.sessionService, - track: (event, props) => this.host.track(event, props), - onTaskReady, - }, - this.log, - ); - result = await creator.run(input); - } + const creator = new TaskCreationSaga( + { + posthogClient, + host: this.host, + sessionService: this.sessionService, + piRunner: this.piRunner, + track: (event, props) => this.host.track(event, props), + onTaskReady, + }, + this.log, + ); + const result = await creator.run(input); if (result.success) { this.effects.onWorkspaceCreated(result.data); @@ -142,6 +171,46 @@ export class TaskService { return result; } + public async getTask(taskId: string, taskRunId?: string): Promise { + const posthogClient = await this.host.getAuthenticatedClient(); + if (!posthogClient) { + throw new Error("Not authenticated"); + } + + const task = await posthogClient.getTask(taskId); + if (taskRunId) { + task.latest_run = await posthogClient.getTaskRun(taskId, taskRunId); + } + + return task; + } + + public async getCloudPiTaskSessionStorage( + taskId: string, + taskRunId: string, + ): Promise { + const posthogClient = await this.host.getAuthenticatedClient(); + if (!posthogClient) { + throw new Error("Not authenticated"); + } + + return posthogClient.getTaskSessionStorageAccess(taskId, taskRunId); + } + + public async resumeCloudPiRun( + taskId: string, + taskRunId: string, + ): Promise { + const posthogClient = await this.host.getAuthenticatedClient(); + if (!posthogClient) { + throw new Error("Not authenticated"); + } + + const run = await posthogClient.resumeRunInCloud(taskId, taskRunId); + this.effects.onRunResumed(taskId, run); + return run; + } + public async openTask( taskId: string, taskRunId?: string, @@ -174,6 +243,13 @@ export class TaskService { const runtime = task.runtime === "pi" ? "pi" : "acp"; const existingWorkspace = await this.host.getWorkspace(taskId); + if (runtime === "pi" && task.latest_run?.environment === "cloud") { + return { + success: true, + data: { task, workspace: existingWorkspace }, + }; + } + if (existingWorkspace) { this.log.info("Workspace already exists, fetching task only", { taskId }); try { @@ -223,6 +299,7 @@ export class TaskService { posthogClient, host: this.host, sessionService: this.sessionService, + piRunner: this.piRunner, track: (event, props) => this.host.track(event, props), }, this.log, diff --git a/packages/harness/src/extensions/posthog-provider/models.ts b/packages/harness/src/extensions/posthog-provider/models.ts index 505cb9204d..ffeb20df7a 100644 --- a/packages/harness/src/extensions/posthog-provider/models.ts +++ b/packages/harness/src/extensions/posthog-provider/models.ts @@ -222,14 +222,10 @@ export function fallbackModelConfigs( return FALLBACK_GATEWAY_MODELS.map((model) => toModelConfig(model, region)); } -async function fetchGatewayModels( - region: CloudRegion, - baseUrl = getLlmGatewayUrl(region), +export async function fetchPosthogGatewayModels( + baseUrl: string, apiKey?: string, ): Promise { - if (process.env.PI_OFFLINE || process.env.HARNESS_STATIC_MODELS) { - return []; - } try { const response = await fetch(`${baseUrl.replace(/\/$/, "")}/v1/models`, { headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, @@ -245,20 +241,33 @@ async function fetchGatewayModels( } } +export function resolveModelConfigsFromGatewayModels( + models: GatewayModel[], + region: CloudRegion, +): ProviderModelConfig[] { + if (models.length === 0) { + return fallbackModelConfigs(region); + } + + const withIds = models.filter((model) => Boolean(model.id)); + const usable = withIds.filter((model) => model.allowed !== false); + return (usable.length > 0 ? usable : withIds).map((model) => + toModelConfig(model, region), + ); +} + export async function resolveModelConfigs( region: CloudRegion, baseUrl?: string, apiKey?: string, ): Promise { - const live = await fetchGatewayModels(region, baseUrl, apiKey); - if (live.length === 0) { + if (process.env.PI_OFFLINE || process.env.HARNESS_STATIC_MODELS) { return fallbackModelConfigs(region); } - const withIds = live.filter((model) => Boolean(model.id)); - // pi has no locked-model rendering, so restricted models are dropped. The - // free tier always includes a servable model; guard against empty anyway. - const usable = withIds.filter((model) => model.allowed !== false); - return (usable.length > 0 ? usable : withIds).map((model) => - toModelConfig(model, region), + + const models = await fetchPosthogGatewayModels( + baseUrl ?? getLlmGatewayUrl(region), + apiKey, ); + return resolveModelConfigsFromGatewayModels(models, region); } diff --git a/packages/harness/src/extensions/posthog-provider/provider.ts b/packages/harness/src/extensions/posthog-provider/provider.ts index 07980c777b..dc8deaf166 100644 --- a/packages/harness/src/extensions/posthog-provider/provider.ts +++ b/packages/harness/src/extensions/posthog-provider/provider.ts @@ -1,6 +1,10 @@ -import type { Api, Model, OAuthCredentials } from "@earendil-works/pi-ai"; import type { - AuthStorage, + Api, + CredentialStore, + Model, + OAuthCredentials, +} from "@earendil-works/pi-ai"; +import type { ProviderConfig, ProviderModelConfig, } from "@earendil-works/pi-coding-agent"; @@ -36,11 +40,14 @@ export function parsePosthogOAuthCredentials( : null; } -export function setPosthogOAuthCredentials( - storage: AuthStorage, +export async function setPosthogOAuthCredentials( + storage: CredentialStore, credentials: PosthogOAuthCredentials, -): void { - storage.set(POSTHOG_PROVIDER_NAME, { type: "oauth", ...credentials }); +): Promise { + await storage.modify(POSTHOG_PROVIDER_NAME, async () => ({ + type: "oauth", + ...credentials, + })); } /** diff --git a/packages/harness/src/extensions/registry.ts b/packages/harness/src/extensions/registry.ts index 1e19739ca7..ac813b00c4 100644 --- a/packages/harness/src/extensions/registry.ts +++ b/packages/harness/src/extensions/registry.ts @@ -3,15 +3,12 @@ import type { ExtensionFactory, InlineExtension, } from "@earendil-works/pi-coding-agent"; -import { createBackgroundJobsExtension } from "./background-jobs/extension"; import type { HogBrandingOptions } from "./hog-branding/extension"; import { createHogBrandingExtension } from "./hog-branding/extension"; import { createMcpExtension } from "./mcp/extension"; import { createPosthogProviderExtension } from "./posthog-provider/extension"; import type { PosthogProviderOptions } from "./posthog-provider/provider"; -import { createSubagentExtension } from "./subagent/extension"; import { createWebAccessExtension } from "./web-access/extension"; -import { createWorkflowExtension } from "./workflow/extension"; export type HarnessExtensionOptions = PosthogProviderOptions & HogBrandingOptions; @@ -25,9 +22,6 @@ const EXTENSIONS: HarnessExtension[] = [ { name: "hog-branding", create: createHogBrandingExtension }, { name: "posthog-provider", create: createPosthogProviderExtension }, { name: "web-access", create: createWebAccessExtension }, - { name: "background-jobs", create: () => createBackgroundJobsExtension() }, - { name: "subagent", create: createSubagentExtension }, - { name: "workflow", create: createWorkflowExtension }, // createMcpExtension's options are test seams (config loader, transport // factory), not HarnessExtensionOptions, so drop the registry options. { name: "mcp", create: () => createMcpExtension() }, diff --git a/packages/harness/src/runtime.test.ts b/packages/harness/src/runtime.test.ts index 335a50b543..6314e26b35 100644 --- a/packages/harness/src/runtime.test.ts +++ b/packages/harness/src/runtime.test.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { InMemoryCredentialStore } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createHarnessRuntime } from "./runtime"; @@ -34,7 +35,7 @@ describe("createHarnessRuntime", () => { const runtime = await createHarnessRuntime({ agentDir, - authStorage: pi.AuthStorage.inMemory(), + credentialStore: new InMemoryCredentialStore(), cwd, sessionManager: pi.SessionManager.inMemory(cwd), }); @@ -42,19 +43,24 @@ describe("createHarnessRuntime", () => { try { expect(runtime).toBeInstanceOf(pi.AgentSessionRuntime); expect(runtime.session.model?.provider).toBe("posthog"); + expect(runtime.session.getAvailableThinkingLevels()).toContain("off"); expect(runtime.services.settingsManager.isProjectTrusted()).toBe(false); - expect( - runtime.services.resourceLoader - .getExtensions() - .extensions.map((extension) => extension.path), - ).toEqual( + const extensionPaths = runtime.services.resourceLoader + .getExtensions() + .extensions.map((extension) => extension.path); + expect(extensionPaths).toEqual( expect.arrayContaining([ "", "", "", + "", + ]), + ); + expect(extensionPaths).not.toEqual( + expect.arrayContaining([ + "", "", "", - "", ]), ); } finally { @@ -63,6 +69,36 @@ describe("createHarnessRuntime", () => { }, ); + it("restores the session model before calculating context usage", async () => { + vi.stubEnv("PI_OFFLINE", "1"); + const pi = await import("@earendil-works/pi-coding-agent"); + const cwd = await temporaryDirectory(); + const agentDir = await temporaryDirectory(); + const sessionManager = pi.SessionManager.inMemory(cwd); + sessionManager.appendModelChange("posthog", "claude-haiku-4-5"); + sessionManager.appendMessage({ + role: "user", + content: "continue", + timestamp: Date.now(), + }); + + const runtime = await createHarnessRuntime({ + agentDir, + apiKey: "proxy-key", + cwd, + sessionManager, + }); + + try { + expect(runtime.session.model?.id).toBe("claude-haiku-4-5"); + expect(runtime.session.getSessionStats().contextUsage).toMatchObject({ + contextWindow: 200_000, + }); + } finally { + await runtime.dispose(); + } + }); + it("keeps desktop-provided OAuth credentials in memory without touching auth.json", async () => { vi.stubEnv("PI_OFFLINE", "1"); const pi = await import("@earendil-works/pi-coding-agent"); @@ -82,11 +118,9 @@ describe("createHarnessRuntime", () => { }); try { - expect(runtime.services.authStorage.get("posthog")).toMatchObject({ - type: "oauth", - access: "access-token", - refresh: "refresh-token", - }); + await expect( + runtime.services.modelRuntime.getAuth("posthog"), + ).resolves.toMatchObject({ auth: { apiKey: "access-token" } }); expect(existsSync(join(agentDir, "auth.json"))).toBe(false); } finally { await runtime.dispose(); @@ -123,14 +157,12 @@ describe("createHarnessRuntime", () => { }); try { - expect(runtime.services.authStorage.get("anthropic")).toMatchObject({ - type: "api_key", - key: "anthropic-key", - }); - expect(runtime.services.authStorage.get("posthog")).toMatchObject({ - access: "access-token", - refresh: "refresh-token", - }); + await expect( + runtime.services.modelRuntime.getAuth("anthropic"), + ).resolves.toMatchObject({ auth: { apiKey: "anthropic-key" } }); + await expect( + runtime.services.modelRuntime.getAuth("posthog"), + ).resolves.toMatchObject({ auth: { apiKey: "access-token" } }); expect(JSON.parse(await readFile(authPath, "utf8"))).toEqual( storedCredentials, ); @@ -166,18 +198,23 @@ describe("createHarnessRuntime", () => { try { await expect( - runtime.services.modelRegistry.getApiKeyForProvider("posthog"), - ).resolves.toBe("proxy-key"); + runtime.services.modelRuntime.getAuth("posthog"), + ).resolves.toMatchObject({ auth: { apiKey: "proxy-key" } }); } finally { await runtime.dispose(); } }); - it("uses file-backed auth storage when no desktop credentials are provided", async () => { + it("uses file-backed credentials when desktop credentials are absent", async () => { vi.stubEnv("PI_OFFLINE", "1"); const pi = await import("@earendil-works/pi-coding-agent"); const cwd = await temporaryDirectory(); const agentDir = await temporaryDirectory(); + const authPath = join(agentDir, "auth.json"); + await writeFile( + authPath, + JSON.stringify({ posthog: { type: "api_key", key: "stored-key" } }), + ); const runtime = await createHarnessRuntime({ agentDir, @@ -186,14 +223,9 @@ describe("createHarnessRuntime", () => { }); try { - runtime.services.authStorage.set("posthog", { - type: "oauth", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - }); - - expect(existsSync(join(agentDir, "auth.json"))).toBe(true); + await expect( + runtime.services.modelRuntime.listCredentials(), + ).resolves.toContainEqual({ providerId: "posthog", type: "api_key" }); } finally { await runtime.dispose(); } diff --git a/packages/harness/src/runtime.ts b/packages/harness/src/runtime.ts index 91b691c23d..906b53b616 100644 --- a/packages/harness/src/runtime.ts +++ b/packages/harness/src/runtime.ts @@ -1,8 +1,12 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { + type Credential, + type CredentialStore, + InMemoryCredentialStore, +} from "@earendil-works/pi-ai"; import type { AgentSessionRuntime, - AuthStorage, CreateAgentSessionFromServicesOptions, CreateAgentSessionRuntimeFactory, CreateAgentSessionServicesOptions, @@ -16,19 +20,28 @@ import { import type { HarnessExtensionOptions } from "./extensions/registry"; type PiRuntimeTarget = Parameters[0]; -type AuthStorageSnapshot = Parameters[0]; +type CredentialSnapshot = Record; -function loadAuthStorageSnapshot( - authPath: string, -): AuthStorageSnapshot | undefined { +function loadCredentialSnapshot(authPath: string): CredentialSnapshot { try { - return JSON.parse(readFileSync(authPath, "utf8")) as AuthStorageSnapshot; + return JSON.parse(readFileSync(authPath, "utf8")) as CredentialSnapshot; } catch { - return undefined; + return {}; } } +async function createCredentialStore( + snapshot: CredentialSnapshot, +): Promise { + const store = new InMemoryCredentialStore(); + for (const [providerId, credential] of Object.entries(snapshot)) { + await store.modify(providerId, async () => credential); + } + return store; +} + export type HarnessRuntimeOptions = HarnessExtensionOptions & { + credentialStore?: CredentialStore; posthogOAuthCredentials?: PosthogOAuthCredentials; } & Partial< Pick< @@ -54,7 +67,8 @@ export type HarnessRuntimeOptions = HarnessExtensionOptions & { export async function createHarnessRuntime( options: HarnessRuntimeOptions = {}, ): Promise { - const { posthogOAuthCredentials, ...runtimeOptions } = options; + const { credentialStore, posthogOAuthCredentials, ...runtimeOptions } = + options; // Pi reads its application branding when the SDK is first evaluated. Keep // every runtime import below dynamic so this always happens first. installHogBrandEnv(); @@ -75,23 +89,26 @@ export async function createHarnessRuntime( sessionStartEvent, }) => { const authPath = join(runtimeAgentDir, "auth.json"); - const authStorage = - runtimeOptions.authStorage ?? + const credentials = + credentialStore ?? (posthogOAuthCredentials - ? pi.AuthStorage.inMemory(loadAuthStorageSnapshot(authPath)) - : pi.AuthStorage.create(authPath)); - if (posthogOAuthCredentials) { - setPosthogOAuthCredentials(authStorage, posthogOAuthCredentials); - } - if (options.apiKey) { - authStorage.setRuntimeApiKey(POSTHOG_PROVIDER_NAME, options.apiKey); + ? await createCredentialStore(loadCredentialSnapshot(authPath)) + : undefined); + if (credentials && posthogOAuthCredentials) { + await setPosthogOAuthCredentials(credentials, posthogOAuthCredentials); } + const modelRuntime = + runtimeOptions.modelRuntime ?? + (await pi.ModelRuntime.create({ + authPath, + credentials, + })); const services = await pi.createAgentSessionServices({ ...runtimeOptions, cwd: runtimeCwd, agentDir: runtimeAgentDir, - authStorage, + modelRuntime, settingsManager: options.settingsManager ?? pi.SettingsManager.create(runtimeCwd, runtimeAgentDir, { @@ -106,20 +123,33 @@ export async function createHarnessRuntime( }, }); - const preferredModel = services.modelRegistry.find( - "posthog", + if (options.apiKey) { + await services.modelRuntime.setRuntimeApiKey( + POSTHOG_PROVIDER_NAME, + options.apiKey, + ); + } + + const preferredModel = services.modelRuntime.getModel( + POSTHOG_PROVIDER_NAME, DEFAULT_MODEL, ); - const fallbackModel = services.modelRegistry - .getAll() - .find((model) => model.provider === "posthog"); + const fallbackModel = services.modelRuntime + .getModels(POSTHOG_PROVIDER_NAME) + .at(0); + const existingSession = sessionManager.buildSessionContext(); + const hasRestorableModel = + existingSession.messages.length > 0 && existingSession.model !== null; + const defaultModel = hasRestorableModel + ? undefined + : (preferredModel ?? fallbackModel); const created = await pi.createAgentSessionFromServices({ ...runtimeOptions, services, sessionManager, sessionStartEvent, - model: runtimeOptions.model ?? preferredModel ?? fallbackModel, + model: runtimeOptions.model ?? defaultModel, }); return { diff --git a/packages/host-router/package.json b/packages/host-router/package.json index c4df528c7c..40c2d7cf4c 100644 --- a/packages/host-router/package.json +++ b/packages/host-router/package.json @@ -17,6 +17,7 @@ "dependencies": { "@agentclientprotocol/sdk": "0.22.1", "@json-render/core": "^0.19.0", + "@posthog/agent": "workspace:*", "@posthog/core": "workspace:*", "@posthog/di": "workspace:*", "@posthog/host-trpc": "workspace:*", diff --git a/packages/host-router/src/cloud-task-client.ts b/packages/host-router/src/cloud-task-client.ts new file mode 100644 index 0000000000..8476ba818b --- /dev/null +++ b/packages/host-router/src/cloud-task-client.ts @@ -0,0 +1,51 @@ +import type { CloudTaskClient } from "@posthog/core/cloud-task/cloudTaskClient"; +import type { SendCommandInput } from "@posthog/core/cloud-task/schemas"; +import type { CloudTaskUpdatePayload } from "@posthog/shared/domain-types"; +import { inject, injectable } from "inversify"; +import { HOST_TRPC_CLIENT, type HostTrpcClient } from "./client"; + +@injectable() +export class TrpcCloudTaskClient implements CloudTaskClient { + constructor( + @inject(HOST_TRPC_CLIENT) private readonly client: HostTrpcClient, + ) {} + + getContext(): Promise<{ apiHost: string; teamId: number } | null> { + return this.client.cloudTask.context.query(); + } + + async watch(input: { + taskId: string; + runId: string; + apiHost: string; + teamId: number; + }): Promise { + await this.client.cloudTask.watch.mutate(input); + } + + async unwatch(taskId: string, runId: string): Promise { + await this.client.cloudTask.unwatch.mutate({ taskId, runId }); + } + + async retry(taskId: string, runId: string): Promise { + await this.client.cloudTask.retry.mutate({ taskId, runId }); + } + + subscribe( + taskId: string, + runId: string, + onUpdate: (update: CloudTaskUpdatePayload) => void, + onError: (error: unknown) => void, + onStarted: () => void, + ): () => void { + const subscription = this.client.cloudTask.onUpdate.subscribe( + { taskId, runId }, + { onData: onUpdate, onError, onStarted }, + ); + return () => subscription.unsubscribe(); + } + + sendCommand(input: SendCommandInput) { + return this.client.cloudTask.sendCommand.mutate(input); + } +} diff --git a/apps/code/src/renderer/platform-adapters/trpc-pi-runner.ts b/packages/host-router/src/pi-runner.ts similarity index 100% rename from apps/code/src/renderer/platform-adapters/trpc-pi-runner.ts rename to packages/host-router/src/pi-runner.ts diff --git a/packages/host-router/src/pi-session-client.ts b/packages/host-router/src/pi-session-client.ts deleted file mode 100644 index d168a0c914..0000000000 --- a/packages/host-router/src/pi-session-client.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { PiSessionClient } from "@posthog/core/pi-runtime/piSessionController"; -import { inject, injectable } from "inversify"; -import { HOST_TRPC_CLIENT, type HostTrpcClient } from "./client"; - -@injectable() -export class TrpcPiSessionClient implements PiSessionClient { - constructor( - @inject(HOST_TRPC_CLIENT) private readonly client: HostTrpcClient, - ) {} - - health(taskId: string) { - return this.client.piSession.health.query({ taskId }); - } - - conversation(taskId: string) { - return this.client.piSession.conversation.query({ taskId }); - } - - status(taskId: string) { - return this.client.piSession.status.query({ taskId }); - } - - availableModels(taskId: string) { - return this.client.piSession.availableModels.query({ taskId }); - } - - commands(taskId: string) { - return this.client.piSession.commands.query({ taskId }); - } - - subscribe( - taskId: string, - onEvent: Parameters[1], - onError: Parameters[2], - ): () => void { - const subscription = this.client.piSession.onEvent.subscribe( - { taskId }, - { onData: onEvent, onError }, - ); - - return () => subscription.unsubscribe(); - } - - prompt(taskId: string, prompt: string) { - return this.client.piSession.prompt.mutate({ taskId, prompt }); - } - - steer(taskId: string, message: string) { - return this.client.piSession.steer.mutate({ taskId, message }); - } - - followUp(taskId: string, message: string) { - return this.client.piSession.followUp.mutate({ taskId, message }); - } - - compact(taskId: string, customInstructions?: string) { - return this.client.piSession.compact.mutate({ taskId, customInstructions }); - } - - setModel(taskId: string, provider: string, modelId: string) { - return this.client.piSession.setModel.mutate({ taskId, provider, modelId }); - } - - setThinkingLevel( - taskId: string, - level: Parameters[1], - ) { - return this.client.piSession.setThinkingLevel.mutate({ taskId, level }); - } - - setSteeringMode( - taskId: string, - mode: Parameters[1], - ) { - return this.client.piSession.setSteeringMode.mutate({ taskId, mode }); - } - - setFollowUpMode( - taskId: string, - mode: Parameters[1], - ) { - return this.client.piSession.setFollowUpMode.mutate({ taskId, mode }); - } - - bash(taskId: string, command: string) { - return this.client.piSession.bash.mutate({ taskId, command }); - } - - abort(taskId: string) { - return this.client.piSession.abort.mutate({ taskId }); - } - - abortBash(taskId: string) { - return this.client.piSession.abortBash.mutate({ taskId }); - } -} diff --git a/packages/host-router/src/pi-session-factory.ts b/packages/host-router/src/pi-session-factory.ts new file mode 100644 index 0000000000..79f133ef66 --- /dev/null +++ b/packages/host-router/src/pi-session-factory.ts @@ -0,0 +1,73 @@ +import { + getRemotePiConversation, + RemotePiRpcClient, +} from "@posthog/agent/pi/remote-rpc-client"; +import type { + PiSession, + PiSessionFactory, +} from "@posthog/core/pi-runtime/piSessionController"; +import { inject, injectable } from "inversify"; +import type { HostTrpcClient } from "./client"; +import { HOST_TRPC_CLIENT } from "./client"; + +class TrpcPiSession implements PiSession { + readonly client: RemotePiRpcClient; + + constructor( + private readonly hostClient: HostTrpcClient, + private readonly taskId: string, + ) { + this.client = new RemotePiRpcClient({ + request: async (command) => { + const response = await this.hostClient.piSession.rpc.mutate({ + taskId: this.taskId, + command, + }); + return response; + }, + }); + } + + health() { + return this.hostClient.piSession.health.query({ taskId: this.taskId }); + } + + getConversation() { + return getRemotePiConversation(this.client); + } + + getQueue() { + return this.hostClient.piSession.getQueue.query({ taskId: this.taskId }); + } + + clearQueue() { + return this.hostClient.piSession.clearQueue.mutate({ taskId: this.taskId }); + } + + onConversationEvent( + onEvent: Parameters[0], + onError: Parameters[1], + ): () => void { + const subscription = this.hostClient.piSession.onEvent.subscribe( + { taskId: this.taskId }, + { onData: onEvent, onError }, + ); + + return () => subscription.unsubscribe(); + } +} + +@injectable() +export class TrpcPiSessionFactory implements PiSessionFactory { + constructor( + @inject(HOST_TRPC_CLIENT) private readonly client: HostTrpcClient, + ) {} + + get(taskId: string): Promise { + return Promise.resolve(new TrpcPiSession(this.client, taskId)); + } + + readSessionConfig(downloadUrl: string) { + return this.client.piSession.readSessionConfig.query({ downloadUrl }); + } +} diff --git a/packages/host-router/src/routers/agent.router.ts b/packages/host-router/src/routers/agent.router.ts index f7a7a43f52..1b1e32163d 100644 --- a/packages/host-router/src/routers/agent.router.ts +++ b/packages/host-router/src/routers/agent.router.ts @@ -9,8 +9,8 @@ import { cancelPermissionInput, cancelPromptInput, cancelSessionInput, - getGatewayModelsInput, - getGatewayModelsOutput, + getPiModelCatalogInput, + getPiModelCatalogOutput, getPreviewConfigOptionsInput, getPreviewConfigOptionsOutput, listSessionsInput, @@ -217,13 +217,13 @@ export const agentRouter = router({ } }), - getGatewayModels: publicProcedure - .input(getGatewayModelsInput) - .output(getGatewayModelsOutput) + getPiModelCatalog: publicProcedure + .input(getPiModelCatalogInput) + .output(getPiModelCatalogOutput) .query(({ ctx, input }) => ctx.container .get(AGENT_SERVICE) - .getGatewayModels(input.apiHost), + .getPiModelCatalog(input.apiHost, input.region), ), getPreviewConfigOptions: publicProcedure diff --git a/packages/host-router/src/routers/cloud-task.router.ts b/packages/host-router/src/routers/cloud-task.router.ts index 15d577ce59..57061998d3 100644 --- a/packages/host-router/src/routers/cloud-task.router.ts +++ b/packages/host-router/src/routers/cloud-task.router.ts @@ -2,6 +2,7 @@ import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task"; import { CLOUD_TASK_SERVICE } from "@posthog/core/cloud-task/identifiers"; import { CloudTaskEvent, + cloudContextOutput, designateRelayedMcpServersInput, onUpdateInput, retryInput, @@ -15,6 +16,12 @@ import { import { publicProcedure, router } from "@posthog/host-trpc/trpc"; export const cloudTaskRouter = router({ + context: publicProcedure + .output(cloudContextOutput) + .query(({ ctx }) => + ctx.container.get(CLOUD_TASK_SERVICE).getCloudContext(), + ), + watch: publicProcedure .input(watchInput) .mutation(({ ctx, input }) => diff --git a/packages/host-router/src/routers/pi-session.router.ts b/packages/host-router/src/routers/pi-session.router.ts index 625fc0e009..23a6705c68 100644 --- a/packages/host-router/src/routers/pi-session.router.ts +++ b/packages/host-router/src/routers/pi-session.router.ts @@ -2,40 +2,14 @@ import { publicProcedure, router } from "@posthog/host-trpc/trpc"; import { PI_SESSION_SERVICE } from "@posthog/workspace-server/services/pi-session/identifiers"; import type { PiSessionService } from "@posthog/workspace-server/services/pi-session/pi-session"; import { - piConversationOutput, - piSessionAvailableModelsOutput, - piSessionBashInput, - piSessionBashOutput, - piSessionCancelledOutput, - piSessionCommandsOutput, - piSessionCompactInput, - piSessionCycleModelOutput, - piSessionEnabledInput, - piSessionEntriesInput, - piSessionEntryInput, - piSessionExportInput, - piSessionExportOutput, - piSessionForkMessagesOutput, - piSessionForkOutput, + piQueueSnapshotOutput, + piRpcResponseSchema, + piSessionConfigInput, + piSessionConfigOutput, piSessionHealthOutput, - piSessionLastAssistantTextOutput, - piSessionMessageInput, - piSessionModelInput, - piSessionModelOutput, - piSessionNameInput, - piSessionNewInput, - piSessionPathInput, - piSessionPromptAndWaitInput, - piSessionPromptInput, - piSessionQueueModeInput, + piSessionRpcInput, piSessionStartOutput, - piSessionStatusOutput, - piSessionStderrOutput, - piSessionThinkingCycleOutput, - piSessionThinkingLevelInput, - piSessionTimeoutInput, - piSessionTranscriptInput, - piSessionUnknownOutput, + piSessionTaskInput, resumePiSessionInput, startPiSessionInput, } from "@posthog/workspace-server/services/pi-session/schemas"; @@ -53,270 +27,45 @@ export const piSessionRouter = router({ .input(resumePiSessionInput) .mutation(({ ctx, input }) => getService(ctx.container).resume(input)), - prompt: publicProcedure - .input(piSessionPromptInput) + rpc: publicProcedure + .input(piSessionRpcInput) + .output(piRpcResponseSchema) .mutation(({ ctx, input }) => - getService(ctx.container).prompt( - input.taskId, - input.prompt, - input.images, - ), + getService(ctx.container).request(input.taskId, input.command), ), - steer: publicProcedure - .input(piSessionMessageInput) - .mutation(({ ctx, input }) => - getService(ctx.container).steer( - input.taskId, - input.message, - input.images, - ), - ), - - followUp: publicProcedure - .input(piSessionMessageInput) - .mutation(({ ctx, input }) => - getService(ctx.container).followUp( - input.taskId, - input.message, - input.images, - ), - ), - - abort: publicProcedure - .input(piSessionTranscriptInput) - .mutation(({ ctx, input }) => - getService(ctx.container).abort(input.taskId), - ), - - newSession: publicProcedure - .input(piSessionNewInput) - .output(piSessionCancelledOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).newSession(input.taskId, input.parentSession), - ), - - setModel: publicProcedure - .input(piSessionModelInput) - .output(piSessionModelOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).setModel( - input.taskId, - input.provider, - input.modelId, - ), - ), - - cycleModel: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionCycleModelOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).cycleModel(input.taskId), - ), - - availableModels: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionAvailableModelsOutput) - .query(({ ctx, input }) => - getService(ctx.container).availableModels(input.taskId), - ), - - setThinkingLevel: publicProcedure - .input(piSessionThinkingLevelInput) - .mutation(({ ctx, input }) => - getService(ctx.container).setThinkingLevel(input.taskId, input.level), - ), - - cycleThinkingLevel: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionThinkingCycleOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).cycleThinkingLevel(input.taskId), - ), - - setSteeringMode: publicProcedure - .input(piSessionQueueModeInput) - .mutation(({ ctx, input }) => - getService(ctx.container).setSteeringMode(input.taskId, input.mode), - ), - - setFollowUpMode: publicProcedure - .input(piSessionQueueModeInput) - .mutation(({ ctx, input }) => - getService(ctx.container).setFollowUpMode(input.taskId, input.mode), - ), - - compact: publicProcedure - .input(piSessionCompactInput) - .output(piSessionUnknownOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).compact(input.taskId, input.customInstructions), - ), - - setAutoCompaction: publicProcedure - .input(piSessionEnabledInput) - .mutation(({ ctx, input }) => - getService(ctx.container).setAutoCompaction(input.taskId, input.enabled), - ), - - setAutoRetry: publicProcedure - .input(piSessionEnabledInput) - .mutation(({ ctx, input }) => - getService(ctx.container).setAutoRetry(input.taskId, input.enabled), - ), - - abortRetry: publicProcedure - .input(piSessionTranscriptInput) - .mutation(({ ctx, input }) => - getService(ctx.container).abortRetry(input.taskId), - ), - - bash: publicProcedure - .input(piSessionBashInput) - .output(piSessionBashOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).bash(input.taskId, input.command), - ), - - abortBash: publicProcedure - .input(piSessionTranscriptInput) - .mutation(({ ctx, input }) => - getService(ctx.container).abortBash(input.taskId), - ), - - sessionStats: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionUnknownOutput) - .query(({ ctx, input }) => - getService(ctx.container).sessionStats(input.taskId), - ), - - exportHtml: publicProcedure - .input(piSessionExportInput) - .output(piSessionExportOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).exportHtml(input.taskId, input.outputPath), - ), - - switchSession: publicProcedure - .input(piSessionPathInput) - .output(piSessionCancelledOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).switchSession(input.taskId, input.sessionPath), - ), - - fork: publicProcedure - .input(piSessionEntryInput) - .output(piSessionForkOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).fork(input.taskId, input.entryId), - ), - - clone: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionCancelledOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).clone(input.taskId), - ), - - forkMessages: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionForkMessagesOutput) - .query(({ ctx, input }) => - getService(ctx.container).forkMessages(input.taskId), - ), - - setSessionName: publicProcedure - .input(piSessionNameInput) - .mutation(({ ctx, input }) => - getService(ctx.container).setSessionName(input.taskId, input.name), - ), - - status: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionStatusOutput) - .query(({ ctx, input }) => getService(ctx.container).status(input.taskId)), - - conversation: publicProcedure - .input(piSessionTranscriptInput) - .output(piConversationOutput) - .query(({ ctx, input }) => - getService(ctx.container).conversation(input.taskId), - ), - - entries: publicProcedure - .input(piSessionEntriesInput) - .query(({ ctx, input }) => - getService(ctx.container).entries(input.taskId, input.since), - ), - - tree: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionUnknownOutput) - .query(({ ctx, input }) => getService(ctx.container).tree(input.taskId)), + stop: publicProcedure + .input(piSessionTaskInput) + .mutation(({ ctx, input }) => getService(ctx.container).stop(input.taskId)), - lastAssistantText: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionLastAssistantTextOutput) - .query(({ ctx, input }) => - getService(ctx.container).lastAssistantText(input.taskId), - ), + health: publicProcedure + .input(piSessionTaskInput) + .output(piSessionHealthOutput) + .query(({ ctx, input }) => getService(ctx.container).health(input.taskId)), - messages: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionUnknownOutput) + readSessionConfig: publicProcedure + .input(piSessionConfigInput) + .output(piSessionConfigOutput) .query(({ ctx, input }) => - getService(ctx.container).messages(input.taskId), + getService(ctx.container).readSessionConfig(input.downloadUrl), ), - commands: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionCommandsOutput) + getQueue: publicProcedure + .input(piSessionTaskInput) + .output(piQueueSnapshotOutput) .query(({ ctx, input }) => - getService(ctx.container).commands(input.taskId), + getService(ctx.container).getQueue(input.taskId), ), - waitForIdle: publicProcedure - .input(piSessionTimeoutInput) + clearQueue: publicProcedure + .input(piSessionTaskInput) + .output(piQueueSnapshotOutput) .mutation(({ ctx, input }) => - getService(ctx.container).waitForIdle(input.taskId, input.timeout), + getService(ctx.container).clearQueue(input.taskId), ), - collectEvents: publicProcedure - .input(piSessionTimeoutInput) - .output(piSessionUnknownOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).collectEvents(input.taskId, input.timeout), - ), - - promptAndWait: publicProcedure - .input(piSessionPromptAndWaitInput) - .output(piSessionUnknownOutput) - .mutation(({ ctx, input }) => - getService(ctx.container).promptAndWait( - input.taskId, - input.prompt, - input.images, - input.timeout, - ), - ), - - stderr: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionStderrOutput) - .query(({ ctx, input }) => getService(ctx.container).stderr(input.taskId)), - - stop: publicProcedure - .input(piSessionTranscriptInput) - .mutation(({ ctx, input }) => getService(ctx.container).stop(input.taskId)), - - health: publicProcedure - .input(piSessionTranscriptInput) - .output(piSessionHealthOutput) - .query(({ ctx, input }) => getService(ctx.container).health(input.taskId)), - onEvent: publicProcedure - .input(piSessionTranscriptInput) + .input(piSessionTaskInput) .subscription(async function* (opts) { const service = getService(opts.ctx.container); const iterable = service.toIterable("event", { signal: opts.signal }); diff --git a/packages/shared/src/agent-conversation.ts b/packages/shared/src/agent-conversation.ts index 9f03b345f6..e1ec62cd2b 100644 --- a/packages/shared/src/agent-conversation.ts +++ b/packages/shared/src/agent-conversation.ts @@ -17,6 +17,8 @@ export type AgentToolCallStatus = | "completed" | "failed"; +export type AgentProgressStatus = "in_progress" | "completed" | "failed"; + export interface AgentTextContent { type: "text"; text: string; @@ -26,6 +28,7 @@ export interface AgentImageContent { type: "image"; data: string; mimeType: string; + fileName?: string; } export interface AgentAudioContent { @@ -105,9 +108,14 @@ export interface AgentToolCall { rawInput?: unknown; rawOutput?: unknown; parentId?: string; + origin?: "agent" | "user_shell"; +} + +interface AgentConversationEventIdentity { + sourceId?: string; } -export type AgentConversationEvent = +export type AgentConversationEvent = ( | { type: "user_message"; id: string; @@ -134,6 +142,21 @@ export type AgentConversationEvent = timestamp: number; toolCall: Pick & Partial>; } + | { + type: "progress"; + timestamp: number; + step: string; + status: AgentProgressStatus; + label: string; + group: string; + detail?: string; + } + | { + type: "queue_update"; + timestamp: number; + steering: string[]; + followUp: string[]; + } | { type: "runtime_status"; timestamp: number; @@ -155,4 +178,6 @@ export type AgentConversationEvent = type: "turn_completed"; timestamp: number; stopReason?: string; - }; + } +) & + AgentConversationEventIdentity; diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 83f6edc458..2da7a31ed6 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -918,7 +918,8 @@ export type ChannelActionType = | "view_activity" | "open_mention" | "canvas_mode_toggle" - | "activity_tab_change"; + | "activity_tab_change" + | "artifacts_view_change"; export interface ChannelActionProperties { action_type: ChannelActionType; @@ -939,6 +940,7 @@ export interface ChannelActionProperties { armed?: boolean; /** For activity_tab_change: the tab landed on. */ tab?: string; + view_mode?: "list" | "grid" | "masonry"; /** Whether the underlying mutation resolved successfully. */ success?: boolean; } @@ -947,6 +949,8 @@ export type DashboardActionType = | "open" | "create" | "delete" + /** The delete was undone inside its undo window, so nothing was removed. */ + | "delete_undo" | "rename" | "save" | "fork" diff --git a/packages/shared/src/errors.test.ts b/packages/shared/src/errors.test.ts index 05b39ca57f..4b5b59c8e1 100644 --- a/packages/shared/src/errors.test.ts +++ b/packages/shared/src/errors.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { classifyGatewayLimitError, + classifyPromptFailure, getErrorMessage, isAuthError, isFatalSessionError, @@ -141,6 +142,21 @@ describe("classifyGatewayLimitError", () => { }); }); +describe("classifyPromptFailure", () => { + it.each([ + ["Rate limit exceeded", undefined, "usage_limit", false], + ["API Error: 529 overloaded", undefined, "transient", true], + ["boom", "upstream_timeout", "transient", true], + ["Authentication required", undefined, "authentication", true], + ["process exited", undefined, "fatal_session", true], + ["invalid model", undefined, "unknown", false], + ] as const)("classifies %j as %s", (message, errorType, kind, retryable) => { + expect( + classifyPromptFailure(new Error(message), undefined, errorType), + ).toMatchObject({ kind, retryable }); + }); +}); + describe("isFatalSessionError", () => { it.each([ "internal error", diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts index 8a0508a832..3e98b7c60a 100644 --- a/packages/shared/src/errors.ts +++ b/packages/shared/src/errors.ts @@ -155,6 +155,70 @@ export function isTransientUpstreamError( ); } +export type PromptFailureKind = + | "usage_limit" + | "transient" + | "authentication" + | "fatal_session" + | "unknown"; + +export interface PromptFailure { + kind: PromptFailureKind; + message: string; + retryable: boolean; + limitCause: GatewayLimitCause | null; +} + +export function classifyPromptFailure( + error: unknown, + errorDetails?: string, + errorType?: string, +): PromptFailure { + const message = getErrorMessage(error) || String(error); + const limitCause = classifyGatewayLimitError(message, errorDetails); + if (limitCause !== null || isRateLimitError(message, errorDetails)) { + return { + kind: "usage_limit", + message, + retryable: false, + limitCause, + }; + } + if ( + errorType?.startsWith("upstream_") || + isTransientUpstreamError(message, errorDetails) + ) { + return { + kind: "transient", + message, + retryable: true, + limitCause: null, + }; + } + if (isNotAuthenticatedError(error) || isAuthError(error)) { + return { + kind: "authentication", + message, + retryable: true, + limitCause: null, + }; + } + if (isFatalSessionError(message, errorDetails)) { + return { + kind: "fatal_session", + message, + retryable: true, + limitCause: null, + }; + } + return { + kind: "unknown", + message, + retryable: false, + limitCause: null, + }; +} + export function isFatalSessionError( errorMessage: string, errorDetails?: string, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 043cc19625..6d3b6b608a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -138,6 +138,7 @@ export { export * from "./enrichment"; export { classifyGatewayLimitError, + classifyPromptFailure, type GatewayLimitCause, getErrorMessage, isAuthError, @@ -146,6 +147,8 @@ export { isRateLimitError, isTransientUpstreamError, NotAuthenticatedError, + type PromptFailure, + type PromptFailureKind, type SerializedError, serializeError, } from "./errors"; diff --git a/packages/shared/src/session-events.ts b/packages/shared/src/session-events.ts index c2a4a375b3..90a7f58253 100644 --- a/packages/shared/src/session-events.ts +++ b/packages/shared/src/session-events.ts @@ -1,3 +1,5 @@ +import type { AgentConversationEvent } from "./agent-conversation"; + /** * JSON-RPC message types for ACP protocol communication. * These types are used in both main process (session-manager.ts) @@ -72,8 +74,10 @@ export const IMPORTED_USER_PROMPT_META_KEY = "importedUserPrompt"; * Used when fetching historical logs and appending new entries. */ export interface StoredLogEntry { + id?: string; type: string; timestamp?: string; + event?: AgentConversationEvent; notification?: { id?: number; method?: string; diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 136489b667..cb3e01eba2 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -31,6 +31,15 @@ import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMa import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { navigateToChannelTask, navigateToTaskDetail, @@ -38,7 +47,7 @@ import { import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import type { ReactNode } from "react"; -import { useCallback, useEffect, useMemo } from "react"; +import { memo, useCallback, useEffect, useMemo } from "react"; import { activityReadPayload, channelIdForName, @@ -278,8 +287,11 @@ export function ActivityView() { () => createChannelIdByName(folderChannels), [folderChannels], ); - const folderChannelIdFor = (channelName: string | null): string | null => - channelIdForName(folderIdByName, channelName); + const folderChannelIdFor = useCallback( + (channelName: string | null): string | null => + channelIdForName(folderIdByName, channelName), + [folderIdByName], + ); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "view_activity", @@ -287,77 +299,164 @@ export function ActivityView() { }); }, []); - return ( -
-
-
-
- - Activity - - - Tasks you're involved in across{" "} - {spacesLayout ? "spaces" : "channels"}. - -
- {unreadCount > 0 && ( - - )} -
-
- {isLoading && items.length === 0 ? ( -
- -
- ) : items.length === 0 ? ( - - - - - - No activity yet - - Tasks you create, get tagged in, or reply to across{" "} - {spacesLayout ? "spaces" : "channels"} land here. - - - - ) : ( -
- {items.map((item) => ( - - ))} - {hasNextPage && ( - - )} + const markAllReadButton = useMemo( + () => + unreadCount > 0 ? ( + + ) : null, + [unreadCount, unreadItems.length, isMarkingRead, markAllRead], + ); + + const feed = ( + + ); + + // The shared page header ships with the spaces layout; without it the page + // keeps the in-container title it has always had. Delete the legacy branch + // when the layout flag graduates. + if (!spacesLayout) { + return ( +
+
+
+
+ + Activity + + + Tasks you're involved in across{" "} + {spacesLayout ? "spaces" : "channels"}. +
- )} + {markAllReadButton} +
+
{feed}
+ ); + } + + return ( +
+ + + + Activity + {unreadCount > 0 && ( + }> + {unreadCount} unread + + )} + {markAllReadButton && ( + {markAllReadButton} + )} + + + Tasks you're involved in across{" "} + {spacesLayout ? "spaces" : "channels"}. + + + +
+
{feed}
+
); } + +/** + * The feed body. A memo'd child rather than JSX built in the parent: the parent + * picks between two page shells and returns early, and this way the branch it + * doesn't take costs nothing. + */ +const ActivityFeed = memo(function ActivityFeed({ + items, + isLoading, + spacesLayout, + folderChannelIdFor, + markRead, + currentUser, + hasNextPage, + isFetchingNextPage, + fetchNextPage, +}: { + items: TaskActivityItem[]; + isLoading: boolean; + spacesLayout: boolean; + folderChannelIdFor: (channelName: string | null) => string | null; + markRead: (item: TaskActivityItem) => void; + currentUser?: UserBasic | null; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; +}) { + if (isLoading && items.length === 0) { + return ( +
+ +
+ ); + } + + if (items.length === 0) { + return ( + + + + + + No activity yet + + Tasks you create, get tagged in, or reply to across{" "} + {spacesLayout ? "spaces" : "channels"} land here. + + + + ); + } + + return ( +
+ {items.map((item) => ( + + ))} + {hasNextPage && ( + + )} +
+ ); +}); diff --git a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx new file mode 100644 index 0000000000..c2b91d3aae --- /dev/null +++ b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx @@ -0,0 +1,68 @@ +import { Kanban, ListIcon, SquaresFourIcon } from "@phosphor-icons/react"; +import { + ToggleGroup, + ToggleGroupItem, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; +import { + ARTIFACTS_VIEW_MODES, + type ArtifactsViewMode, + useArtifactsViewStore, +} from "@posthog/ui/features/canvas/stores/artifactsViewStore"; +import type { ComponentType } from "react"; + +const OPTIONS: { + mode: ArtifactsViewMode; + label: string; + Icon: ComponentType<{ size?: number; weight?: "bold" }>; +}[] = [ + { mode: "list", label: "List", Icon: ListIcon }, + { mode: "grid", label: "Grid", Icon: SquaresFourIcon }, + { mode: "masonry", label: "Masonry", Icon: Kanban }, +]; + +function isViewMode(value: string | undefined): value is ArtifactsViewMode { + return ARTIFACTS_VIEW_MODES.some((mode) => mode === value); +} + +// Layout switcher for the artifacts list. A quill ToggleGroup carries the +// pressed state itself, so there's no hand-rolled active styling here. +export function ArtifactsViewToggle({ channelId }: { channelId?: string }) { + const view = useArtifactsViewStore((s) => s.view); + const setView = useArtifactsViewStore((s) => s.setView); + + return ( + + { + // Pressing the active item would otherwise clear the group — a view + // is always on, so ignore the empty result. + const mode = next[0]; + if (isViewMode(mode)) setView(mode, channelId); + }} + > + {OPTIONS.map(({ mode, label, Icon }) => ( + + + + + } + /> + {label} + + ))} + + + ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx index f7124726ee..0353b144bf 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx @@ -1,9 +1,21 @@ import { Theme } from "@radix-ui/themes"; import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted so the module factory below can read them, and each test can steer +// the current route / assert navigations. +const router = vi.hoisted(() => ({ + pathname: "/website/team/artifacts", + navigate: vi.fn(), +})); vi.mock("@tanstack/react-router", () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => router.navigate, + useRouterState: ({ + select, + }: { + select: (state: { location: { pathname: string } }) => T; + }) => select({ location: { pathname: router.pathname } }), })); vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ useChannelsLayout: () => true, @@ -12,6 +24,11 @@ vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ import { ChannelBreadcrumb } from "./ChannelBreadcrumb"; describe("ChannelBreadcrumb", () => { + beforeEach(() => { + router.pathname = "/website/team/artifacts"; + router.navigate.mockClear(); + }); + it("closes title editing when the editable leaf changes", () => { const onRename = vi.fn(); const { rerender } = render( @@ -25,7 +42,14 @@ describe("ChannelBreadcrumb", () => { , ); - fireEvent.doubleClick(screen.getByText("Task A")); + // A renamable leaf stays a live control, so it isn't marked disabled. + expect(screen.getByRole("button", { name: "Task A" })).not.toHaveAttribute( + "aria-disabled", + ); + + // One click opens the editor — the leaf never navigates, so a click has + // nothing else to mean. + fireEvent.click(screen.getByRole("button", { name: "Task A" })); expect(screen.getByRole("textbox")).toHaveValue("Task A"); rerender( @@ -43,4 +67,65 @@ describe("ChannelBreadcrumb", () => { expect(screen.getByText("Task B")).toBeInTheDocument(); expect(onRename).not.toHaveBeenCalled(); }); + + it("navigates home from the root segment on a sub-page", () => { + render( + + + , + ); + + const root = screen.getByRole("button", { name: /Team/ }); + expect(root).not.toHaveAttribute("aria-disabled", "true"); + fireEvent.click(root); + expect(router.navigate).toHaveBeenCalledWith({ + to: "/website/$channelId", + params: { channelId: "team" }, + }); + }); + + it("links the middle segment to its section", () => { + const onMiddleClick = vi.fn(); + render( + + + , + ); + + // Every segment is a Button so they share padding and height; the leaf is + // the current page, so it's the disabled one. + expect(screen.getAllByRole("button")).toHaveLength(3); + fireEvent.click(screen.getByRole("button", { name: "Loops" })); + expect(onMiddleClick).toHaveBeenCalledTimes(1); + expect( + screen.getByRole("button", { name: "CI failure summary" }), + ).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables the root segment on the space's own index", () => { + router.pathname = "/website/team"; + render( + + + , + ); + + const root = screen.getByRole("button", { name: /Team/ }); + expect(root).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(root); + expect(router.navigate).not.toHaveBeenCalled(); + }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx index e47614aa05..670f071518 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx @@ -1,5 +1,6 @@ import { Button, + cn, Tooltip, TooltipContent, TooltipTrigger, @@ -8,7 +9,7 @@ import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyp import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor"; import { Flex, Text } from "@radix-ui/themes"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; import { type ReactNode, useState } from "react"; interface ChannelBreadcrumbProps { @@ -19,14 +20,23 @@ interface ChannelBreadcrumbProps { * sidebar channel row and the channel-view header. */ channelId?: string; + /** + * An optional segment between the space and the leaf — the section a scene + * belongs to, e.g. "{space} / Loops / {loop}". `onClick` links it; without + * one it reads as a plain step. + */ + middle?: { icon?: ReactNode; label: string; onClick?: () => void }; /** Optional leading icon for the leaf segment (e.g. a canvas's tier icon). */ leafIcon?: ReactNode; - /** The trailing (current page) segment label. */ - leafLabel: string; + /** + * The trailing (current page) segment label. Omitted at a space's root, which + * renders the channel segment alone — same size and styling either way. + */ + leafLabel?: string; editScopeKey?: string; /** - * When provided, the leaf becomes inline-editable: double-click to rename, - * Enter or blur to submit, Escape to cancel. Receives the trimmed new value. + * When provided, the leaf becomes inline-editable: click to rename, Enter or + * blur to submit, Escape to cancel. Receives the trimmed new value. */ onRename?: (next: string) => void; /** Right-aligned slot pushed to the far end of the bar (e.g. an opener). */ @@ -35,11 +45,13 @@ interface ChannelBreadcrumbProps { // "# channel / leaf" header breadcrumb shared across channel scenes (CONTEXT.md, // new + existing tasks, canvases). The leaf can carry a tier icon and, when -// onRename is given, edits inline using the same editor as task titles. When -// channelId is given, the "# channel" segment links back to the channel home. +// onRename is given, edits inline on a single click using the same editor as +// task titles. When channelId is given, the "# channel" segment links back to +// the channel home. export function ChannelBreadcrumb({ channelName, channelId, + middle, leafIcon, leafLabel, editScopeKey, @@ -47,84 +59,164 @@ export function ChannelBreadcrumb({ trailing, }: ChannelBreadcrumbProps) { const spacesLayout = useChannelsLayout(); - const currentEditScope = editScopeKey ?? leafLabel; + // Only a leaf is renamable, so the scope key falls back to its label. + const currentEditScope = editScopeKey ?? leafLabel ?? ""; const [editingScope, setEditingScope] = useState(null); const editing = editingScope === currentEditScope; const navigate = useNavigate(); - - const channelSegment = ( - <> - {channelGlyph(channelName, { - size: 12, - space: spacesLayout, - className: "mt-px shrink-0 text-muted-foreground/80", - })} - - {channelName} - - - ); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const atChannelHome = channelId + ? pathname === `/website/${channelId}` + : false; return ( - - {channelId ? ( - - ) : ( -
{channelSegment}
- )} - / -
- {leafIcon && ( - {leafIcon} - )} - {editing && onRename ? ( - { - setEditingScope(null); - onRename(next); - }} - onCancel={() => setEditingScope(null)} + {/* flex-1 so the inline editor can stretch across the row; the trailing + slot still sits at the far end. */} + + + void navigate({ + to: "/website/$channelId", + params: { channelId }, + }) + : undefined + } + /> + {middle && ( + <> + + - ) : ( - - setEditingScope(currentEditScope) - : undefined - } + + )} + {leafLabel !== undefined && ( + <> + + {editing && onRename ? ( + // Matches the segment it replaces — same height, padding and type + // scale as a `size="sm"` button — so opening the editor doesn't + // jump the row. It takes the rest of the row, since a long name is + // exactly what you're most likely to be editing. + { + setEditingScope(null); + onRename(next); + }} + onCancel={() => setEditingScope(null)} + className="h-6 px-2 font-normal text-[13px]" + /> + ) : onRename ? ( + // Only a renamable leaf gets a tooltip: it carries a user-authored + // name that can be long enough to truncate. Fixed section labels + // never overflow, so a tooltip there is just noise. + + }> + {/* A renamable leaf is a live control — a click opens the + editor — so it reads as one: full-strength text, pointer + cursor, hover fill. */} + setEditingScope(currentEditScope)} /> - } - > - {leafLabel} - - {leafLabel} - - )} -
+ + {leafLabel} + + ) : ( + + )} + + )}
{trailing}
); } + +/** + * One segment of the breadcrumb. Always a Button, so every segment carries the + * same padding, height and icon gap whether or not it goes anywhere — the leaf + * used to be bare text, which left it visually adrift from its siblings. + * + * Without `onClick` the segment is genuinely inert: `aria-disabled` (so quill + * drops the hover fill and assistive tech reads it as unavailable) plus + * `pointer-events-none`, and out of the tab order. The disabled dimming is + * overridden — a breadcrumb has to stay readable. + */ +function BreadcrumbSegment({ + icon, + label, + strong, + muted, + onClick, + ...rest +}: { + icon?: ReactNode; + label: string; + /** The root segment carries the space name, which reads heavier. */ + strong?: boolean; + /** The leaf is the current page, so it sits back from the linked segments. */ + muted?: boolean; + /** Navigates, or (on a renamable leaf) opens the inline editor. */ + onClick?: () => void; +}) { + const interactive = Boolean(onClick); + + return ( + + ); +} + +function BreadcrumbSeparator() { + return ( + / + ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 289cb5b713..40f65fb29e 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -1,25 +1,64 @@ import { Button, cn } from "@posthog/quill"; +import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { + type ChannelPageKey, + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -// The shared channel header. The new layout drops the section tab strip — the -// channel sidebar carries those entries — while flag off keeps it. Starring -// lives on the sidebar back row and the channel list, not here. -export function ChannelHeader({ channelId }: { channelId: string }) { - const navigate = useNavigate(); +// The shared channel header. Every space scene renders the same breadcrumb — +// the root segment is identical whether or not there's a leaf, so the space +// name doesn't change size between the space home and its sub-pages. The new +// layout drops the section tab strip (the channel sidebar carries those +// entries); flag off keeps it. Starring lives on the sidebar back row and the +// channel list, not here. +export function ChannelHeader({ + channelId, + page, +}: { + channelId: string; + /** + * Which space page this is — supplies the leaf's label and icon. Every space + * page names itself, the feed included ("{space} / Feed"); omitting it leaves + * the root segment alone, for scenes that carry no page of their own. + */ + page?: ChannelPageKey; +}) { const channelsLayout = useChannelsLayout(); const { channels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name; - const pathname = useRouterState({ select: (s) => s.location.pathname }); - const isHome = pathname === `/website/${channelId}`; // Every channel surface renders this header, so mark the channel read here. useMarkChannelSeen(channelName); + // Channels-layout off keeps the header it has always had: the channel pill + // plus the section tab strip, no breadcrumb. Delete this branch when the + // layout flag graduates. + if (!channelsLayout) return ; + + return ( + + ); +} + +function LegacyChannelHeader({ channelId }: { channelId: string }) { + const navigate = useNavigate(); + const { channels } = useChannels(); + const channelName = channels.find((c) => c.id === channelId)?.name; + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const isHome = pathname === `/website/${channelId}`; + return (
- {!channelsLayout && } +
); } diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 23f0a6fe81..9c324c7676 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -98,6 +98,10 @@ interface NavButtonProps extends ComponentPropsWithRef<"button"> { badge?: ReactNode; } +// Same quill Button as NavIcon above — this variant only exists because the +// Activity entry is a Popover trigger, so it needs to forward the trigger's +// props and ref. Hand-rolling the button here left it a size larger than its +// neighbours. function NavButton({ icon, label, @@ -109,23 +113,23 @@ function NavButton({ ...buttonProps }: NavButtonProps) { return ( - + ); } diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index e1589e2a1f..c069e1ac7d 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -1,10 +1,8 @@ import { - BookOpenTextIcon, ChatsCircleIcon, FunnelSimple as FunnelSimpleIcon, MagnifyingGlass, PackageIcon, - RepeatIcon, } from "@phosphor-icons/react"; import type { CreatedByFilter } from "@posthog/core/canvas/channelItems"; import { filterChannelItems } from "@posthog/core/canvas/channelItems"; @@ -32,6 +30,11 @@ import type { TaskRunStatus } from "@posthog/shared/domain-types"; import { ChannelBackRow } from "@posthog/ui/features/canvas/components/ChannelBackRow"; import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; +import { + type ChannelPageKey, + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -42,7 +45,7 @@ import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { navigateToCommandCenter } from "@posthog/ui/router/navigationBridge"; import { logger } from "@posthog/ui/shell/logger"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { type ReactNode, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] = [ @@ -338,16 +341,17 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { /> ); + // Label and icon come from the shared space-page table, so a sidebar row and + // the header breadcrumb for the same page can never disagree. const sectionRow = ( - label: string, - icon: ReactNode, + page: ChannelPageKey, to: string, onClick: () => void, ) => ( @@ -359,15 +363,13 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
{sectionRow( - "Feed", - , + "home", base, () => void navigate({ to: "/website/$channelId", params: { channelId } }), )} {sectionRow( - "Context", - , + "context", `${base}/context`, () => void navigate({ @@ -377,8 +379,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { )} {loopsEnabled && sectionRow( - "Loops", - , + "loops", `${base}/loops`, () => void navigate({ @@ -387,8 +388,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { }), )} {sectionRow( - "Artifacts", - , + "artifacts", `${base}/artifacts`, () => void navigate({ diff --git a/packages/ui/src/features/canvas/components/FreeformPreview.tsx b/packages/ui/src/features/canvas/components/FreeformPreview.tsx new file mode 100644 index 0000000000..37b1f62bfc --- /dev/null +++ b/packages/ui/src/features/canvas/components/FreeformPreview.tsx @@ -0,0 +1,137 @@ +import { ShapesIcon, WarningIcon } from "@phosphor-icons/react"; +import { cn, Skeleton, Text } from "@posthog/quill"; +import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas"; +import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; +import { useInView } from "@posthog/ui/primitives/hooks/useInView"; +import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; +import { Box, Flex } from "@radix-ui/themes"; +import { useQueryClient } from "@tanstack/react-query"; +import { type ReactNode, useCallback } from "react"; + +// Render each canvas's live app at 1/SCALE of the card width, then shrink so it +// fits inside the preview frame as a thumbnail. +const PREVIEW_SCALE = 0.4; + +// Mount a preview only while it's near the viewport, and UNMOUNT it once it +// scrolls away (once: false). This caps how many full preview trees / sandbox +// iframes are live at any time, so a channel with many large canvases doesn't +// accumulate pages of off-screen DOM. The margin pre-mounts a little early so +// scrolling doesn't flash an empty frame. The fixed-height frame keeps the +// layout stable across mount/unmount (no scroll jump). +const PREVIEW_VIEWPORT = { once: false, rootMargin: "400px 0px" } as const; + +// A freeform (React-in-iframe) canvas preview: the app rendered at PREVIEW_SCALE +// in a clipped frame. Deferred until near the viewport, and runs with NO +// analytics so it fires no events. +export function FreeformPreview({ + code, + height = 176, + className, +}: { + code?: string; + /** Frame height in px. Taller frames simply reveal more of the app. */ + height?: number; + className?: string; +}) { + const [ref, inView] = useInView(PREVIEW_VIEWPORT); + + // Preview data handler: swallow captures so a thumbnail never emits analytics + // events, but let reads through (cached, shared with the full view) so the + // preview shows real-ish content. (posthog-js itself is never booted — no + // `analytics` prop — so there's no autocapture/pageview/replay either.) + const queryClient = useQueryClient(); + const onDataRequest = useCallback( + (method: string, payload: unknown) => + method === "capture" + ? Promise.resolve({ ok: true }) + : handleFreeformDataRequest(method, payload, queryClient), + [queryClient], + ); + + return ( + + {code ? ( + inView ? ( + + } + label="Preview unavailable" + /> + } + > + + + + ) : ( + // Deferred, not broken: a shimmer reads as "coming", where a line of + // text reads as the final state. + + ) + ) : ( + } + label="Nothing built yet" + /> + )} + + ); +} + +function PreviewPlaceholder({ + icon, + label, +}: { + icon?: ReactNode; + label: string; +}) { + return ( + + {icon} + + {label} + + + ); +} + +/** Stand-in for a preview that hasn't mounted yet — the shape of a small app: + * a title bar, a chart block, a couple of rows. */ +function PreviewSkeleton() { + return ( +
+ + +
+ + +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index 4ea4783e1c..d9c9569358 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -1,23 +1,52 @@ -import { CaretRightIcon } from "@phosphor-icons/react"; +import { CaretRightIcon, FilesIcon, TrashIcon } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; +import { + Badge, + Card, + CardContent, + cn, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Text, +} from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { ArtifactsViewToggle } from "@posthog/ui/features/canvas/components/ArtifactsViewToggle"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { FreeformPreview } from "@posthog/ui/features/canvas/components/FreeformPreview"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useArtifactsViewStore } from "@posthog/ui/features/canvas/stores/artifactsViewStore"; +import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; +import { masonryPreviewHeight } from "@posthog/ui/features/canvas/utils/masonryPreviewHeight"; import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { track } from "@posthog/ui/shell/analytics"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; -import { Text } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; import { type ReactNode, useCallback, useEffect, useMemo } from "react"; +// Uniform media height for the grid: cards line up row to row, and a PR tile +// (which has nothing to preview) fills the same band as a canvas thumbnail. +const GRID_PREVIEW_HEIGHT = 176; + // Artifacts are the durable outputs of a channel's work. Canvases for now; PRs // are surfaced from each filed task's latest run output. More kinds (reports, // files, …) slot into this union later. @@ -29,6 +58,8 @@ type ArtifactItem = ts: number; templateId: string; dashboardId: string; + /** Live React source, along for the ride so cards preview without a get(). */ + code?: string; } | { kind: "pr"; @@ -40,10 +71,12 @@ type ArtifactItem = // A channel's artifacts: canvases and the pull requests produced by its tasks, // most recent first. Sibling of the History tab, but scoped to outputs rather -// than the full activity stream. +// than the full activity stream. The view toggle switches between a dense row +// list and card layouts that preview each canvas live. export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { const spacesLayout = useChannelsLayout(); const navigate = useNavigate(); + const view = useArtifactsViewStore((s) => s.view); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -54,7 +87,10 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { }, [channelId]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const { dashboards } = useDashboards(channelId); @@ -71,6 +107,7 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ts: d.updatedAt, templateId: d.templateId, dashboardId: d.id, + code: d.code, }), ); @@ -124,50 +161,212 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ); return ( -
-
- {items.length === 0 ? ( -
- - No artifacts yet - - +
+ {/* Full-bleed header over a container-width body — the Inbox shape. Ships + behind the spaces layout like every other space page header; off, + the view switcher rides above the list instead. */} + {spacesLayout ? ( + + + + Artifacts + {items.length > 0 && ( + }> + {items.length} item{items.length === 1 ? "" : "s"} + + )} + + + + + Canvases and pull requests from this{" "} - {spacesLayout ? "space's" : "channel's"} tasks show up here. - -
- ) : ( -
- {items.map((item) => - item.kind === "canvas" ? ( - + + + ) : null} + +
+ {/* Full width, flush with the page header above it — every layout + here (rows and cards alike) is a scannable list, not prose, so a + measure cap just strands whitespace on wide windows. */} +
+ {!spacesLayout && ( +
+ + {items.length === 0 + ? "Artifacts" + : `${items.length} artifact${items.length === 1 ? "" : "s"}`} + + +
+ )} + {items.length === 0 ? ( + + + + + + No artifacts yet + + Canvases and pull requests from this{" "} + {spacesLayout ? "space's" : "channel's"} tasks show up here. + + + + ) : view === "list" ? ( +
+ {items.map((item) => ( + openCanvas(item.dashboardId)} + item={item} + onOpenCanvas={openCanvas} + onOpenPr={openPr} /> - ) : ( - + ) : view === "grid" ? ( + // items-stretch + a full-height card: a PR tile (no preview to + // show) matches the canvas cards in its row instead of ending + // short. +
+ {items.map((item) => ( + - ), - )} -
- )} + ))} +
+ ) : ( + // CSS columns rather than a JS masonry: cards are self-contained and + // never reflow into each other, so break-inside-avoid is enough. The + // trade-off is column-major order — newest runs down column one, not + // across the row — which is fine for a browse-y wall of previews. +
+ {items.map((item) => ( +
+ +
+ ))} +
+ )} +
); } +function ArtifactListItem({ + item, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + onOpenCanvas: (dashboardId: string) => void; + onOpenPr: (safeUrl: string) => void; +}) { + return item.kind === "canvas" ? ( + + ) : ( + + ); +} + +function ArtifactCard({ + item, + previewHeight, + fillHeight, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + previewHeight: number; + /** Grid only: stretch to the tallest card in the row. */ + fillHeight?: boolean; + onOpenCanvas: (dashboardId: string) => void; + onOpenPr: (safeUrl: string) => void; +}) { + return item.kind === "canvas" ? ( + + ) : ( + + ); +} + +// A canvas artifact row. While the canvas is inside its delete-undo window the +// row stays put — its template icon becomes a pulsing trash can and the row +// stops opening — so undoing puts it back exactly where it was. +function CanvasArtifactRow({ + dashboardId, + templateId, + title, + ts, + onClick, +}: { + dashboardId: string; + templateId: string; + title: string; + ts: number; + onClick: (dashboardId: string) => void; +}) { + const deleting = useIsCanvasPendingDelete(dashboardId); + + return ( + + ) : ( + iconForTemplate(templateId, { size: 15, className: "text-violet-9" }) + ) + } + title={title} + subtitle={ + deleting ? "Deleting…" : `Canvas · ${formatRelativeTimeShort(ts)}` + } + onClick={deleting ? undefined : () => onClick(dashboardId)} + /> + ); +} + // A PR artifact row. The PR's lifecycle state (open / draft / merged / closed) // comes from usePrArtifact, which also gates the URL — PR links come from run // output, so a row must not fetch from whatever host that names. @@ -225,7 +424,7 @@ function ArtifactRow({ type="button" onClick={onClick} disabled={!onClick} - className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors enabled:hover:bg-gray-3" + className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors enabled:hover:bg-fill-hover" > {title} - + {subtitle} ); } + +// The card form of a canvas artifact: a live preview of the canvas above its +// title. Same delete-undo behaviour as the row — the card stays in place, +// dimmed, until the undo window closes. +function CanvasArtifactCard({ + dashboardId, + templateId, + title, + ts, + code, + previewHeight, + fillHeight, + onClick, +}: { + dashboardId: string; + templateId: string; + title: string; + ts: number; + code?: string; + previewHeight: number; + fillHeight?: boolean; + onClick: (dashboardId: string) => void; +}) { + const deleting = useIsCanvasPendingDelete(dashboardId); + + return ( + + + {deleting && ( +
+ + + Deleting… + +
+ )} + + } + icon={iconForTemplate(templateId, { + size: 14, + className: "text-violet-9", + })} + title={title} + badge="Canvas" + subtitle={ + deleting ? "Deleting…" : `Updated ${formatRelativeTimeShort(ts)}` + } + dimmed={deleting} + fillHeight={fillHeight} + onClick={deleting ? undefined : () => onClick(dashboardId)} + /> + ); +} + +// The card form of a PR artifact. A PR has nothing to preview, so its media +// slot is a short tinted band carrying the lifecycle icon — which also keeps PR +// cards visibly shorter than canvas cards in the masonry layout. +function PrArtifactCard({ + title, + prUrl, + ts, + mediaHeight, + fillHeight, + onClick, +}: { + title: string; + prUrl: string; + ts: number; + /** Grid only: match the canvas thumbnails' band instead of a short strip. */ + mediaHeight?: number; + fillHeight?: boolean; + onClick: (safeUrl: string) => void; +}) { + const { + safeUrl, + title: prTitle, + stateLabel, + Icon, + iconColor, + accentColor, + } = usePrArtifact(prUrl); + + const subtitle = [prTitle, formatRelativeTimeShort(ts)] + .filter(Boolean) + .join(" · "); + + return ( + + +
+ } + icon={} + title={title} + badge={stateLabel || "Pull request"} + subtitle={subtitle} + fillHeight={fillHeight} + onClick={safeUrl ? () => onClick(safeUrl) : undefined} + /> + ); +} + +function ArtifactCardShell({ + media, + icon, + title, + badge, + subtitle, + dimmed, + fillHeight, + onClick, +}: { + media: ReactNode; + icon: ReactNode; + title: string; + badge: string; + subtitle: string; + dimmed?: boolean; + /** Grid only: fill the row so neighbouring cards end at the same line. */ + fillHeight?: boolean; + /** Absent for a card with nowhere safe to go — a non-github PR link. */ + onClick?: () => void; +}) { + return ( + + ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx index 4780ce51b0..2dfe4a6106 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx @@ -42,7 +42,10 @@ export function WebsiteChannelHistory({ channelId }: { channelId: string }) { }, [channelId]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const { dashboards } = useDashboards(channelId); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index bee6dd19a9..ba9c092d2c 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -96,7 +96,10 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { }, [backendChannel, feedMessages]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const composerRef = useRef(null); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx index 38231afa59..44efad88aa 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx @@ -1,7 +1,17 @@ import { CloudIcon, PlusIcon } from "@phosphor-icons/react"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Button } from "@posthog/ui/primitives/Button"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge"; import { Flex, Heading, Text } from "@radix-ui/themes"; import { useMemo } from "react"; @@ -47,6 +57,7 @@ function contextQuickStarts(name: string): { label: string; prompt: string }[] { * this context. `channelId` is the desktop folder id, matching `context_target.folder_id`. */ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { const { data: loops, isLoading, isError } = useLoops(); + const spacesLayout = useChannelsLayout(); const limits = useLoopLimits(); const limitReason = limits?.atLimit === true @@ -58,7 +69,10 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { const isPersonal = contextName === PERSONAL_CHANNEL_NAME; useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const attachedLoops = useMemo( @@ -99,56 +113,78 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { navigateToNewLoop(); }; + const title = isPersonal ? "Loops" : `Automate #${contextName}`; + const description = + "Put your work on autopilot. Loops run on a schedule, on an API call, or when something happens on GitHub. You can finally close the laptop!"; + const createButton = ( + + ); + return ( + {/* The shared page header ships with the spaces layout; without it the + in-container title block below is used. Delete that branch when the + layout flag graduates. */} + {spacesLayout && ( + + + + {title} + }> + Runs entirely in the cloud + + {createButton} + + {description} + + + )}
-
- - - - {isPersonal ? "Loops" : `Automate #${contextName}`} - - - - - Runs entirely in the cloud - + {!spacesLayout && ( +
+ + + {title} + + + + Runs entirely in the cloud + + + + {description} + - - Put your work on autopilot. Loops run on a schedule, on an API - call, or when something happens on GitHub. You can finally close - the laptop! - - - -
+ {createButton} +
+ )} {isLoading ? ( diff --git a/packages/ui/src/features/canvas/components/WebsiteContext.tsx b/packages/ui/src/features/canvas/components/WebsiteContext.tsx index 3c815483b2..4b8f783b37 100644 --- a/packages/ui/src/features/canvas/components/WebsiteContext.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteContext.tsx @@ -13,6 +13,7 @@ import { import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { channelPageIcon } from "@posthog/ui/features/canvas/components/channelPages"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { @@ -22,6 +23,14 @@ import { } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + PageHeader, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { track } from "@posthog/ui/shell/analytics"; import { Box, @@ -90,7 +99,7 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { }, [latest?.content, hasDraft]); const headerContent = useMemo( - () => , + () => , [channelId], ); useSetHeaderContent(headerContent); @@ -163,6 +172,27 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { return ( + {/* The shared page header ships with the spaces layout; without it the + page opens straight onto its mode toolbar as it always has. */} + {spacesLayout && ( + + + + Context + {latest?.version != null && ( + + v{latest.version} + + )} + + + Background every agent working in this{" "} + {spacesLayout ? "space" : "channel"} reads before it starts — what + lives here, who cares about it, and how to work on it. + + + + )} + - + + + {deleting && ( + + + + Deleting… + + + )} + @@ -155,64 +176,6 @@ const DashboardCard = memo(function DashboardCard({ ); }); -// A freeform (React-in-iframe) canvas preview: the app rendered at PREVIEW_SCALE -// in a clipped frame, the same shape as DashboardPreview. Deferred until near -// the viewport, and runs with NO analytics so it fires no events. -function FreeformPreview({ code }: { code?: string }) { - const [ref, inView] = useInView(PREVIEW_VIEWPORT); - - // Preview data handler: swallow captures so a thumbnail never emits analytics - // events, but let reads through (cached, shared with the full view) so the - // preview shows real-ish content. (posthog-js itself is never booted — no - // `analytics` prop — so there's no autocapture/pageview/replay either.) - const queryClient = useQueryClient(); - const onDataRequest = useCallback( - (method: string, payload: unknown) => - method === "capture" - ? Promise.resolve({ ok: true }) - : handleFreeformDataRequest(method, payload, queryClient), - [queryClient], - ); - - return ( - - {code ? ( - inView ? ( - - } - > - - - - ) : ( - - ) - ) : ( - - )} - - ); -} - function DashboardCardMenu({ id, name, @@ -223,31 +186,24 @@ function DashboardCardMenu({ channelId: string; }) { const [open, setOpen] = useState(false); - const { deleteDashboard, isDeleting } = useDashboardMutations(); + const spacesLayout = useChannelsLayout(); + const containerNoun = spacesLayout ? "space" : "channel"; + // "Delete…" opens a confirmation rather than deleting inline — the canvas and + // its version history go away for everyone in the space. + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const { invalidateDashboards } = useDashboardMutations(); - const onDelete = () => { - deleteDashboard(id) - .then(() => { - track(ANALYTICS_EVENTS.DASHBOARD_ACTION, { - action_type: "delete", - surface: "dashboards_grid", - channel_id: channelId, - dashboard_id: id, - success: true, - }); - }) - .catch((error) => { - track(ANALYTICS_EVENTS.DASHBOARD_ACTION, { - action_type: "delete", - surface: "dashboards_grid", - channel_id: channelId, - dashboard_id: id, - success: false, - }); - toast.error("Couldn't delete canvas", { - description: error instanceof Error ? error.message : String(error), - }); - }); + // The card disappears immediately, but the delete isn't sent until the undo + // toast's timer runs out — Undo simply cancels it. + const confirmDelete = () => { + setConfirmDeleteOpen(false); + deleteCanvasWithUndo({ + dashboardId: id, + channelId, + name, + surface: "dashboards_grid", + invalidate: invalidateDashboards, + }); }; return ( @@ -280,28 +236,38 @@ function DashboardCardMenu({ setConfirmDeleteOpen(true)} > - Delete + Delete… + {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */} + + + + Delete canvas + + Delete {name}? Its code and + version history go for everyone in the {containerNoun}. You get a + few seconds to undo, then it's permanent. + + + + + Cancel + + } + /> + + + + ); } - -function PreviewPlaceholder({ label }: { label: string }) { - return ( - - - {label} - - - ); -} diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 70105c1e05..a98cd7256d 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -5,9 +5,17 @@ import { LinkIcon, PencilSimpleIcon, PushPinIcon, + TrashIcon, XIcon, } from "@phosphor-icons/react"; import { + AlertDialog, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Button, DropdownMenu, DropdownMenuContent, @@ -17,7 +25,12 @@ import { import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu"; +import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo"; import { CanvasFrameHost } from "@posthog/ui/features/canvas/freeform/CanvasFrameHost"; import { useCanvasFrameStore } from "@posthog/ui/features/canvas/freeform/canvasFrameStore"; import { CANVAS_QUERY_KEY } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; @@ -50,7 +63,7 @@ import { useParams, useRouterState, } from "@tanstack/react-router"; -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; function threadIdFor(dashboardId: string): string { return `dashboard:${dashboardId}`; @@ -69,11 +82,37 @@ function FreeformEditControls({ dashboardId: string; }) { const navigate = useNavigate(); + // Pinning is scoped to whatever holds the canvas; the new layout calls that a + // space, the old one a channel. + const spacesLayout = useChannelsLayout(); + const containerNoun = spacesLayout ? "space" : "channel"; const editing = useIsDashboardEditing(dashboardId); const setEditing = useDashboardEditStore((s) => s.setEditing); const { dashboard } = useDashboard(dashboardId); - const { forkFreeform, isCreating, setPinned } = useDashboardMutations(); + const { forkFreeform, isCreating, setPinned, invalidateDashboards } = + useDashboardMutations(); const isPinned = dashboard?.pinnedAt != null; + // "Delete…" opens a confirmation rather than deleting inline — the canvas and + // its version history go away for everyone in the space. + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + + // Once confirmed the canvas vanishes from every list and we leave for the + // space's artifacts list, but the delete isn't sent until the undo toast's + // timer runs out — Undo simply cancels it. + const confirmDelete = () => { + setConfirmDeleteOpen(false); + deleteCanvasWithUndo({ + dashboardId, + channelId, + name: dashboard?.name ?? "Canvas", + surface: "canvas", + invalidate: invalidateDashboards, + }); + void navigate({ + to: "/website/$channelId/artifacts", + params: { channelId }, + }); + }; const onTogglePin = () => { void setPinned(dashboardId, !isPinned) @@ -233,7 +272,14 @@ function FreeformEditControls({ } /> - + {/* Sized to its longest item — the default width clipped "Unpin from + space". Same treatment as the channel-list menus. */} + Refresh @@ -248,10 +294,46 @@ function FreeformEditControls({ - {isPinned ? "Unpin from channel" : "Pin to channel"} + {isPinned + ? `Unpin from ${containerNoun}` + : `Pin to ${containerNoun}`} + + setConfirmDeleteOpen(true)} + > + + Delete… + {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */} + + + + Delete canvas + + Delete{" "} + {dashboard?.name ?? "Canvas"} + ? Its code and version history go for everyone in the{" "} + {containerNoun}. You get a few seconds to undo, then it's + permanent. + + + + + Cancel + + } + /> + + + + + ); + + // Only the loaded, non-empty list has tabs to show — the skeleton, the error + // notice and the empty state all render without them. + const hasTabs = !isLoading && !error && loops.length > 0; + + const body = ( + <>
-
- - - Loops - - - - Runs entirely in the cloud - + {!sharedPageHeader && ( +
+ + + Loops + + + + Runs entirely in the cloud + + + + Put your work on autopilot. Loops run on a schedule, on an + API call, or when something happens on GitHub. You can + finally close the laptop! + - - Put your work on autopilot. Loops run on a schedule, on an API - call, or when something happens on GitHub. You can finally - close the laptop! - - - -
+ {createButton} +
+ )} {isLoading ? ( @@ -268,14 +280,26 @@ export function LoopsListViewPresentation({ } /> ) : loops.length > 0 ? ( - + sharedPageHeader ? ( + // Triggers live in the page header; only the panels sit here. + + ) : ( + + ) ) : ( )} @@ -302,7 +326,50 @@ export function LoopsListViewPresentation({
-
+ + ); + + if (!sharedPageHeader) { + return ( + + {body} + + ); + } + + // One Tabs root spanning header and body: the trigger strip sits in the + // header's sub-nav, its panels stay down in the scrolling body. + return ( + + + + + Loops + }> + Runs entirely in the cloud + + {createButton} + + + Put your work on autopilot. Loops run on a schedule, on an API call, + or when something happens on GitHub. You can finally close the + laptop! + + + {hasTabs && ( + + + + )} + + {body} + ); } @@ -323,18 +390,64 @@ function LoopListTabs({ }) { return ( - - - - My loops ({personalLoops.length}) - - - - - Team loops ({teamLoops.length}) - - - + + + + ); +} + +/** The trigger strip. Rendered inside the page header when one is present. */ +function LoopTabsList({ + personalCount, + teamCount, +}: { + personalCount: number; + teamCount: number; +}) { + return ( + + + + My loops ({personalCount}) + + + + + Team loops ({teamCount}) + + + + ); +} + +/** The panels. Always in the scrolling body, wherever the triggers live. */ +function LoopTabPanels({ + personalLoops, + teamLoops, + members, + membersLoading, + membersError, + membersComplete, +}: { + personalLoops: LoopSchemas.Loop[]; + teamLoops: LoopSchemas.Loop[]; + members: UserBasic[]; + membersLoading: boolean; + membersError: boolean; + membersComplete: boolean; +}) { + return ( + <> {personalLoops.length > 0 ? ( )} - + ); } diff --git a/packages/ui/src/features/pi-sessions/PiQueuedMessagesDock.test.tsx b/packages/ui/src/features/pi-sessions/PiQueuedMessagesDock.test.tsx new file mode 100644 index 0000000000..d245710e2b --- /dev/null +++ b/packages/ui/src/features/pi-sessions/PiQueuedMessagesDock.test.tsx @@ -0,0 +1,67 @@ +import { Theme } from "@radix-ui/themes"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PiQueuedMessagesDock } from "./PiQueuedMessagesDock"; + +describe("PiQueuedMessagesDock", () => { + it("renders the single native queued message with ACP-style actions", () => { + const onEdit = vi.fn(); + const onRemove = vi.fn(); + + render( + + + , + ); + + expect(screen.getByText("then summarize")).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: "Edit queued message" }), + ); + expect(onEdit).toHaveBeenCalledOnce(); + + fireEvent.click( + screen.getByRole("button", { name: "Discard queued message" }), + ); + expect(onRemove).toHaveBeenCalledOnce(); + }); + + it("preserves legacy multi-message queues behind one edit action", () => { + render( + + + , + ); + + expect(screen.getByText("first")).toBeInTheDocument(); + expect(screen.getByText("second")).toBeInTheDocument(); + expect(screen.getByText("third")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Discard queued message" }), + ).not.toBeInTheDocument(); + }); + + it("does not render an empty queue", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/ui/src/features/pi-sessions/PiQueuedMessagesDock.tsx b/packages/ui/src/features/pi-sessions/PiQueuedMessagesDock.tsx new file mode 100644 index 0000000000..8b5ff30bdb --- /dev/null +++ b/packages/ui/src/features/pi-sessions/PiQueuedMessagesDock.tsx @@ -0,0 +1,34 @@ +import type { PiQueueSnapshot } from "@posthog/core/pi-runtime/piSessionController"; +import { QueuedMessageView } from "@posthog/ui/features/sessions/components/session-update/QueuedMessageView"; + +interface PiQueuedMessagesDockProps { + queue: PiQueueSnapshot; + onEdit(): void; + onRemove(): void; +} + +export function PiQueuedMessagesDock({ + queue, + onEdit, + onRemove, +}: PiQueuedMessagesDockProps) { + const messages = [...queue.steering, ...queue.followUp]; + const content = messages.join("\n\n"); + if (!content) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/packages/ui/src/features/pi-sessions/PiSessionControls.tsx b/packages/ui/src/features/pi-sessions/PiSessionControls.tsx index 48bd2fb75e..a295fa5ea4 100644 --- a/packages/ui/src/features/pi-sessions/PiSessionControls.tsx +++ b/packages/ui/src/features/pi-sessions/PiSessionControls.tsx @@ -1,7 +1,6 @@ import { Brain, CaretDown, Lightning, Stack } from "@phosphor-icons/react"; import type { - PiModelOption, - PiQueueMode, + PiModelSelection, PiThinkingLevel, } from "@posthog/core/pi-runtime/piSessionController"; import { @@ -18,13 +17,13 @@ import type { MessagingMode } from "@posthog/ui/features/sessions/messagingModeS import { Fragment } from "react"; interface PiModelSelectorProps { - models: PiModelOption[]; - currentModel?: Pick; + models: PiModelSelection[]; + currentModel?: PiModelSelection; disabled?: boolean; - onChange: (model: PiModelOption) => void; + onChange: (model: PiModelSelection) => void; } -function modelKey(model: Pick): string { +function modelKey(model: PiModelSelection): string { return JSON.stringify([model.provider, model.id]); } @@ -38,7 +37,7 @@ export function PiModelSelector({ return null; } - const modelsByProvider = new Map(); + const modelsByProvider = new Map(); for (const model of models) { const providerModels = modelsByProvider.get(model.provider) ?? []; providerModels.push(model); @@ -177,20 +176,16 @@ export function PiThinkingLevelSelector({ interface PiMessagingModeSelectorProps { mode: MessagingMode; - queueMode: PiQueueMode; queuedCount: number; disabled?: boolean; onModeChange: (mode: MessagingMode) => void; - onQueueModeChange: (mode: PiQueueMode) => void; } export function PiMessagingModeSelector({ mode, - queueMode, queuedCount, disabled, onModeChange, - onQueueModeChange, }: PiMessagingModeSelectorProps) { let label = "Queue"; if (mode === "steer") { @@ -242,17 +237,6 @@ export function PiMessagingModeSelector({ Queue for the next turn - - Process queued messages - onQueueModeChange(value as PiQueueMode)} - > - - One per turn - - All at once - ); diff --git a/packages/ui/src/features/pi-sessions/PiSessionView.tsx b/packages/ui/src/features/pi-sessions/PiSessionView.tsx index f0c5894c7e..d3e2c4317e 100644 --- a/packages/ui/src/features/pi-sessions/PiSessionView.tsx +++ b/packages/ui/src/features/pi-sessions/PiSessionView.tsx @@ -1,40 +1,63 @@ +import { + contentToXml, + isContentEmpty, + xmlToContent, +} from "@posthog/core/message-editor/content"; import { PI_SESSION_CONTROLLER } from "@posthog/core/pi-runtime/identifiers"; -import type { - PiModelOption, - PiQueueMode, - PiSessionController, - PiThinkingLevel, +import { + type PiModelSelection, + PiOperationError, + type PiSessionController, + type PiThinkingLevel, } from "@posthog/core/pi-runtime/piSessionController"; +import { toPiContextUsage } from "@posthog/core/pi-runtime/piSessionUsage"; import { useService } from "@posthog/di/react"; import { + Button, Empty, + EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle, + Skeleton, } from "@posthog/quill"; +import type { AgentConversationEvent } from "@posthog/shared"; +import { isTerminalStatus } from "@posthog/shared/domain-types"; +import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore"; import { PromptInput } from "@posthog/ui/features/message-editor/components/PromptInput"; import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; +import { CloudInitializingView } from "@posthog/ui/features/sessions/components/CloudInitializingView"; +import { + CloudConnectionBanner, + CloudStreamDisconnectedBanner, +} from "@posthog/ui/features/sessions/components/CloudSessionLifecycle"; import { ChatThread } from "@posthog/ui/features/sessions/components/chat-thread/ChatThread"; +import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; -import { useMessagingMode } from "@posthog/ui/features/sessions/hooks/useMessagingMode"; import { useMessagingModeStore } from "@posthog/ui/features/sessions/messagingModeStore"; import { useWorkspace } from "@posthog/ui/features/workspace/useWorkspace"; +import { useConnectivity } from "@posthog/ui/hooks/useConnectivity"; import { toast } from "@posthog/ui/primitives/toast"; import { TaskDetailSkeleton } from "@posthog/ui/router/routeSkeletons"; +import { logger } from "@posthog/ui/shell/logger"; import { Box, Flex } from "@radix-ui/themes"; -import { useCallback, useEffect } from "react"; +import { type ReactElement, useCallback, useEffect, useRef } from "react"; import { useStore } from "zustand"; +import { PiQueuedMessagesDock } from "./PiQueuedMessagesDock"; import { PiMessagingModeSelector, PiModelSelector, PiThinkingLevelSelector, } from "./PiSessionControls"; +const log = logger.scope("pi-session-view"); + interface PiSessionViewProps { taskId: string; + taskRunId?: string; } -export function PiSessionView({ taskId }: PiSessionViewProps) { +export function PiSessionView({ taskId, taskRunId }: PiSessionViewProps) { const piSessionController = useService( PI_SESSION_CONTROLLER, ); @@ -45,15 +68,23 @@ export function PiSessionView({ taskId }: PiSessionViewProps) { const draftActions = useDraftStore((state) => state.actions); const workspace = useWorkspace(taskId); const repoPath = workspace?.worktreePath ?? workspace?.folderPath; - const messagingMode = useMessagingMode(taskId); + const messagingMode = useMessagingModeStore( + (state) => state.modesByTaskId[taskId] ?? "steer", + ); const setMessagingMode = useMessagingModeStore((state) => state.setMode); + const { isOnline } = useConnectivity(); + const showUsageLimit = useUsageLimitStore((state) => state.show); + const promptRecallRef = useRef(null); + const handlePromptRecall = useCallback( + (direction) => promptRecallRef.current?.(direction) ?? null, + [], + ); useEffect(() => { - void piSessionController.ensureConnected(taskId); + void piSessionController.ensureConnected(taskId, taskRunId).catch(() => {}); return () => piSessionController.disconnect(taskId); - }, [piSessionController, taskId]); + }, [piSessionController, taskId, taskRunId]); - const sessionAvailable = session?.connectionState === "connected"; const status = session?.status; const isStreaming = status?.isStreaming ?? false; const isCompacting = status?.isCompacting ?? false; @@ -63,7 +94,7 @@ export function PiSessionView({ taskId }: PiSessionViewProps) { draftActions.setContext(taskId, { taskId, repoPath, - disabled: !sessionAvailable || isCompacting, + disabled: isCompacting, isLoading: isStreaming || isBashRunning, }); }, [ @@ -72,7 +103,6 @@ export function PiSessionView({ taskId }: PiSessionViewProps) { isCompacting, isStreaming, repoPath, - sessionAvailable, taskId, ]); @@ -98,6 +128,17 @@ export function PiSessionView({ taskId }: PiSessionViewProps) { ]); }, [draftActions, session?.commands, taskId]); + const handleControllerError = useCallback( + (error: unknown, fallback: string) => { + log.error(fallback, error); + if (error instanceof PiOperationError) { + return; + } + toast.error(fallback); + }, + [], + ); + const sendPrompt = useCallback( (text: string) => { const message = text.trim(); @@ -117,42 +158,43 @@ export function PiSessionView({ taskId }: PiSessionViewProps) { toast.success("Pi context compacted"); } }) - .catch(() => { + .catch((error) => { const failureMessage = action === "compact" ? "Failed to compact Pi context" : "Failed to send message to Pi"; - toast.error(failureMessage); + handleControllerError(error, failureMessage); }); }, - [isStreaming, messagingMode, piSessionController, taskId], + [ + handleControllerError, + isStreaming, + messagingMode, + piSessionController, + taskId, + ], ); const setModel = useCallback( - (model: PiModelOption) => { + (model: PiModelSelection) => { void piSessionController .setModel(taskId, model) - .catch(() => toast.error("Failed to change Pi model")); + .catch((error) => + handleControllerError(error, "Failed to change Pi model"), + ); }, - [piSessionController, taskId], + [handleControllerError, piSessionController, taskId], ); const setThinkingLevel = useCallback( (level: PiThinkingLevel) => { void piSessionController .setThinkingLevel(taskId, level) - .catch(() => toast.error("Failed to change Pi thinking level")); + .catch((error) => + handleControllerError(error, "Failed to change Pi thinking level"), + ); }, - [piSessionController, taskId], - ); - - const setQueueMode = useCallback( - (mode: PiQueueMode) => { - void piSessionController - .setQueueMode(taskId, messagingMode, mode) - .catch(() => toast.error("Failed to change Pi queue behavior")); - }, - [messagingMode, piSessionController, taskId], + [handleControllerError, piSessionController, taskId], ); const toggleMessagingMode = useCallback(() => { @@ -163,96 +205,271 @@ export function PiSessionView({ taskId }: PiSessionViewProps) { const runBashCommand = (command: string) => { void piSessionController .bash(taskId, command) - .catch(() => toast.error("Failed to run Pi bash command")); + .catch((error) => + handleControllerError(error, "Failed to run Pi bash command"), + ); }; const cancelPrompt = () => { if (isBashRunning) { - void piSessionController.abortBash(taskId); + void piSessionController + .abortBash(taskId) + .catch((error) => handleControllerError(error, "Failed to stop bash")); return; } - void piSessionController.abort(taskId); + void piSessionController + .abort(taskId) + .catch((error) => handleControllerError(error, "Failed to stop Pi")); }; - const sessionError = session?.error; - if (sessionError) { + const retry = useCallback(() => { + void piSessionController + .retry(taskId) + .catch((error) => + handleControllerError(error, "Failed to reconnect to Pi"), + ); + }, [handleControllerError, piSessionController, taskId]); + + const restart = useCallback(() => { + void piSessionController + .restart(taskId) + .catch((error) => handleControllerError(error, "Failed to restart Pi")); + }, [handleControllerError, piSessionController, taskId]); + + const editQueuedMessage = useCallback(() => { + void piSessionController + .clearQueue(taskId) + .then((queue) => { + const queuedText = [...queue.steering, ...queue.followUp].join("\n\n"); + if (!queuedText) { + return; + } + const draft = draftActions.getDraft(taskId); + const draftText = + typeof draft === "string" ? draft : draft ? contentToXml(draft) : ""; + const content = [queuedText, draftText] + .filter((value) => value.trim()) + .join("\n\n"); + draftActions.setPendingContent(taskId, xmlToContent(content)); + draftActions.requestFocus(taskId); + }) + .catch((error) => + handleControllerError(error, "Failed to edit queued Pi message"), + ); + }, [draftActions, handleControllerError, piSessionController, taskId]); + + const removeQueuedMessage = useCallback(() => { + void piSessionController + .clearQueue(taskId) + .catch((error) => + handleControllerError(error, "Failed to discard queued Pi message"), + ); + }, [handleControllerError, piSessionController, taskId]); + + useEffect(() => { + const failure = session?.error; + if (!failure || failure.scope !== "operation") { + return; + } + if ( + failure.recoveryPrompt && + isContentEmpty(useDraftStore.getState().drafts[taskId] ?? null) + ) { + draftActions.setPendingContent( + taskId, + xmlToContent(failure.recoveryPrompt), + ); + draftActions.requestFocus(taskId); + } + if (failure.kind === "usage_limit") { + showUsageLimit( + failure.limitCause ? { cause: failure.limitCause } : undefined, + ); + } else { + toast.error(failure.title, { description: failure.message }); + } + piSessionController.acknowledgeOperationFailure(taskId, failure.id); + }, [ + draftActions, + piSessionController, + session?.error, + showUsageLimit, + taskId, + ]); + + if (!session) { + return ; + } + + const latestProgress = session.events.findLast( + (event): event is Extract => + event.type === "progress" && event.status === "in_progress", + ); + const isConnecting = session.connectionState === "connecting"; + const isAuthRestoring = session.authRestoring; + const connectionError = + session.error?.scope === "connection" ? session.error : undefined; + const contextUsage = toPiContextUsage(session.stats); + const hasTranscript = session.events.some( + (event) => event.type !== "progress", + ); + const sessionAvailable = + session.connectionState === "connected" || hasTranscript; + if (isConnecting && !hasTranscript) { + return ( + + + + ); + } + + if (connectionError && !hasTranscript) { return ( - Pi session failed to start - {sessionError} + {connectionError.title} + {connectionError.message} + + {connectionError.retryable && ( + + )} + + ); } - if (!session || !status) { + if (!status && !hasTranscript) { return ; } - const pending = isStreaming || isBashRunning; - const currentModel = session.models.find( - (model) => - model.provider === status.model?.provider && model.id === status.model.id, + const controlsPending = status ? isStreaming || isBashRunning : false; + const controlsDisabled = + controlsPending || + isCompacting || + session.connectionState !== "connected" || + (session.cloudStatus !== undefined && + isTerminalStatus(session.cloudStatus)); + const hasQueuedMessage = + session.queue.steering.length + session.queue.followUp.length > 0; + let modelSelector: ReactElement = ( + + ); + let reasoningSelector: ReactElement | null = ( + + ); + let messagingModeToggle: ReactElement = ( + ); - const thinkingLevels = currentModel?.thinkingLevels ?? []; - const supportsThinking = thinkingLevels.some((level) => level !== "off"); - const queueMode = - messagingMode === "steer" ? status.steeringMode : status.followUpMode; + + if (status && session.modelsLoaded) { + modelSelector = ( + + ); + } + + if (status && session.thinkingLevelsLoaded) { + const supportsThinking = session.thinkingLevels.some( + (level) => level !== "off", + ); + reasoningSelector = supportsThinking ? ( + + ) : null; + } + + if (status) { + messagingModeToggle = ( + setMessagingMode(taskId, mode)} + /> + ); + } return ( + {isAuthRestoring && ( + + )} + {connectionError && hasTranscript && ( + + )} + - } - reasoningSelector={ - supportsThinking ? ( - - ) : null + disabled={isCompacting} + isLoading={controlsPending} + submitDisabledExternal={ + !sessionAvailable || + !status || + !isOnline || + hasQueuedMessage || + isAuthRestoring } - messagingModeToggle={ - setMessagingMode(taskId, mode)} - onQueueModeChange={setQueueMode} - /> + submitTooltipOverride={ + !isOnline + ? "No internet connection" + : isAuthRestoring + ? "Restoring authentication" + : hasQueuedMessage + ? "A message is already queued" + : undefined } + enableBashMode + enableCommands + modelSelector={modelSelector} + reasoningSelector={reasoningSelector} + messagingModeToggle={messagingModeToggle} onToggleMessagingMode={toggleMessagingMode} + onPromptRecall={handlePromptRecall} onSubmit={sendPrompt} onBashCommand={runBashCommand} onCancel={cancelPrompt} diff --git a/packages/ui/src/features/sessions/components/CloudInitializingView.tsx b/packages/ui/src/features/sessions/components/CloudInitializingView.tsx index b444721d55..8e77036a09 100644 --- a/packages/ui/src/features/sessions/components/CloudInitializingView.tsx +++ b/packages/ui/src/features/sessions/components/CloudInitializingView.tsx @@ -6,6 +6,8 @@ import zenHedgehog from "../../../assets/images/zen.png"; interface CloudInitializingViewProps { cloudStatus: TaskRunStatus | null; + heading?: string; + subtitle?: string; } const REVEAL_DELAY_MS = 2000; @@ -35,8 +37,12 @@ function copyFor(cloudStatus: TaskRunStatus | null): { export function CloudInitializingView({ cloudStatus, + heading, + subtitle, }: CloudInitializingViewProps) { - const { heading, subtitle } = copyFor(cloudStatus); + const copy = copyFor(cloudStatus); + const visibleHeading = heading ?? copy.heading; + const visibleSubtitle = subtitle ?? copy.subtitle; const [revealed, setRevealed] = useState(false); useEffect(() => { @@ -70,10 +76,10 @@ export function CloudInitializingView({ - {heading} + {visibleHeading} - {subtitle} + {visibleSubtitle} diff --git a/packages/ui/src/features/sessions/components/CloudSessionLifecycle.tsx b/packages/ui/src/features/sessions/components/CloudSessionLifecycle.tsx new file mode 100644 index 0000000000..6b279c88c8 --- /dev/null +++ b/packages/ui/src/features/sessions/components/CloudSessionLifecycle.tsx @@ -0,0 +1,85 @@ +import { Spinner, Warning } from "@phosphor-icons/react"; +import { Button, Flex, Text } from "@radix-ui/themes"; + +interface CloudConnectionBannerProps { + message: string; +} + +export function CloudConnectionBanner({ message }: CloudConnectionBannerProps) { + return ( + + + + {message} + + + ); +} + +interface CloudStreamDisconnectedBannerProps { + errorTitle?: string; + errorMessage?: string; + onRetry?: () => void; + onRestart?: () => void; +} + +export function CloudStreamDisconnectedBanner({ + errorTitle, + errorMessage, + onRetry, + onRestart, +}: CloudStreamDisconnectedBannerProps) { + return ( + + + + {errorTitle && ( + + {errorTitle} + + )} + {errorMessage && ( + + {errorMessage} + + )} + + + {onRetry && ( + + )} + {onRestart && ( + + )} + + + ); +} + +export function ConnectingToAgent() { + return ( + <> + + + Connecting to agent... + + + ); +} diff --git a/packages/ui/src/features/sessions/components/ContextBreakdownPopover.test.tsx b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.test.tsx index 2cc33beb79..cf30e422f2 100644 --- a/packages/ui/src/features/sessions/components/ContextBreakdownPopover.test.tsx +++ b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.test.tsx @@ -54,6 +54,17 @@ describe("ContextBreakdownPopover", () => { ).toBeInTheDocument(); }); + it("does not mention a breakdown when the runtime cannot provide one", () => { + render( + + + , + ); + expect(screen.queryByText(/breakdown/i)).not.toBeInTheDocument(); + }); + it("renders one row per non-zero category", () => { render( diff --git a/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx index 4269e1504f..2379254317 100644 --- a/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx +++ b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx @@ -47,7 +47,7 @@ export function ContextBreakdownPopover({ )} - {breakdown ? ( + {breakdown && ( {CONTEXT_CATEGORIES.filter((c) => breakdown[c.key] > 0).map((cat) => ( ))} - ) : ( + )} + + {!breakdown && usage.breakdownAvailable !== false && ( Detailed breakdown available after the first response. diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index 44b1630b66..2fe3e49007 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -19,6 +19,10 @@ import { useAutoFocusOnTyping } from "@posthog/ui/features/message-editor/useAut import { resolveAndAttachDroppedFiles } from "@posthog/ui/features/message-editor/utils/persistFile"; import { PermissionSelector } from "@posthog/ui/features/permissions/PermissionSelector"; import { CloudInitializingView } from "@posthog/ui/features/sessions/components/CloudInitializingView"; +import { + CloudStreamDisconnectedBanner, + ConnectingToAgent, +} from "@posthog/ui/features/sessions/components/CloudSessionLifecycle"; import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { copyFromContextMenu, @@ -102,17 +106,6 @@ interface SessionViewProps { const DEFAULT_ERROR_MESSAGE = "Failed to resume this session. The working directory may have been deleted. Please start a new session."; -function ConnectingToAgent() { - return ( - <> - - - Connecting to agent... - - - ); -} - /** Centers composer-slot content at the chat width (or compact padding). */ function ComposerWidth({ compact, @@ -156,48 +149,6 @@ function ComposerSlot({ ); } -interface CloudStreamDisconnectedBannerProps { - errorTitle?: string; - errorMessage?: string; - onRetry?: () => void; -} - -function CloudStreamDisconnectedBanner({ - errorTitle, - errorMessage, - onRetry, -}: CloudStreamDisconnectedBannerProps) { - return ( - - - - {errorTitle && ( - - {errorTitle} - - )} - {errorMessage && ( - - {errorMessage} - - )} - - {onRetry && ( - - )} - - ); -} - export function SessionView({ events, taskId, diff --git a/packages/ui/src/features/sessions/components/UserMessageAttachments.tsx b/packages/ui/src/features/sessions/components/UserMessageAttachments.tsx index 7b4b4d45b4..72900e654f 100644 --- a/packages/ui/src/features/sessions/components/UserMessageAttachments.tsx +++ b/packages/ui/src/features/sessions/components/UserMessageAttachments.tsx @@ -38,7 +38,8 @@ function ImageAttachment({ const authIdentity = useAuthStateValue(getAuthIdentity); const sessionService = useService(SESSION_SERVICE); const cloudArtifact = attachment.cloudArtifact; - const { data: previewUrl } = useQuery({ + const inlinePreviewUrl = attachment.previewUrl; + const { data: fetchedPreviewUrl } = useQuery({ queryKey: cloudArtifact ? [ "cloudArtifactPreview", @@ -58,12 +59,15 @@ function ImageAttachment({ } return readFileAsDataUrl({ filePath: filePath ?? "" }); }, - enabled: cloudArtifact - ? taskId !== null && authIdentity !== null - : filePath !== null, + enabled: + inlinePreviewUrl === undefined && + (cloudArtifact + ? taskId !== null && authIdentity !== null + : filePath !== null), retry: false, staleTime: cloudArtifact ? 50 * 60 * 1000 : Infinity, }); + const previewUrl = inlinePreviewUrl ?? fetchedPreviewUrl; const parsedImage = previewUrl?.startsWith("data:") ? parseImageDataUrl(previewUrl) : null; diff --git a/packages/ui/src/features/sessions/components/buildAgentConversationItems.test.ts b/packages/ui/src/features/sessions/components/buildAgentConversationItems.test.ts index 29d869124b..09796d9a87 100644 --- a/packages/ui/src/features/sessions/components/buildAgentConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/buildAgentConversationItems.test.ts @@ -86,6 +86,185 @@ describe("buildAgentConversationItems", () => { }); }); + it("keeps inline Pi images on the rendered user message", () => { + const result = buildAgentConversationItems( + [ + { + type: "user_message", + id: "user-1", + timestamp: 1, + content: [ + { type: "text", text: "What is this?" }, + { + type: "image", + data: "aW1hZ2U=", + mimeType: "image/png", + fileName: "screenshot.png", + }, + ], + }, + ], + false, + ); + + expect(result.items[0]).toMatchObject({ + type: "user_message", + content: "What is this?", + attachments: [ + { + id: expect.stringMatching(/^inline-image:/), + label: "screenshot.png", + previewUrl: "data:image/png;base64,aW1hZ2U=", + }, + ], + }); + }); + + it("keeps generic extension tool result content for rendering", () => { + const rawOutput = [{ type: "text", text: "Workflow finished" }]; + const result = buildAgentConversationItems( + [ + { + type: "tool_call_started", + timestamp: 1, + toolCall: { + id: "workflow-1", + title: "workflow", + kind: null, + status: "pending", + rawInput: { name: "release" }, + }, + }, + { + type: "tool_call_updated", + timestamp: 2, + toolCall: { + id: "workflow-1", + status: "completed", + rawOutput, + content: [ + { + type: "content", + content: { type: "text", text: "Workflow finished" }, + }, + ], + }, + }, + ], + false, + ); + + expect(result.items).toContainEqual( + expect.objectContaining({ + type: "session_update", + update: expect.objectContaining({ + sessionUpdate: "tool_call", + toolCallId: "workflow-1", + title: "workflow", + status: "completed", + rawOutput, + content: [ + { + type: "content", + content: { type: "text", text: "Workflow finished" }, + }, + ], + }), + }), + ); + }); + + it("groups runtime-neutral provisioning progress", () => { + const result = buildAgentConversationItems( + [ + { + type: "progress", + timestamp: 1, + step: "sandbox", + status: "completed", + label: "Set up sandbox", + group: "setup:run-1", + }, + { + type: "progress", + timestamp: 2, + step: "clone", + status: "in_progress", + label: "Cloning repository", + group: "setup:run-1", + detail: "posthog/code", + }, + ], + true, + ); + + expect(result.items).toContainEqual( + expect.objectContaining({ + type: "session_update", + update: { + sessionUpdate: "progress_group", + isActive: true, + steps: [ + { + key: "sandbox", + status: "completed", + label: "Set up sandbox", + detail: undefined, + }, + { + key: "clone", + status: "in_progress", + label: "Cloning repository", + detail: "posthog/code", + }, + ], + }, + }), + ); + }); + + it("settles a completed runtime-neutral agent step", () => { + const result = buildAgentConversationItems( + [ + { + type: "progress", + timestamp: 1, + step: "agent", + status: "in_progress", + label: "Starting agent", + group: "setup:run-1", + }, + { + type: "progress", + timestamp: 2, + step: "agent", + status: "completed", + label: "Started agent", + group: "setup:run-1", + }, + ], + true, + ); + + expect(result.items).toContainEqual( + expect.objectContaining({ + type: "session_update", + update: { + sessionUpdate: "progress_group", + isActive: false, + steps: [ + { + key: "agent", + status: "completed", + label: "Started agent", + detail: undefined, + }, + ], + }, + }), + ); + }); + it("builds and completes a generic compaction status", () => { const result = buildAgentConversationItems( [ diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.ts b/packages/ui/src/features/sessions/components/buildConversationItems.ts index 83d33c41c1..0beaaf2077 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -372,6 +372,11 @@ export function processAgentConversationEvent( return; } + if (event.type === "progress") { + handleProgress(b, event, event.timestamp, false); + return; + } + if (event.type === "runtime_status") { handleRuntimeStatus(b, event, event.timestamp); return; @@ -403,6 +408,10 @@ export function processAgentConversationEvent( return; } + if (event.type === "queue_update") { + return; + } + if (b.currentTurn) { completePromptTurn(b, b.currentTurn, event.timestamp, { stopReason: event.stopReason, @@ -795,9 +804,15 @@ function ensureProgressCardForGroup( return card; } -function syncProgressCard(card: ProgressCardState, b: ItemBuilder) { +function syncProgressCard( + card: ProgressCardState, + b: ItemBuilder, + waitForRunStarted = true, +) { const gateAgentStep = - card.runId !== "" && !b.runStartedRunIds.has(card.runId); + waitForRunStarted && + card.runId !== "" && + !b.runStartedRunIds.has(card.runId); const ordered: Step[] = Array.from(card.steps.values()).map((step) => step.key === "agent" && step.status === "completed" && gateAgentStep ? { ...step, status: "in_progress" as StepStatus } @@ -807,7 +822,12 @@ function syncProgressCard(card: ProgressCardState, b: ItemBuilder) { card.renderItem.isActive = ordered.some((s) => s.status === "in_progress"); } -function handleProgress(b: ItemBuilder, rawParams: unknown, ts: number) { +function handleProgress( + b: ItemBuilder, + rawParams: unknown, + ts: number, + waitForRunStarted = true, +) { const params = rawParams as | { step?: string; @@ -831,7 +851,7 @@ function handleProgress(b: ItemBuilder, rawParams: unknown, ts: number) { label: params.label, detail: params.detail, }); - syncProgressCard(card, b); + syncProgressCard(card, b, waitForRunStarted); } function normalizeStepStatus(raw: string | undefined): StepStatus { diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index fbe795c71e..dbacd99334 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -6,6 +6,7 @@ import { Scroll, } from "@phosphor-icons/react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; +import type { ContextUsage } from "@posthog/core/sessions/contextUsage"; import { useService } from "@posthog/di/react"; import { Button, @@ -43,7 +44,10 @@ import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; import { useSmoothedText } from "@posthog/ui/features/editor/components/useSmoothedText"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; -import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import type { + BuildResult, + ConversationItem, +} from "@posthog/ui/features/sessions/components/buildConversationItems"; import { CloudArtifactDownloads } from "@posthog/ui/features/sessions/components/CloudArtifactDownloads"; import { ChatMarkdown, @@ -1036,6 +1040,8 @@ interface SharedChatThreadProps { repoPath?: string | null; task?: Task; taskId?: string; + usage?: ContextUsage | null; + footerState?: Omit; } export interface ChatThreadProps extends SharedChatThreadProps { @@ -1062,7 +1068,10 @@ export interface AcpChatThreadProps extends SharedChatThreadProps { } export function ChatThread({ events, ...props }: ChatThreadProps) { - const { items } = useAgentConversationItems(events, props.isPromptPending); + const { items, ...footerState } = useAgentConversationItems( + events, + props.isPromptPending, + ); return ( ); } @@ -1103,6 +1113,8 @@ function ChatThreadRenderer({ repoPath, task, taskId, + usage, + footerState, promptRecallRef, }: ChatThreadRendererProps) { const diffWorkerFactory = useService(DIFF_WORKER_FACTORY); @@ -1228,6 +1240,8 @@ function ChatThreadRenderer({ promptStartedAt={promptStartedAt} task={task} taskId={taskId} + usage={usage} + footerState={footerState} /> ); diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx index cabb5777e3..80af68e991 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx @@ -1,5 +1,7 @@ +import type { ContextUsage } from "@posthog/core/sessions/contextUsage"; import type { AcpMessage } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import type { BuildResult } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { SessionFooter } from "@posthog/ui/features/sessions/components/SessionFooter"; import { useContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; @@ -16,6 +18,8 @@ interface ChatThreadFooterProps { promptStartedAt?: number | null; task?: Task; taskId?: string; + usage?: ContextUsage | null; + footerState?: Omit; } /** @@ -34,11 +38,22 @@ export function ChatThreadFooter({ promptStartedAt, task, taskId, + usage, + footerState, }: ChatThreadFooterProps) { const showDebugLogs = useSettingsStore((s) => s.debugLogsCloudRuns); - const contextUsage = useContextUsage(events); - const { lastTurnInfo, isCompacting, completedToolCallCount } = - useConversationItems(events, isPromptPending, { showDebugLogs }); + const eventContextUsage = useContextUsage(events); + const contextUsage = usage === undefined ? eventContextUsage : usage; + const eventFooterState = useConversationItems(events, isPromptPending, { + showDebugLogs, + }); + const lastTurnInfo = + footerState?.lastTurnInfo ?? eventFooterState.lastTurnInfo; + const isCompacting = + footerState?.isCompacting ?? eventFooterState.isCompacting; + const completedToolCallCount = + footerState?.completedToolCallCount ?? + eventFooterState.completedToolCallCount; const pendingPermissions = usePendingPermissionsForTask(taskId ?? ""); const queuedCount = useQueuedMessagesForTask(taskId).length; const session = useSessionForTask(taskId); diff --git a/packages/ui/src/features/sessions/components/session-update/ProgressGroupView.tsx b/packages/ui/src/features/sessions/components/session-update/ProgressGroupView.tsx index 9ca9b06d8f..e5b3efcd76 100644 --- a/packages/ui/src/features/sessions/components/session-update/ProgressGroupView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ProgressGroupView.tsx @@ -48,10 +48,12 @@ export function ProgressGroupView({ // trigger is disabled and forced open, so the user sees progress stream in without a flicker between // consecutive step transitions. Once the turn completes, the header auto-collapses (default: open) // and becomes interactive. Single-step groups have no header — the one step row IS the whole view. + const isSettled = turnComplete && !isActive; + if (!chatChrome) { const isOpen = !hasHeader ? true - : !turnComplete + : !isSettled ? true : (userToggledOpen ?? true); const summaryLabel = resolveHeaderLabel(steps) ?? ""; @@ -61,11 +63,11 @@ export function ProgressGroupView({ { - if (hasHeader && turnComplete) setUserToggledOpen(next); + if (hasHeader && isSettled) setUserToggledOpen(next); }} > {hasHeader && ( - + + {dragHandleRef && ( + + )} { }); }); + it("persists and rehydrates the last used agent runtime", async () => { + useSettingsStore.getState().setLastUsedAgentRuntime("pi"); + + await waitForPersistedWrite(); + + const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; + const persisted = JSON.parse(lastCall[1]); + expect(persisted.state.lastUsedAgentRuntime).toBe("pi"); + + getItem.mockResolvedValue( + JSON.stringify({ + state: { lastUsedAgentRuntime: "pi" }, + version: 0, + }), + ); + useSettingsStore.setState({ lastUsedAgentRuntime: "acp" }); + + await useSettingsStore.persist.rehydrate(); + + expect(useSettingsStore.getState().lastUsedAgentRuntime).toBe("pi"); + }); + + it("persists Pi and ACP model selections independently", async () => { + const settings = useSettingsStore.getState(); + settings.setLastUsedModel("claude-sonnet-4-5"); + settings.setLastUsedPiModel("claude-opus-4-8"); + + expect(useSettingsStore.getState()).toMatchObject({ + lastUsedModel: "claude-sonnet-4-5", + lastUsedPiModel: "claude-opus-4-8", + }); + await waitForPersistedWrite(); + + const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; + const persisted = JSON.parse(lastCall[1]); + expect(persisted.state).toMatchObject({ + lastUsedModel: "claude-sonnet-4-5", + lastUsedPiModel: "claude-opus-4-8", + }); + }); + it("persists the last used cloud repository", async () => { useSettingsStore.getState().setLastUsedCloudRepository("posthog/posthog"); diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index 2d71ae4c17..da38be2ecd 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -1,5 +1,10 @@ import type { UserRepositoryIntegrationRef } from "@posthog/core/integrations/repositories"; -import type { Adapter, ExecutionMode, WorkspaceMode } from "@posthog/shared"; +import type { + Adapter, + AgentRuntime, + ExecutionMode, + WorkspaceMode, +} from "@posthog/shared"; import { COLLAPSE_MODE_DEFAULT, type CollapseMode, @@ -103,8 +108,10 @@ interface SettingsStore { lastUsedRunMode: "local" | "cloud"; lastUsedLocalWorkspaceMode: LocalWorkspaceMode; lastUsedWorkspaceMode: WorkspaceMode; + lastUsedAgentRuntime: AgentRuntime; lastUsedAdapter: AgentAdapter; lastUsedModel: string | null; + lastUsedPiModel: string | null; lastUsedReasoningEffort: string | null; lastUsedCloudRepository: string | null; cachedCloudRepositoryMap: Record; @@ -124,8 +131,10 @@ interface SettingsStore { setLastUsedRunMode: (mode: "local" | "cloud") => void; setLastUsedLocalWorkspaceMode: (mode: LocalWorkspaceMode) => void; setLastUsedWorkspaceMode: (mode: WorkspaceMode) => void; + setLastUsedAgentRuntime: (runtime: AgentRuntime) => void; setLastUsedAdapter: (adapter: AgentAdapter) => void; setLastUsedModel: (model: string) => void; + setLastUsedPiModel: (model: string) => void; setLastUsedReasoningEffort: (effort: string) => void; setLastUsedCloudRepository: (repo: string | null) => void; setCachedCloudRepositoryMap: ( @@ -296,8 +305,10 @@ export const useSettingsStore = create()( lastUsedRunMode: "local", lastUsedLocalWorkspaceMode: "local", lastUsedWorkspaceMode: DEFAULT_WORKSPACE_MODE, + lastUsedAgentRuntime: "acp", lastUsedAdapter: "claude", lastUsedModel: null, + lastUsedPiModel: null, lastUsedReasoningEffort: null, lastUsedCloudRepository: null, cachedCloudRepositoryMap: {}, @@ -313,8 +324,11 @@ export const useSettingsStore = create()( setLastUsedLocalWorkspaceMode: (mode) => set({ lastUsedLocalWorkspaceMode: mode }), setLastUsedWorkspaceMode: (mode) => set({ lastUsedWorkspaceMode: mode }), + setLastUsedAgentRuntime: (runtime) => + set({ lastUsedAgentRuntime: runtime }), setLastUsedAdapter: (adapter) => set({ lastUsedAdapter: adapter }), setLastUsedModel: (model) => set({ lastUsedModel: model }), + setLastUsedPiModel: (model) => set({ lastUsedPiModel: model }), setLastUsedReasoningEffort: (effort) => set({ lastUsedReasoningEffort: effort }), setLastUsedCloudRepository: (repo) => @@ -521,8 +535,10 @@ export const useSettingsStore = create()( lastUsedRunMode: state.lastUsedRunMode, lastUsedLocalWorkspaceMode: state.lastUsedLocalWorkspaceMode, lastUsedWorkspaceMode: state.lastUsedWorkspaceMode, + lastUsedAgentRuntime: state.lastUsedAgentRuntime, lastUsedAdapter: state.lastUsedAdapter, lastUsedModel: state.lastUsedModel, + lastUsedPiModel: state.lastUsedPiModel, lastUsedReasoningEffort: state.lastUsedReasoningEffort, lastUsedCloudRepository: state.lastUsedCloudRepository, cachedCloudRepositoryMap: state.cachedCloudRepositoryMap, diff --git a/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx b/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx index 51d425fd8c..c5f15aba09 100644 --- a/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx +++ b/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx @@ -1,15 +1,23 @@ +import { cn } from "@posthog/quill"; import { useEffect, useRef, useState } from "react"; interface HeaderTitleEditorProps { initialTitle: string; onSubmit: (newTitle: string) => void; onCancel: () => void; + /** + * Extends the base styling — callers match the input to whatever it replaces + * (e.g. a breadcrumb segment's type scale and height) so opening the editor + * doesn't resize the row. + */ + className?: string; } export function HeaderTitleEditor({ initialTitle, onSubmit, onCancel, + className, }: HeaderTitleEditorProps) { const [editValue, setEditValue] = useState(initialTitle); const inputRef = useRef(null); @@ -53,7 +61,10 @@ export function HeaderTitleEditor({ onChange={(e) => setEditValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleSubmit} - className="no-drag h-5 min-w-0 flex-1 rounded-sm border border-accent-8 bg-gray-2 px-1 font-medium text-[12px] text-gray-12 outline-none" + className={cn( + "no-drag h-5 min-w-0 flex-1 rounded-sm border border-accent-8 bg-gray-2 px-1 font-medium text-[12px] text-gray-12 outline-none", + className, + )} /> ); } diff --git a/packages/ui/src/features/task-detail/components/TaskDetail.tsx b/packages/ui/src/features/task-detail/components/TaskDetail.tsx index 865d8438c6..458b6d954a 100644 --- a/packages/ui/src/features/task-detail/components/TaskDetail.tsx +++ b/packages/ui/src/features/task-detail/components/TaskDetail.tsx @@ -51,9 +51,9 @@ export function TaskDetail({ channelId, }: TaskDetailProps) { const taskId = initialTask.id; - const { task } = useTaskData({ taskId, initialTask }); const runtime = task.runtime === "pi" ? "pi" : "acp"; + const selectedTaskRunId = task.latest_run?.id; const effectiveRepoPath = useCwd(taskId); @@ -273,7 +273,9 @@ export function TaskDetail({ - {runtime === "pi" && } + {runtime === "pi" && ( + + )} {runtime === "acp" && } diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 14fc8a1377..6dd6d4b05f 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -5,11 +5,19 @@ import { buildKickoffPreamble } from "@posthog/core/autoresearch/prompts"; import { buildFileLineReferencePrompt } from "@posthog/core/code-review/reviewPrompts"; import type { EditorContent } from "@posthog/core/message-editor/content"; import { xmlToContent } from "@posthog/core/message-editor/content"; +import type { + PiModelSelection, + PiThinkingLevel, +} from "@posthog/core/pi-runtime/piSessionController"; import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; import { useServiceOptional } from "@posthog/di/react"; import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { ButtonGroup } from "@posthog/quill"; -import { type AgentRuntime, ANALYTICS_EVENTS } from "@posthog/shared"; +import { + type AgentRuntime, + ANALYTICS_EVENTS, + getCloudUrlFromRegion, +} from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import type { TaskInputReportAssociation } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; @@ -69,6 +77,10 @@ import type { EditorHandle } from "../../message-editor/types"; import { useAutoFocusOnTyping } from "../../message-editor/useAutoFocusOnTyping"; import { resolveAndAttachDroppedFiles } from "../../message-editor/utils/persistFile"; import { usePanelLayoutStore } from "../../panels/panelLayoutStore"; +import { + PiModelSelector, + PiThinkingLevelSelector, +} from "../../pi-sessions/PiSessionControls"; import { DropZoneOverlay } from "../../sessions/components/DropZoneOverlay"; import { ReasoningLevelSelector } from "../../sessions/components/ReasoningLevelSelector"; import { UnifiedModelSelector } from "../../sessions/components/UnifiedModelSelector"; @@ -197,6 +209,8 @@ export function TaskInput({ lastUsedLocalWorkspaceMode, lastUsedWorkspaceMode, setLastUsedWorkspaceMode, + lastUsedAgentRuntime, + setLastUsedAgentRuntime, lastUsedAdapter, setLastUsedAdapter, lastUsedCloudRepository, @@ -210,6 +224,8 @@ export function TaskInput({ lastUsedInitialTaskMode, setLastUsedReasoningEffort, setLastUsedModel, + lastUsedPiModel, + setLastUsedPiModel, _hasHydrated: settingsHydrated, } = useSettingsStore(); const { data: skills } = useSkills(); @@ -240,6 +256,23 @@ export function TaskInput({ const [isCreatingBranch, setIsCreatingBranch] = useState(false); const [selectedBranch, setSelectedBranch] = useState(null); const [runtime, setRuntime] = useState("acp"); + const didResolveRuntimeRef = useRef(false); + const [selectedPiModelId, setSelectedPiModelId] = useState( + null, + ); + const [selectedPiThinkingLevel, setSelectedPiThinkingLevel] = + useState(null); + const piApiHost = useMemo( + () => (cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null), + [cloudRegion], + ); + const { data: piModelCatalog = [], isPending: isPiConfigLoading } = useQuery({ + ...trpc.agent.getPiModelCatalog.queryOptions({ + apiHost: piApiHost ?? "", + region: cloudRegion ?? "us", + }), + enabled: runtime === "pi" && piApiHost !== null, + }); const [cloudRepoSearchQuery, setCloudRepoSearchQuery] = useState(""); const [isCloudRepoPickerOpen, setIsCloudRepoPickerOpen] = useState(false); const [cloudBranchSearchQuery, setCloudBranchSearchQuery] = useState(""); @@ -353,6 +386,16 @@ export function TaskInput({ hasGithubIntegration, }); + useEffect(() => { + if (didResolveRuntimeRef.current || !settingsHydrated || !flagsLoaded) { + return; + } + didResolveRuntimeRef.current = true; + setRuntime( + piHarnessEnabled && lastUsedAgentRuntime === "pi" ? "pi" : "acp", + ); + }, [flagsLoaded, lastUsedAgentRuntime, piHarnessEnabled, settingsHydrated]); + const [workspaceMode, setWorkspaceModeState] = useState(() => { if (initialCloudRepository) return "cloud"; if (!localWorkspaces) return "cloud"; @@ -404,7 +447,6 @@ export function TaskInput({ const setWorkspaceMode = (mode: WorkspaceMode) => { didResolveWorkspaceModeRef.current = true; - if (mode === "cloud") setRuntime("acp"); setWorkspaceModeState(mode); setLastUsedWorkspaceMode(mode); if (mode !== "cloud") { @@ -726,6 +768,16 @@ export function TaskInput({ modeFallback; const currentReasoningLevel = thoughtOption?.type === "select" ? thoughtOption.currentValue : undefined; + const currentPiModel = + piModelCatalog.find((model) => model.id === selectedPiModelId) ?? + piModelCatalog.find((model) => model.id === lastUsedPiModel) ?? + piModelCatalog[0]; + const piThinkingLevels = currentPiModel?.thinkingLevels ?? []; + const currentPiThinkingLevel = piThinkingLevels.includes( + selectedPiThinkingLevel ?? "high", + ) + ? (selectedPiThinkingLevel ?? "high") + : piThinkingLevels[0]; const autoresearchEnabled = useAutoresearchEnabled(); const armedAutoresearchDraft = useAutoresearchDraftStore( @@ -744,6 +796,9 @@ export function TaskInput({ const effectiveReasoningLevel = autoresearchDraft ? (autoresearchDraft.measureEffort ?? currentReasoningLevel) : currentReasoningLevel; + const taskModel = runtime === "pi" ? currentPiModel?.id : effectiveModel; + const taskReasoningLevel = + runtime === "pi" ? currentPiThinkingLevel : effectiveReasoningLevel; useWarmTask({ workspaceMode, @@ -873,9 +928,9 @@ export function TaskInput({ editorIsEmpty, adapter, runtime, - executionMode: currentExecutionMode, - model: effectiveModel, - reasoningLevel: effectiveReasoningLevel, + executionMode: runtime === "pi" ? undefined : currentExecutionMode, + model: taskModel, + reasoningLevel: taskReasoningLevel, onTaskCreated, onTaskCreatedEffect: handleAutoresearchTaskCreated, environmentId: selectedEnvironment, @@ -993,6 +1048,30 @@ export function TaskInput({ [thoughtOption, setConfigOption, setLastUsedReasoningEffort], ); + const handleRuntimeChange = useCallback( + (nextRuntime: AgentRuntime) => { + didResolveRuntimeRef.current = true; + setRuntime(nextRuntime); + setLastUsedAgentRuntime(nextRuntime); + if (nextRuntime === "pi") { + useAutoresearchDraftStore.getState().clearDraft(sessionId); + } + }, + [sessionId, setLastUsedAgentRuntime], + ); + + const handlePiModelChange = useCallback( + (model: PiModelSelection) => { + setSelectedPiModelId(model.id); + setLastUsedPiModel(model.id); + }, + [setLastUsedPiModel], + ); + + const handlePiThinkingLevelChange = useCallback((level: PiThinkingLevel) => { + setSelectedPiThinkingLevel(level); + }, []); + const { isOnline } = useConnectivity(); const promptSessionId = sessionId; @@ -1119,10 +1198,10 @@ export function TaskInput({ align="center" className="absolute bottom-full left-0 mb-2 min-w-0" > - {piHarnessEnabled && workspaceMode !== "cloud" && ( + {piHarnessEnabled && ( )} @@ -1296,15 +1375,18 @@ export function TaskInput({ !canSubmit || isCreatingTask || !isOnline || - isPreviewLoading + (runtime === "pi" ? isPiConfigLoading : isPreviewLoading) || + (runtime === "pi" && !currentPiModel) } tourTarget="task-input" repoPath={selectedDirectory} - modeOption={modeOption} - onModeChange={handleModeChange} + modeOption={runtime === "pi" ? undefined : modeOption} + onModeChange={runtime === "pi" ? undefined : handleModeChange} allowBypassPermissions={allowBypassPermissions} autoresearch={ - autoresearchService && autoresearchEnabled + runtime !== "pi" && + autoresearchService && + autoresearchEnabled ? { active: !!autoresearchDraft, onToggle: handleAutoresearchToggle, @@ -1314,7 +1396,14 @@ export function TaskInput({ enableCommands enableBashMode={false} modelSelector={ - autoresearchDraft ? null : ( + autoresearchDraft ? null : runtime === "pi" ? ( + + ) : ( } reasoningSelector={ - autoresearchDraft ? null : ( + autoresearchDraft ? null : runtime === "pi" ? ( + currentPiThinkingLevel ? ( + + ) : null + ) : ( (taskKeys.detail(taskId), (task) => + task ? { ...task, latest_run: run } : task, + ); + client.setQueriesData({ queryKey: taskKeys.lists() }, (tasks) => + tasks?.map((task) => + task.id === taskId ? { ...task, latest_run: run } : task, + ), + ); + void client.invalidateQueries({ queryKey: taskKeys.allSummaries() }); + }, + onCreateSuccess(output: TaskCreationOutput, input?: TaskCreationInput): void { if (!input) return; diff --git a/packages/ui/src/primitives/PageHeader.stories.tsx b/packages/ui/src/primitives/PageHeader.stories.tsx new file mode 100644 index 0000000000..328d466b3e --- /dev/null +++ b/packages/ui/src/primitives/PageHeader.stories.tsx @@ -0,0 +1,108 @@ +import { CloudIcon, FilesIcon } from "@phosphor-icons/react"; +import { + Button, + ButtonGroup, + Tabs, + TabsList, + TabsTrigger, +} from "@posthog/quill"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderFilters, + PageHeaderHeading, + PageHeaderNav, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta = { + title: "Primitives/PageHeader", + component: PageHeader, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** The Inbox shape: title, description, tab strip with a filter on the right. */ +export const WithTabsAndFilters: Story = { + args: { + children: ( + <> + + + Inbox + + + Work done by your agents – pull requests, reports, and live runs. + + + + + + + Pull requests + + + Reports + + + + + + + + + ), + }, +}; + +/** The Artifacts shape: a count chip beside the title, view switcher on the right. */ +export const WithChipAndActions: Story = { + args: { + children: ( + + + Artifacts + }> + 12 items + + + + + + + + + + Canvases and pull requests from this space's tasks. + + + ), + }, +}; + +/** Title only — the minimum a page has to spend. */ +export const TitleOnly: Story = { + args: { + children: ( + + + Loops + }> + Runs entirely in the cloud + + + + ), + }, +}; diff --git a/packages/ui/src/primitives/PageHeader.tsx b/packages/ui/src/primitives/PageHeader.tsx new file mode 100644 index 0000000000..2580511ff1 --- /dev/null +++ b/packages/ui/src/primitives/PageHeader.tsx @@ -0,0 +1,189 @@ +import { cn } from "@posthog/quill"; +import type { ReactNode } from "react"; + +/** + * The shared page header section. Full-bleed (the page body below it keeps its + * own container), bordered off from the content, and composed from parts so + * each surface takes only what it needs: + * + * + * + * + * Inbox + * Runs in the cloud + * + * + * + * + * + * + * + * + * + * + * Layout base is the Inbox header (full width, title + description + tab bar); + * the chip comes from Loops. + */ +export function PageHeader({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** Title row + description, tight against each other. */ +export function PageHeaderHeading({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** The title line: title, any chips, and (pushed right) actions. */ +export function PageHeaderTitleRow({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export function PageHeaderTitle({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +

+ {children} +

+ ); +} + +/** A pill next to the title — a count, a mode, "Runs entirely in the cloud". */ +export function PageHeaderChip({ + icon, + className, + children, +}: { + icon?: ReactNode; + className?: string; + children: ReactNode; +}) { + return ( + + {icon} + {children} + + ); +} + +export function PageHeaderDescription({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +

+ {children} +

+ ); +} + +/** Trailing controls on the title line (create buttons, view switchers). */ +export function PageHeaderActions({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** + * The sub-nav row: a tab strip, with filters pushed to the right. Cancels the + * header's bottom padding so an underlined tab strip sits on the header border + * the way the Inbox tabs do; the tabs' own padding keeps the breathing room. + */ +export function PageHeaderNav({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** Filters/controls sitting to the right of the sub-nav. */ +export function PageHeaderFilters({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/src/shell/ContentHeader.tsx b/packages/ui/src/shell/ContentHeader.tsx index 74f34c089d..741a785734 100644 --- a/packages/ui/src/shell/ContentHeader.tsx +++ b/packages/ui/src/shell/ContentHeader.tsx @@ -10,10 +10,17 @@ import { Flex } from "@radix-ui/themes"; // review-panel toggle, cloud/local handoff, skill buttons and task actions that // used to live in the Code header bar. // -// This breadcrumb row is now scoped to the task-detail view only: every other -// page drops it (the title bar search carries wayfinding instead). The /website -// (Channels) space keeps its own header (WebsiteLayout), so it's unaffected — -// this is mounted only outside it. +// This breadcrumb row is scoped to views that have somewhere to walk back to: +// task detail, and the loop scenes (list / detail / form), which live outside +// the space routes but can belong to a space. Every other page drops it (the +// title bar search carries wayfinding instead). The /website (Channels) space +// keeps its own header (WebsiteLayout), so it's unaffected — this is mounted +// only outside it. +// +// A loop with no space pushes null, so the row collapses for it too: what a +// view puts in the header store decides, this only says who may. +const BREADCRUMB_VIEWS = new Set(["task-detail", "loops"]); + export function ContentHeader() { const content = useHeaderStore((state) => state.content); const view = useAppView(); @@ -25,8 +32,7 @@ export function ContentHeader() { : undefined; const showTaskSection = view.type === "task-detail" && Boolean(activeTask); - // Only the task-detail view keeps the breadcrumb row. - if (view.type !== "task-detail") return null; + if (!BREADCRUMB_VIEWS.has(view.type)) return null; if (!content && !showTaskSection) return null; diff --git a/packages/ui/src/shell/GlobalEventHandlers.tsx b/packages/ui/src/shell/GlobalEventHandlers.tsx index 8f714ff383..fb5ddafed7 100644 --- a/packages/ui/src/shell/GlobalEventHandlers.tsx +++ b/packages/ui/src/shell/GlobalEventHandlers.tsx @@ -1,8 +1,10 @@ +import { PI_SESSION_CONTROLLER } from "@posthog/core/pi-runtime/identifiers"; +import type { PiSessionController } from "@posthog/core/pi-runtime/piSessionController"; import { SESSION_SERVICE, type SessionService, } from "@posthog/core/sessions/sessionService"; -import { useService } from "@posthog/di/react"; +import { useService, useServiceOptional } from "@posthog/di/react"; import { useHostTRPC } from "@posthog/host-router/react"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; @@ -47,6 +49,9 @@ export function GlobalEventHandlers({ }: GlobalEventHandlersProps) { const trpcReact = useHostTRPC(); const sessionService = useService(SESSION_SERVICE); + const piSessionController = useServiceOptional( + PI_SESSION_CONTROLLER, + ); const commandMenuOpen = useCommandMenuStore((s) => s.isOpen); const openSettingsDialog = openSettings; const view = useAppView(); @@ -285,10 +290,11 @@ export function GlobalEventHandlers({ const handleFocus = () => { loadFolders(); sessionService.retryUnhealthyCloudSessions(); + piSessionController?.retryUnhealthyCloudSessions(); }; window.addEventListener("focus", handleFocus); return () => window.removeEventListener("focus", handleFocus); - }, [loadFolders, sessionService]); + }, [loadFolders, piSessionController, sessionService]); // Freeze perpetual CSS animations while the window is backgrounded (see the // `.ph-window-blurred` rule in globals.css). Driven by the shared focus store diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index bd0821e254..83a31a6340 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -25,12 +25,8 @@ import { getAvailableCodexModes, getAvailableModes, } from "@posthog/agent/execution-mode"; -import { - fetchGatewayModels, - formatGatewayModelName, - getClaudeModelRecency, - getProviderName, -} from "@posthog/agent/gateway-models"; +import { fetchGatewayModels } from "@posthog/agent/gateway-models"; +import { fetchPosthogPiModelCatalog } from "@posthog/agent/pi/model-catalog"; import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import { findPrUrls, @@ -65,6 +61,7 @@ import { type AcpMessage, type Adapter, buildCloudTaskConfigOptions, + type CloudRegion, type ExecutionMode, isAuthError, resolveCloudInitialPermissionMode, @@ -2353,33 +2350,13 @@ For git operations while detached: }); } - async getGatewayModels(apiHost: string) { + async getPiModelCatalog(apiHost: string, region: CloudRegion) { const gatewayUrl = getLlmGatewayUrl(apiHost); - const models = await fetchGatewayModels({ + return fetchPosthogPiModelCatalog( gatewayUrl, - authToken: (await this.agentAuthAdapter.gatewayAuthToken()) ?? undefined, - }); - - const mapped = models.map((model) => ({ - modelId: model.id, - name: formatGatewayModelName(model), - description: `Context: ${model.context_window.toLocaleString()} tokens`, - provider: getProviderName(model.owned_by), - })); - - return mapped.sort((a, b) => { - const providerOrder = ["Anthropic", "OpenAI", "Gemini"]; - const aProviderIdx = providerOrder.indexOf(a.provider ?? ""); - const bProviderIdx = providerOrder.indexOf(b.provider ?? ""); - if (aProviderIdx !== bProviderIdx) { - const aIdx = aProviderIdx === -1 ? 999 : aProviderIdx; - const bIdx = bProviderIdx === -1 ? 999 : bProviderIdx; - return aIdx - bIdx; - } - return ( - getClaudeModelRecency(a.modelId) - getClaudeModelRecency(b.modelId) - ); - }); + region, + (await this.agentAuthAdapter.gatewayAuthToken()) ?? undefined, + ); } async getPreviewConfigOptions( diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 548865f337..5792aec7d0 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -103,14 +103,17 @@ export const startSessionInput = z.object({ export type StartSessionInput = z.infer; -export const modelOptionSchema = z.object({ - modelId: z.string(), +export const piModelCatalogEntrySchema = z.object({ + provider: z.literal("posthog"), + id: z.string(), name: z.string(), - description: z.string().nullish(), - provider: z.string().optional(), + contextWindow: z.number(), + thinkingLevels: z.array( + z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]), + ), }); -export type ModelOption = z.infer; +export type PiModelCatalogEntry = z.infer; const sessionConfigSelectOptionSchema = z.looseObject({ value: z.string(), @@ -356,11 +359,12 @@ export const sessionInfoSchema = z.object({ export const listSessionsOutput = z.array(sessionInfoSchema); -export const getGatewayModelsInput = z.object({ +export const getPiModelCatalogInput = z.object({ apiHost: z.string(), + region: z.enum(["us", "eu", "dev"]), }); -export const getGatewayModelsOutput = z.array(modelOptionSchema); +export const getPiModelCatalogOutput = z.array(piModelCatalogEntrySchema); export const getPreviewConfigOptionsInput = z.object({ apiHost: z.string(), diff --git a/packages/workspace-server/src/services/pi-session/pi-session.test.ts b/packages/workspace-server/src/services/pi-session/pi-session.test.ts index e32d9fada2..9195c8e53d 100644 --- a/packages/workspace-server/src/services/pi-session/pi-session.test.ts +++ b/packages/workspace-server/src/services/pi-session/pi-session.test.ts @@ -1,25 +1,98 @@ -import { describe, expect, it } from "vitest"; -import { selectPiPoolEvictionCandidate } from "./pi-session"; +import type { PiRpcClient } from "@posthog/agent/pi/rpc-client"; +import type { RpcCommand, RpcResponse } from "@posthog/agent/pi/rpc-transport"; +import type { PiRuntime } from "@posthog/agent/pi/runtime"; +import type { RootLogger } from "@posthog/di/logger"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ITaskMetadataRepository } from "../../db/repositories/task-metadata-repository"; +import type { ProcessTrackingService } from "../process-tracking/process-tracking"; +import type { PiRuntimeFactory } from "./identifiers"; +import { PiSessionService, selectPiPoolEvictionCandidate } from "./pi-session"; + +const scopedLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; +const rootLogger: RootLogger = { + ...scopedLogger, + scope: () => scopedLogger, +}; + +function successfulResponse(command: string): RpcResponse { + return { + type: "response", + command, + success: true, + } as RpcResponse; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); describe("selectPiPoolEvictionCandidate", () => { it("selects the least recently used idle session", () => { expect( selectPiPoolEvictionCandidate([ - { taskId: "recent", state: "idle", lastUsedAt: 30 }, - { taskId: "oldest", state: "idle", lastUsedAt: 10 }, - { taskId: "middle", state: "idle", lastUsedAt: 20 }, + { + taskId: "recent", + state: "idle", + lastUsedAt: 30, + activeRequestCount: 0, + }, + { + taskId: "oldest", + state: "idle", + lastUsedAt: 10, + activeRequestCount: 0, + }, + { + taskId: "middle", + state: "idle", + lastUsedAt: 20, + activeRequestCount: 0, + }, ]), ).toBe("oldest"); }); - it("pins streaming, starting, and protected sessions", () => { + it("pins streaming, starting, protected, and requested sessions", () => { expect( selectPiPoolEvictionCandidate( [ - { taskId: "streaming", state: "streaming", lastUsedAt: 1 }, - { taskId: "starting", state: "starting", lastUsedAt: 2 }, - { taskId: "protected", state: "idle", lastUsedAt: 3 }, - { taskId: "evictable", state: "idle", lastUsedAt: 4 }, + { + taskId: "streaming", + state: "streaming", + lastUsedAt: 1, + activeRequestCount: 0, + }, + { + taskId: "starting", + state: "starting", + lastUsedAt: 2, + activeRequestCount: 0, + }, + { + taskId: "requested", + state: "idle", + lastUsedAt: 3, + activeRequestCount: 1, + }, + { + taskId: "protected", + state: "idle", + lastUsedAt: 4, + activeRequestCount: 0, + }, + { + taskId: "evictable", + state: "idle", + lastUsedAt: 5, + activeRequestCount: 0, + }, ], "protected", ), @@ -29,9 +102,234 @@ describe("selectPiPoolEvictionCandidate", () => { it("returns null when every session is pinned", () => { expect( selectPiPoolEvictionCandidate([ - { taskId: "streaming", state: "streaming", lastUsedAt: 1 }, - { taskId: "starting", state: "starting", lastUsedAt: 2 }, + { + taskId: "streaming", + state: "streaming", + lastUsedAt: 1, + activeRequestCount: 0, + }, + { + taskId: "starting", + state: "starting", + lastUsedAt: 2, + activeRequestCount: 0, + }, + { + taskId: "requested", + state: "idle", + lastUsedAt: 3, + activeRequestCount: 2, + }, ]), ).toBeNull(); }); }); + +describe("PiSessionService task session config", () => { + it("uses Pi session context resolution for model and thinking", async () => { + const content = [ + { + type: "session", + version: 3, + id: "session-1", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: "/repo", + }, + { + type: "model_change", + id: "model-1", + parentId: null, + timestamp: "2026-01-01T00:00:01.000Z", + provider: "posthog", + modelId: "claude-opus-4-8", + }, + { + type: "thinking_level_change", + id: "thinking-1", + parentId: "model-1", + timestamp: "2026-01-01T00:00:02.000Z", + thinkingLevel: "high", + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(content)), + ); + const service = new PiSessionService( + {} as PiRuntimeFactory, + {} as ITaskMetadataRepository, + {} as ProcessTrackingService, + rootLogger, + ); + + await expect( + service.readSessionConfig("https://storage.example/session.jsonl"), + ).resolves.toEqual({ + model: { provider: "posthog", id: "claude-opus-4-8" }, + thinkingLevel: "high", + }); + }); +}); + +describe("PiSessionService start", () => { + it("sets the selected thinking level before the initial prompt", async () => { + const setThinkingLevel = vi.fn().mockResolvedValue(undefined); + const prompt = vi.fn().mockResolvedValue(undefined); + const client = { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + getState: vi.fn().mockResolvedValue({ + isStreaming: false, + sessionFile: "/tmp/session.jsonl", + sessionId: "session-1", + }), + setThinkingLevel, + prompt, + } as unknown as PiRpcClient; + const runtimeFactory = { + create: vi.fn(async () => ({ + client, + process: undefined, + onRuntimeEvent: vi.fn(), + onConversationEvent: vi.fn(), + })), + } as unknown as PiRuntimeFactory; + const taskMetadataRepository = { + upsert: vi.fn(), + } as unknown as ITaskMetadataRepository; + const processTracking = { + register: vi.fn(), + unregister: vi.fn(), + } as unknown as ProcessTrackingService; + const service = new PiSessionService( + runtimeFactory, + taskMetadataRepository, + processTracking, + rootLogger, + ); + + await service.start({ + taskId: "task-1", + cwd: "/tmp", + prompt: "hello", + thinkingLevel: "high", + }); + + expect(setThinkingLevel).toHaveBeenCalledWith("high"); + expect(setThinkingLevel.mock.invocationCallOrder[0]).toBeLessThan( + prompt.mock.invocationCallOrder[0], + ); + }); +}); + +describe("PiSessionService RPC request pinning", () => { + it("keeps a session pinned until command and queue requests settle", async () => { + vi.stubEnv("POSTHOG_CODE_PI_HOT_POOL_SIZE", "1"); + let timestamp = 0; + vi.spyOn(Date, "now").mockImplementation(() => timestamp++); + + const requestResolvers: Array<(response: RpcResponse) => void> = []; + let resolveQueue: (queue: { + steering: string[]; + followUp: string[]; + }) => void = () => {}; + const firstClient = { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + getState: vi.fn().mockResolvedValue({ + isStreaming: false, + sessionFile: "/tmp/first.jsonl", + }), + send: vi.fn( + () => + new Promise((resolve) => { + requestResolvers.push(resolve); + }), + ), + getQueue: vi.fn( + () => + new Promise<{ steering: string[]; followUp: string[] }>((resolve) => { + resolveQueue = resolve; + }), + ), + } as unknown as PiRpcClient; + const secondClient = { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + getState: vi.fn().mockResolvedValue({ + isStreaming: false, + sessionFile: "/tmp/second.jsonl", + }), + send: vi.fn(), + } as unknown as PiRpcClient; + const thirdClient = { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + getState: vi.fn().mockResolvedValue({ + isStreaming: false, + sessionFile: "/tmp/third.jsonl", + }), + send: vi.fn(), + } as unknown as PiRpcClient; + const clients = [firstClient, secondClient, thirdClient]; + const runtimeFactory = { + create: vi.fn(async () => { + const client = clients.shift() as PiRpcClient; + return { + client, + process: undefined, + sendCommand: vi.fn((command) => + ( + client as unknown as { + send(command: RpcCommand): Promise; + } + ).send(command), + ), + onRuntimeEvent: vi.fn(), + onConversationEvent: vi.fn(), + } as unknown as PiRuntime; + }), + } as PiRuntimeFactory; + const taskMetadataRepository = { + findByTaskId: vi.fn((taskId: string) => ({ + piSessionFile: `/tmp/${taskId}.jsonl`, + })), + upsert: vi.fn(), + } as unknown as ITaskMetadataRepository; + const processTracking = { + register: vi.fn(), + unregister: vi.fn(), + } as unknown as ProcessTrackingService; + const service = new PiSessionService( + runtimeFactory, + taskMetadataRepository, + processTracking, + rootLogger, + ); + + await service.resume({ taskId: "first", cwd: "/tmp" }); + const bashRequest = service.request("first", { + type: "bash", + command: "sleep 1", + }); + const queueRequest = service.getQueue("first"); + + await service.resume({ taskId: "second", cwd: "/tmp" }); + expect(firstClient.stop).not.toHaveBeenCalled(); + + requestResolvers[0](successfulResponse("bash")); + await bashRequest; + await vi.waitFor(() => expect(secondClient.stop).toHaveBeenCalledOnce()); + expect(firstClient.stop).not.toHaveBeenCalled(); + + resolveQueue({ steering: [], followUp: [] }); + await queueRequest; + expect(firstClient.stop).not.toHaveBeenCalled(); + + await service.resume({ taskId: "third", cwd: "/tmp" }); + expect(firstClient.stop).toHaveBeenCalledOnce(); + expect(thirdClient.stop).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/workspace-server/src/services/pi-session/pi-session.ts b/packages/workspace-server/src/services/pi-session/pi-session.ts index 837dee3823..cdc1f7af15 100644 --- a/packages/workspace-server/src/services/pi-session/pi-session.ts +++ b/packages/workspace-server/src/services/pi-session/pi-session.ts @@ -1,6 +1,18 @@ +import { + buildSessionContext, + type FileEntry, + migrateSessionEntries, + parseSessionEntries, + type SessionEntry, +} from "@earendil-works/pi-coding-agent"; import type { PiRpcClient } from "@posthog/agent/pi/rpc-client"; +import type { RpcCommand, RpcResponse } from "@posthog/agent/pi/rpc-transport"; import type { PiRuntime } from "@posthog/agent/pi/runtime"; -import type { PiModelOption } from "@posthog/agent/pi/types"; +import { + PI_THINKING_LEVELS, + type PiPersistedSessionConfig, + type PiQueueSnapshot, +} from "@posthog/agent/pi/types"; import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; import { type AgentConversationEvent, @@ -21,6 +33,7 @@ interface PiPoolEntry { taskId: string; state: PiPoolSessionState; lastUsedAt: number; + activeRequestCount: number; } export function selectPiPoolEvictionCandidate( @@ -29,7 +42,10 @@ export function selectPiPoolEvictionCandidate( ): string | null { const candidate = entries .filter( - (entry) => entry.taskId !== protectedTaskId && entry.state === "idle", + (entry) => + entry.taskId !== protectedTaskId && + entry.state === "idle" && + entry.activeRequestCount === 0, ) .sort((left, right) => left.lastUsedAt - right.lastUsedAt)[0]; @@ -47,6 +63,7 @@ interface ManagedPiSession { runtime: PiRuntime; state: PiPoolSessionState; lastUsedAt: number; + activeRequestCount: number; pid?: number; } @@ -105,6 +122,9 @@ export class PiSessionService extends TypedEventEmitter { const session = this.registerSession(input.taskId, runtime); return this.startSession(input.taskId, client, session, async () => { + if (input.thinkingLevel) { + await client.setThinkingLevel(input.thinkingLevel); + } const state = await client.getState(); if (!state.sessionFile) { @@ -161,227 +181,70 @@ export class PiSessionService extends TypedEventEmitter { await this.startSession(input.taskId, client, session, async () => {}); } - async prompt( - taskId: string, - prompt: string, - images?: Parameters[1], - ): Promise { - await this.requireSession(taskId).client.prompt(prompt, images); - } - - async steer( - taskId: string, - message: string, - images?: Parameters[1], - ): Promise { - await this.requireSession(taskId).client.steer(message, images); - } - - async followUp( - taskId: string, - message: string, - images?: Parameters[1], - ): Promise { - await this.requireSession(taskId).client.followUp(message, images); - } - - async abort(taskId: string): Promise { - await this.requireSession(taskId).client.abort(); - } + request(taskId: string, command: RpcCommand): Promise { + return this.withActiveRequest(taskId, async (session) => { + const response = await session.runtime.sendCommand(command); - async newSession( - taskId: string, - parentSession?: string, - ): ReturnType { - const result = - await this.requireSession(taskId).client.newSession(parentSession); - - if (!result.cancelled) { - await this.persistSessionState(taskId); - } - - return result; - } - - setModel( - taskId: string, - provider: string, - modelId: string, - ): ReturnType { - return this.requireSession(taskId).client.setModel(provider, modelId); - } - - cycleModel(taskId: string): ReturnType { - return this.requireSession(taskId).client.cycleModel(); - } - - availableModels(taskId: string): Promise { - return this.requireSession(taskId).runtime.availableModels(); - } - - setThinkingLevel( - taskId: string, - level: Parameters[0], - ): ReturnType { - return this.requireSession(taskId).client.setThinkingLevel(level); - } - - cycleThinkingLevel( - taskId: string, - ): ReturnType { - return this.requireSession(taskId).client.cycleThinkingLevel(); - } - - setSteeringMode( - taskId: string, - mode: Parameters[0], - ): ReturnType { - return this.requireSession(taskId).client.setSteeringMode(mode); - } - - setFollowUpMode( - taskId: string, - mode: Parameters[0], - ): ReturnType { - return this.requireSession(taskId).client.setFollowUpMode(mode); - } - - compact( - taskId: string, - customInstructions?: string, - ): ReturnType { - return this.requireSession(taskId).client.compact(customInstructions); - } - - setAutoCompaction( - taskId: string, - enabled: boolean, - ): ReturnType { - return this.requireSession(taskId).client.setAutoCompaction(enabled); - } - - setAutoRetry( - taskId: string, - enabled: boolean, - ): ReturnType { - return this.requireSession(taskId).client.setAutoRetry(enabled); - } - - abortRetry(taskId: string): ReturnType { - return this.requireSession(taskId).client.abortRetry(); - } - - bash(taskId: string, command: string): ReturnType { - return this.requireSession(taskId).client.bash(command); - } - - abortBash(taskId: string): ReturnType { - return this.requireSession(taskId).client.abortBash(); - } + if ( + response.success && + ["new_session", "switch_session", "fork", "clone"].includes( + command.type, + ) + ) { + await this.persistSessionState(taskId); + } - sessionStats(taskId: string): ReturnType { - return this.requireSession(taskId).client.getSessionStats(); + return response; + }); } - exportHtml( - taskId: string, - outputPath?: string, - ): ReturnType { - return this.requireSession(taskId).client.exportHtml(outputPath); + getQueue(taskId: string): Promise { + return this.withActiveRequest(taskId, (session) => + session.client.getQueue(), + ); } - async switchSession( - taskId: string, - sessionPath: string, - ): ReturnType { - const result = - await this.requireSession(taskId).client.switchSession(sessionPath); - - if (!result.cancelled) { - await this.persistSessionState(taskId); - } - - return result; + clearQueue(taskId: string): Promise { + return this.withActiveRequest(taskId, async (session) => { + const queue = await session.client.clearQueue(); + session.runtime.clearPendingQueuedUserMessages(); + return queue; + }); } - async fork(taskId: string, entryId: string): ReturnType { - const result = await this.requireSession(taskId).client.fork(entryId); - - if (!result.cancelled) { - await this.persistSessionState(taskId); + async readSessionConfig( + downloadUrl: string, + ): Promise { + const response = await fetch(downloadUrl, { + signal: AbortSignal.timeout(30_000), + }); + if (response.status === 404) { + return null; } - - return result; - } - - async clone(taskId: string): ReturnType { - const result = await this.requireSession(taskId).client.clone(); - - if (!result.cancelled) { - await this.persistSessionState(taskId); + if (!response.ok) { + throw new Error( + `Failed to download Pi task session: ${response.statusText}`, + ); } - return result; - } - - forkMessages(taskId: string): ReturnType { - return this.requireSession(taskId).client.getForkMessages(); - } - - tree(taskId: string): ReturnType { - return this.requireSession(taskId).client.getTree(); - } - - lastAssistantText( - taskId: string, - ): ReturnType { - return this.requireSession(taskId).client.getLastAssistantText(); - } - - setSessionName( - taskId: string, - name: string, - ): ReturnType { - return this.requireSession(taskId).client.setSessionName(name); - } - - messages(taskId: string): ReturnType { - return this.requireSession(taskId).client.getMessages(); - } - - commands(taskId: string): ReturnType { - return this.requireSession(taskId).client.getCommands(); - } - - waitForIdle( - taskId: string, - timeout?: number, - ): ReturnType { - return this.requireSession(taskId).client.waitForIdle(timeout); - } - - collectEvents( - taskId: string, - timeout?: number, - ): ReturnType { - return this.requireSession(taskId).client.collectEvents(timeout); - } - - promptAndWait( - taskId: string, - prompt: string, - images?: Parameters[1], - timeout?: number, - ): ReturnType { - return this.requireSession(taskId).client.promptAndWait( - prompt, - images, - timeout, + const fileEntries = parseSessionEntries( + await response.text(), + ) as FileEntry[]; + migrateSessionEntries(fileEntries); + const entries = fileEntries.filter( + (entry): entry is SessionEntry => entry.type !== "session", + ); + const context = buildSessionContext(entries); + const thinkingLevel = PI_THINKING_LEVELS.find( + (level) => level === context.thinkingLevel, ); - } - stderr(taskId: string): string { - return this.requireSession(taskId).client.getStderr(); + return { + model: context.model + ? { provider: context.model.provider, id: context.model.modelId } + : null, + thinkingLevel: thinkingLevel ?? "off", + }; } async stop(taskId: string): Promise { @@ -420,21 +283,6 @@ export class PiSessionService extends TypedEventEmitter { }; } - status(taskId: string): ReturnType { - return this.requireSession(taskId).client.getState(); - } - - conversation(taskId: string): Promise { - return this.requireSession(taskId).runtime.conversation(); - } - - entries( - taskId: string, - since?: string, - ): ReturnType { - return this.requireSession(taskId).client.getEntries(since); - } - async cleanup(): Promise { await Promise.all( [...this.sessions.keys()].map((taskId) => this.stop(taskId)), @@ -513,6 +361,7 @@ export class PiSessionService extends TypedEventEmitter { runtime, state: "starting", lastUsedAt: Date.now(), + activeRequestCount: 0, }; this.sessions.set(taskId, session); @@ -562,6 +411,21 @@ export class PiSessionService extends TypedEventEmitter { }); } + private async withActiveRequest( + taskId: string, + operation: (session: ManagedPiSession) => Promise, + ): Promise { + const session = this.requireSession(taskId); + session.activeRequestCount += 1; + + try { + return await operation(session); + } finally { + session.activeRequestCount -= 1; + void this.enforceHotPoolLimit(); + } + } + private requireSession(taskId: string): ManagedPiSession { const session = this.sessions.get(taskId); @@ -614,6 +478,7 @@ export class PiSessionService extends TypedEventEmitter { taskId, state: session.state, lastUsedAt: session.lastUsedAt, + activeRequestCount: session.activeRequestCount, })), protectedTaskId, ); @@ -621,12 +486,22 @@ export class PiSessionService extends TypedEventEmitter { if (!taskId) { return; } - this.log.info("Evicting least recently used Pi session", { - taskId, - maxHotSessions: this.maxHotSessions, - }); try { - await this.stop(taskId); + await this.runExclusive(taskId, async () => { + const session = this.sessions.get(taskId); + const isEvictable = + session?.state === "idle" && session.activeRequestCount === 0; + + if (!isEvictable || taskId === protectedTaskId) { + return; + } + + this.log.info("Evicting least recently used Pi session", { + taskId, + maxHotSessions: this.maxHotSessions, + }); + await this.stopLocked(taskId); + }); } catch (error) { this.log.warn("Failed to evict Pi session", { taskId, error }); return; diff --git a/packages/workspace-server/src/services/pi-session/schemas.ts b/packages/workspace-server/src/services/pi-session/schemas.ts index 03dcb93c2f..344b6738a9 100644 --- a/packages/workspace-server/src/services/pi-session/schemas.ts +++ b/packages/workspace-server/src/services/pi-session/schemas.ts @@ -1,161 +1,23 @@ import { - PI_QUEUE_MODES, - PI_THINKING_LEVELS, - type PiCommand, - type PiModelOption, - type PiSessionStatus, -} from "@posthog/agent/pi/types"; + piRpcCommandSchema, + piRpcResponseSchema, +} from "@posthog/agent/pi/rpc-transport"; import { z } from "zod"; -const agentContent = z.discriminatedUnion("type", [ - z.object({ type: z.literal("text"), text: z.string() }), - z.object({ - type: z.literal("image"), - data: z.string(), - mimeType: z.string(), - }), - z.object({ - type: z.literal("audio"), - data: z.string(), - mimeType: z.string(), - }), - z.object({ - type: z.literal("resource_link"), - uri: z.string(), - name: z.string(), - description: z.string().nullable().optional(), - mimeType: z.string().nullable().optional(), - size: z.number().nullable().optional(), - title: z.string().nullable().optional(), - }), - z.object({ - type: z.literal("resource"), - resource: z.union([ - z.object({ - uri: z.string(), - mimeType: z.string().nullable().optional(), - text: z.string(), - }), - z.object({ - uri: z.string(), - mimeType: z.string().nullable().optional(), - blob: z.string(), - }), - ]), - }), -]); - -const agentToolContent = z.discriminatedUnion("type", [ - z.object({ type: z.literal("content"), content: agentContent }), - z.object({ - type: z.literal("diff"), - path: z.string(), - oldText: z.string().nullable().optional(), - newText: z.string(), - }), - z.object({ type: z.literal("terminal"), terminalId: z.string() }), -]); - -const agentToolCall = z.object({ - id: z.string(), - title: z.string(), - kind: z - .enum([ - "read", - "edit", - "delete", - "move", - "search", - "execute", - "think", - "fetch", - "switch_mode", - "question", - "other", - ]) - .nullable() - .optional(), - status: z - .enum(["pending", "in_progress", "completed", "failed"]) - .nullable() - .optional(), - content: z.array(agentToolContent).optional(), - locations: z - .array( - z.object({ path: z.string(), line: z.number().nullable().optional() }), - ) - .optional(), - rawInput: z.unknown().optional(), - rawOutput: z.unknown().optional(), - parentId: z.string().optional(), -}); - -export const piConversationEvent = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("user_message"), - id: z.string(), - timestamp: z.number(), - content: z.array(agentContent), - }), - z.object({ - type: z.literal("assistant_message_chunk"), - timestamp: z.number(), - content: agentContent, - }), - z.object({ - type: z.literal("assistant_thought_chunk"), - timestamp: z.number(), - content: agentContent, - }), - z.object({ - type: z.literal("tool_call_started"), - timestamp: z.number(), - toolCall: agentToolCall, - }), - z.object({ - type: z.literal("tool_call_updated"), - timestamp: z.number(), - toolCall: agentToolCall.partial().required({ id: true }), - }), - z.object({ - type: z.literal("runtime_status"), - timestamp: z.number(), - status: z.string(), - isComplete: z.boolean().optional(), - error: z.string().optional(), - message: z.string().optional(), - attempt: z.number().optional(), - maxAttempts: z.number().optional(), - delayMs: z.number().optional(), - }), - z.object({ - type: z.literal("runtime_error"), - timestamp: z.number(), - errorType: z.string(), - message: z.string(), - }), - z.object({ - type: z.literal("turn_completed"), - timestamp: z.number(), - stopReason: z.string().optional(), - }), -]); - -export const piConversationOutput = z.array(piConversationEvent); - -export const piImageContent = z.object({ - type: z.literal("image"), - data: z.string(), - mimeType: z.string(), -}); +export { piRpcResponseSchema }; export const startPiSessionInput = z.object({ taskId: z.string(), cwd: z.string(), prompt: z.string(), model: z.string().optional(), + thinkingLevel: z + .enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]) + .optional(), }); +export type StartPiSessionInput = z.infer; + export const piSessionStartOutput = z.object({ sessionFile: z.string().nullable(), sessionId: z.string(), @@ -167,164 +29,41 @@ export const piSessionHealthOutput = z.object({ lastUsedAt: z.number().optional(), }); -export const piSessionVoidOutput = z.void(); - -export const piSessionCancelledOutput = z.object({ cancelled: z.boolean() }); - -export const piSessionModelOutput = z.object({ - provider: z.string(), - id: z.string(), -}); - -export const piThinkingLevel = z.enum(PI_THINKING_LEVELS); -export const piQueueMode = z.enum(PI_QUEUE_MODES); - -export const piSessionCycleModelOutput = z - .object({ - model: piSessionModelOutput, - thinkingLevel: piThinkingLevel, - isScoped: z.boolean(), - }) - .nullable(); - -export const piSessionAvailableModelsOutput = z.array( - piSessionModelOutput.extend({ - contextWindow: z.number(), - reasoning: z.boolean(), - thinkingLevels: z.array(piThinkingLevel), - }), -) satisfies z.ZodType; - -export const piSessionThinkingCycleOutput = z - .object({ level: piThinkingLevel }) - .nullable(); - -export const piSessionStatusOutput = z.object({ - model: piSessionModelOutput.optional(), - thinkingLevel: piThinkingLevel, - isStreaming: z.boolean(), - isCompacting: z.boolean(), - steeringMode: piQueueMode, - followUpMode: piQueueMode, - sessionFile: z.string().optional(), - sessionId: z.string(), - sessionName: z.string().optional(), - autoCompactionEnabled: z.boolean(), - messageCount: z.number(), - pendingMessageCount: z.number(), -}) satisfies z.ZodType; - -export const piSessionBashOutput = z.object({ - output: z.string(), - exitCode: z.number().optional(), - cancelled: z.boolean(), - truncated: z.boolean(), - fullOutputPath: z.string().optional(), -}); - -export const piSessionExportOutput = z.object({ path: z.string() }); - -export const piSessionForkOutput = z.object({ - text: z.string(), - cancelled: z.boolean(), -}); - -export const piSessionForkMessagesOutput = z.array( - z.object({ entryId: z.string(), text: z.string() }), -); - -export const piSessionCommandsOutput = z.array( - z.object({ - name: z.string(), - description: z.string().optional(), - source: z.enum(["extension", "prompt", "skill"]), - sourceInfo: z.object({ - path: z.string(), - source: z.string(), - scope: z.enum(["user", "project", "temporary"]), - origin: z.enum(["package", "top-level"]), - baseDir: z.string().optional(), - }), - }), -) satisfies z.ZodType; - -export const piSessionLastAssistantTextOutput = z.string().nullable(); -export const piSessionStderrOutput = z.string(); -export const piSessionUnknownOutput = z.unknown(); - export const resumePiSessionInput = z.object({ taskId: z.string(), cwd: z.string(), }); -export const piSessionTranscriptInput = z.object({ taskId: z.string() }); - -export const piSessionPromptInput = piSessionTranscriptInput.extend({ - prompt: z.string().min(1), - images: z.array(piImageContent).optional(), -}); - -export const piSessionMessageInput = piSessionTranscriptInput.extend({ - message: z.string().min(1), - images: z.array(piImageContent).optional(), -}); - -export const piSessionBashInput = piSessionTranscriptInput.extend({ - command: z.string().min(1), -}); - -export const piSessionModelInput = piSessionTranscriptInput.extend({ - provider: z.string().min(1), - modelId: z.string().min(1), -}); - -export const piSessionThinkingLevelInput = piSessionTranscriptInput.extend({ - level: piThinkingLevel, -}); - -export const piSessionQueueModeInput = piSessionTranscriptInput.extend({ - mode: piQueueMode, -}); - -export const piSessionCompactInput = piSessionTranscriptInput.extend({ - customInstructions: z.string().optional(), -}); - -export const piSessionEnabledInput = piSessionTranscriptInput.extend({ - enabled: z.boolean(), -}); - -export const piSessionNewInput = piSessionTranscriptInput.extend({ - parentSession: z.string().optional(), -}); +export const piSessionTaskInput = z.object({ taskId: z.string() }); -export const piSessionPathInput = piSessionTranscriptInput.extend({ - sessionPath: z.string().min(1), -}); - -export const piSessionEntryInput = piSessionTranscriptInput.extend({ - entryId: z.string().min(1), -}); - -export const piSessionNameInput = piSessionTranscriptInput.extend({ - name: z.string().min(1), -}); - -export const piSessionExportInput = piSessionTranscriptInput.extend({ - outputPath: z.string().optional(), -}); +export const piSessionConfigInput = z.object({ downloadUrl: z.url() }); -export const piSessionTimeoutInput = piSessionTranscriptInput.extend({ - timeout: z.number().int().positive().optional(), -}); +export const piSessionConfigOutput = z + .object({ + model: z + .object({ + provider: z.string(), + id: z.string(), + }) + .nullable(), + thinkingLevel: z.enum([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ]), + }) + .nullable(); -export const piSessionPromptAndWaitInput = piSessionPromptInput.extend({ - timeout: z.number().int().positive().optional(), +export const piQueueSnapshotOutput = z.object({ + steering: z.array(z.string()), + followUp: z.array(z.string()), }); -export const piSessionEntriesInput = piSessionTranscriptInput.extend({ - since: z.string().optional(), +export const piSessionRpcInput = z.object({ + taskId: z.string(), + command: piRpcCommandSchema, }); - -export type StartPiSessionInput = z.infer; -export type PiSessionPromptInput = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f35ccf04a..553c21e92f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,17 +7,17 @@ settings: catalogs: default: '@earendil-works/pi-agent-core': - specifier: 0.80.6 - version: 0.80.6 + specifier: 0.82.1 + version: 0.82.1 '@earendil-works/pi-ai': - specifier: 0.80.6 - version: 0.80.6 + specifier: 0.82.1 + version: 0.82.1 '@earendil-works/pi-coding-agent': - specifier: 0.80.6 - version: 0.80.6 + specifier: 0.82.1 + version: 0.82.1 '@earendil-works/pi-tui': - specifier: 0.80.6 - version: 0.80.6 + specifier: 0.82.1 + version: 0.82.1 '@hono/node-server': specifier: ^1.13.7 version: 1.19.9 @@ -768,13 +768,13 @@ importers: version: 0.109.0(zod@4.4.3) '@earendil-works/pi-agent-core': specifier: 'catalog:' - version: 0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) '@earendil-works/pi-ai': specifier: 'catalog:' - version: 0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) '@earendil-works/pi-coding-agent': specifier: 'catalog:' - version: 0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) '@hono/node-server': specifier: ^1.19.9 version: 1.19.9(hono@4.11.7) @@ -1053,13 +1053,13 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: 'catalog:' - version: 0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) '@earendil-works/pi-coding-agent': specifier: 'catalog:' - version: 0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) '@earendil-works/pi-tui': specifier: 'catalog:' - version: 0.80.6 + version: 0.82.1 '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.4.3) @@ -1100,6 +1100,9 @@ importers: '@json-render/core': specifier: ^0.19.0 version: 0.19.0(zod@4.4.3) + '@posthog/agent': + specifier: workspace:* + version: link:../agent '@posthog/core': specifier: workspace:* version: link:../core @@ -2749,22 +2752,22 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - '@earendil-works/pi-agent-core@0.80.6': - resolution: {integrity: sha512-Lvn89ko42h5ETUb6Z0Ku6ldskEqXaTdQBYvSa0+7bdG9V6rUEpXptv5e0OVZ1HDcvi8s6/2lGCQWsxKX+DFHNw==} + '@earendil-works/pi-agent-core@0.82.1': + resolution: {integrity: sha512-Z3kloziJIE2dmrisRckZX8zDca/gIv9/YdFAzeoqpHiLV2wsni6bL4hInNSjVKLbqT+4kqLIkph2JQLKvSepjg==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.80.6': - resolution: {integrity: sha512-7xfLk8sANBp+bpPEbjoOZTbPxsa+++b1JXAoSJsNa3vbs9AHHEclmvg54XLQcxH+fuwaeti/g2jeIfJ+mVYLpA==} + '@earendil-works/pi-ai@0.82.1': + resolution: {integrity: sha512-3WFYRhEp3lQB3444EhPMBcM7zSaEUE3eJgHOR7s4081NLqbw/FsWilIKWXSua0Gv3sRr7m9xMidR3pPDE7jI/A==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.80.6': - resolution: {integrity: sha512-vcfD6tOk402isLl3Cm/qbn2O10TvgroMp1+/fEGM24ZdvETFCdOYv5VZ7m59EI5fPsjfSJh+CpQ5bhBrhfOg7g==} + '@earendil-works/pi-coding-agent@0.82.1': + resolution: {integrity: sha512-zbkAhoIuDPMF3pKuja0ajZabrMWU29FUMV9A/XMXT/XC1yXs5xt6t6t13GogQFsDrDqbFP4DkZQO1w8rWRAzYA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-tui@0.80.6': - resolution: {integrity: sha512-bSuzS4EVSqEPj/Qr/p9eqCESfKsGuDNbl77EGci8Iaqqt/C/XCBZL1MjXaxSWW1NsT5afjp/Cb0NTPzOLv/aPA==} + '@earendil-works/pi-tui@0.82.1': + resolution: {integrity: sha512-9yN8hALfKaxZq7n54EMxqhFCWnMi6LHkraMJ/1YjHiATq75XrI6XDMVppn9EDtiK7Fks8hUe1SDXUTrIvwRWfQ==} engines: {node: '>=22.19.0'} '@ecies/ciphers@0.2.6': @@ -2858,11 +2861,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.hirok.io' + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.hirok.io' + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -9575,8 +9578,8 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - deslop-js@0.7.8: - resolution: {integrity: sha512-QMmb3Z/ARvYZmZneudb8cnY/4mVvZTdhUyA9TC2skwOcm7KvY9zyOdn0TApQc4rL0VM2TffFkmo3ky/lJZX7qw==} + deslop-js@0.8.3: + resolution: {integrity: sha512-axNV/iX3Zq9xt0MYesmbBGxneeeY/HrYgXTsaM4+GOrdxXP9JyCfTdC4j8zx09bBML94oSIpFNyn9U1f0oEPqQ==} destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} @@ -12945,8 +12948,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-plugin-react-doctor@0.7.8: - resolution: {integrity: sha512-3f9/jFLIC/KRLPYqxiXSk20cq47luGy9Oz5Ru7nK7w0EI9B9zuMvAU92bK9jFiwFE7Lc9PA8ER6Y+naYaGFQGw==} + oxlint-plugin-react-doctor@0.8.3: + resolution: {integrity: sha512-S1Gq1H9+BpziApWcZ/sWPNYYy1FMkFmtSEGiqIJ4/Aac76LfkeutPFtSRlkJF1JlcrbYFf7LXcfcxZCJgmVBBQ==} engines: {node: ^20.19.0 || >=22.13.0} oxlint@1.66.0: @@ -13592,8 +13595,8 @@ packages: resolution: {integrity: sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==} engines: {node: ^20.9.0 || >=22} - react-doctor@0.7.8: - resolution: {integrity: sha512-G3spmtZJE/gWWPRJ3rpgUWTPRDJpEmdRja7iNZ7RAXlfpEO+NWVzPTca/cPI9hLwPo2Aq5/BZggo5JDBrwGrlA==} + react-doctor@0.8.3: + resolution: {integrity: sha512-FfG7YQKb1yv1UNk2gknZzLAPx6L3RMOhYsYbslr4MSpVMNk5xBHlu9VxjtS0br0DEBWjkZovrf/C1MrchiIzAw==} engines: {node: ^20.19.0 || >=22.13.0} hasBin: true @@ -17180,9 +17183,10 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} - '@earendil-works/pi-agent-core@0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + diff: 8.0.4 ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -17194,7 +17198,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -17215,11 +17219,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.80.6 + '@earendil-works/pi-agent-core': 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.82.1 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -17245,7 +17249,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.80.6': + '@earendil-works/pi-tui@0.82.1': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 @@ -24231,7 +24235,7 @@ snapshots: dequal@2.0.3: {} - deslop-js@0.7.8: + deslop-js@0.8.3: dependencies: '@oxc-project/types': 0.138.0 fast-glob: 3.3.3 @@ -28608,7 +28612,7 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.45.0 '@oxfmt/binding-win32-x64-msvc': 0.45.0 - oxlint-plugin-react-doctor@0.7.8: + oxlint-plugin-react-doctor@0.8.3: dependencies: '@typescript-eslint/types': 8.62.0 eslint-scope: 9.1.2 @@ -29361,19 +29365,19 @@ snapshots: transitivePeerDependencies: - supports-color - react-doctor@0.7.8(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.208.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)): + react-doctor@0.8.3(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.208.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)): dependencies: '@babel/code-frame': 7.29.0 '@sentry/node': 10.61.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.208.0(@opentelemetry/api@1.9.1)) agent-install: 0.0.5 conf: 15.1.0 confbox: 0.2.4 - deslop-js: 0.7.8 + deslop-js: 0.8.3 eslint-plugin-react-hooks: 7.1.1(eslint@10.5.0(jiti@2.7.0)) jiti: 2.7.0 magicast: 0.5.3 oxlint: 1.66.0 - oxlint-plugin-react-doctor: 0.7.8 + oxlint-plugin-react-doctor: 0.8.3 prompts: 2.4.2 typescript: 5.9.3 vscode-languageserver: 9.0.1 @@ -29648,7 +29652,7 @@ snapshots: preact: 10.29.2 prompts: 2.4.2 react: 19.2.6 - react-doctor: 0.7.8(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.208.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)) + react-doctor: 0.8.3(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.208.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)) react-dom: 19.2.6(react@19.2.6) react-grab: 0.1.48(react@19.2.6) optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4b7843a910..d0aa2e24e7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,10 +6,10 @@ packages: catalog: '@hono/node-server': ^1.13.7 '@hono/trpc-server': ^0.3.4 - '@earendil-works/pi-agent-core': 0.80.6 - '@earendil-works/pi-ai': 0.80.6 - '@earendil-works/pi-coding-agent': 0.80.6 - '@earendil-works/pi-tui': 0.80.6 + '@earendil-works/pi-agent-core': 0.82.1 + '@earendil-works/pi-ai': 0.82.1 + '@earendil-works/pi-coding-agent': 0.82.1 + '@earendil-works/pi-tui': 0.82.1 '@parcel/watcher': ^2.5.6 '@phosphor-icons/react': ^2.1.10 '@posthog/quill': 0.3.0-beta.24