diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index 97d7648ec..06f3c306c 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -210,6 +210,7 @@ export const AgentServiceEvent = { PermissionRequest: "permission-request", SessionsIdle: "sessions-idle", SessionIdleKilled: "session-idle-killed", + AgentFileActivity: "agent-file-activity", } as const; export interface AgentSessionEventPayload { @@ -230,11 +231,17 @@ export interface SessionIdleKilledPayload { taskId: string; } +export interface AgentFileActivityPayload { + taskId: string; + branchName: string | null; +} + export interface AgentServiceEvents { [AgentServiceEvent.SessionEvent]: AgentSessionEventPayload; [AgentServiceEvent.PermissionRequest]: PermissionRequestPayload; [AgentServiceEvent.SessionsIdle]: undefined; [AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload; + [AgentServiceEvent.AgentFileActivity]: AgentFileActivityPayload; } // Permission response input for tRPC diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 156c52677..a75967f7b 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -22,6 +22,7 @@ import { } from "@posthog/agent/gateway-models"; import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import type { OnLogCallback } from "@posthog/agent/types"; +import { getCurrentBranch } from "@posthog/git/queries"; import { isAuthError } from "@shared/errors"; import type { AcpMessage } from "@shared/types/session-events"; import { app, powerMonitor } from "electron"; @@ -1129,8 +1130,8 @@ For git operations while detached: }; emitToRenderer(acpMessage); - // Detect PR URLs in bash tool results and attach to task - this.detectAndAttachPrUrl(taskRunId, message as AcpMessage["message"]); + // Inspect tool call updates for PR URLs and file activity + this.handleToolCallUpdate(taskRunId, message as AcpMessage["message"]); }; const tappedReadable = createTappedReadableStream( @@ -1410,11 +1411,7 @@ For git operations while detached: }; } - /** - * Detect GitHub PR URLs in bash tool results and attach to task. - * This enables webhook tracking by populating the pr_url in TaskRun output. - */ - private detectAndAttachPrUrl(taskRunId: string, message: unknown): void { + private handleToolCallUpdate(taskRunId: string, message: unknown): void { try { const msg = message as { method?: string; @@ -1436,86 +1433,136 @@ For git operations while detached: if (msg.method !== "session/update") return; if (msg.params?.update?.sessionUpdate !== "tool_call_update") return; - const toolMeta = msg.params.update._meta?.claudeCode; + const update = msg.params.update; + const toolMeta = update._meta?.claudeCode; const toolName = toolMeta?.toolName; + if (!toolName) return; - // Only process Bash tool results - if ( - !toolName || - (!toolName.includes("Bash") && !toolName.includes("bash")) - ) { - return; + const session = this.sessions.get(taskRunId); + + // PR URLs only appear in Bash tool output + if (toolName.includes("Bash") || toolName.includes("bash")) { + this.detectAndAttachPrUrl(taskRunId, session, toolMeta, update.content); } - // Extract text content from tool response or update content - let textToSearch = ""; - - // Check toolResponse (hook response with raw output) - const toolResponse = toolMeta?.toolResponse; - if (toolResponse) { - if (typeof toolResponse === "string") { - textToSearch = toolResponse; - } else if (typeof toolResponse === "object" && toolResponse !== null) { - // May be { stdout?: string, stderr?: string } or similar - const respObj = toolResponse as Record; - textToSearch = - String(respObj.stdout || "") + String(respObj.stderr || ""); - if (!textToSearch && respObj.output) { - textToSearch = String(respObj.output); - } + this.trackAgentFileActivity(taskRunId, session, toolName); + } catch (err) { + log.debug("Error in tool call update handling", { + taskRunId, + error: err, + }); + } + } + + /** + * Detect GitHub PR URLs in bash tool results and attach to task. + * This enables webhook tracking by populating the pr_url in TaskRun output. + */ + private detectAndAttachPrUrl( + taskRunId: string, + session: ManagedSession | undefined, + toolMeta: { toolName?: string; toolResponse?: unknown }, + content?: Array<{ type?: string; text?: string }>, + ): void { + let textToSearch = ""; + + // Check toolResponse (hook response with raw output) + const toolResponse = toolMeta?.toolResponse; + if (toolResponse) { + if (typeof toolResponse === "string") { + textToSearch = toolResponse; + } else if (typeof toolResponse === "object" && toolResponse !== null) { + // May be { stdout?: string, stderr?: string } or similar + const respObj = toolResponse as Record; + textToSearch = + String(respObj.stdout || "") + String(respObj.stderr || ""); + if (!textToSearch && respObj.output) { + textToSearch = String(respObj.output); } } + } - // Also check content array - const content = msg.params.update.content; - if (Array.isArray(content)) { - for (const item of content) { - if (item.type === "text" && item.text) { - textToSearch += ` ${item.text}`; - } + // Also check content array + if (Array.isArray(content)) { + for (const item of content) { + if (item.type === "text" && item.text) { + textToSearch += ` ${item.text}`; } } + } - if (!textToSearch) return; + if (!textToSearch) return; - // Match GitHub PR URLs - const prUrlMatch = textToSearch.match( - /https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/, - ); - if (!prUrlMatch) return; + // Match GitHub PR URLs + const prUrlMatch = textToSearch.match( + /https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/, + ); + if (!prUrlMatch) return; - const prUrl = prUrlMatch[0]; - log.info("Detected PR URL in bash output", { taskRunId, prUrl }); + const prUrl = prUrlMatch[0]; + log.info("Detected PR URL in bash output", { taskRunId, prUrl }); - // Find session and attach PR URL - const session = this.sessions.get(taskRunId); - if (!session) { - log.warn("Session not found for PR attachment", { taskRunId }); - return; - } + // Attach PR URL + if (!session) { + log.warn("Session not found for PR attachment", { taskRunId }); + return; + } - // Attach asynchronously without blocking message flow - session.agent - .attachPullRequestToTask(session.taskId, prUrl) - .then(() => { - log.info("PR URL attached to task", { - taskRunId, - taskId: session.taskId, - prUrl, - }); - }) - .catch((err) => { - log.error("Failed to attach PR URL to task", { - taskRunId, - taskId: session.taskId, - prUrl, - error: err, - }); + // Attach asynchronously without blocking message flow + session.agent + .attachPullRequestToTask(session.taskId, prUrl) + .then(() => { + log.info("PR URL attached to task", { + taskRunId, + taskId: session.taskId, + prUrl, }); - } catch (err) { - // Don't let detection errors break message flow - log.debug("Error in PR URL detection", { taskRunId, error: err }); - } + }) + .catch((err) => { + log.error("Failed to attach PR URL to task", { + taskRunId, + taskId: session.taskId, + prUrl, + error: err, + }); + }); + } + + /** + * Track agent file activity for branch association observability. + */ + private static readonly FILE_MODIFYING_TOOLS = new Set([ + "Edit", + "Write", + "FileEditTool", + "FileWriteTool", + "MultiEdit", + "NotebookEdit", + ]); + + private trackAgentFileActivity( + taskRunId: string, + session: ManagedSession | undefined, + toolName: string, + ): void { + if (!session) return; + if (!AgentService.FILE_MODIFYING_TOOLS.has(toolName)) return; + + getCurrentBranch(session.repoPath) + .then((branchName) => { + this.emit(AgentServiceEvent.AgentFileActivity, { + taskId: session.taskId, + branchName, + }); + }) + .catch((err) => { + log.error("Failed to emit agent file activity event", { + taskRunId, + taskId: session.taskId, + toolName, + error: err, + }); + }); } async getGatewayModels(apiHost: string) { diff --git a/apps/code/src/main/trpc/routers/agent.ts b/apps/code/src/main/trpc/routers/agent.ts index f6907f61a..c136d34e6 100644 --- a/apps/code/src/main/trpc/routers/agent.ts +++ b/apps/code/src/main/trpc/routers/agent.ts @@ -189,6 +189,16 @@ export const agentRouter = router({ } }), + onAgentFileActivity: publicProcedure.subscription(async function* (opts) { + const service = getService(); + for await (const event of service.toIterable( + AgentServiceEvent.AgentFileActivity, + { signal: opts.signal }, + )) { + yield event; + } + }), + getGatewayModels: publicProcedure .input(getGatewayModelsInput) .output(getGatewayModelsOutput) diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index b0d2fbeba..89d5b148f 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -14,9 +14,10 @@ import { initializeConnectivityStore } from "@renderer/stores/connectivityStore" import { useFocusStore } from "@renderer/stores/focusStore"; import { useThemeStore } from "@renderer/stores/themeStore"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; -import { initializePostHog } from "@utils/analytics"; +import { initializePostHog, track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; import { AnimatePresence, motion } from "framer-motion"; @@ -108,6 +109,17 @@ function App() { }), ); + useSubscription( + trpcReact.agent.onAgentFileActivity.subscriptionOptions(undefined, { + onData: (data) => { + track(ANALYTICS_EVENTS.AGENT_FILE_ACTIVITY, { + task_id: data.taskId, + branch_name: data.branchName, + }); + }, + }), + ); + // Auto-unfocus when user manually checks out to a different branch useSubscription( trpcReact.focus.onForeignBranchCheckout.subscriptionOptions(undefined, { diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts b/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts index b65c6d74e..6312c3d3f 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts +++ b/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts @@ -1,3 +1,4 @@ +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; import { computeGitInteractionState } from "@features/git-interaction/state/gitInteractionLogic"; import { @@ -19,6 +20,7 @@ import { getSuggestedBranchName } from "@features/git-interaction/utils/getSugge import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; import { partitionByStaged } from "@features/git-interaction/utils/partitionByStaged"; import { updateGitCacheFromSnapshot } from "@features/git-interaction/utils/updateGitCache"; +import { useSessionStore } from "@features/sessions/stores/sessionStore"; import { trpc, trpcClient } from "@renderer/trpc"; import type { ChangedFile } from "@shared/types"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; @@ -115,6 +117,21 @@ function trackGitAction( }); } +function attachPrUrlToTask(taskId: string, prUrl: string) { + const taskRunId = useSessionStore.getState().taskIdIndex[taskId]; + if (!taskRunId) return; + + getAuthenticatedClient() + .then((client) => + client?.updateTaskRun(taskId, taskRunId, { + output: { pr_url: prUrl }, + }), + ) + .catch((err) => + log.warn("Failed to attach PR URL to task", { taskId, prUrl, err }), + ); +} + export function useGitInteraction( taskId: string, repoPath?: string, @@ -255,6 +272,7 @@ export function useGitInteraction( if (result.prUrl) { await trpcClient.os.openExternal.mutate({ url: result.prUrl }); + attachPrUrlToTask(taskId, result.prUrl); } modal.closeCreatePr(); diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index c4aa8d2ad..f7ceec963 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -111,6 +111,11 @@ export interface PrCreatedProperties { success: boolean; } +export interface AgentFileActivityProperties { + task_id: string; + branch_name: string | null; +} + // File interactions export interface FileOpenedProperties { file_extension: string; @@ -224,6 +229,7 @@ export const ANALYTICS_EVENTS = { // Git operations GIT_ACTION_EXECUTED: "Git action executed", PR_CREATED: "PR created", + AGENT_FILE_ACTIVITY: "Agent file activity", // File interactions FILE_OPENED: "File opened", @@ -278,6 +284,7 @@ export type EventPropertyMap = { // Git operations [ANALYTICS_EVENTS.GIT_ACTION_EXECUTED]: GitActionExecutedProperties; [ANALYTICS_EVENTS.PR_CREATED]: PrCreatedProperties; + [ANALYTICS_EVENTS.AGENT_FILE_ACTIVITY]: AgentFileActivityProperties; // File interactions [ANALYTICS_EVENTS.FILE_OPENED]: FileOpenedProperties;