From 10e164419df624d7ab8b2eaeb20bec1a76fde41c Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Sun, 21 Jun 2026 08:34:31 -0700 Subject: [PATCH 1/2] fix(channels): reuse local folders, stop git-init prompt on scratch tasks Follow-up to #2735 (generic chat box with lazy repo attach). Two local-mode regressions: 1. The channel system prompt sent the agent straight to list_repos/clone_repo, cloning from remote even when the user already has the repo checked out locally. AgentService now fetches the user's previously-used local folders and embeds them in the channel prompt, with guidance to reuse a local match (or ask the user for a path) first and only clone from remote as a last resort, after confirming. 2. Opening a repo-less channel task popped the native "initialize git?" dialog: the synthetic scratch workspace has folderId "" (falsy), so the navigation task binder's guard missed and it registered the empty scratch dir as a folder. Mark scratch workspaces with isScratch and short-circuit so they are never registered or git-init'd. The dialog now only fires when a real folder is selected for a coding task. Generated-By: PostHog Code Task-Id: d7d5491e-f66a-4848-9620-98006834f3d6 --- packages/shared/src/workspace-domain.ts | 6 ++++ .../src/features/navigation/taskBinderImpl.ts | 8 +++++ .../src/services/agent/agent.test.ts | 4 +++ .../src/services/agent/agent.ts | 36 ++++++++++++++++--- .../src/services/workspace/workspace.ts | 1 + .../workspace/workspace.verify.test.ts | 4 +++ 6 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/workspace-domain.ts b/packages/shared/src/workspace-domain.ts index 4235be5f46..5538b0a1db 100644 --- a/packages/shared/src/workspace-domain.ts +++ b/packages/shared/src/workspace-domain.ts @@ -35,6 +35,12 @@ export const workspaceSchema = z.object({ baseBranch: z.string().nullable(), linkedBranch: z.string().nullable(), createdAt: z.string(), + /** + * Synthetic workspace for a repo-less channel task: its folderPath is a + * scratch dir, not a registered folder. Marks it so callers (e.g. the + * navigation task binder) don't try to register it as a folder or git-init it. + */ + isScratch: z.boolean().optional(), }); export type WorktreeInfo = z.infer; diff --git a/packages/ui/src/features/navigation/taskBinderImpl.ts b/packages/ui/src/features/navigation/taskBinderImpl.ts index 3d08df40d8..a3ba46c5e1 100644 --- a/packages/ui/src/features/navigation/taskBinderImpl.ts +++ b/packages/ui/src/features/navigation/taskBinderImpl.ts @@ -47,6 +47,14 @@ export const navigationTaskBinder: NavigationTaskBinder = { const workspaces = await hostClient().workspace.getAll.query(); const existingWorkspace = workspaces?.[task.id] ?? null; + + // Repo-less channel task: its workspace is a synthetic scratch dir, not a + // registered folder. Never register it (that would pop the "initialize git" + // dialog on the empty scratch dir) or write a workspace row for it. + if (existingWorkspace?.isScratch) { + return undefined; + } + if (existingWorkspace?.folderId) { const folders = await hostClient().folders.getFolders.query(); const folder = folders.find((f) => f.id === existingWorkspace.folderId); diff --git a/packages/workspace-server/src/services/agent/agent.test.ts b/packages/workspace-server/src/services/agent/agent.test.ts index 40c713a625..e5f711ca0a 100644 --- a/packages/workspace-server/src/services/agent/agent.test.ts +++ b/packages/workspace-server/src/services/agent/agent.test.ts @@ -176,6 +176,9 @@ function createMockDependencies() { workspaceSettings: { getWorktreeLocation: () => "/mock/worktrees", }, + foldersService: { + getFolders: vi.fn().mockResolvedValue([]), + }, loggerFactory: { scope: () => ({ info: vi.fn(), @@ -220,6 +223,7 @@ describe("AgentService", () => { deps.storagePaths as never, deps.workspaceRepository as never, deps.workspaceSettings as never, + deps.foldersService as never, deps.loggerFactory as never, ); vi.spyOn(service, "emit"); diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 593981a00d..ebf83a9d3a 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -66,6 +66,9 @@ import { import { inject, injectable, preDestroy } from "inversify"; import { WORKSPACE_REPOSITORY } from "../../db/identifiers"; import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; +import type { FoldersService } from "../folders/folders"; +import { FOLDERS_SERVICE } from "../folders/identifiers"; +import type { RegisteredFolder } from "../folders/schemas"; import { POSTHOG_PLUGIN_SERVICE } from "../posthog-plugin/identifiers"; import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin"; import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; @@ -372,6 +375,8 @@ export class AgentService extends TypedEventEmitter { private readonly workspaceRepository: IWorkspaceRepository, @inject(WORKSPACE_SETTINGS_SERVICE) private readonly workspaceSettings: IWorkspaceSettings, + @inject(FOLDERS_SERVICE) + private readonly foldersService: FoldersService, @inject(AGENT_LOGGER) loggerFactory: AgentLogger, ) { @@ -535,6 +540,7 @@ export class AgentService extends TypedEventEmitter { additionalDirectories?: string[], systemPromptOverride?: string, channelMode?: boolean, + knownLocalFolders?: RegisteredFolder[], ): { append: string; } { @@ -575,16 +581,30 @@ When creating pull requests, add the following footer at the end of the PR descr \`\`\``; if (channelMode) { + const localFolders = (knownLocalFolders ?? []).filter( + (f) => f.exists !== false, + ); + const localFoldersBlock = localFolders.length + ? `\n\nThe user already has these repositories checked out locally on this machine. Prefer reusing one of these over cloning anything:\n${localFolders + .map( + (f) => + ` - ${f.name} — ${f.path}${f.remoteUrl ? ` (${f.remoteUrl})` : ""}`, + ) + .join("\n")}` + : ""; + prompt += ` ## Channel task (no repository attached) You are running in a PostHog channel as a general-purpose assistant. This task may NOT need a code repository at all — it could be data analysis via PostHog tools, drafting a message, or answering a question. Do not assume you need a repo. - Your working directory is a scratch directory, not a git checkout. Treat it as empty. -- Decide from the user's request (and the channel CONTEXT.md included above, if any) whether the task actually requires working inside a code repository. -- Only if a repository is genuinely required: pick which one from the request and CONTEXT.md. Repositories named in CONTEXT.md are the most likely candidates — prefer them. Call \`list_repos\` to see what is available. -- Bring a repo into your workspace with the \`clone_repo\` tool (pass \`owner/repo\`). It clones into a subdirectory of your working directory and returns the path — cd into that path for all git work. -- If a repository is required but you cannot confidently determine which one, use the AskUserQuestion tool to ask the user to choose before cloning. Do not guess.`; +- Decide from the user's request (and the channel CONTEXT.md included above, if any) whether the task actually requires working inside a code repository. If it doesn't, just do the work in the scratch directory — do NOT attach a repo. + +If a repository IS genuinely required, attach one in this priority order: +1. **Reuse a folder the user already has locally.** ${localFolders.length ? "Pick the one that best matches the request and the channel CONTEXT.md, then `cd` into its absolute path and do all git and file work there. It is already on disk — do NOT clone it again." : "If the user names a folder or path, `cd` into that absolute path and work there."} +2. **If you can't confidently pick one** (none clearly match, or it's ambiguous), use the AskUserQuestion tool to ask the user which local folder to use, or for the path where the folder lives on this machine. Do not guess. +3. **Only as a last resort** — when the user has no local copy, or explicitly wants a fresh checkout — clone from remote. Call \`list_repos\` to see what's available (prefer repos named in CONTEXT.md), then **confirm with the user via AskUserQuestion before cloning**, and use \`clone_repo\` (pass \`owner/repo\`); it clones into a subdirectory of your working directory and returns the path to \`cd\` into.${localFoldersBlock}`; } if (customInstructions) { @@ -660,6 +680,13 @@ You are running in a PostHog channel as a general-purpose assistant. This task m this.workspaceSettings.getWorktreeLocation(), ); + // In channel mode the agent decides at runtime whether it needs a repo. Give + // it the user's previously-used local folders so it can reuse one (or ask) + // instead of cloning from remote. Only fetched for channel sessions. + const knownLocalFolders = channelMode + ? await this.foldersService.getFolders().catch(() => []) + : []; + const additionalDirectories = taskId === "__preview__" ? [] @@ -717,6 +744,7 @@ You are running in a PostHog channel as a general-purpose assistant. This task m additionalDirectories, systemPromptOverride, channelMode, + knownLocalFolders, ); const bundledSkillsDir = join( diff --git a/packages/workspace-server/src/services/workspace/workspace.ts b/packages/workspace-server/src/services/workspace/workspace.ts index c21277fec8..df3d844c58 100644 --- a/packages/workspace-server/src/services/workspace/workspace.ts +++ b/packages/workspace-server/src/services/workspace/workspace.ts @@ -898,6 +898,7 @@ export class WorkspaceService extends TypedEventEmitter baseBranch: null, linkedBranch: null, createdAt: new Date().toISOString(), + isScratch: true, }; } diff --git a/packages/workspace-server/src/services/workspace/workspace.verify.test.ts b/packages/workspace-server/src/services/workspace/workspace.verify.test.ts index 0a086feb41..e287f2ad31 100644 --- a/packages/workspace-server/src/services/workspace/workspace.verify.test.ts +++ b/packages/workspace-server/src/services/workspace/workspace.verify.test.ts @@ -166,10 +166,14 @@ describe("WorkspaceService.verifyWorkspaceExists", () => { mode: "local", folderPath: scratchPath, worktreePath: null, + // Marked so the navigation task binder skips folder registration (and the + // "initialize git" dialog) for repo-less channel tasks. + isScratch: true, }); const all = await service.getAllWorkspaces(); expect(all[TASK_ID]?.folderPath).toBe(scratchPath); + expect(all[TASK_ID]?.isScratch).toBe(true); // It is not backed by a DB row. expect(workspaceRepo.findByTaskId(TASK_ID)).toBeNull(); From ed8dddb1330331d9f31907b82ba33660605e4612 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Sun, 21 Jun 2026 08:34:57 -0700 Subject: [PATCH 2/2] test(channels): exercise local-folders prompt branch The channel system prompt's local-folders block was never covered: the foldersService mock always resolved to [], so the `localFolders.length > 0` path in buildSystemPrompt never ran. Add parameterised cases over buildSystemPrompt in channel mode covering exists:true with a remoteUrl, exists undefined with a null remoteUrl (no empty parens), and exists:false (filtered out, block omitted), plus a mixed-list case and the empty-list fallback. Addresses the review comment on agent.test.ts. Generated-By: PostHog Code Task-Id: e61fbdf2-151f-42d2-9c3b-3aaac1bb7959 --- .../src/services/agent/agent.test.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/packages/workspace-server/src/services/agent/agent.test.ts b/packages/workspace-server/src/services/agent/agent.test.ts index e5f711ca0a..adeffc2afb 100644 --- a/packages/workspace-server/src/services/agent/agent.test.ts +++ b/packages/workspace-server/src/services/agent/agent.test.ts @@ -99,6 +99,7 @@ vi.mock("node:fs", async (importOriginal) => { }); // --- Import after mocks --- +import type { RegisteredFolder } from "../folders/schemas"; import { AgentService, buildAutoApproveOutcome } from "./agent"; // --- Test helpers --- @@ -520,6 +521,135 @@ describe("AgentService", () => { ); }); }); + + describe("channel system prompt local folders", () => { + const credentials = { apiHost: "https://app.posthog.com", projectId: 1 }; + const FOLDERS_HEADER = + "already has these repositories checked out locally on this machine"; + + function makeFolder( + overrides: Partial, + ): RegisteredFolder { + return { + id: "folder-id", + path: "/src/example", + name: "example", + remoteUrl: null, + lastAccessed: "2026-01-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; + } + + function buildChannelPrompt(folders: RegisteredFolder[]): string { + return ( + service as unknown as { + buildSystemPrompt: ( + credentials: { apiHost: string; projectId: number }, + taskId: string, + customInstructions?: string, + additionalDirectories?: string[], + systemPromptOverride?: string, + channelMode?: boolean, + knownLocalFolders?: RegisteredFolder[], + ) => { append: string }; + } + ).buildSystemPrompt( + credentials, + "task-1", + undefined, + undefined, + undefined, + true, + folders, + ).append; + } + + it.each([ + { + desc: "exists:true with a remoteUrl renders name, path and URL", + folder: makeFolder({ + name: "posthog", + path: "/src/posthog", + remoteUrl: "git@github.com:PostHog/posthog.git", + exists: true, + }), + included: true, + line: " - posthog — /src/posthog (git@github.com:PostHog/posthog.git)", + }, + { + desc: "exists undefined with a null remoteUrl renders without parens", + folder: makeFolder({ + name: "local-only", + path: "/src/local-only", + remoteUrl: null, + }), + included: true, + line: " - local-only — /src/local-only", + }, + { + desc: "exists:false is filtered out and omits the block", + folder: makeFolder({ + name: "stale", + path: "/src/stale", + exists: false, + }), + included: false, + line: " - stale — /src/stale", + }, + ])("$desc", ({ folder, included, line }) => { + const prompt = buildChannelPrompt([folder]); + + if (included) { + expect(prompt).toContain(FOLDERS_HEADER); + expect(prompt).toContain(line); + // The reuse-first guidance only appears when a folder is on disk. + expect(prompt).toContain("do NOT clone it again"); + } else { + expect(prompt).not.toContain(line); + // The only folder was filtered out, so the block is dropped entirely + // and the prompt falls back to the "ask for a path" guidance. + expect(prompt).not.toContain(FOLDERS_HEADER); + expect(prompt).toContain("If the user names a folder or path"); + } + }); + + it("lists only existing folders when given a mix", () => { + const prompt = buildChannelPrompt([ + makeFolder({ + id: "1", + name: "posthog", + path: "/src/posthog", + remoteUrl: "git@github.com:PostHog/posthog.git", + exists: true, + }), + makeFolder({ id: "2", name: "local-only", path: "/src/local-only" }), + makeFolder({ + id: "3", + name: "stale", + path: "/src/stale", + exists: false, + }), + ]); + + expect(prompt).toContain(FOLDERS_HEADER); + expect(prompt).toContain( + " - posthog — /src/posthog (git@github.com:PostHog/posthog.git)", + ); + expect(prompt).toContain(" - local-only — /src/local-only"); + // A null remoteUrl must not render an empty "()" suffix. + expect(prompt).not.toContain("/src/local-only ("); + // exists:false folders never reach the prompt. + expect(prompt).not.toContain("stale"); + }); + + it("omits the local-folders block entirely when none are known", () => { + const prompt = buildChannelPrompt([]); + + expect(prompt).not.toContain(FOLDERS_HEADER); + expect(prompt).toContain("If the user names a folder or path"); + }); + }); }); describe("buildAutoApproveOutcome", () => {