Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
81ac2d1
feat(loops): add loops desktop and mobile ui
charlesvien Jul 8, 2026
91e6315
feat(loops): rework create flow with wizard, templates, and prompt la…
charlesvien Jul 9, 2026
866e71d
chore(loops): remove mobile UI, desktop-only PR
charlesvien Jul 14, 2026
11ccb3d
fix(loops): preserve full repository list through the edit form
charlesvien Jul 14, 2026
4dc0e06
feat(loops): widen landing, revamp prompt area, add works-with to tem…
charlesvien Jul 14, 2026
3e22d34
feat(loops): june-style landing with bottom composer and template polish
charlesvien Jul 14, 2026
02bf8fe
Update LoopsListView.tsx
charlesvien Jul 14, 2026
87b2df2
upd
charlesvien Jul 14, 2026
1fb2a1e
feat(loops): friendly schedule frequency picker, subtitle and wizard …
charlesvien Jul 14, 2026
07d8cd6
feat(loops): move model behind advanced with server-picked default
charlesvien Jul 14, 2026
e3756da
add auto-fix pull requests toggle to loop form
charlesvien Jul 14, 2026
af970f3
allow jumping to any loop wizard step
charlesvien Jul 14, 2026
6d27cdd
feat(loops): surface safety-limit errors distinctly to the user
charlesvien Jul 14, 2026
e54eeac
feat(loops): expose internal and origin_product on the loop type
charlesvien Jul 14, 2026
fba6f19
Update LoopsItem.tsx
charlesvien Jul 14, 2026
c5ec477
feat(loops): add operations template category and sidebar alpha badge
charlesvien Jul 14, 2026
dc5217f
Update LoopsListView.tsx
charlesvien Jul 15, 2026
e04b456
feat(loops): attach a loop to a context in the create form
charlesvien Jul 15, 2026
dfae352
feat(oauth): request explicit scopes instead of * on sign-in
charlesvien Jul 15, 2026
752fd18
feat(loops): add a Loops tab to each context
charlesvien Jul 15, 2026
3e4dc4a
fix(loops): hug the sidebar Alpha badge to the label
charlesvien Jul 15, 2026
39ae0c3
sync loop api types with backend changes
charlesvien Jul 15, 2026
10ae652
feat(loops): revamp the empty state with a getting-started guide
charlesvien Jul 15, 2026
6c96e6a
feat(loops): rename header button to Create manually
charlesvien Jul 15, 2026
8dd09eb
updates
charlesvien Jul 15, 2026
887803b
updates
charlesvien Jul 15, 2026
3048899
Update useLoopBuilderTask.ts
charlesvien Jul 15, 2026
d8342b2
Update loopBuilderPrompt.ts
charlesvien Jul 15, 2026
e64a411
Update LoopsListView.tsx
charlesvien Jul 15, 2026
49d4779
render channel header on context Loops tab
charlesvien Jul 16, 2026
0004a10
load mcp ui app cards on cloud task runs
charlesvien Jul 16, 2026
9b4d588
add description field to loop create form
charlesvien Jul 16, 2026
27280c4
make loop run history rows open the run detail
charlesvien Jul 16, 2026
446d4da
add finish tool for model-driven run shutdown
charlesvien Jul 16, 2026
71d20d7
refine loop run history cards and model row
charlesvien Jul 16, 2026
d36f27e
start loop builder in accept-edits mode
charlesvien Jul 16, 2026
624f7d1
mock new mcpAppsService methods in agent tests
charlesvien Jul 16, 2026
959362d
make loop builder present the review card
charlesvien Jul 17, 2026
c7a486a
resolve github integration ids in loop builder
charlesvien Jul 17, 2026
059ebf3
make loop empty run history a dashed box
charlesvien Jul 17, 2026
e37d285
ui/fixes
charlesvien Jul 17, 2026
35667ed
match context loops tab layout to main page
charlesvien Jul 17, 2026
42b2c4d
dedupe concurrent mcp config resolution
charlesvien Jul 17, 2026
153349e
preserve custom cron schedules in trigger editor
charlesvien Jul 17, 2026
90617da
add detail parsing to loops api error
charlesvien Jul 17, 2026
dcb9bfb
send real app version from loops client
charlesvien Jul 17, 2026
c2d87e4
dedupe loops ui helpers and fallbacks
charlesvien Jul 17, 2026
8fecf1e
remove unused SectionCard
charlesvien Jul 17, 2026
812fd7e
require team visibility for context loops
charlesvien Jul 17, 2026
8997600
guard ime composition in loop composer
charlesvien Jul 17, 2026
1373b14
make mcp config registration private
charlesvien Jul 17, 2026
972fd97
add loop form validation tests
charlesvien Jul 17, 2026
e955834
add request finish tests for claude agent
charlesvien Jul 17, 2026
881d373
prefill template description into loop wizard
charlesvien Jul 17, 2026
944c3e3
move behavior toggle into advanced fold
charlesvien Jul 17, 2026
b92713d
de-emphasize create manually button
charlesvien Jul 17, 2026
2095312
run loop builder repo-less
charlesvien Jul 17, 2026
90d7b73
show visibility badge on loop rows
charlesvien Jul 17, 2026
921592e
gate loops behind posthog-desktop-loops flag
charlesvien Jul 17, 2026
c840d74
gate loop creation at the project cap
charlesvien Jul 17, 2026
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
121 changes: 121 additions & 0 deletions packages/agent/src/adapters/claude/claude-agent.finish.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PostHogAPIConfig } from "../../types";

vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: vi.fn(),
}));

const mocks = vi.hoisted(() => ({
updateTaskRun: vi.fn(),
constructedConfigs: [] as unknown[],
}));

vi.mock("../../posthog-api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../posthog-api")>();
class FakePostHogAPIClient {
updateTaskRun = mocks.updateTaskRun;
constructor(config: unknown) {
mocks.constructedConfigs.push(config);
}
}
return { ...actual, PostHogAPIClient: FakePostHogAPIClient };
});

const { ClaudeAcpAgent } = await import("./claude-agent");

const API_CONFIG: PostHogAPIConfig = {
apiUrl: "https://us.posthog.com",
getApiKey: () => "key",
projectId: 1,
};

type RequestFinish = (
status: "completed" | "failed",
message?: string,
) => Promise<void>;

function buildRequestFinish(
posthogApiConfig: PostHogAPIConfig | undefined,
taskId: string | undefined,
taskRunId: string | undefined,
): RequestFinish | undefined {
const client = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentSideConnection;
const agent = new ClaudeAcpAgent(client, { posthogApiConfig });
return (
agent as unknown as {
buildRequestFinish(
taskId: string | undefined,
taskRunId: string | undefined,
): RequestFinish | undefined;
}
).buildRequestFinish(taskId, taskRunId);
}

