Skip to content

Commit 320d310

Browse files
committed
fix(canvas): workflow-link fixes from live dogfooding
- Parse the PostHog MCP's YAML tool results in the workflow-built pairing: workflows-create/get return a YAML document, not JSON, so the link notification never fired. Add a top-level-line YAML fallback with a regression test against a captured real payload. - Resolve the backend feed channel in useGenerateFreeformCanvas when the caller doesn't pass one (the canvas hero didn't), so hero-initiated builds land in the channel feed like composer submits. - Never demote an auto/bypass session to acceptEdits when the user answers a permission card with "always allow" - the upgrade helper previously preferred acceptEdits unconditionally, which turned one "always allow" click into a prompt storm for the rest of an auto canvas build. Generated-By: PostHog Code Task-Id: 3b50883f-cab5-443a-8b92-3d0c948688da
1 parent 2675c18 commit 320d310

4 files changed

Lines changed: 182 additions & 22 deletions

File tree

packages/agent/src/adapters/claude/hooks.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,3 +798,100 @@ describe("createPostToolUseHook workflow link", () => {
798798
expect(signals).toEqual([]);
799799
});
800800
});
801+
802+
describe("createPostToolUseHook workflow link - YAML MCP results", () => {
803+
// The PostHog MCP renders tool results as YAML documents, not JSON — this is
804+
// the real shape a workflows-create returns (captured from a live session).
805+
const YAML_WORKFLOW_RESULT = [
806+
"id: 019f6f90-0d70-0000-5171-4622c6016e1e",
807+
"name: Welcome email after signup",
808+
'description: "Sends a welcome email to every new user immediately after they sign up."',
809+
"version: 1",
810+
"status: draft",
811+
'created_at: "2026-07-17T10:12:19.441125Z"',
812+
"created_by:",
813+
" id: 546751",
814+
" first_name: Harley",
815+
"trigger:",
816+
" type: event",
817+
" filters:",
818+
" source: events",
819+
" events[1]{id,name,type,order}:",
820+
" user signed up,user signed up,events,0",
821+
].join("\n");
822+
823+
test("pairs a YAML workflows-create result with the canvas publish", async () => {
824+
const signals: WorkflowBuiltSignal[] = [];
825+
const hook = createPostToolUseHook({
826+
onWorkflowBuilt: (s) => signals.push(s),
827+
});
828+
829+
await hook(
830+
{
831+
hook_event_name: "PostToolUse",
832+
tool_name: "mcp__posthog__exec",
833+
tool_input: { command: "call --json workflows-create {}" },
834+
tool_response: {
835+
content: [{ type: "text", text: YAML_WORKFLOW_RESULT }],
836+
},
837+
} as unknown as HookInput,
838+
"tu1",
839+
{ signal: new AbortController().signal },
840+
);
841+
await hook(
842+
{
843+
hook_event_name: "PostToolUse",
844+
tool_name: "mcp__posthog__exec",
845+
tool_input: {
846+
command:
847+
'call --json desktop-file-system-canvas-partial-update {"id":"dash-1","code":"x"}',
848+
},
849+
} as unknown as HookInput,
850+
"tu2",
851+
{ signal: new AbortController().signal },
852+
);
853+
854+
expect(signals).toEqual([
855+
{
856+
dashboardId: "dash-1",
857+
workflowId: "019f6f90-0d70-0000-5171-4622c6016e1e",
858+
workflowStatus: "draft",
859+
workflowName: "Welcome email after signup",
860+
},
861+
]);
862+
});
863+
864+
test("does not read a nested block's id as the workflow id", async () => {
865+
const signals: WorkflowBuiltSignal[] = [];
866+
const hook = createPostToolUseHook({
867+
onWorkflowBuilt: (s) => signals.push(s),
868+
});
869+
870+
// No top-level id line at all — only the nested created_by.id.
871+
const nestedOnly = ["name: X", "created_by:", " id: 546751"].join("\n");
872+
await hook(
873+
{
874+
hook_event_name: "PostToolUse",
875+
tool_name: "mcp__posthog__exec",
876+
tool_input: { command: "call --json workflows-create {}" },
877+
tool_response: { content: [{ type: "text", text: nestedOnly }] },
878+
} as unknown as HookInput,
879+
"tu1",
880+
{ signal: new AbortController().signal },
881+
);
882+
await hook(
883+
{
884+
hook_event_name: "PostToolUse",
885+
tool_name: "mcp__posthog__exec",
886+
tool_input: {
887+
command:
888+
'call --json desktop-file-system-canvas-partial-update {"id":"dash-1","code":"x"}',
889+
},
890+
} as unknown as HookInput,
891+
"tu2",
892+
{ signal: new AbortController().signal },
893+
);
894+
895+
expect(signals).toEqual([]);
896+
});
897+
});

