Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions packages/agent/src/server/agent-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Comment on lines 1990 to +2017

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Prefer parameterised tests for similar cases

The three new tests all exercise the autoPublish: true path and share a common assertion structure (checking for/against "gh pr create --draft" and "stop with local changes ready for review"). Tests 1 and 2 are especially close — they differ only in createPr: false and expected prompt content — and could be folded into an it.each block alongside the existing origin-based parameterised tests just below. The no-repo test (test 3) shares the "draft PR" assertion with test 1 and could similarly be grouped. Using it.each here would make the full matrix of (autoPublish, createPr, mode) behaviour easier to scan and extend.

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


// 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<typeof vi.fn> };
resolveWarmAutoPublishUpgrade(): Promise<string | null>;
buildCloudSystemPrompt(): string;
};
const makeWarmServer = (
state: Record<string, unknown> | Error,
overrides: Partial<ConstructorParameters<typeof AgentServer>[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" },
Expand Down
101 changes: 92 additions & 9 deletions packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
private installedSkillBundleInfo = new Map<string, InstalledSkillBundle>();
private installingSkillBundles = new Map<string, Promise<void>>();
Expand Down Expand Up @@ -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<string, unknown> = {
...(builtPrompt.meta ?? {}),
...(this.detectedPrUrl
? {
prContext: this.buildDetectedPrContext(this.detectedPrUrl),
}
...(hostContext.length > 0
? { prContext: hostContext.join("\n\n") }
: {}),
};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1131,6 +1145,10 @@ export class AgentServer {
}),
]);

this.prewarmedRun =
(preTaskRun?.state as Record<string, unknown> | undefined)?.prewarmed ===
true;

const gatewayEnv = this.configureEnvironment({
isInternal: preTask?.internal === true,
originProduct: preTask?.origin_product,
Expand Down Expand Up @@ -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<string | null> {
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<string, unknown> | undefined;
try {
const run = await this.posthogAPI.getTaskRun(
this.session.payload.task_id,
this.session.payload.run_id,
);
state = run?.state as Record<string, unknown> | 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 {
Expand Down Expand Up @@ -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/<owner>/<repo> using \`gh repo clone <owner>/<repo> /tmp/workspace/repos/<owner>/<repo>\`
- 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 #<n>\` / \`Refs #<n>\` 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/<owner>/<repo> using \`gh repo clone <owner>/<repo> /tmp/workspace/repos/<owner>/<repo>\`
- Work from inside that cloned repository for follow-up code changes
Expand Down
9 changes: 9 additions & 0 deletions packages/agent/src/server/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ program
"MCP servers config as JSON array (ACP McpServer[] format)",
)
.option("--createPr <boolean>", "Whether this run may publish changes")
.option(
"--autoPublish <boolean>",
"Whether this run should push and open a draft PR on completion without an explicit ask",
)
.option("--baseBranch <branch>", "Base branch for PR creation")
.option(
"--claudeCodeConfig <json>",
Expand All @@ -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,
Expand Down Expand Up @@ -182,6 +190,7 @@ program
taskId: options.taskId,
runId: options.runId,
createPr,
autoPublish,
mcpServers,
baseBranch: options.baseBranch,
claudeCode,
Expand Down
3 changes: 3 additions & 0 deletions packages/agent/src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ interface CloudRunOptions {
reasoningLevel?: string;
sandboxEnvironmentId?: string;
prAuthorshipMode?: PrAuthorshipMode;
autoPublish?: boolean;
runSource?: CloudRunSource;
signalReportId?: string;
initialPermissionMode?: PermissionMode;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/task-detail/taskCreationApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface CreateTaskRunClientOptions {
reasoningLevel?: string;
sandboxEnvironmentId?: string;
prAuthorshipMode?: PrAuthorshipMode;
autoPublish?: boolean;
runSource?: CloudRunSource;
signalReportId?: string;
initialPermissionMode?: string;
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/task-detail/taskCreationSaga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ describe("TaskCreationSaga", () => {
adapter: "codex",
model: "gpt-5.4",
reasoningLevel: "high",
cloudAutoPublish: true,
});

expect(result.success).toBe(true);
Expand All @@ -168,6 +169,7 @@ describe("TaskCreationSaga", () => {
reasoningLevel: "high",
sandboxEnvironmentId: undefined,
prAuthorshipMode: "user",
autoPublish: true,
runSource: "manual",
signalReportId: undefined,
initialPermissionMode: "auto",
Expand Down Expand Up @@ -555,6 +557,7 @@ describe("TaskCreationSaga", () => {
repository: "posthog/posthog",
workspaceMode: "cloud",
branch: "main",
cloudAutoPublish: true,
});

expect(result.success).toBe(true);
Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/task-detail/taskCreationSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
},
Expand Down
Loading
Loading