diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index af80ec302..0add0f735 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -35,6 +35,7 @@ import { isOpenAIModel, } from "@posthog/agent/gateway-models"; import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; +import { extractCreatedPrUrl } from "@posthog/agent/pr-url-detector"; import type * as AgentTypes from "@posthog/agent/types"; import { getCurrentBranch } from "@posthog/git/queries"; import type { IAppMeta } from "@posthog/platform/app-meta"; @@ -1524,6 +1525,7 @@ For git operations while detached: claudeCode?: { toolName?: string; toolResponse?: unknown; + bashCommand?: string; }; }; content?: Array<{ type?: string; text?: string }>; @@ -1542,10 +1544,7 @@ For git operations while detached: 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); - } + this.detectAndAttachPrUrl(taskRunId, session, toolMeta, update.content); this.trackAgentFileActivity(taskRunId, session, toolName); } catch (err) { @@ -1557,60 +1556,37 @@ 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. + * Detect GitHub PR URLs in `gh pr create` output and attach to task. + * Gated on the originating bash command so that unrelated PR URLs (e.g. + * `gh pr view`, `gh search prs`) don't get latched onto the run. */ private detectAndAttachPrUrl( taskRunId: string, session: ManagedSession | undefined, - toolMeta: { toolName?: string; toolResponse?: unknown }, + toolMeta: + | { + toolName?: string; + toolResponse?: unknown; + bashCommand?: string; + } + | undefined, 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 - if (Array.isArray(content)) { - for (const item of content) { - if (item.type === "text" && item.text) { - textToSearch += ` ${item.text}`; - } - } - } - - if (!textToSearch) return; - - // Match GitHub PR URLs - const prUrlMatch = textToSearch.match( - /https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/, - ); - if (!prUrlMatch) return; + const prUrl = extractCreatedPrUrl({ + toolName: toolMeta?.toolName, + bashCommand: toolMeta?.bashCommand, + toolResponse: toolMeta?.toolResponse, + content, + }); + if (!prUrl) return; - const prUrl = prUrlMatch[0]; - log.info("Detected PR URL in bash output", { taskRunId, prUrl }); + log.info("Detected PR URL from gh pr create", { taskRunId, prUrl }); - // 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(() => { diff --git a/packages/agent/package.json b/packages/agent/package.json index 47ca765eb..b8615b6c7 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -20,6 +20,10 @@ "types": "./dist/posthog-api.d.ts", "import": "./dist/posthog-api.js" }, + "./pr-url-detector": { + "types": "./dist/pr-url-detector.d.ts", + "import": "./dist/pr-url-detector.js" + }, "./types": { "types": "./dist/types.d.ts", "import": "./dist/types.js" diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts index 847bf6f6b..bb52abf8c 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts @@ -82,13 +82,23 @@ function toolMeta( toolName: string, toolResponse?: unknown, parentToolCallId?: string, + bashCommand?: string, ): ToolUpdateMeta { const meta: ToolUpdateMeta["claudeCode"] = { toolName }; if (toolResponse !== undefined) meta.toolResponse = toolResponse; if (parentToolCallId) meta.parentToolCallId = parentToolCallId; + if (bashCommand) meta.bashCommand = bashCommand; return { claudeCode: meta }; } +function bashCommandFromToolUse( + toolUse: ToolUseCache[string] | undefined, +): string | undefined { + if (!toolUse || toolUse.name !== "Bash") return undefined; + const command = (toolUse.input as { command?: unknown } | undefined)?.command; + return typeof command === "string" ? command : undefined; +} + function handleTextChunk( chunk: { text: string }, role: Role, @@ -173,7 +183,12 @@ function handleToolUseChunk( await ctx.client.sessionUpdate({ sessionId: ctx.sessionId, update: { - _meta: toolMeta(toolUse.name, toolResponse, ctx.parentToolCallId), + _meta: toolMeta( + toolUse.name, + toolResponse, + ctx.parentToolCallId, + bashCommandFromToolUse(toolUse), + ), toolCallId: toolUseId, sessionUpdate: "tool_call_update", ...(editUpdate ? editUpdate : {}), @@ -203,7 +218,12 @@ function handleToolUseChunk( }); const meta: Record = { - ...toolMeta(chunk.name, undefined, ctx.parentToolCallId), + ...toolMeta( + chunk.name, + undefined, + ctx.parentToolCallId, + bashCommandFromToolUse(chunk), + ), }; if (chunk.name === "Bash" && ctx.supportsTerminalOutput && !alreadyCached) { meta.terminal_info = { terminal_id: chunk.id }; @@ -343,7 +363,12 @@ function handleToolResultChunk( } const meta: Record = { - ...toolMeta(toolUse.name, undefined, ctx.parentToolCallId), + ...toolMeta( + toolUse.name, + undefined, + ctx.parentToolCallId, + bashCommandFromToolUse(toolUse), + ), ...(resultMeta?.terminal_exit ? { terminal_exit: resultMeta.terminal_exit } : {}), diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 1efd77258..058c322d6 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -96,6 +96,7 @@ export type ToolUpdateMeta = { toolName: string; toolResponse?: unknown; parentToolCallId?: string; + bashCommand?: string; }; terminal_info?: TerminalInfo; terminal_output?: TerminalOutput; diff --git a/packages/agent/src/pr-url-detector.test.ts b/packages/agent/src/pr-url-detector.test.ts new file mode 100644 index 000000000..fef29d7c8 --- /dev/null +++ b/packages/agent/src/pr-url-detector.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { extractCreatedPrUrl } from "./pr-url-detector"; + +describe("extractCreatedPrUrl", () => { + const PR_URL = "https://github.com/PostHog/posthog/pull/12345"; + + it("returns the URL when gh pr create produced it (string toolResponse)", () => { + expect( + extractCreatedPrUrl({ + toolName: "Bash", + bashCommand: 'gh pr create --title "x" --body "y"', + toolResponse: `${PR_URL}\n`, + }), + ).toBe(PR_URL); + }); + + it("returns the URL when gh pr create produced it (object toolResponse)", () => { + expect( + extractCreatedPrUrl({ + toolName: "Bash", + bashCommand: "gh pr create --fill", + toolResponse: { stdout: `${PR_URL}\n`, stderr: "" }, + }), + ).toBe(PR_URL); + }); + + it("ignores PR URLs from gh pr view", () => { + expect( + extractCreatedPrUrl({ + toolName: "Bash", + bashCommand: `gh pr view ${PR_URL}`, + toolResponse: { stdout: PR_URL }, + }), + ).toBeNull(); + }); + + it("ignores PR URLs from gh search prs", () => { + expect( + extractCreatedPrUrl({ + toolName: "Bash", + bashCommand: 'gh search prs "fix login"', + toolResponse: PR_URL, + }), + ).toBeNull(); + }); + + it("ignores PR URLs from gh pr list", () => { + expect( + extractCreatedPrUrl({ + toolName: "Bash", + bashCommand: "gh pr list --json url", + toolResponse: PR_URL, + }), + ).toBeNull(); + }); + + it("returns null when bashCommand is missing", () => { + expect( + extractCreatedPrUrl({ + toolName: "Bash", + bashCommand: undefined, + toolResponse: PR_URL, + }), + ).toBeNull(); + }); + + it("returns null for non-Bash tools", () => { + expect( + extractCreatedPrUrl({ + toolName: "Edit", + bashCommand: "gh pr create", + toolResponse: PR_URL, + }), + ).toBeNull(); + }); + + it("accepts the lowercase 'bash' tool variant", () => { + expect( + extractCreatedPrUrl({ + toolName: "bash", + bashCommand: "gh pr create", + toolResponse: PR_URL, + }), + ).toBe(PR_URL); + }); + + it("returns null when output has no PR URL", () => { + expect( + extractCreatedPrUrl({ + toolName: "Bash", + bashCommand: "gh pr create", + toolResponse: "no pr was created", + }), + ).toBeNull(); + }); + + it("finds the URL in the content array when toolResponse is empty", () => { + expect( + extractCreatedPrUrl({ + toolName: "Bash", + bashCommand: "gh pr create --fill", + toolResponse: undefined, + content: [{ type: "text", text: `Created: ${PR_URL}` }], + }), + ).toBe(PR_URL); + }); + + it("handles output field on object toolResponse", () => { + expect( + extractCreatedPrUrl({ + toolName: "Bash", + bashCommand: "gh pr create", + toolResponse: { output: PR_URL }, + }), + ).toBe(PR_URL); + }); + + it("matches gh pr create even with a chained command", () => { + expect( + extractCreatedPrUrl({ + toolName: "Bash", + bashCommand: "git push -u origin feat/x && gh pr create --fill", + toolResponse: { stdout: PR_URL }, + }), + ).toBe(PR_URL); + }); + + it("does not match a fake command containing 'pr create' as text", () => { + expect( + extractCreatedPrUrl({ + toolName: "Bash", + bashCommand: "echo 'i should pr create later'", + toolResponse: PR_URL, + }), + ).toBeNull(); + }); +}); diff --git a/packages/agent/src/pr-url-detector.ts b/packages/agent/src/pr-url-detector.ts new file mode 100644 index 000000000..6c8d20521 --- /dev/null +++ b/packages/agent/src/pr-url-detector.ts @@ -0,0 +1,46 @@ +const PR_URL_REGEX = /https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/; +const GH_PR_CREATE_REGEX = /\bgh\s+pr\s+create\b/; + +export interface ExtractCreatedPrUrlInput { + toolName: string | undefined; + bashCommand: string | undefined; + toolResponse: unknown; + content?: Array<{ type?: string; text?: string }>; +} + +export function extractCreatedPrUrl( + input: ExtractCreatedPrUrlInput, +): string | null { + const { toolName, bashCommand, toolResponse, content } = input; + + if (!toolName || !/bash/i.test(toolName)) return null; + if (!bashCommand || !GH_PR_CREATE_REGEX.test(bashCommand)) return null; + + let textToSearch = ""; + + if (toolResponse) { + if (typeof toolResponse === "string") { + textToSearch = toolResponse; + } else if (typeof toolResponse === "object" && toolResponse !== null) { + const respObj = toolResponse as Record; + textToSearch = + String(respObj.stdout || "") + String(respObj.stderr || ""); + if (!textToSearch && respObj.output) { + textToSearch = String(respObj.output); + } + } + } + + if (Array.isArray(content)) { + for (const item of content) { + if (item.type === "text" && item.text) { + textToSearch += ` ${item.text}`; + } + } + } + + if (!textToSearch) return null; + + const match = textToSearch.match(PR_URL_REGEX); + return match ? match[0] : null; +} diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index f61aa293d..e0099f78b 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -529,7 +529,7 @@ describe("AgentServer HTTP Mode", () => { }); describe("detectedPrUrl tracking", () => { - it("stores PR URL when detectAndAttachPrUrl finds a match", () => { + it("stores PR URL when gh pr create produces it", () => { const s = createServer(); const payload = { task_id: "test-task-id", @@ -539,6 +539,7 @@ describe("AgentServer HTTP Mode", () => { _meta: { claudeCode: { toolName: "Bash", + bashCommand: 'gh pr create --title "x" --body "y"', toolResponse: { stdout: "https://github.com/PostHog/posthog/pull/42\nCreating pull request...", @@ -563,6 +564,7 @@ describe("AgentServer HTTP Mode", () => { _meta: { claudeCode: { toolName: "Bash", + bashCommand: "gh pr create", toolResponse: { stdout: "just some output" }, }, }, @@ -571,6 +573,71 @@ describe("AgentServer HTTP Mode", () => { (s as unknown as TestableServer).detectAndAttachPrUrl(payload, update); expect((s as unknown as TestableServer).detectedPrUrl).toBeNull(); }); + + it("does not attach PR URL when the bash command is gh pr view", () => { + const s = createServer(); + const payload = { + task_id: "test-task-id", + run_id: "test-run-id", + }; + const update = { + _meta: { + claudeCode: { + toolName: "Bash", + bashCommand: "gh pr view 42 --json url", + toolResponse: { + stdout: "https://github.com/PostHog/posthog/pull/42", + }, + }, + }, + }; + + (s as unknown as TestableServer).detectAndAttachPrUrl(payload, update); + expect((s as unknown as TestableServer).detectedPrUrl).toBeNull(); + }); + + it("does not attach PR URL when the bash command is gh search prs", () => { + const s = createServer(); + const payload = { + task_id: "test-task-id", + run_id: "test-run-id", + }; + const update = { + _meta: { + claudeCode: { + toolName: "Bash", + bashCommand: 'gh search prs "fix login"', + toolResponse: { + stdout: "https://github.com/PostHog/posthog/pull/42", + }, + }, + }, + }; + + (s as unknown as TestableServer).detectAndAttachPrUrl(payload, update); + expect((s as unknown as TestableServer).detectedPrUrl).toBeNull(); + }); + + it("does not attach PR URL when bashCommand is missing", () => { + const s = createServer(); + const payload = { + task_id: "test-task-id", + run_id: "test-run-id", + }; + const update = { + _meta: { + claudeCode: { + toolName: "Bash", + toolResponse: { + stdout: "https://github.com/PostHog/posthog/pull/42", + }, + }, + }, + }; + + (s as unknown as TestableServer).detectAndAttachPrUrl(payload, update); + expect((s as unknown as TestableServer).detectedPrUrl).toBeNull(); + }); }); describe("buildCloudSystemPrompt", () => { diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index f534144f4..49148e332 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -29,6 +29,7 @@ import type { PermissionMode } from "../execution-mode"; import { DEFAULT_CODEX_MODEL } from "../gateway-models"; import { HandoffCheckpointTracker } from "../handoff-checkpoint"; import { PostHogAPIClient } from "../posthog-api"; +import { extractCreatedPrUrl } from "../pr-url-detector"; import { formatConversationForResume, type ResumeState, @@ -2059,45 +2060,21 @@ ${attributionInstructions} const meta = (update?._meta as Record)?.claudeCode as | Record | undefined; - const toolResponse = meta?.toolResponse; - - // Extract text content from tool response - let textToSearch = ""; - - if (toolResponse) { - if (typeof toolResponse === "string") { - textToSearch = toolResponse; - } else if (typeof toolResponse === "object" && toolResponse !== null) { - 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 = update?.content; - if (Array.isArray(content)) { - for (const item of content) { - if (item.type === "text" && item.text) { - textToSearch += ` ${item.text}`; - } - } - } - if (!textToSearch) return; + const content = update?.content as + | Array<{ type?: string; text?: string }> + | undefined; - // Match GitHub PR URLs - const prUrlMatch = textToSearch.match( - /https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/, - ); - if (!prUrlMatch) return; + const prUrl = extractCreatedPrUrl({ + toolName: meta?.toolName as string | undefined, + bashCommand: meta?.bashCommand as string | undefined, + toolResponse: meta?.toolResponse, + content, + }); + if (!prUrl) return; - const prUrl = prUrlMatch[0]; this.detectedPrUrl = prUrl; - this.logger.debug("Detected PR URL in bash output", { + this.logger.debug("Detected PR URL from gh pr create", { runId: payload.run_id, prUrl, }); diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index a21a206b5..9ee6c6ed3 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -76,6 +76,7 @@ export default defineConfig([ "src/gateway-models.ts", "src/handoff-checkpoint.ts", "src/posthog-api.ts", + "src/pr-url-detector.ts", "src/resume.ts", "src/types.ts", "src/adapters/claude/questions/utils.ts",