Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/code/src/main/services/agent/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof stopLongRunningTaskInput>;

// Reconnect session input
export const reconnectSessionInput = z.object({
taskId: z.string(),
Expand Down
36 changes: 36 additions & 0 deletions apps/code/src/main/services/agent/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> {
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<void> {
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);
}
Expand Down
10 changes: 10 additions & 0 deletions apps/code/src/main/trpc/routers/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import {
respondToPermissionInput,
sessionResponseSchema,
setConfigOptionInput,
startLongRunningTaskInput,
startSessionInput,
stopLongRunningTaskInput,
subscribeSessionInput,
} from "../../services/agent/schemas";
import type { AgentService } from "../../services/agent/service";
Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Box className="rounded-md border border-iris-6 bg-iris-2 px-3 py-2">
<Flex align="center" gap="3" justify="between">
<Flex align="center" gap="2" className="min-w-0">
<Text size="1" weight="medium" className="shrink-0 text-iris-11">
Long-running task
</Text>
<Text size="1" className="shrink-0 text-iris-11">
• {task.iterations}/{task.maxIterations}
</Text>
{task.goal && (
<Tooltip content={task.goal}>
<Text size="1" truncate className="min-w-0 text-iris-12">
• {task.goal}
</Text>
</Tooltip>
)}
</Flex>
<Tooltip content="Stop the auto-continuation loop. Current turn finishes normally.">
<IconButton
size="1"
variant="ghost"
color="iris"
onClick={stopLoop}
aria-label="Stop long-running task"
>
<StopCircleIcon size={16} />
</IconButton>
</Tooltip>
</Flex>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -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("<TASK_COMPLETE>");
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 (
<Flex
direction="column"
gap="3"
className="rounded-md border-2 border-iris-6 bg-iris-2 p-4"
>
<Flex align="center" justify="between">
<Text size="2" weight="medium" className="text-iris-12">
Start long-running task?
</Text>
<Text size="1" className="text-iris-11">
Auto-continues until verification passes or {maxIterations}{" "}
iterations.
</Text>
</Flex>

{editing ? (
<Flex direction="column" gap="2">
<FieldLabel>Goal</FieldLabel>
<TextArea
value={goal}
onChange={(e) => setGoal(e.target.value)}
rows={2}
/>
<FieldLabel>Success criterion (objectively measurable)</FieldLabel>
<TextArea
value={successCriterion}
onChange={(e) => setSuccessCriterion(e.target.value)}
rows={2}
/>
<Flex gap="3">
<Flex direction="column" gap="1" className="flex-1">
<FieldLabel>Completion marker</FieldLabel>
<TextField.Root
value={marker}
onChange={(e) => setMarker(e.target.value)}
/>
</Flex>
<Flex direction="column" gap="1" style={{ width: 120 }}>
<FieldLabel>Max iterations</FieldLabel>
<TextField.Root
type="number"
min={1}
max={200}
value={maxIterations}
onChange={(e) =>
setMaxIterations(
Math.max(1, Math.min(200, Number(e.target.value) || 0)),
)
}
/>
</Flex>
</Flex>
</Flex>
) : (
<Flex direction="column" gap="2">
<FieldRow label="Goal" value={goal} />
<FieldRow label="Success criterion" value={successCriterion} />
{proposal.approach && (
<FieldRow label="Approach" value={proposal.approach} />
)}
<FieldRow label="Marker" value={marker} mono />
<FieldRow label="Max iterations" value={String(maxIterations)} mono />
</Flex>
)}

<Flex gap="2" justify="end">
<Button
size="2"
variant="soft"
color="gray"
onClick={dismiss}
disabled={submitting}
>
Cancel
</Button>
{editing ? (
<Button
size="2"
variant="soft"
onClick={() => setEditing(false)}
disabled={submitting}
>
Done editing
</Button>
) : (
<Button
size="2"
variant="soft"
onClick={() => setEditing(true)}
disabled={submitting}
>
Edit
</Button>
)}
<Button
size="2"
onClick={start}
disabled={submitting || !canStart}
loading={submitting}
>
Start
</Button>
</Flex>
</Flex>
);
}

function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<Text size="1" className="text-iris-11">
{children}
</Text>
);
}

function FieldRow({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<Flex direction="column" gap="1">
<Text size="1" className="text-iris-11">
{label}
</Text>
<Text
size="2"
className={mono ? "font-mono text-iris-12" : "text-iris-12"}
>
{value}
</Text>
</Flex>
);
}
Loading
Loading