packages/agent/src/adapters/claude/hooks.ts

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -81,38 +81,61 @@ function parseCanvasPublishDashboardId(toolInput: unknown): string | null {
8181
return arg && typeof arg.id === "string" ? arg.id : null;
8282
}
8383

84-
// The workflow id + status from a `workflows-create` result. The result JSON may
85-
// be the workflow itself or wrapped, so look under common envelopes too. Best
86-
// effort - returns null if no workflow id is present.
84+
// A top-level `key: value` line from a YAML-ish blob (no leading indentation,
85+
// so nested blocks like `created_by:` don't shadow the workflow's own fields).
86+
// The PostHog MCP renders results as YAML, not JSON.
87+
function parseTopLevelYamlValue(text: string, key: string): string | undefined {
88+
const match = text.match(new RegExp(`^${key}:[ \\t]*(.+)$`, "m"));
89+
if (!match) return undefined;
90+
const value = match[1].trim().replace(/^"(.*)"$/, "$1");
91+
return value || undefined;
92+
}
93+
94+
// The workflow id + status from a `workflows-create` / `workflows-get` result.
95+
// The PostHog MCP returns the workflow as a YAML document (top-level `id:` /
96+
// `name:` / `status:` lines); older/other shapes may be JSON, possibly wrapped.
97+
// Best effort - returns null if no workflow id is present.
8798
function parseWorkflowFromResponse(response: unknown): {
8899
workflowId: string;
89100
workflowStatus?: string;
90101
workflowName?: string;
91102
} | null {
92103
const text = extractTextFromToolResponse(response);
93104
if (!text) return null;
105+
94106
const root = parseEmbeddedJsonObject(text);
95-
if (!root) return null;
96-
for (const candidate of [
97-
root,
98-
root.workflow,
99-
root.result,
100-
root.data,
101-
] as Record<string, unknown>[]) {
102-
if (candidate && typeof candidate === "object") {
103-
const id = (candidate as { id?: unknown }).id;
104-
if (typeof id === "string" && id) {
105-
const status = (candidate as { status?: unknown }).status;
106-
const name = (candidate as { name?: unknown }).name;
107-
return {
108-
workflowId: id,
109-
workflowStatus: typeof status === "string" ? status : undefined,
110-
workflowName:
111-
typeof name === "string" && name.trim() ? name : undefined,
112-
};
107+
if (root) {
108+
for (const candidate of [
109+
root,
110+
root.workflow,
111+
root.result,
112+
root.data,
113+
] as Record<string, unknown>[]) {
114+
if (candidate && typeof candidate === "object") {
115+
const id = (candidate as { id?: unknown }).id;
116+
if (typeof id === "string" && id) {
117+
const status = (candidate as { status?: unknown }).status;
118+
const name = (candidate as { name?: unknown }).name;
119+
return {
120+
workflowId: id,
121+
workflowStatus: typeof status === "string" ? status : undefined,
122+
workflowName:
123+
typeof name === "string" && name.trim() ? name : undefined,
124+
};
125+
}
113126
}
114127
}
115128
}
129+
130+
// YAML document (what the MCP actually emits): top-level key: value lines.
131+
const id = parseTopLevelYamlValue(text, "id");
132+
if (id && /^[0-9a-f][0-9a-f-]{10,}$/i.test(id)) {
133+
return {
134+
workflowId: id,
135+
workflowStatus: parseTopLevelYamlValue(text, "status"),
136+
workflowName: parseTopLevelYamlValue(text, "name"),
137+
};
138+
}
116139
return null;
117140
}
118141

packages/core/src/sessions/sessionService.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5549,6 +5549,12 @@ export class SessionService {
55495549
modeOption: SessionConfigOption | undefined,
55505550
): string | undefined {
55515551
if (modeOption?.type !== "select") return undefined;
5552+
// Never DEMOTE a session that already runs unattended: an "always allow"
5553+
// answer in an auto/bypass session must not drop it to acceptEdits, which
5554+
// would make every subsequent gated tool prompt (the opposite of what the
5555+
// user just asked for).
5556+
const current = modeOption.currentValue;
5557+
if (current === "auto" || current === "bypassPermissions") return undefined;
55525558
const availableIds = new Set(
55535559
flattenSelectOptions(modeOption.options).map((opt) => opt.value),
55545560
);

packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,20 @@ import {
1515
getCloudUrlFromRegion,
1616
type WorkspaceMode,
1717
} from "@posthog/shared";
18+
import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient";
1819
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
1920
import { buildFreeformGenerationPrompt } from "@posthog/ui/features/canvas/freeformPrompt";
21+
import { channelFeedQueryKey } from "@posthog/ui/features/canvas/hooks/useChannelFeed";
2022
import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks";
2123
import {
2224
isPlaceholderCanvasName,
2325
useDashboardMutations,
2426
} from "@posthog/ui/features/canvas/hooks/useDashboards";
2527
import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions";
28+
import {
29+
normalizeChannelName,
30+
PERSONAL_CHANNEL_NAME,
31+
} from "@posthog/ui/features/canvas/hooks/useTaskChannels";
2632
import { useCanvasGenerationTrackerStore } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore";
2733
import { toastError } from "@posthog/ui/features/notifications/errorDetails";
2834
import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations";
@@ -58,6 +64,7 @@ export function useGenerateFreeformCanvas(args: {
5864
);
5965
const trpc = useHostTRPC();
6066
const queryClient = useQueryClient();
67+
const apiClient = useOptionalAuthenticatedClient();
6168
const { invalidateTasks } = useCreateTask();
6269
const { fileTask } = useChannelTaskMutations();
6370
const { setGenerationTask, renameDashboard } = useDashboardMutations();
@@ -103,7 +110,6 @@ export function useGenerateFreeformCanvas(args: {
103110
templateId,
104111
instruction,
105112
currentCode,
106-
backendChannelId,
107113
adapter = "claude",
108114
reasoningLevel,
109115
useStarter,
@@ -115,6 +121,26 @@ export function useGenerateFreeformCanvas(args: {
115121
} = opts;
116122
setIsStarting(true);
117123
try {
124+
// Resolve the backend channel that owns the task so the run shows as a
125+
// card in the channel feed — the same mapping the channel composer
126+
// resolves. Callers that already know it (the composer) pass it; the
127+
// canvas hero doesn't, so fall back to resolving by channel name here.
128+
// Best-effort: an unresolved feed channel shouldn't block generation.
129+
let backendChannelId = opts.backendChannelId;
130+
const normalizedName = channelName
131+
? normalizeChannelName(channelName)
132+
: "";
133+
if (
134+
!backendChannelId &&
135+
apiClient &&
136+
normalizedName &&
137+
normalizedName !== PERSONAL_CHANNEL_NAME
138+
) {
139+
backendChannelId = await apiClient
140+
.resolveTaskChannel(normalizedName)
141+
.then((c) => c.id)
142+
.catch(() => undefined);
143+
}
118144
// A cloud run requires an explicit adapter + model (the API rejects a
119145
// cloud runtime without a model). Resolve the caller's pick — or the
120146
// adapter's server default when none — the same way the inbox one-click
@@ -186,6 +212,13 @@ export function useGenerateFreeformCanvas(args: {
186212
void queryClient.invalidateQueries({
187213
queryKey: trpc.workspace.getAll.queryKey(),
188214
});
215+
// Surface the run's card in the channel feed without waiting for the
216+
// feed's next poll.
217+
if (backendChannelId) {
218+
void queryClient.invalidateQueries({
219+
queryKey: channelFeedQueryKey(backendChannelId),
220+
});
221+
}
189222
// Auto-name a still-unnamed canvas from its generation prompt, using the
190223
// same helper model that names tasks. Best-effort: a failure (or a user
191224
// who already named the canvas) leaves the existing title untouched.
@@ -217,6 +250,7 @@ export function useGenerateFreeformCanvas(args: {
217250
titleGenerator,
218251
trpc,
219252
queryClient,
253+
apiClient,
220254
invalidateTasks,
221255
fileTask,
222256
setGenerationTask,

0 commit comments

Comments
 (0)