Skip to content

Commit 9e0be66

Browse files
authored
feat(code): persist PR URLs to backend & emit file activity events (#1434)
## Problem when agents create PRs, we detect the URLs from bash output and store them on the backend but when created via UI in posthog code, we do no such thing also, we generally need a better way to correlate local tasks to branches (and therefore PRs) <!-- Who is this for and what problem does it solve? --> <!-- Closes #ISSUE_ID --> ## Changes when PRs are created via UI, call `updateTaskRun` with the PR URL i accidentally also combined this with the change to emit events on file changes (which helps build a "loose" task<>branch association) <!-- What did you change and why? --> <!-- If there are frontend changes, include screenshots. --> ## How did you test this? created PR via UI, verified task in db: ``` {"pr_url": "PostHog/posthog#53123"} ``` <!-- Describe what you tested -- manual steps, automated tests, or both. --> <!-- If you're an agent, only list tests you actually ran. -->
1 parent fa9a488 commit 9e0be66

6 files changed

Lines changed: 174 additions & 73 deletions

File tree

apps/code/src/main/services/agent/schemas.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ export const AgentServiceEvent = {
210210
PermissionRequest: "permission-request",
211211
SessionsIdle: "sessions-idle",
212212
SessionIdleKilled: "session-idle-killed",
213+
AgentFileActivity: "agent-file-activity",
213214
} as const;
214215

215216
export interface AgentSessionEventPayload {
@@ -230,11 +231,17 @@ export interface SessionIdleKilledPayload {
230231
taskId: string;
231232
}
232233

234+
export interface AgentFileActivityPayload {
235+
taskId: string;
236+
branchName: string | null;
237+
}
238+
233239
export interface AgentServiceEvents {
234240
[AgentServiceEvent.SessionEvent]: AgentSessionEventPayload;
235241
[AgentServiceEvent.PermissionRequest]: PermissionRequestPayload;
236242
[AgentServiceEvent.SessionsIdle]: undefined;
237243
[AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload;
244+
[AgentServiceEvent.AgentFileActivity]: AgentFileActivityPayload;
238245
}
239246

240247
// Permission response input for tRPC

apps/code/src/main/services/agent/service.ts

Lines changed: 119 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from "@posthog/agent/gateway-models";
2828
import { getLlmGatewayUrl } from "@posthog/agent/posthog-api";
2929
import type { OnLogCallback } from "@posthog/agent/types";
30+
import { getCurrentBranch } from "@posthog/git/queries";
3031
import { isAuthError } from "@shared/errors";
3132
import type { AcpMessage } from "@shared/types/session-events";
3233
import { app, powerMonitor } from "electron";
@@ -1134,8 +1135,8 @@ For git operations while detached:
11341135
};
11351136
emitToRenderer(acpMessage);
11361137

1137-
// Detect PR URLs in bash tool results and attach to task
1138-
this.detectAndAttachPrUrl(taskRunId, message as AcpMessage["message"]);
1138+
// Inspect tool call updates for PR URLs and file activity
1139+
this.handleToolCallUpdate(taskRunId, message as AcpMessage["message"]);
11391140
};
11401141

11411142
const tappedReadable = createTappedReadableStream(
@@ -1415,11 +1416,7 @@ For git operations while detached:
14151416
};
14161417
}
14171418

