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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
AGENT_BUILDER_CLIENT_TOOLS,
useAgentBuilderClientTools,
} from "./useAgentBuilderClientTools";
import { useFollowSpecEdits } from "./useFollowSpecEdits";

const CHAT_ID = AGENT_BUILDER_CHAT_ID;

Expand Down Expand Up @@ -109,6 +110,9 @@ export function AgentBuilderDock() {
],
);

// Refresh the viewed revision when the builder edits a spec (follow mode).
useFollowSpecEdits();

const clientTools = useAgentBuilderClientTools();
const chat = useAgentChat({
chatId: CHAT_ID,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { AcpMessage } from "@posthog/shared";
import { describe, expect, it } from "vitest";
import {
sessionUpdateMessage,
toolCallStartUpdate,
toolResultUpdate,
} from "../chat/acpEnvelope";
import { createRevisionEditDetector } from "./useFollowSpecEdits";

// Wire format the runner reports: "<serverId>__<tool>".
const SPEC_UPDATE = "posthog__agent-applications-revisions-spec-update";
const AGENT_MD = "posthog__agent-applications-revisions-agent-md-update";
const PROMOTE = "posthog__agent-applications-revisions-promote-create";
const VALIDATE = "posthog__agent-applications-revisions-validate-create"; // read-ish
const RETRIEVE = "posthog__agent-applications-revisions-retrieve"; // read

function start(id: string, name: string): AcpMessage {
return sessionUpdateMessage(
toolCallStartUpdate(id, name, {}, "in_progress"),
0,
);
}
function done(id: string): AcpMessage {
return sessionUpdateMessage(toolResultUpdate(id, "ok", false), 0);
}

describe("createRevisionEditDetector", () => {
it("fires once when a revision-writing call completes after the baseline", () => {
const d = createRevisionEditDetector();
expect(d.scan([])).toBe(0); // baseline
expect(d.scan([start("c1", SPEC_UPDATE)])).toBe(0); // in-flight, no result yet
expect(d.scan([start("c1", SPEC_UPDATE), done("c1")])).toBe(1); // completed
// Idempotent: rescanning the same transcript doesn't re-fire.
expect(d.scan([start("c1", SPEC_UPDATE), done("c1")])).toBe(0);
});

it("reacts to the whole write family, not just spec-update", () => {
const d = createRevisionEditDetector();
d.scan([]);
expect(d.scan([start("c1", AGENT_MD), done("c1")])).toBe(1);
expect(
d.scan([
start("c1", AGENT_MD),
done("c1"),
start("c2", PROMOTE),
done("c2"),
]),
).toBe(1);
});

it("ignores reads and validate (non-mutating tools)", () => {
const d = createRevisionEditDetector();
d.scan([]);
expect(d.scan([start("c1", RETRIEVE), done("c1")])).toBe(0);
expect(d.scan([start("c2", VALIDATE), done("c2")])).toBe(0);
expect(d.scan([start("c3", "posthog__insight-query"), done("c3")])).toBe(0);
Comment on lines +55 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Exclusion assertions pass vacuously

After d.scan([start("c1", RETRIEVE), done("c1")]) the detector's processed is 2. The second scan receives [start("c2", VALIDATE), done("c2")] — also 2 messages — so the loop runs from i = 2 to messages.length (2): an empty range. It returns 0 because nothing was processed, not because VALIDATE was excluded. The same applies to the third assertion. Adding VALIDATE or "posthog__insight-query" to REVISION_WRITE_TOOLS would not break either assertion, so this gives no regression protection. Each excluded-tool check needs its own fresh detector (or a growing accumulation array) to be meaningful.

});
Comment on lines +37 to +57

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests not parameterised; write-family coverage limited to 2 of 13 tools

The project prefers it.each for tests that share the same logic across multiple inputs. Both the write-family check and the exclusion check are natural candidates: the write family has 13 entries but only AGENT_MD and PROMOTE are exercised, while the exclusion test covers only RETRIEVE, VALIDATE, and one ad-hoc name. A typo in the remaining 10 allowlist entries would go unnoticed. Using it.each would also fix the vacuous-assertion problem in the exclusion test (each case gets a fresh detector).

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


it("ignores calls already present at the baseline (resumed history)", () => {
const d = createRevisionEditDetector();
// Both the start and its completion are backlog → never fires.
d.scan([start("c1", SPEC_UPDATE), done("c1")]);
expect(d.scan([start("c1", SPEC_UPDATE), done("c1")])).toBe(0);
});

it("resets when the transcript shrinks (new chat)", () => {
const d = createRevisionEditDetector();
d.scan([]);
expect(d.scan([start("c1", SPEC_UPDATE), done("c1")])).toBe(1); // c1 now acted
d.scan([]); // newChat clears the transcript → state resets
expect(d.scan([start("c1", SPEC_UPDATE), done("c1")])).toBe(1); // refires
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { agentChatStore } from "@posthog/core/agent-chat/agentChatStore";
import type { AcpMessage } from "@posthog/shared";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import {
AGENT_BUILDER_CHAT_ID,
useAgentBuilderStore,
} from "./agentBuilderStore";

/**
* Agent-builder posthog-MCP tools that mutate a revision (or the app metadata)
* the configuration view renders — drawn from the write tools in the
* agent-builder spec's `mcps[].tools` allowlist. Reads (`-retrieve`/`-list`),
* `validate`, and session/skill tools are intentionally excluded. The runner
* reports `<serverId>__<tool>` (e.g. `posthog__…`, or `mcp__posthog__…`), so we
* compare against the segment after the last `__`.
*/
const REVISION_WRITE_TOOLS = new Set([
"agent-applications-partial-update",
"agent-applications-revisions-create",
"agent-applications-revisions-new-draft-create",
"agent-applications-revisions-clone-from-create",
"agent-applications-revisions-partial-update",
"agent-applications-revisions-agent-md-update",
"agent-applications-revisions-skill-refs-set",
"agent-applications-revisions-tools-update",
"agent-applications-revisions-tools-destroy",
"agent-applications-revisions-spec-update",
"agent-applications-revisions-freeze-create",
"agent-applications-revisions-promote-create",
"agent-applications-revisions-archive-create",
]);

/** True when a tool-call title names a revision/app-mutating posthog MCP tool. */
function isRevisionWrite(title: string | undefined): boolean {
if (!title) return false;
return REVISION_WRITE_TOOLS.has(title.split("__").pop() ?? "");
}

/**
* Revision-view query prefixes to refresh after a revision edit. Prefix-matched
* by TanStack, so they cover every project/agent/revision without needing ids —
* the same caches `useApplyAgentSpec` invalidates on a manual edit.
*/
const REVISION_PREFIXES = [
["agent-applications", "detail"],
["agent-applications", "revisions"],
["agent-applications", "revision"],
["agent-applications", "bundle"],
] as const;

interface ToolCallUpdate {
sessionUpdate?: string;
toolCallId?: string;
title?: string;
status?: string;
}

/** The `SessionUpdate` from a `session/update` ACP notification, else null. */
function toolUpdate(m: AcpMessage): ToolCallUpdate | null {
const msg = m.message;
if (!("method" in msg) || msg.method !== "session/update") return null;
return (
(msg as { params?: { update?: ToolCallUpdate } }).params?.update ?? null
);
}

/**
* Stateful scanner over a growing agent-builder transcript that counts
* revision-writing MCP calls that have just *completed*. Correlates
* start→completion by `toolCallId` (the completion update drops the tool name),
* fires once per call, and treats whatever is present at the first scan as
* backlog (resumed history) so a remount never replays past edits. A shrinking
* transcript (newChat) resets the state.
*/
export function createRevisionEditDetector(): {
scan: (messages: AcpMessage[]) => number;
} {
const writeCalls = new Set<string>();
const acted = new Set<string>();
let processed = -1; // -1 until the first scan establishes the backlog baseline.

return {
scan(messages: AcpMessage[]): number {
if (processed < 0) {
processed = messages.length;
return 0;
}
if (messages.length < processed) {
writeCalls.clear();
acted.clear();
processed = 0;
}
let completed = 0;
for (let i = processed; i < messages.length; i++) {
const u = toolUpdate(messages[i]);
if (!u?.toolCallId) continue;
if (u.sessionUpdate === "tool_call" && isRevisionWrite(u.title)) {
writeCalls.add(u.toolCallId);
} else if (
u.sessionUpdate === "tool_call_update" &&
u.status === "completed" &&
writeCalls.has(u.toolCallId) &&
!acted.has(u.toolCallId)
) {
acted.add(u.toolCallId);
completed += 1;
}
}
processed = messages.length;
return completed;
},
};
}

/**
* While the agent builder dock is open, watch its live stream for a completed
* revision-writing MCP call (spec/agent-md/tools/skill edits, draft/clone,
* freeze/promote/archive) and, when follow mode is on, invalidate the
* revision-view queries so the currently-viewed revision refetches the edit the
* builder just made. Revision queries are 30s-stale with no polling, so without
* this the view shows the agent's change only after a manual refresh.
*
* Follow mode is read at fire time so toggling it never resubscribes — a call
* that completes while paused is consumed by the detector, not retroactively
* applied if follow mode is later turned on.
*/
export function useFollowSpecEdits(): void {
const queryClient = useQueryClient();

useEffect(() => {
const detector = createRevisionEditDetector();
// Seed the backlog baseline from the current transcript (resumed history).
detector.scan(
agentChatStore.getState().chats[AGENT_BUILDER_CHAT_ID]?.messages ?? [],
);

return agentChatStore.subscribe((s) => {
const messages = s.chats[AGENT_BUILDER_CHAT_ID]?.messages ?? [];
if (detector.scan(messages) === 0) return;
if (!useAgentBuilderStore.getState().followMode) return;
for (const queryKey of REVISION_PREFIXES) {
void queryClient.invalidateQueries({ queryKey });
}
});
}, [queryClient]);
}
Loading