Skip to content
Open
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
18 changes: 18 additions & 0 deletions packages/agent/src/acp-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export const POSTHOG_NOTIFICATIONS = {
/** PostHog products used during a turn (derived from MCP exec calls) */
RESOURCES_USED: "_posthog/resources_used",

/** A workflow build linked a PostHog workflow to its canvas (derived from the
* workflows-create result + the canvas publish call). The host writes the
* link onto the dashboard row (the agent can't persist it itself). */
WORKFLOW_BUILT: "_posthog/workflow_built",

/** Response to a relayed permission request (plan approval, question) */
PERMISSION_RESPONSE: "_posthog/permission_response",

Expand All @@ -93,6 +98,19 @@ export const POSTHOG_NOTIFICATIONS = {
MCP_RESPONSE: "_posthog/mcp_response",
} as const;

/**
* Payload of a `_posthog/workflow_built` notification: the PostHog workflow a
* build attached to its canvas. `dashboardId` is the canvas the workflow
* tracks; the host writes the rest onto that row's meta.
*/
export interface WorkflowBuiltPayload {
dashboardId: string;
workflowId: string;
workflowStatus?: string;
workflowName?: string;
workflowType?: string;
}

export type NativeGoalState = {
objective: string;
status:
Expand Down
16 changes: 15 additions & 1 deletion packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ import {
type TaskState,
taskStateToPlanEntries,
} from "./conversion/task-state";
import type { EnrichedReadCache } from "./hooks";
import type { EnrichedReadCache, WorkflowBuiltSignal } from "./hooks";
import { createLocalToolsMcpServer } from "./mcp/local-tools";
import {
clearMcpToolMetadataCache,
Expand Down Expand Up @@ -1975,6 +1975,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
settingsManager,
onModeChange: this.createOnModeChange(),
onPostHogResourceUsed: this.createOnPostHogResourceUsed(),
onWorkflowBuilt: this.createOnWorkflowBuilt(),
onProcessSpawned: this.options?.onProcessSpawned,
onProcessExited: this.options?.onProcessExited,
effort,
Expand Down Expand Up @@ -2277,6 +2278,19 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
};
}

/** Emits the workflow link (workflow ↔ canvas) the moment a build forms it,
* so the host can persist it onto the dashboard row (the agent has no MCP
* tool to write it itself). Deterministic - derived from the build's tool
* calls, no model cooperation required. */
private createOnWorkflowBuilt() {
return (signal: WorkflowBuiltSignal) => {
void this.client.extNotification(POSTHOG_NOTIFICATIONS.WORKFLOW_BUILT, {
sessionId: this.sessionId,
...signal,
});
};
}

/** Adds products to the session-wide set and emits any newly-seen ones.
* Session-wide dedup: only the first use of a product emits, so the client's
* persistent list shows each chip once across all turns. */
Expand Down
297 changes: 297 additions & 0 deletions packages/agent/src/adapters/claude/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ vi.mock("../../enrichment/file-enricher", () => ({
import { Logger } from "../../utils/logger";
import type { TaskState } from "./conversion/task-state";
import {
createPostToolUseHook,
createPreToolUseHook,
createReadEnrichmentHook,
createReadImageGuardHook,
createSignedCommitGuardHook,
createTaskHook,
type EnrichedReadCache,
type WorkflowBuiltSignal,
} from "./hooks";
import type {
PermissionCheckResult,
Expand Down Expand Up @@ -694,3 +696,298 @@ describe("createTaskHook", () => {
expect(state.get("t1")?.subject).toBe("Fix bug");
});
});

describe("createPostToolUseHook workflow link", () => {
function execInput(command: string, tool_response?: unknown): HookInput {
return {
hook_event_name: "PostToolUse",
tool_name: "mcp__posthog__exec",
tool_input: { command },
tool_response,
} as unknown as HookInput;
}

test("pairs a workflows-create result with the canvas publish it precedes", async () => {
const signals: WorkflowBuiltSignal[] = [];
const hook = createPostToolUseHook({
onWorkflowBuilt: (s) => signals.push(s),
});

await hook(
execInput(
"call --json workflows-create {}",
JSON.stringify({
id: "wf-42",
status: "draft",
name: "Welcome sequence",
}),
),
"tu1",
{ signal: new AbortController().signal },
);
await hook(
execInput(
'call --json desktop-file-system-canvas-partial-update {"id":"dash-7","code":"export default () => null;"}',
),
"tu2",
{ signal: new AbortController().signal },
);

expect(signals).toEqual([
{
dashboardId: "dash-7",
workflowId: "wf-42",
workflowStatus: "draft",
workflowName: "Welcome sequence",
},
]);
});

test("links an existing workflow fetched with workflows-get", async () => {
const signals: WorkflowBuiltSignal[] = [];
const hook = createPostToolUseHook({
onWorkflowBuilt: (s) => signals.push(s),
});

// Attach-existing flow: no workflows-create, just a workflows-get on the
// target, then the canvas publish.
await hook(
execInput(
"call --json workflows-get {}",
JSON.stringify({
id: "wf-existing",
status: "active",
name: "Welcome sequence",
}),
),
"tu1",
{ signal: new AbortController().signal },
);
await hook(
execInput(
'call --json desktop-file-system-canvas-partial-update {"id":"dash-9","code":"x"}',
),
"tu2",
{ signal: new AbortController().signal },
);

expect(signals).toEqual([
{
dashboardId: "dash-9",
workflowId: "wf-existing",
workflowStatus: "active",
workflowName: "Welcome sequence",
},
]);
});

test("does not fire on a canvas publish with no workflow created first", async () => {
const signals: WorkflowBuiltSignal[] = [];
const hook = createPostToolUseHook({
onWorkflowBuilt: (s) => signals.push(s),
});

await hook(
execInput(
'call --json desktop-file-system-canvas-partial-update {"id":"dash-7","code":"x"}',
),
"tu1",
{ signal: new AbortController().signal },
);

expect(signals).toEqual([]);
});
});

describe("createPostToolUseHook workflow link - YAML MCP results", () => {
// The PostHog MCP renders tool results as YAML documents, not JSON — this is
// the real shape a workflows-create returns (captured from a live session).
const YAML_WORKFLOW_RESULT = [
"id: 019f6f90-0d70-0000-5171-4622c6016e1e",
"name: Welcome email after signup",
'description: "Sends a welcome email to every new user immediately after they sign up."',
"version: 1",
"status: draft",
'created_at: "2026-07-17T10:12:19.441125Z"',
"created_by:",
" id: 546751",
" first_name: Harley",
"trigger:",
" type: event",
" filters:",
" source: events",
" events[1]{id,name,type,order}:",
" user signed up,user signed up,events,0",
].join("\n");

test("pairs a YAML workflows-create result with the canvas publish", async () => {
const signals: WorkflowBuiltSignal[] = [];
const hook = createPostToolUseHook({
onWorkflowBuilt: (s) => signals.push(s),
});

await hook(
{
hook_event_name: "PostToolUse",
tool_name: "mcp__posthog__exec",
tool_input: { command: "call --json workflows-create {}" },
tool_response: {
content: [{ type: "text", text: YAML_WORKFLOW_RESULT }],
},
} as unknown as HookInput,
"tu1",
{ signal: new AbortController().signal },
);
await hook(
{
hook_event_name: "PostToolUse",
tool_name: "mcp__posthog__exec",
tool_input: {
command:
'call --json desktop-file-system-canvas-partial-update {"id":"dash-1","code":"x"}',
},
} as unknown as HookInput,
"tu2",
{ signal: new AbortController().signal },
);

expect(signals).toEqual([
{
dashboardId: "dash-1",
workflowId: "019f6f90-0d70-0000-5171-4622c6016e1e",
workflowStatus: "draft",
workflowName: "Welcome email after signup",
},
]);
});

test("does not read a nested block's id as the workflow id", async () => {
const signals: WorkflowBuiltSignal[] = [];
const hook = createPostToolUseHook({
onWorkflowBuilt: (s) => signals.push(s),
});

// No top-level id line at all — only the nested created_by.id.
const nestedOnly = ["name: X", "created_by:", " id: 546751"].join("\n");
await hook(
{
hook_event_name: "PostToolUse",
tool_name: "mcp__posthog__exec",
tool_input: { command: "call --json workflows-create {}" },
tool_response: { content: [{ type: "text", text: nestedOnly }] },
} as unknown as HookInput,
"tu1",
{ signal: new AbortController().signal },
);
await hook(
{
hook_event_name: "PostToolUse",
tool_name: "mcp__posthog__exec",
tool_input: {
command:
'call --json desktop-file-system-canvas-partial-update {"id":"dash-1","code":"x"}',
},
} as unknown as HookInput,
"tu2",
{ signal: new AbortController().signal },
);

expect(signals).toEqual([]);
});
});

describe("createPostToolUseHook workflow link - slot consumption", () => {
function execInput(command: string, tool_response?: unknown): HookInput {
return {
hook_event_name: "PostToolUse",
tool_name: "mcp__posthog__exec",
tool_input: { command },
tool_response,
} as unknown as HookInput;
}
const opts = { signal: new AbortController().signal };

test("a second publish without a fresh lookup does not re-emit the link", async () => {
const signals: WorkflowBuiltSignal[] = [];
const hook = createPostToolUseHook({
onWorkflowBuilt: (s) => signals.push(s),
});

await hook(
execInput(
"call --json workflows-create {}",
JSON.stringify({ id: "wf-1", status: "draft", name: "Alert" }),
),
"tu1",
opts,
);
await hook(
execInput(
'call --json desktop-file-system-canvas-partial-update {"id":"dash-1","code":"a"}',
),
"tu2",
opts,
);
// An edit-turn republish of the same (or another) canvas: no new
// workflows-create/get happened, so no link may be emitted.
await hook(
execInput(
'call --json desktop-file-system-canvas-partial-update {"id":"dash-2","code":"b"}',
),
"tu3",
opts,
);

expect(signals).toEqual([
{
dashboardId: "dash-1",
workflowId: "wf-1",
workflowStatus: "draft",
workflowName: "Alert",
},
]);
});

test("a fresh workflows-get re-arms the pairing after a publish", async () => {
const signals: WorkflowBuiltSignal[] = [];
const hook = createPostToolUseHook({
onWorkflowBuilt: (s) => signals.push(s),
});

await hook(
execInput(
"call --json workflows-create {}",
JSON.stringify({ id: "wf-1", status: "draft", name: "First" }),
),
"tu1",
opts,
);
await hook(
execInput(
'call --json desktop-file-system-canvas-partial-update {"id":"dash-1","code":"a"}',
),
"tu2",
opts,
);
await hook(
execInput(
"call --json workflows-get {}",
JSON.stringify({ id: "wf-2", status: "active", name: "Second" }),
),
"tu3",
opts,
);
await hook(
execInput(
'call --json desktop-file-system-canvas-partial-update {"id":"dash-2","code":"b"}',
),
"tu4",
opts,
);

expect(signals.map((s) => [s.dashboardId, s.workflowId])).toEqual([
["dash-1", "wf-1"],
["dash-2", "wf-2"],
]);
});
});
Loading
Loading