diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 615bd854c1..8ad788196e 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -1990,6 +1990,112 @@ describe("AgentServer HTTP Mode", () => { delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; }); + it("returns auto-PR prompt for manual runs when the user opted into auto-publish", () => { + const s = createServer({ autoPublish: true }); + const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt(); + expect(prompt).toContain("gh pr create --draft"); + expect(prompt).not.toContain("stop with local changes ready for review"); + // Manual runs keep the PostHog Code attribution. + expect(prompt).toContain( + "Created with [PostHog Code](https://posthog.com/code?ref=pr)", + ); + }); + + it("keeps review-first prompt when auto-publish is on but createPr is false", () => { + const s = createServer({ autoPublish: true, createPr: false }); + const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt(); + expect(prompt).toContain("stop with local changes ready for review"); + expect(prompt).not.toContain("gh pr create --draft"); + }); + + it("auto-publishes in no-repository mode when the user opted in", () => { + const s = createServer({ repositoryPath: undefined, autoPublish: true }); + const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt(); + expect(prompt).toContain("Cloud Task Execution — No Repository Mode"); + expect(prompt).toContain("without waiting to be asked"); + expect(prompt).not.toContain("unless the user explicitly asks for that"); + }); + + // Prewarmed runs boot before the user's choice exists; the upgrade is + // resolved from run state when the first message arrives. + type WarmTestable = { + prewarmedRun: boolean; + session: { payload: { task_id: string; run_id: string } } | null; + posthogAPI: { getTaskRun: ReturnType }; + resolveWarmAutoPublishUpgrade(): Promise; + buildCloudSystemPrompt(): string; + }; + const makeWarmServer = ( + state: Record | Error, + overrides: Partial[0]> = {}, + ): WarmTestable => { + const t = createServer(overrides) as unknown as WarmTestable; + t.prewarmedRun = true; + t.session = { + payload: { task_id: "test-task-id", run_id: "test-run-id" }, + }; + t.posthogAPI = { + getTaskRun: + state instanceof Error + ? vi.fn(async () => { + throw state; + }) + : vi.fn(async () => ({ state })), + }; + return t; + }; + + it("upgrades a prewarmed run to auto-publish from run state on the first message", async () => { + const t = makeWarmServer({ prewarmed: true, auto_publish: true }); + + const override = await t.resolveWarmAutoPublishUpgrade(); + expect(override).toContain("OVERRIDE PREVIOUS INSTRUCTIONS"); + expect(override).toContain("gh pr create --draft"); + // The flip persists for the rest of the session... + expect(t.buildCloudSystemPrompt()).toContain("gh pr create --draft"); + // ...and the override is injected only once. + expect(await t.resolveWarmAutoPublishUpgrade()).toBeNull(); + expect(t.posthogAPI.getTaskRun).toHaveBeenCalledTimes(1); + }); + + it("keeps a prewarmed run review-first when run state has no auto_publish", async () => { + const t = makeWarmServer({ prewarmed: true }); + + expect(await t.resolveWarmAutoPublishUpgrade()).toBeNull(); + expect(t.buildCloudSystemPrompt()).toContain( + "stop with local changes ready for review", + ); + expect(await t.resolveWarmAutoPublishUpgrade()).toBeNull(); + expect(t.posthogAPI.getTaskRun).toHaveBeenCalledTimes(1); + }); + + it("never upgrades a prewarmed run when createPr is false", async () => { + // PostHog AI warm runs launch with createPr=false; auto-publish must not + // override that even if auto_publish somehow lands in state. + const t = makeWarmServer( + { prewarmed: true, auto_publish: true }, + { createPr: false }, + ); + + expect(await t.resolveWarmAutoPublishUpgrade()).toBeNull(); + expect(t.posthogAPI.getTaskRun).not.toHaveBeenCalled(); + expect(t.buildCloudSystemPrompt()).toContain( + "stop with local changes ready for review", + ); + }); + + it("retries the state fetch on a later message when it fails", async () => { + const t = makeWarmServer(new Error("fetch failed")); + expect(await t.resolveWarmAutoPublishUpgrade()).toBeNull(); + + t.posthogAPI.getTaskRun = vi.fn(async () => ({ + state: { prewarmed: true, auto_publish: true }, + })); + expect(await t.resolveWarmAutoPublishUpgrade()).toContain( + "gh pr create --draft", + ); + }); + it.each([ { label: "Slack", origin: "slack" }, { label: "signal_report", origin: "signal_report" }, diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 172e5e666f..ea94745721 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -314,6 +314,11 @@ export class AgentServer { private lastReportedBranch: string | null = null; private resumeState: ResumeState | null = null; private nativeResume: { sessionId: string; warm: boolean } | null = null; + // Prewarmed runs boot before the user's first message exists, so the boot-time + // --autoPublish flag can't carry the user's choice; it is resolved from run + // state when the first message arrives (see resolveWarmAutoPublishUpgrade). + private prewarmedRun = false; + private warmAutoPublishResolved = false; private installedSkillBundles = new Set(); private installedSkillBundleInfo = new Map(); private installingSkillBundles = new Map>(); @@ -877,12 +882,19 @@ export class AgentServer { this.session.logWriter.resetTurnMessages(this.session.payload.run_id); + // Resolve before buildDetectedPrContext so a warm auto-publish upgrade + // also flips the detected-PR context to its push variant. + const autoPublishUpgrade = await this.resolveWarmAutoPublishUpgrade(); + const hostContext = [ + ...(autoPublishUpgrade ? [autoPublishUpgrade] : []), + ...(this.detectedPrUrl + ? [this.buildDetectedPrContext(this.detectedPrUrl)] + : []), + ]; const promptMeta: Record = { ...(builtPrompt.meta ?? {}), - ...(this.detectedPrUrl - ? { - prContext: this.buildDetectedPrContext(this.detectedPrUrl), - } + ...(hostContext.length > 0 + ? { prContext: hostContext.join("\n\n") } : {}), }; @@ -1100,6 +1112,8 @@ export class AgentServer { this.resumeState = null; this.nativeResume = null; + this.prewarmedRun = false; + this.warmAutoPublishResolved = false; this.logger.debug("Initializing session", { runId: payload.run_id, @@ -1131,6 +1145,10 @@ export class AgentServer { }), ]); + this.prewarmedRun = + (preTaskRun?.state as Record | undefined)?.prewarmed === + true; + const gatewayEnv = this.configureEnvironment({ isInternal: preTask?.internal === true, originProduct: preTask?.origin_product, @@ -2557,12 +2575,66 @@ export class AgentServer { } /** - * Automated-origin cloud runs auto-publish by default. Every other origin is - * review-first unless the user explicitly asks, and createPr=false always - * disables publishing. + * Automated-origin cloud runs auto-publish by default, and manual runs + * auto-publish when the user opted in (Settings → Advanced, sent as + * autoPublish). Every other run is review-first unless the user explicitly + * asks, and createPr=false always disables publishing. */ private shouldAutoPublishCloudChanges(): boolean { - return this.isAutomatedOrigin() && this.config.createPr !== false; + return ( + (this.isAutomatedOrigin() || this.config.autoPublish === true) && + this.config.createPr !== false + ); + } + + /** + * A prewarmed run boots before the user's first message exists, so the + * --autoPublish flag can't carry the user's choice; the backend persists it + * into the run's state at warm activation instead. Nothing has been sent to + * the agent until that first message arrives, so resolving it here still + * governs the whole conversation: flip the config (so later consumers like + * buildDetectedPrContext see it) and return the auto-publish cloud + * instructions to inject into the first prompt as an override. + */ + private async resolveWarmAutoPublishUpgrade(): Promise { + if (!this.prewarmedRun || this.warmAutoPublishResolved || !this.session) { + return null; + } + if ( + this.config.autoPublish === true || + this.config.createPr === false || + this.isAutomatedOrigin() + ) { + // The boot decision already publishes (or never may) — nothing to upgrade. + this.warmAutoPublishResolved = true; + return null; + } + let state: Record | undefined; + try { + const run = await this.posthogAPI.getTaskRun( + this.session.payload.task_id, + this.session.payload.run_id, + ); + state = run?.state as Record | undefined; + } catch (error) { + // Leave unresolved so the next message retries; stay review-first for now. + this.logger.debug("Failed to fetch run state for auto-publish upgrade", { + error, + }); + return null; + } + this.warmAutoPublishResolved = true; + if (state?.auto_publish !== true) { + return null; + } + this.config.autoPublish = true; + this.logger.debug("Warm run upgraded to auto-publish from run state"); + return [ + "IMPORTANT — OVERRIDE PREVIOUS INSTRUCTIONS ABOUT CREATING BRANCHES/PRs.", + "The user has auto-publish enabled for this run. The review-first cloud task instructions in your system prompt are replaced by the following:", + "", + this.buildCloudSystemPrompt(this.detectedPrUrl), + ].join("\n"); } private buildDetectedPrContext(prUrl: string): string { @@ -2731,7 +2803,18 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} When the user asks for code changes: - You may clone a repository and make local edits in that clone - Do NOT create branches, commits, push changes, or open pull requests in this run` - : ` + : shouldAutoCreatePr + ? ` +When the user asks to clone or work in a GitHub repository: +- Clone the repository into /tmp/workspace/repos// using \`gh repo clone / /tmp/workspace/repos//\` +- Work from inside that cloned repository for follow-up code changes +- After completing code changes in a cloned repository, create a branch, stage your changes with \`git add\` and commit them with the \`git_signed_commit\` tool (do NOT use \`git commit\`/\`git push\` — they are blocked), and open a draft pull request from inside the clone without waiting to be asked. Before opening the PR, check the cloned repo for a PR template at \`.github/pull_request_template.md\` (or variants; fall back to the org's \`.github\` repo via \`gh api\`) and use it as the body structure, and search for matching open issues with \`gh issue list --search\` to include \`Closes #\` / \`Refs #\` links. +- Keep the PR description brief overall. Summarize only the most important changes — do NOT enumerate every change you made. A few sentences or bullets is plenty. +${whyContextInstruction.trimStart()} +${publicRepoSafetyInstruction.trimStart()} +- End the PR description with a horizontal rule followed by this footer line: ${prFooter} +- Always create the PR as a draft. Do not ask for confirmation before publishing completed code changes` + : ` When the user explicitly asks to clone or work in a GitHub repository: - Clone the repository into /tmp/workspace/repos// using \`gh repo clone / /tmp/workspace/repos//\` - Work from inside that cloned repository for follow-up code changes diff --git a/packages/agent/src/server/bin.ts b/packages/agent/src/server/bin.ts index 4f515b15ec..3904517b71 100644 --- a/packages/agent/src/server/bin.ts +++ b/packages/agent/src/server/bin.ts @@ -106,6 +106,10 @@ program "MCP servers config as JSON array (ACP McpServer[] format)", ) .option("--createPr ", "Whether this run may publish changes") + .option( + "--autoPublish ", + "Whether this run should push and open a draft PR on completion without an explicit ask", + ) .option("--baseBranch ", "Base branch for PR creation") .option( "--claudeCodeConfig ", @@ -130,6 +134,10 @@ program const mode = options.mode === "background" ? "background" : "interactive"; const createPr = parseBooleanOption(options.createPr, "--createPr"); + const autoPublish = parseBooleanOption( + options.autoPublish, + "--autoPublish", + ); const mcpServers = parseJsonOption( options.mcpServers, @@ -182,6 +190,7 @@ program taskId: options.taskId, runId: options.runId, createPr, + autoPublish, mcpServers, baseBranch: options.baseBranch, claudeCode, diff --git a/packages/agent/src/server/types.ts b/packages/agent/src/server/types.ts index 50a3fa1395..264aecc0ac 100644 --- a/packages/agent/src/server/types.ts +++ b/packages/agent/src/server/types.ts @@ -25,6 +25,9 @@ export interface AgentServerConfig { taskId: string; runId: 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. + autoPublish?: boolean; version?: string; mcpServers?: RemoteMcpServer[]; baseBranch?: string; diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e359023d56..5f171b21f4 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -478,6 +478,7 @@ interface CloudRunOptions { reasoningLevel?: string; sandboxEnvironmentId?: string; prAuthorshipMode?: PrAuthorshipMode; + autoPublish?: boolean; runSource?: CloudRunSource; signalReportId?: string; initialPermissionMode?: PermissionMode; @@ -551,6 +552,9 @@ function buildCloudRunRequestBody( if (options?.prAuthorshipMode) { body.pr_authorship_mode = options.prAuthorshipMode; } + if (options?.autoPublish) { + body.auto_publish = options.autoPublish; + } if (options?.runSource) { body.run_source = options.runSource; } @@ -2220,6 +2224,7 @@ export class PostHogAPIClient { channel?: string | null; pending_user_message?: string; pending_user_artifact_ids?: string[]; + auto_publish?: boolean; }, ) { const teamId = await this.getTeamId(); diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 4c960bdca2..b51904fc69 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -2927,6 +2927,7 @@ export class SessionService { pendingUserArtifactIds: artifactIds.length > 0 ? artifactIds : undefined, prAuthorshipMode, + autoPublish: previousState.auto_publish === true || undefined, runSource: getCloudRunSource(previousState), signalReportId: typeof previousState.signal_report_id === "string" diff --git a/packages/core/src/task-detail/taskCreationApiClient.ts b/packages/core/src/task-detail/taskCreationApiClient.ts index 3ef2f5ed56..1150c4790b 100644 --- a/packages/core/src/task-detail/taskCreationApiClient.ts +++ b/packages/core/src/task-detail/taskCreationApiClient.ts @@ -10,6 +10,7 @@ export interface CreateTaskRunClientOptions { reasoningLevel?: string; sandboxEnvironmentId?: string; prAuthorshipMode?: PrAuthorshipMode; + autoPublish?: boolean; runSource?: CloudRunSource; signalReportId?: string; initialPermissionMode?: string; diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index 7c295c0abe..8a6cb369e2 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -152,6 +152,7 @@ describe("TaskCreationSaga", () => { adapter: "codex", model: "gpt-5.4", reasoningLevel: "high", + cloudAutoPublish: true, }); expect(result.success).toBe(true); @@ -168,6 +169,7 @@ describe("TaskCreationSaga", () => { reasoningLevel: "high", sandboxEnvironmentId: undefined, prAuthorshipMode: "user", + autoPublish: true, runSource: "manual", signalReportId: undefined, initialPermissionMode: "auto", @@ -555,6 +557,7 @@ describe("TaskCreationSaga", () => { repository: "posthog/posthog", workspaceMode: "cloud", branch: "main", + cloudAutoPublish: true, }); expect(result.success).toBe(true); @@ -578,6 +581,8 @@ describe("TaskCreationSaga", () => { branch: "main", pending_user_message: "/my-skill do it", pending_user_artifact_ids: ["skill-artifact-1"], + // Warm activation skips run creation, so the choice must ride along here. + auto_publish: true, }), ); // Warm-activated at create time: no fresh run is created or started. diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index b021119f1d..f0b8d1528e 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -401,6 +401,7 @@ export class TaskCreationSaga extends Saga< reasoningLevel: input.reasoningLevel, sandboxEnvironmentId: input.sandboxEnvironmentId, prAuthorshipMode, + autoPublish: input.cloudAutoPublish, runSource: input.cloudRunSource ?? "manual", signalReportId: input.signalReportId, homeQuickAction: input.homeQuickActionLabel, @@ -766,6 +767,12 @@ export class TaskCreationSaga extends Saga< channel: input.channelId ?? undefined, 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 + ? true + : undefined, }); return result as unknown as Task; }, diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index dba4749919..a0d49eae24 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -23,6 +23,7 @@ export interface PrepareTaskInputOptions { channelName?: string; channelId?: string; customInstructions?: string; + autoPublishCloudRuns?: boolean; allowNoRepo?: boolean; } @@ -56,6 +57,7 @@ export function prepareTaskInput( options.signalReportId && isCloud ? "user" : undefined, cloudRunSource: options.signalReportId && isCloud ? "signal_report" : undefined, + cloudAutoPublish: isCloud ? options.autoPublishCloudRuns : undefined, signalReportId: options.signalReportId, additionalDirectories: isCloud ? undefined : options.additionalDirectories, channelContext: options.channelContext, diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 3208609fc3..f000b51f02 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -36,6 +36,11 @@ export interface TaskCreationInput { sandboxEnvironmentId?: string; cloudPrAuthorshipMode?: PrAuthorshipMode; cloudRunSource?: CloudRunSource; + /** + * When true, the cloud run agent pushes its work and opens a draft PR on + * completion without waiting for an explicit ask (Settings → Advanced). + */ + cloudAutoPublish?: boolean; signalReportId?: string; additionalDirectories?: string[]; /** diff --git a/packages/ui/src/features/settings/sections/AdvancedSettings.tsx b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx index bb1a125567..2763015ea8 100644 --- a/packages/ui/src/features/settings/sections/AdvancedSettings.tsx +++ b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx @@ -23,10 +23,24 @@ export function AdvancedSettings() { ); const useNewChatThread = useSettingsStore((s) => s.useNewChatThread); const setUseNewChatThread = useSettingsStore((s) => s.setUseNewChatThread); + const autoPublishCloudRuns = useSettingsStore((s) => s.autoPublishCloudRuns); + const setAutoPublishCloudRuns = useSettingsStore( + (s) => s.setAutoPublishCloudRuns, + ); const devModeClient = useServiceOptional(DEV_MODE_CLIENT); return ( + + + void; setPreventSleepWhileRunning: (enabled: boolean) => void; setDebugLogsCloudRuns: (enabled: boolean) => void; + setAutoPublishCloudRuns: (enabled: boolean) => void; // Terminal terminalFont: TerminalFont; @@ -319,11 +323,14 @@ export const useSettingsStore = create()( allowBypassPermissions: false, preventSleepWhileRunning: false, debugLogsCloudRuns: false, + autoPublishCloudRuns: true, setAllowBypassPermissions: (enabled) => set({ allowBypassPermissions: enabled }), setPreventSleepWhileRunning: (enabled) => set({ preventSleepWhileRunning: enabled }), setDebugLogsCloudRuns: (enabled) => set({ debugLogsCloudRuns: enabled }), + setAutoPublishCloudRuns: (enabled) => + set({ autoPublishCloudRuns: enabled }), // Terminal terminalFont: "berkeley-mono", @@ -436,6 +443,7 @@ export const useSettingsStore = create()( allowBypassPermissions: state.allowBypassPermissions, preventSleepWhileRunning: state.preventSleepWhileRunning, debugLogsCloudRuns: state.debugLogsCloudRuns, + autoPublishCloudRuns: state.autoPublishCloudRuns, // Terminal terminalFont: state.terminalFont, diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index d1e1251a56..6f2e4992ab 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -299,6 +299,7 @@ export function useTaskCreation({ } } + const settings = useSettingsStore.getState(); const input = prepareTaskInput(serializedContent, filePaths, { // In channels chat-box mode no repo is attached up front, even if a // directory/repo is lingering in the persisted picker state. @@ -321,7 +322,8 @@ export function useTaskCreation({ channelContext, channelName, channelId, - customInstructions: useSettingsStore.getState().customInstructions, + customInstructions: settings.customInstructions, + autoPublishCloudRuns: settings.autoPublishCloudRuns, allowNoRepo, });