describe("ClaudeAcpAgent.buildRequestFinish", () => {
beforeEach(() => {
mocks.updateTaskRun.mockReset().mockResolvedValue({});
mocks.constructedConfigs.length = 0;
});

it.each([
{
name: "no posthogApiConfig",
config: undefined,
taskId: "task-1",
taskRunId: "run-1",
},
{
name: "no taskId",
config: API_CONFIG,
taskId: undefined,
taskRunId: "run-1",
},
{
name: "no taskRunId",
config: API_CONFIG,
taskId: "task-1",
taskRunId: undefined,
},
])("is unavailable with $name", ({ config, taskId, taskRunId }) => {
expect(buildRequestFinish(config, taskId, taskRunId)).toBeUndefined();
});

it("marks the run completed without an error_message", async () => {
const requestFinish = buildRequestFinish(API_CONFIG, "task-1", "run-1");
await requestFinish?.("completed");

expect(mocks.constructedConfigs).toEqual([API_CONFIG]);
expect(mocks.updateTaskRun).toHaveBeenCalledWith("task-1", "run-1", {
status: "completed",
});
});

it("marks the run failed with the message as error_message", async () => {
const requestFinish = buildRequestFinish(API_CONFIG, "task-1", "run-1");
await requestFinish?.("failed", "blocked on missing credentials");

expect(mocks.updateTaskRun).toHaveBeenCalledWith("task-1", "run-1", {
status: "failed",
error_message: "blocked on missing credentials",
});
});

it("omits error_message on a failed finish without a message", async () => {
const requestFinish = buildRequestFinish(API_CONFIG, "task-1", "run-1");
await requestFinish?.("failed");

expect(mocks.updateTaskRun).toHaveBeenCalledWith("task-1", "run-1", {
status: "failed",
});
});

it("rethrows when the API update fails", async () => {
mocks.updateTaskRun.mockRejectedValue(new Error("api down"));
const requestFinish = buildRequestFinish(API_CONFIG, "task-1", "run-1");

await expect(requestFinish?.("completed")).rejects.toThrow("api down");
});
});
35 changes: 33 additions & 2 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
type Enrichment,
type FileEnrichmentDeps,
} from "../../enrichment/file-enricher";
import { PostHogAPIClient } from "../../posthog-api";
import {
classifyPostHogExecCall,
isUnclassifiedPostHogSubTool,
Expand All @@ -74,7 +75,7 @@ import { resolveGithubToken } from "../../utils/github-token";
import { Logger } from "../../utils/logger";
import { Pushable } from "../../utils/streams";
import { BaseAcpAgent } from "../base-acp-agent";
import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
import { LOCAL_TOOLS_MCP_NAME, type LocalToolCtx } from "../local-tools";
import { resolveSpokenNarration, resolveTaskId } from "../session-meta";
import {
buildBreakdown,
Expand Down Expand Up @@ -1834,6 +1835,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
}
}

// Backs the `finish` local tool: marks the task run terminal so the Temporal
// workflow tears the sandbox down. Only wired when we have both the run
// identifiers and a PostHog API config, i.e. a real cloud run.
private buildRequestFinish(
taskId: string | undefined,
taskRunId: string | undefined,
): LocalToolCtx["requestFinish"] {
const config = this.options?.posthogApiConfig;
if (!config || !taskId || !taskRunId) {
return undefined;
}
return async (status, message) => {
try {
await new PostHogAPIClient(config).updateTaskRun(taskId, taskRunId, {
status,
...(status === "failed" && message ? { error_message: message } : {}),
});
} catch (error) {
this.logger.error("finish tool failed to mark run terminal", error);
throw error;
}
};
}

