Skip to content

Commit 0f5a1d9

Browse files
authored
feat(settings): auto-publish cloud runs toggle in advanced settings (#3217)
1 parent d666152 commit 0f5a1d9

14 files changed

Lines changed: 261 additions & 10 deletions

File tree

packages/agent/src/server/agent-server.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2062,6 +2062,112 @@ describe("AgentServer HTTP Mode", () => {
20622062
delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
20632063
});
20642064

2065+
it("returns auto-PR prompt for manual runs when the user opted into auto-publish", () => {
2066+
const s = createServer({ autoPublish: true });
2067+
const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt();
2068+
expect(prompt).toContain("gh pr create --draft");
2069+
expect(prompt).not.toContain("stop with local changes ready for review");
2070+
// Manual runs keep the PostHog Code attribution.
2071+
expect(prompt).toContain(
2072+
"Created with [PostHog Code](https://posthog.com/code?ref=pr)",
2073+
);
2074+
});
2075+
2076+
it("keeps review-first prompt when auto-publish is on but createPr is false", () => {
2077+
const s = createServer({ autoPublish: true, createPr: false });
2078+
const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt();
2079+
expect(prompt).toContain("stop with local changes ready for review");
2080+
expect(prompt).not.toContain("gh pr create --draft");
2081+
});
2082+
2083+
it("auto-publishes in no-repository mode when the user opted in", () => {
2084+
const s = createServer({ repositoryPath: undefined, autoPublish: true });
2085+
const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt();
2086+
expect(prompt).toContain("Cloud Task Execution — No Repository Mode");
2087+
expect(prompt).toContain("without waiting to be asked");
2088+
expect(prompt).not.toContain("unless the user explicitly asks for that");
2089+
});
2090+
2091+
// Prewarmed runs boot before the user's choice exists; the upgrade is
2092+
// resolved from run state when the first message arrives.
2093+
type WarmTestable = {
2094+
prewarmedRun: boolean;
2095+
session: { payload: { task_id: string; run_id: string } } | null;
2096+
posthogAPI: { getTaskRun: ReturnType<typeof vi.fn> };
2097+
resolveWarmAutoPublishUpgrade(): Promise<string | null>;
2098+
buildCloudSystemPrompt(): string;
2099+
};
2100+
const makeWarmServer = (
2101+
state: Record<string, unknown> | Error,
2102+
overrides: Partial<ConstructorParameters<typeof AgentServer>[0]> = {},
2103+
): WarmTestable => {
2104+
const t = createServer(overrides) as unknown as WarmTestable;
2105+
t.prewarmedRun = true;
2106+
t.session = {
2107+
payload: { task_id: "test-task-id", run_id: "test-run-id" },
2108+
};
2109+
t.posthogAPI = {
2110+
getTaskRun:
2111+
state instanceof Error
2112+
? vi.fn(async () => {
2113+
throw state;
2114+
})
2115+
: vi.fn(async () => ({ state })),
2116+
};
2117+
return t;
2118+
};
2119+
2120+
it("upgrades a prewarmed run to auto-publish from run state on the first message", async () => {
2121+
const t = makeWarmServer({ prewarmed: true, auto_publish: true });
2122+
2123+
const override = await t.resolveWarmAutoPublishUpgrade();
2124+
expect(override).toContain("OVERRIDE PREVIOUS INSTRUCTIONS");
2125+
expect(override).toContain("gh pr create --draft");
2126+
// The flip persists for the rest of the session...
2127+
expect(t.buildCloudSystemPrompt()).toContain("gh pr create --draft");
2128+
// ...and the override is injected only once.
2129+
expect(await t.resolveWarmAutoPublishUpgrade()).toBeNull();
2130+
expect(t.posthogAPI.getTaskRun).toHaveBeenCalledTimes(1);
2131+
});
2132+
2133+
it("keeps a prewarmed run review-first when run state has no auto_publish", async () => {
2134+
const t = makeWarmServer({ prewarmed: true });
2135+
2136+
expect(await t.resolveWarmAutoPublishUpgrade()).toBeNull();
2137+
expect(t.buildCloudSystemPrompt()).toContain(
2138+
"stop with local changes ready for review",
2139+
);
2140+
expect(await t.resolveWarmAutoPublishUpgrade()).toBeNull();
2141+
expect(t.posthogAPI.getTaskRun).toHaveBeenCalledTimes(1);
2142+
});
2143+
2144+
it("never upgrades a prewarmed run when createPr is false", async () => {
2145+
// PostHog AI warm runs launch with createPr=false; auto-publish must not
2146+
// override that even if auto_publish somehow lands in state.
2147+
const t = makeWarmServer(
2148+
{ prewarmed: true, auto_publish: true },
2149+
{ createPr: false },
2150+
);
2151+
2152+
expect(await t.resolveWarmAutoPublishUpgrade()).toBeNull();
2153+
expect(t.posthogAPI.getTaskRun).not.toHaveBeenCalled();
2154+
expect(t.buildCloudSystemPrompt()).toContain(
2155+
"stop with local changes ready for review",
2156+
);
2157+
});
2158+
2159+
it("retries the state fetch on a later message when it fails", async () => {
2160+
const t = makeWarmServer(new Error("fetch failed"));
2161+
expect(await t.resolveWarmAutoPublishUpgrade()).toBeNull();
2162+
2163+
t.posthogAPI.getTaskRun = vi.fn(async () => ({
2164+
state: { prewarmed: true, auto_publish: true },
2165+
}));
2166+
expect(await t.resolveWarmAutoPublishUpgrade()).toContain(
2167+
"gh pr create --draft",
2168+
);
2169+
});
2170+
20652171
it.each([
20662172
{ label: "Slack", origin: "slack" },
20672173
{ label: "signal_report", origin: "signal_report" },

packages/agent/src/server/agent-server.ts

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ export class AgentServer {
316316
private lastReportedBranch: string | null = null;
317317
private resumeState: ResumeState | null = null;
318318
private nativeResume: { sessionId: string; warm: boolean } | null = null;
319+
// Prewarmed runs boot before the user's first message exists, so the boot-time
320+
// --autoPublish flag can't carry the user's choice; it is resolved from run
321+
// state when the first message arrives (see resolveWarmAutoPublishUpgrade).
322+
private prewarmedRun = false;
323+
private warmAutoPublishResolved = false;
319324
private installedSkillBundles = new Set<string>();
320325
private installedSkillBundleInfo = new Map<string, InstalledSkillBundle>();
321326
private installingSkillBundles = new Map<string, Promise<void>>();
@@ -893,12 +898,19 @@ export class AgentServer {
893898

894899
this.session.logWriter.resetTurnMessages(this.session.payload.run_id);
895900

901+
// Resolve before buildDetectedPrContext so a warm auto-publish upgrade
902+
// also flips the detected-PR context to its push variant.
903+
const autoPublishUpgrade = await this.resolveWarmAutoPublishUpgrade();
904+
const hostContext = [
905+
...(autoPublishUpgrade ? [autoPublishUpgrade] : []),
906+
...(this.detectedPrUrl
907+
? [this.buildDetectedPrContext(this.detectedPrUrl)]
908+
: []),
909+
];
896910
const promptMeta: Record<string, unknown> = {
897911
...(builtPrompt.meta ?? {}),
898-
...(this.detectedPrUrl
899-
? {
900-
prContext: this.buildDetectedPrContext(this.detectedPrUrl),
901-
}
912+
...(hostContext.length > 0
913+
? { prContext: hostContext.join("\n\n") }
902914
: {}),
903915
};
904916

@@ -1116,6 +1128,8 @@ export class AgentServer {
11161128

11171129
this.resumeState = null;
11181130
this.nativeResume = null;
1131+
this.prewarmedRun = false;
1132+
this.warmAutoPublishResolved = false;
11191133

11201134
this.logger.debug("Initializing session", {
11211135
runId: payload.run_id,
@@ -1147,6 +1161,10 @@ export class AgentServer {
11471161
}),
11481162
]);
11491163

1164+
this.prewarmedRun =
1165+
(preTaskRun?.state as Record<string, unknown> | undefined)?.prewarmed ===
1166+
true;
1167+
11501168
const gatewayEnv = this.configureEnvironment({
11511169
isInternal: preTask?.internal === true,
11521170
originProduct: preTask?.origin_product,
@@ -2586,12 +2604,66 @@ export class AgentServer {
25862604
}
25872605

25882606
/**
2589-
* Automated-origin cloud runs auto-publish by default. Every other origin is
2590-
* review-first unless the user explicitly asks, and createPr=false always
2591-
* disables publishing.
2607+
* Automated-origin cloud runs auto-publish by default, and manual runs
2608+
* auto-publish when the user opted in (Settings → Advanced, sent as
2609+
* autoPublish). Every other run is review-first unless the user explicitly
2610+
* asks, and createPr=false always disables publishing.
25922611
*/
25932612
private shouldAutoPublishCloudChanges(): boolean {
2594-
return this.isAutomatedOrigin() && this.config.createPr !== false;
2613+
return (
2614+
(this.isAutomatedOrigin() || this.config.autoPublish === true) &&
2615+
this.config.createPr !== false
2616+
);
2617+
}
2618+
2619+
/**
2620+
* A prewarmed run boots before the user's first message exists, so the
2621+
* --autoPublish flag can't carry the user's choice; the backend persists it
2622+
* into the run's state at warm activation instead. Nothing has been sent to
2623+
* the agent until that first message arrives, so resolving it here still
2624+
* governs the whole conversation: flip the config (so later consumers like
2625+
* buildDetectedPrContext see it) and return the auto-publish cloud
2626+
* instructions to inject into the first prompt as an override.
2627+
*/
2628+
private async resolveWarmAutoPublishUpgrade(): Promise<string | null> {
2629+
if (!this.prewarmedRun || this.warmAutoPublishResolved || !this.session) {
2630+
return null;
2631+
}
2632+
if (
2633+
this.config.autoPublish === true ||
2634+
this.config.createPr === false ||
2635+
this.isAutomatedOrigin()
2636+
) {
2637+
// The boot decision already publishes (or never may) — nothing to upgrade.
2638+
this.warmAutoPublishResolved = true;
2639+
return null;
2640+
}
2641+
let state: Record<string, unknown> | undefined;
2642+
try {
2643+
const run = await this.posthogAPI.getTaskRun(
2644+
this.session.payload.task_id,
2645+
this.session.payload.run_id,
2646+
);
2647+
state = run?.state as Record<string, unknown> | undefined;
2648+
} catch (error) {
2649+
// Leave unresolved so the next message retries; stay review-first for now.
2650+
this.logger.debug("Failed to fetch run state for auto-publish upgrade", {
2651+
error,
2652+
});
2653+
return null;
2654+
}
2655+
this.warmAutoPublishResolved = true;
2656+
if (state?.auto_publish !== true) {
2657+
return null;
2658+
}
2659+
this.config.autoPublish = true;
2660+
this.logger.debug("Warm run upgraded to auto-publish from run state");
2661+
return [
2662+
"IMPORTANT — OVERRIDE PREVIOUS INSTRUCTIONS ABOUT CREATING BRANCHES/PRs.",
2663+
"The user has auto-publish enabled for this run. The review-first cloud task instructions in your system prompt are replaced by the following:",
2664+
"",
2665+
this.buildCloudSystemPrompt(this.detectedPrUrl),
2666+
].join("\n");
25952667
}
25962668

25972669
private buildDetectedPrContext(prUrl: string): string {
@@ -2760,7 +2832,18 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
27602832
When the user asks for code changes:
27612833
- You may clone a repository and make local edits in that clone
27622834
- Do NOT create branches, commits, push changes, or open pull requests in this run`
2763-
: `
2835+
: shouldAutoCreatePr
2836+
? `
2837+
When the user asks to clone or work in a GitHub repository:
2838+
- Clone the repository into /tmp/workspace/repos/<owner>/<repo> using \`gh repo clone <owner>/<repo> /tmp/workspace/repos/<owner>/<repo>\`
2839+
- Work from inside that cloned repository for follow-up code changes
2840+
- 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 #<n>\` / \`Refs #<n>\` links.
2841+
- 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.
2842+
${whyContextInstruction.trimStart()}
2843+
${publicRepoSafetyInstruction.trimStart()}
2844+
- End the PR description with a horizontal rule followed by this footer line: ${prFooter}
2845+
- Always create the PR as a draft. Do not ask for confirmation before publishing completed code changes`
2846+
: `
27642847
When the user explicitly asks to clone or work in a GitHub repository:
27652848
- Clone the repository into /tmp/workspace/repos/<owner>/<repo> using \`gh repo clone <owner>/<repo> /tmp/workspace/repos/<owner>/<repo>\`
27662849
- Work from inside that cloned repository for follow-up code changes

packages/agent/src/server/bin.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ program
106106
"MCP servers config as JSON array (ACP McpServer[] format)",
107107
)
108108
.option("--createPr <boolean>", "Whether this run may publish changes")
109+
.option(
110+
"--autoPublish <boolean>",
111+
"Whether this run should push and open a draft PR on completion without an explicit ask",
112+
)
109113
.option("--baseBranch <branch>", "Base branch for PR creation")
110114
.option(
111115
"--claudeCodeConfig <json>",
@@ -130,6 +134,10 @@ program
130134

131135
const mode = options.mode === "background" ? "background" : "interactive";
132136
const createPr = parseBooleanOption(options.createPr, "--createPr");
137+
const autoPublish = parseBooleanOption(
138+
options.autoPublish,
139+
"--autoPublish",
140+
);
133141

134142
const mcpServers = parseJsonOption(
135143
options.mcpServers,
@@ -182,6 +190,7 @@ program
182190
taskId: options.taskId,
183191
runId: options.runId,
184192
createPr,
193+
autoPublish,
185194
mcpServers,
186195
baseBranch: options.baseBranch,
187196
claudeCode,

packages/agent/src/server/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export interface AgentServerConfig {
2626
taskId: string;
2727
runId: string;
2828
createPr?: boolean;
29+
// User-opted auto-publish: push and open a draft PR on completion even for
30+
// manual (non-automated-origin) cloud runs. createPr=false still wins.
31+
autoPublish?: boolean;
2932
version?: string;
3033
mcpServers?: RemoteMcpServer[];
3134
baseBranch?: string;

packages/api-client/src/posthog-client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,7 @@ interface CloudRunOptions {
479479
reasoningLevel?: string;
480480
sandboxEnvironmentId?: string;
481481
prAuthorshipMode?: PrAuthorshipMode;
482+
autoPublish?: boolean;
482483
runSource?: CloudRunSource;
483484
signalReportId?: string;
484485
initialPermissionMode?: PermissionMode;
@@ -552,6 +553,9 @@ function buildCloudRunRequestBody(
552553
if (options?.prAuthorshipMode) {
553554
body.pr_authorship_mode = options.prAuthorshipMode;
554555
}
556+
if (options?.autoPublish) {
557+
body.auto_publish = options.autoPublish;
558+
}
555559
if (options?.runSource) {
556560
body.run_source = options.runSource;
557561
}
@@ -2221,6 +2225,7 @@ export class PostHogAPIClient {
22212225
channel?: string | null;
22222226
pending_user_message?: string;
22232227
pending_user_artifact_ids?: string[];
2228+
auto_publish?: boolean;
22242229
},
22252230
) {
22262231
const teamId = await this.getTeamId();

packages/core/src/sessions/sessionService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2976,6 +2976,7 @@ export class SessionService {
29762976
pendingUserArtifactIds:
29772977
artifactIds.length > 0 ? artifactIds : undefined,
29782978
prAuthorshipMode,
2979+
autoPublish: previousState.auto_publish === true || undefined,
29792980
runSource: getCloudRunSource(previousState),
29802981
signalReportId:
29812982
typeof previousState.signal_report_id === "string"

packages/core/src/task-detail/taskCreationApiClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface CreateTaskRunClientOptions {
1414
reasoningLevel?: string;
1515
sandboxEnvironmentId?: string;
1616
prAuthorshipMode?: PrAuthorshipMode;
17+
autoPublish?: boolean;
1718
runSource?: CloudRunSource;
1819
signalReportId?: string;
1920
initialPermissionMode?: string;

packages/core/src/task-detail/taskCreationSaga.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ describe("TaskCreationSaga", () => {
152152
adapter: "codex",
153153
model: "gpt-5.4",
154154
reasoningLevel: "high",
155+
cloudAutoPublish: true,
155156
});
156157

157158
expect(result.success).toBe(true);
@@ -168,6 +169,7 @@ describe("TaskCreationSaga", () => {
168169
reasoningLevel: "high",
169170
sandboxEnvironmentId: undefined,
170171
prAuthorshipMode: "user",
172+
autoPublish: true,
171173
runSource: "manual",
172174
signalReportId: undefined,
173175
initialPermissionMode: "auto",
@@ -555,6 +557,7 @@ describe("TaskCreationSaga", () => {
555557
repository: "posthog/posthog",
556558
workspaceMode: "cloud",
557559
branch: "main",
560+
cloudAutoPublish: true,
558561
});
559562

560563
expect(result.success).toBe(true);
@@ -578,6 +581,8 @@ describe("TaskCreationSaga", () => {
578581
branch: "main",
579582
pending_user_message: "/my-skill do it",
580583
pending_user_artifact_ids: ["skill-artifact-1"],
584+
// Warm activation skips run creation, so the choice must ride along here.
585+
auto_publish: true,
581586
}),
582587
);
583588
// Warm-activated at create time: no fresh run is created or started.

packages/core/src/task-detail/taskCreationSaga.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ export class TaskCreationSaga extends Saga<
401401
reasoningLevel: input.reasoningLevel,
402402
sandboxEnvironmentId: input.sandboxEnvironmentId,
403403
prAuthorshipMode,
404+
autoPublish: input.cloudAutoPublish,
404405
runSource: input.cloudRunSource ?? "manual",
405406
signalReportId: input.signalReportId,
406407
homeQuickAction: input.homeQuickActionLabel,
@@ -766,6 +767,12 @@ export class TaskCreationSaga extends Saga<
766767
channel: input.channelId ?? undefined,
767768
pending_user_message: warmPayload?.pendingUserMessage,
768769
pending_user_artifact_ids: warmPayload?.pendingUserArtifactIds,
770+
// If creation activates a pre-warmed run, this is the only request
771+
// that can carry the choice — the saga skips run creation entirely.
772+
auto_publish:
773+
input.workspaceMode === "cloud" && input.cloudAutoPublish
774+
? true
775+
: undefined,
769776
});
770777
return result as unknown as Task;
771778
},

0 commit comments

Comments
 (0)