1418-
/**
1419-
* Detect GitHub PR URLs in bash tool results and attach to task.
1420-
* This enables webhook tracking by populating the pr_url in TaskRun output.
1421-
*/
1422-
private detectAndAttachPrUrl(taskRunId: string, message: unknown): void {
1419+
private handleToolCallUpdate(taskRunId: string, message: unknown): void {
14231420
try {
14241421
const msg = message as {
14251422
method?: string;
@@ -1441,86 +1438,136 @@ For git operations while detached:
14411438
if (msg.method !== "session/update") return;
14421439
if (msg.params?.update?.sessionUpdate !== "tool_call_update") return;
14431440

1444-
const toolMeta = msg.params.update._meta?.claudeCode;
1441+
const update = msg.params.update;
1442+
const toolMeta = update._meta?.claudeCode;
14451443
const toolName = toolMeta?.toolName;
1444+
if (!toolName) return;
14461445

1447-
// Only process Bash tool results
1448-
if (
1449-
!toolName ||
1450-
(!toolName.includes("Bash") && !toolName.includes("bash"))
1451-
) {
1452-
return;
1446+
const session = this.sessions.get(taskRunId);
1447+
1448+
// PR URLs only appear in Bash tool output
1449+
if (toolName.includes("Bash") || toolName.includes("bash")) {
1450+
this.detectAndAttachPrUrl(taskRunId, session, toolMeta, update.content);
14531451
}
14541452

1455-
// Extract text content from tool response or update content
1456-
let textToSearch = "";
1457-
1458-
// Check toolResponse (hook response with raw output)
1459-
const toolResponse = toolMeta?.toolResponse;
1460-
if (toolResponse) {
1461-
if (typeof toolResponse === "string") {
1462-
textToSearch = toolResponse;
1463-
} else if (typeof toolResponse === "object" && toolResponse !== null) {
1464-
// May be { stdout?: string, stderr?: string } or similar
1465-
const respObj = toolResponse as Record<string, unknown>;
1466-
textToSearch =
1467-
String(respObj.stdout || "") + String(respObj.stderr || "");
1468-
if (!textToSearch && respObj.output) {
1469-
textToSearch = String(respObj.output);
1470-
}
1453+
this.trackAgentFileActivity(taskRunId, session, toolName);
1454+
} catch (err) {
1455+
log.debug("Error in tool call update handling", {
1456+
taskRunId,
1457+
error: err,
1458+
});
1459+
}
1460+
}
1461+
1462+
/**
1463+
* Detect GitHub PR URLs in bash tool results and attach to task.
1464+
* This enables webhook tracking by populating the pr_url in TaskRun output.
1465+
*/
1466+
private detectAndAttachPrUrl(
1467+
taskRunId: string,
1468+
session: ManagedSession | undefined,
1469+
toolMeta: { toolName?: string; toolResponse?: unknown },
1470+
content?: Array<{ type?: string; text?: string }>,
1471+
): void {
1472+
let textToSearch = "";
1473+
1474+
// Check toolResponse (hook response with raw output)
1475+
const toolResponse = toolMeta?.toolResponse;
1476+
if (toolResponse) {
1477+
if (typeof toolResponse === "string") {
1478+
textToSearch = toolResponse;
1479+
} else if (typeof toolResponse === "object" && toolResponse !== null) {
1480+
// May be { stdout?: string, stderr?: string } or similar
1481+
const respObj = toolResponse as Record<string, unknown>;
1482+
textToSearch =
1483+
String(respObj.stdout || "") + String(respObj.stderr || "");
1484+
if (!textToSearch && respObj.output) {
1485+
textToSearch = String(respObj.output);
14711486
}
14721487
}
1488+
}
14731489

1474-
// Also check content array
1475-
const content = msg.params.update.content;
1476-
if (Array.isArray(content)) {
1477-
for (const item of content) {
1478-
if (item.type === "text" && item.text) {
1479-
textToSearch += ` ${item.text}`;
1480-
}
1490+
// Also check content array
1491+
if (Array.isArray(content)) {
1492+
for (const item of content) {
1493+
if (item.type === "text" && item.text) {
1494+
textToSearch += ` ${item.text}`;
14811495
}
14821496
}
1497+
}
14831498

1484-
if (!textToSearch) return;
1499+
if (!textToSearch) return;
14851500

1486-
// Match GitHub PR URLs
1487-
const prUrlMatch = textToSearch.match(
1488-
/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/,
1489-
);
1490-
if (!prUrlMatch) return;
1501+
// Match GitHub PR URLs
1502+
const prUrlMatch = textToSearch.match(
1503+
/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/,
1504+
);
1505+
if (!prUrlMatch) return;
14911506

1492-
const prUrl = prUrlMatch[0];
1493-
log.info("Detected PR URL in bash output", { taskRunId, prUrl });
1507+
const prUrl = prUrlMatch[0];
1508+
log.info("Detected PR URL in bash output", { taskRunId, prUrl });
14941509

1495-
// Find session and attach PR URL
1496-
const session = this.sessions.get(taskRunId);
1497-
if (!session) {
1498-
log.warn("Session not found for PR attachment", { taskRunId });
1499-
return;
1500-
}
1510+
// Attach PR URL
1511+
if (!session) {
1512+
log.warn("Session not found for PR attachment", { taskRunId });
1513+
return;
1514+
}
15011515

1502-
// Attach asynchronously without blocking message flow
1503-
session.agent
1504-
.attachPullRequestToTask(session.taskId, prUrl)
1505-
.then(() => {
1506-
log.info("PR URL attached to task", {
1507-
taskRunId,
1508-
taskId: session.taskId,
1509-
prUrl,
1510-
});
1511-
})
1512-
.catch((err) => {
1513-
log.error("Failed to attach PR URL to task", {
1514-
taskRunId,
1515-
taskId: session.taskId,
1516-
prUrl,
1517-
error: err,
1518-
});
1516+
// Attach asynchronously without blocking message flow
1517+
session.agent
1518+
.attachPullRequestToTask(session.taskId, prUrl)
1519+
.then(() => {
1520+
log.info("PR URL attached to task", {
1521+
taskRunId,
1522+
taskId: session.taskId,
1523+
prUrl,
15191524
});
1520-
} catch (err) {
1521-
// Don't let detection errors break message flow
1522-
log.debug("Error in PR URL detection", { taskRunId, error: err });
1523-
}
1525+
})
1526+
.catch((err) => {
1527+
log.error("Failed to attach PR URL to task", {
1528+
taskRunId,
1529+
taskId: session.taskId,
1530+
prUrl,
1531+
error: err,
1532+
});
1533+
});
1534+
}
1535+
1536+
/**
1537+
* Track agent file activity for branch association observability.
1538+
*/
1539+
private static readonly FILE_MODIFYING_TOOLS = new Set([
1540+
"Edit",
1541+
"Write",
1542+
"FileEditTool",
1543+
"FileWriteTool",
1544+
"MultiEdit",
1545+
"NotebookEdit",
1546+
]);
1547+
1548+
private trackAgentFileActivity(
1549+
taskRunId: string,
1550+
session: ManagedSession | undefined,
1551+
toolName: string,
1552+
): void {
1553+
if (!session) return;
1554+
if (!AgentService.FILE_MODIFYING_TOOLS.has(toolName)) return;
1555+
1556+
getCurrentBranch(session.repoPath)
1557+
.then((branchName) => {
1558+
this.emit(AgentServiceEvent.AgentFileActivity, {
1559+
taskId: session.taskId,
1560+
branchName,
1561+
});
1562+
})
1563+
.catch((err) => {
1564+
log.error("Failed to emit agent file activity event", {
1565+
taskRunId,
1566+
taskId: session.taskId,
1567+
toolName,
1568+
error: err,
1569+
});
1570+
});
15241571
}
15251572

15261573
async getGatewayModels(apiHost: string) {

apps/code/src/main/trpc/routers/agent.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,16 @@ export const agentRouter = router({
191191
}
192192
}),
193193

194+
onAgentFileActivity: publicProcedure.subscription(async function* (opts) {
195+
const service = getService();
196+
for await (const event of service.toIterable(
197+
AgentServiceEvent.AgentFileActivity,
198+
{ signal: opts.signal },
199+
)) {
200+
yield event;
201+
}
202+
}),
203+
194204
getGatewayModels: publicProcedure
195205
.input(getGatewayModelsInput)
196206
.output(getGatewayModelsOutput)

apps/code/src/renderer/App.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import { initializeConnectivityStore } from "@renderer/stores/connectivityStore"
1414
import { useFocusStore } from "@renderer/stores/focusStore";
1515
import { useThemeStore } from "@renderer/stores/themeStore";
1616
import { trpcClient, useTRPC } from "@renderer/trpc/client";
17+
import { ANALYTICS_EVENTS } from "@shared/types/analytics";
1718
import { useQueryClient } from "@tanstack/react-query";
1819
import { useSubscription } from "@trpc/tanstack-react-query";
19-
import { initializePostHog } from "@utils/analytics";
20+
import { initializePostHog, track } from "@utils/analytics";
2021
import { logger } from "@utils/logger";
2122
import { toast } from "@utils/toast";
2223
import { AnimatePresence, motion } from "framer-motion";
@@ -108,6 +109,17 @@ function App() {
108109
}),
109110
);
110111

112+
useSubscription(
113+
trpcReact.agent.onAgentFileActivity.subscriptionOptions(undefined, {
114+
onData: (data) => {
115+
track(ANALYTICS_EVENTS.AGENT_FILE_ACTIVITY, {
116+
task_id: data.taskId,
117+
branch_name: data.branchName,
118+
});
119+
},
120+
}),
121+
);
122+
111123
// Auto-unfocus when user manually checks out to a different branch
112124
useSubscription(
113125
trpcReact.focus.onForeignBranchCheckout.subscriptionOptions(undefined, {

apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getAuthenticatedClient } from "@features/auth/hooks/authClient";
12
import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries";
23
import { computeGitInteractionState } from "@features/git-interaction/state/gitInteractionLogic";
34
import {
@@ -19,6 +20,7 @@ import { getSuggestedBranchName } from "@features/git-interaction/utils/getSugge
1920
import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys";
2021
import { partitionByStaged } from "@features/git-interaction/utils/partitionByStaged";
2122
import { updateGitCacheFromSnapshot } from "@features/git-interaction/utils/updateGitCache";
23+
import { useSessionStore } from "@features/sessions/stores/sessionStore";
2224
import { trpc, trpcClient } from "@renderer/trpc";
2325
import type { ChangedFile } from "@shared/types";
2426
import { ANALYTICS_EVENTS } from "@shared/types/analytics";
@@ -115,6 +117,21 @@ function trackGitAction(
115117
});
116118
}
117119

120+
function attachPrUrlToTask(taskId: string, prUrl: string) {
121+
const taskRunId = useSessionStore.getState().taskIdIndex[taskId];
122+
if (!taskRunId) return;
123+
124+
getAuthenticatedClient()
125+
.then((client) =>
126+
client?.updateTaskRun(taskId, taskRunId, {
127+
output: { pr_url: prUrl },
128+
}),
129+
)
130+
.catch((err) =>
131+
log.warn("Failed to attach PR URL to task", { taskId, prUrl, err }),
132+
);
133+
}
134+
118135
export function useGitInteraction(
119136
taskId: string,
120137
repoPath?: string,
@@ -255,6 +272,7 @@ export function useGitInteraction(
255272

256273
if (result.prUrl) {
257274
await trpcClient.os.openExternal.mutate({ url: result.prUrl });
275+
attachPrUrlToTask(taskId, result.prUrl);
258276
}
259277

260278
modal.closeCreatePr();

apps/code/src/shared/types/analytics.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ export interface PrCreatedProperties {
111111
success: boolean;
112112
}
113113

114+
export interface AgentFileActivityProperties {
115+
task_id: string;
116+
branch_name: string | null;
117+
}
118+
114119
// File interactions
115120
export interface FileOpenedProperties {
116121
file_extension: string;
@@ -224,6 +229,7 @@ export const ANALYTICS_EVENTS = {
224229
// Git operations
225230
GIT_ACTION_EXECUTED: "Git action executed",
226231
PR_CREATED: "PR created",
232+
AGENT_FILE_ACTIVITY: "Agent file activity",
227233

228234
// File interactions
229235
FILE_OPENED: "File opened",
@@ -278,6 +284,7 @@ export type EventPropertyMap = {
278284
// Git operations
279285
[ANALYTICS_EVENTS.GIT_ACTION_EXECUTED]: GitActionExecutedProperties;
280286
[ANALYTICS_EVENTS.PR_CREATED]: PrCreatedProperties;
287+
[ANALYTICS_EVENTS.AGENT_FILE_ACTIVITY]: AgentFileActivityProperties;
281288

282289
// File interactions
283290
[ANALYTICS_EVENTS.FILE_OPENED]: FileOpenedProperties;

0 commit comments

Comments
 (0)