private async createSession(
params: {
cwd: string;
Expand Down Expand Up @@ -1892,6 +1917,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
const baseBranch = meta?.baseBranch;
const environment = meta?.environment;
const spokenNarration = resolveSpokenNarration(meta);
const requestFinish = this.buildRequestFinish(taskId, meta?.taskRunId);
const buildInProcessMcpServers = (): Record<
string,
McpSdkServerConfigWithInstance
Expand All @@ -1903,8 +1929,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
taskId,
taskRunId: meta?.taskRunId,
baseBranch,
requestFinish,
},
{
environment,
spokenNarration,
background: meta?.mode === "background",
},
{ environment, spokenNarration },
);
return server ? { [LOCAL_TOOLS_MCP_NAME]: server } : {};
};
Expand Down
7 changes: 7 additions & 0 deletions packages/agent/src/adapters/claude/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type { PostHogProductId } from "../../posthog-products";
import type { AgentMode } from "../../types";
import type { Pushable } from "../../utils/streams";
import type { BaseSession } from "../base-acp-agent";
import type { ContextBreakdownBaseline } from "./context-breakdown";
Expand Down Expand Up @@ -177,6 +178,12 @@ export type NewSessionMeta = {
taskRunId?: string;
taskId?: string;
environment?: "local" | "cloud";
/**
* Run mode. "background" means unattended (loops, durable ingest) — no human
* drives the turns, so the agent may end its own run via the `finish` tool.
* "interactive" runs are driven turn-by-turn and are ended by the human.
*/
mode?: AgentMode;
disableBuiltInTools?: boolean;
systemPrompt?: unknown;
sessionId?: string;
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/adapters/local-tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry";
import { cloneRepoTool } from "./tools/clone-repo";
import { finishTool } from "./tools/finish";
import { listReposTool } from "./tools/list-repos";
import { signedCommitTool } from "./tools/signed-commit";
import { signedMergeTool } from "./tools/signed-merge";
Expand All @@ -23,6 +24,7 @@ export const LOCAL_TOOLS: LocalTool[] = [
listReposTool,
cloneRepoTool,
speakTool,
finishTool,
];

/** Tools whose gate passes for the given context — the set to actually expose. */
Expand Down
16 changes: 16 additions & 0 deletions packages/agent/src/adapters/local-tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ export interface LocalToolCtx {
* back to origin/HEAD detection when unset.
*/
baseBranch?: string;
/**
* Marks this run terminal and lets the workflow tear the sandbox down. Set by
* the adapter for cloud runs that own a sandbox; absent for local sessions.
* Powers the `finish` tool so an unattended run ends the moment the agent
* decides it's done, instead of idling until a timeout.
*/
requestFinish?: (
status: "completed" | "failed",
message?: string,
) => Promise<void>;
}

/** Minimal session-meta shape needed to gate tools (e.g. cloud-only). */
Expand All @@ -30,6 +40,12 @@ export interface LocalToolGateMeta {
channelMode?: boolean;
/** Spoken narration is on for this session: enables the speak tool. */
spokenNarration?: boolean;
/**
* Unattended run (no human driving turns): enables the `finish` tool so the
* agent can end its own run. False/undefined for interactive runs, which a
* human ends.
*/
background?: boolean;
}

/**
Expand Down
85 changes: 85 additions & 0 deletions packages/agent/src/adapters/local-tools/tools/finish.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
import { enabledLocalTools } from "../index";
import type { LocalToolCtx, LocalToolGateMeta } from "../registry";
import { FINISH_TOOL_NAME, finishSchema, finishTool } from "./finish";

describe("finish tool", () => {
const requestFinish = async (): Promise<void> => {};

it.each([
{
name: "background cloud run with requestFinish",
ctx: { cwd: "/repo", requestFinish },
meta: { environment: "cloud", background: true },
expected: true,
},
{
name: "background cloud run without requestFinish",
ctx: { cwd: "/repo" },
meta: { environment: "cloud", background: true },
expected: false,
},
{
name: "interactive cloud run (background unset)",
ctx: { cwd: "/repo", requestFinish },
meta: { environment: "cloud" },
expected: false,
},
{
name: "interactive cloud run (background false)",
ctx: { cwd: "/repo", requestFinish },
meta: { environment: "cloud", background: false },
expected: false,
},
{
name: "background local run",
ctx: { cwd: "/repo", requestFinish },
meta: { environment: "local", background: true },
expected: false,
},
{
name: "no gate meta",
ctx: { cwd: "/repo", requestFinish },
meta: undefined,
expected: false,
},
] as {
name: string;
ctx: LocalToolCtx;
meta: LocalToolGateMeta | undefined;
expected: boolean;
}[])("is exposed only for $name → $expected", ({ ctx, meta, expected }) => {
const tools = enabledLocalTools(ctx, meta);
expect(tools.some((t) => t.name === FINISH_TOOL_NAME)).toBe(expected);
});

it("stays visible without ToolSearch (alwaysLoad)", () => {
expect(finishTool.alwaysLoad).toBe(true);
});

it("defaults status to completed", () => {
expect(finishSchema.status.parse(undefined)).toBe("completed");
});

it("rejects an unknown status", () => {
expect(finishSchema.status.safeParse("aborted").success).toBe(false);
});

it("marks the run terminal via requestFinish", async () => {
const spy = vi.fn(async () => {});
const result = await finishTool.handler(
{ cwd: "/repo", requestFinish: spy },
{ status: "failed", reason: "ran out of quota" },
);
expect(spy).toHaveBeenCalledWith("failed", "ran out of quota");
expect(result.isError).toBeUndefined();
});

it("errors when requestFinish is unavailable", async () => {
const result = await finishTool.handler(
{ cwd: "/repo" },
{ status: "completed" },
);
expect(result.isError).toBe(true);
});
});
Loading
Loading