From 24096e6191490385a28db3d80e593bbc78ec082e Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Wed, 20 May 2026 22:21:47 +0100 Subject: [PATCH 1/2] feat(agent): add long-running task loop with LLM-planned proposals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an auto-continuing turn loop that the agent enters when the user approves a proposal block. While active, the harness pushes a continuation user message after each end_turn until the agent emits a configured marker, hits a max-iteration cap, or the user steers/stops. The /long-running-task slash command sends a brief planning prompt with hidden context — the agent explores the codebase, asks clarifying questions via AskUserQuestion when intent is ambiguous, then emits the config as a JSON block. The renderer parses that block, shows an inline confirmation card (editable goal/criterion/marker/max), and on Start sets the server-side state via a new tRPC mutation + sends the kickoff prompt. While a task is active, a pill above the prompt input shows iterations N/M + goal + a Stop button. Mid-loop user messages pause auto-continuation for that turn so the user can steer. Generated-By: PostHog Code Task-Id: c1333c34-d768-47e2-9dda-d2e456ab701a --- apps/code/src/main/services/agent/schemas.ts | 19 ++ apps/code/src/main/services/agent/service.ts | 36 +++ apps/code/src/main/trpc/routers/agent.ts | 10 + .../long-running-task/LongRunningTaskPill.tsx | 64 +++++ .../LongRunningTaskProposalCard.tsx | 225 ++++++++++++++++++ .../features/message-editor/commands.ts | 42 +++- .../sessions/components/SessionView.tsx | 22 ++ .../features/sessions/service/service.ts | 62 +++++ .../renderer/stores/longRunningTaskStore.ts | 87 +++++++ packages/agent/src/acp-extensions.ts | 28 +++ .../agent/src/adapters/claude/claude-agent.ts | 69 ++++++ .../claude/long-running-task/utils.test.ts | 64 +++++ .../claude/long-running-task/utils.ts | 187 +++++++++++++++ .../adapters/claude/session/instructions.ts | 53 ++++- packages/agent/src/adapters/claude/types.ts | 10 + packages/agent/src/index.ts | 7 +- 16 files changed, 982 insertions(+), 3 deletions(-) create mode 100644 apps/code/src/renderer/components/long-running-task/LongRunningTaskPill.tsx create mode 100644 apps/code/src/renderer/components/long-running-task/LongRunningTaskProposalCard.tsx create mode 100644 apps/code/src/renderer/stores/longRunningTaskStore.ts create mode 100644 packages/agent/src/adapters/claude/long-running-task/utils.test.ts create mode 100644 packages/agent/src/adapters/claude/long-running-task/utils.ts diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index 3ead6cf15b..ff1db8bfc1 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -159,6 +159,25 @@ export const cancelPromptInput = z.object({ reason: interruptReasonSchema.optional(), }); +// Long-running task start/stop +export const startLongRunningTaskInput = z.object({ + sessionId: z.string(), + goal: z.string().min(1).max(2000), + successCriterion: z.string().min(1).max(1000), + marker: z.string().min(1).max(200).optional(), + maxIterations: z.number().int().positive().max(200).optional(), +}); + +export type StartLongRunningTaskInput = z.infer< + typeof startLongRunningTaskInput +>; + +export const stopLongRunningTaskInput = z.object({ + sessionId: z.string(), +}); + +export type StopLongRunningTaskInput = z.infer; + // Reconnect session input export const reconnectSessionInput = z.object({ taskId: z.string(), diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 4c3eecb07f..0c09b9d364 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -15,6 +15,7 @@ import { import { isMcpToolReadOnly, isNotification, + POSTHOG_METHODS, POSTHOG_NOTIFICATIONS, } from "@posthog/agent"; import type { McpToolApprovals } from "@posthog/agent/adapters/claude/mcp/tool-metadata"; @@ -937,6 +938,41 @@ When creating pull requests, add the following footer at the end of the PR descr } } + async startLongRunningTask(input: { + sessionId: string; + goal: string; + successCriterion: string; + marker?: string; + maxIterations?: number; + }): Promise { + const session = this.sessions.get(input.sessionId); + if (!session) { + throw new Error(`Session not found: ${input.sessionId}`); + } + await session.clientSideConnection.extMethod( + POSTHOG_METHODS.START_LONG_RUNNING_TASK, + { + goal: input.goal, + successCriterion: input.successCriterion, + marker: input.marker, + maxIterations: input.maxIterations, + }, + ); + } + + async stopLongRunningTask(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + try { + await session.clientSideConnection.extMethod( + POSTHOG_METHODS.STOP_LONG_RUNNING_TASK, + {}, + ); + } catch (err) { + log.error("Failed to stop long-running task", { sessionId, err }); + } + } + getSession(taskRunId: string): ManagedSession | undefined { return this.sessions.get(taskRunId); } diff --git a/apps/code/src/main/trpc/routers/agent.ts b/apps/code/src/main/trpc/routers/agent.ts index 98c20a8ce6..3f0f4b09c3 100644 --- a/apps/code/src/main/trpc/routers/agent.ts +++ b/apps/code/src/main/trpc/routers/agent.ts @@ -20,7 +20,9 @@ import { respondToPermissionInput, sessionResponseSchema, setConfigOptionInput, + startLongRunningTaskInput, startSessionInput, + stopLongRunningTaskInput, subscribeSessionInput, } from "../../services/agent/schemas"; import type { AgentService } from "../../services/agent/service"; @@ -57,6 +59,14 @@ export const agentRouter = router({ getService().cancelPrompt(input.sessionId, input.reason), ), + startLongRunningTask: publicProcedure + .input(startLongRunningTaskInput) + .mutation(({ input }) => getService().startLongRunningTask(input)), + + stopLongRunningTask: publicProcedure + .input(stopLongRunningTaskInput) + .mutation(({ input }) => getService().stopLongRunningTask(input.sessionId)), + reconnect: publicProcedure .input(reconnectSessionInput) .output(sessionResponseSchema.nullable()) diff --git a/apps/code/src/renderer/components/long-running-task/LongRunningTaskPill.tsx b/apps/code/src/renderer/components/long-running-task/LongRunningTaskPill.tsx new file mode 100644 index 0000000000..899a824466 --- /dev/null +++ b/apps/code/src/renderer/components/long-running-task/LongRunningTaskPill.tsx @@ -0,0 +1,64 @@ +import { StopCircleIcon } from "@phosphor-icons/react"; +import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; +import { trpcClient } from "@renderer/trpc/client"; +import { useLongRunningTaskStore } from "@stores/longRunningTaskStore"; +import { logger } from "@utils/logger"; +import { toast } from "@utils/toast"; +import { useCallback } from "react"; + +const log = logger.scope("long-running-task-pill"); + +interface LongRunningTaskPillProps { + taskRunId: string; +} + +export function LongRunningTaskPill({ taskRunId }: LongRunningTaskPillProps) { + const task = useLongRunningTaskStore((s) => s.byTaskRunId[taskRunId]); + + const stopLoop = useCallback(async () => { + try { + await trpcClient.agent.stopLongRunningTask.mutate({ + sessionId: taskRunId, + }); + toast.success("Long-running task stopped"); + } catch (err) { + log.error("Failed to stop long-running task", { err }); + toast.error("Failed to stop long-running task"); + } + }, [taskRunId]); + + if (!task?.active) return null; + + return ( + + + + + Long-running task + + + • {task.iterations}/{task.maxIterations} + + {task.goal && ( + + + • {task.goal} + + + )} + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/components/long-running-task/LongRunningTaskProposalCard.tsx b/apps/code/src/renderer/components/long-running-task/LongRunningTaskProposalCard.tsx new file mode 100644 index 0000000000..6dc5aca6a0 --- /dev/null +++ b/apps/code/src/renderer/components/long-running-task/LongRunningTaskProposalCard.tsx @@ -0,0 +1,225 @@ +import { getSessionService } from "@features/sessions/service/service"; +import { Button, Flex, Text, TextArea, TextField } from "@radix-ui/themes"; +import { trpcClient } from "@renderer/trpc/client"; +import { useLongRunningTaskStore } from "@stores/longRunningTaskStore"; +import { logger } from "@utils/logger"; +import { toast } from "@utils/toast"; +import { useCallback, useEffect, useState } from "react"; + +const log = logger.scope("long-running-task-proposal"); + +interface LongRunningTaskProposalCardProps { + taskId: string; + taskRunId: string; +} + +export function LongRunningTaskProposalCard({ + taskId, + taskRunId, +}: LongRunningTaskProposalCardProps) { + const proposal = useLongRunningTaskStore( + (s) => s.proposalsByTaskRunId[taskRunId], + ); + const clearProposal = useLongRunningTaskStore((s) => s.clearProposal); + + const [goal, setGoal] = useState(""); + const [successCriterion, setSuccessCriterion] = useState(""); + const [maxIterations, setMaxIterations] = useState(20); + const [marker, setMarker] = useState(""); + const [editing, setEditing] = useState(false); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!proposal) return; + setGoal(proposal.goal); + setSuccessCriterion(proposal.successCriterion); + setMaxIterations(proposal.maxIterations); + setMarker(proposal.marker); + setEditing(false); + }, [proposal]); + + const start = useCallback(async () => { + if (!proposal) return; + setSubmitting(true); + try { + await trpcClient.agent.startLongRunningTask.mutate({ + sessionId: taskRunId, + goal: goal.trim(), + successCriterion: successCriterion.trim(), + marker: marker.trim(), + maxIterations, + }); + clearProposal(taskRunId); + const kickoff = + "Configuration approved. Begin work toward the goal now. " + + `Run the verification command(s) for the success criterion after each meaningful change. ` + + `Output \`${marker.trim()}\` on its own line only after you have actually observed the criterion satisfied.`; + await getSessionService().sendPrompt(taskId, kickoff); + } catch (err) { + log.error("Failed to start long-running task", { err }); + toast.error("Failed to start long-running task"); + } finally { + setSubmitting(false); + } + }, [ + proposal, + taskRunId, + taskId, + goal, + successCriterion, + marker, + maxIterations, + clearProposal, + ]); + + const dismiss = useCallback(() => { + clearProposal(taskRunId); + }, [taskRunId, clearProposal]); + + if (!proposal) return null; + + const canStart = + goal.trim().length > 0 && + successCriterion.trim().length > 0 && + marker.trim().length > 0 && + maxIterations > 0; + + return ( + + + + Start long-running task? + + + Auto-continues until verification passes or {maxIterations}{" "} + iterations. + + + + {editing ? ( + + Goal +