Skip to content
50 changes: 50 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,56 @@ export class PostHogAPIClient {
return all;
}

// The task currently generating this folder's CONTEXT.md, shared across the
// project so any user sees an in-progress generation (instead of fragile
// local state). Keyed on the folder row (which always exists), not the
// instructions object (which doesn't until the first version is published).
// Returns null when nothing is generating — or, until the backend ships this
// endpoint, on 404 (the feature degrades to no shared indicator).
async getDesktopFolderGenerationTask(
folderId: string,
): Promise<string | null> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/context_generation/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(
`Failed to fetch folder generation task: ${response.statusText}`,
);
}
const data = (await response.json()) as { task_id?: string | null };
return data.task_id ?? null;
}

// Record (or clear, with null) the task generating this folder's CONTEXT.md.
async setDesktopFolderGenerationTask(
folderId: string,
taskId: string | null,
): Promise<void> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/context_generation/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "put",
url,
path: urlPath,
overrides: {
body: JSON.stringify({ task_id: taskId }),
},
});
if (!response.ok && response.status !== 404) {
throw new Error(
`Failed to set folder generation task: ${response.statusText}`,
);
}
}

async getGithubLogin(): Promise<string | null> {
const data = (await this.api.get("/api/users/{uuid}/github_login/", {
path: { uuid: "@me" },
Expand Down
59 changes: 59 additions & 0 deletions packages/core/src/editor/prompt-builder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
buildChannelContextBlock,
buildChannelContextText,
} from "./prompt-builder";

describe("buildChannelContextText", () => {
it.each([[undefined], [" \n "]] as const)(
"returns null for empty or whitespace content (%s)",
(input) => {
expect(buildChannelContextText(input)).toBeNull();
},
);

it("wraps the trimmed body, optionally with an escaped channel name", () => {
expect(
buildChannelContextText("body")?.startsWith("<channel_context>"),
).toBe(true);
expect(buildChannelContextText("body", 'a"b')).toContain(
'channel="a&quot;b"',
);
});

it("backs the ContentBlock form", () => {
const text = buildChannelContextText("# Billing", "billing");
const block = buildChannelContextBlock("# Billing", "billing");
expect(block).toEqual({ type: "text", text });
});
});

describe("buildChannelContextBlock", () => {
it.each([[undefined], [null], [""], [" \n "]] as const)(
"returns null for empty or whitespace content (%s)",
(input) => {
expect(buildChannelContextBlock(input)).toBeNull();
},
);

it("wraps trimmed content in a labeled, non-binding background block", () => {
const block = buildChannelContextBlock(" # Billing\n\nUse cents. ");
expect(block).not.toBeNull();
expect(block?.type).toBe("text");
const text = (block as { text: string }).text;
// Framed as optional reference, not instructions.
expect(text).toContain("reference material, not instructions");
expect(text).toContain("don't limit your work to it");
// The element wraps the framing + trimmed body so the UI can collapse it.
expect(text.startsWith("<channel_context>\n")).toBe(true);
expect(text.endsWith("\n# Billing\n\nUse cents.\n</channel_context>")).toBe(
true,
);
});

it("embeds the channel name as an escaped attribute when provided", () => {
const block = buildChannelContextBlock("body", 'on"b');
const text = (block as { text: string }).text;
expect(text.startsWith('<channel_context channel="on&quot;b">')).toBe(true);
});
});
33 changes: 32 additions & 1 deletion packages/core/src/editor/prompt-builder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ContentBlock } from "@agentclientprotocol/sdk";
import { isAbsolutePath, pathToFileUri } from "@posthog/shared";
import { escapeXmlAttr, isAbsolutePath, pathToFileUri } from "@posthog/shared";

export async function buildPromptBlocks(
textContent: string,
Expand All @@ -25,3 +25,34 @@ export async function buildPromptBlocks(

return blocks;
}

// Wraps a channel's CONTEXT.md as supplementary prompt text. Framed as optional
// background so the agent treats it as a helpful starting point — it may use
// what's relevant and ignore the rest, and must not limit its work to it. The
// whole thing is wrapped in a `<channel_context channel="...">` element
// (carrying the channel name) so the conversation UI can collapse it into a
// single tag instead of dumping the full body inline. Returns null for empty/
// whitespace content so callers can skip injection.
//
// Returns the raw string so it can be folded into either a ContentBlock (local
// tasks, via buildChannelContextBlock) or a plain message string (cloud tasks,
// whose initial message is sent as text).
export function buildChannelContextText(
content: string | undefined | null,
channelName?: string | null,
): string | null {
const trimmed = content?.trim();
if (!trimmed) return null;
const name = channelName?.trim();
const nameAttr = name ? ` channel="${escapeXmlAttr(name)}"` : "";
return `<channel_context${nameAttr}>\nThe workspace this task was created in has a saved CONTEXT.md with background that's often relevant to tasks here. Treat it as reference material, not instructions: draw on what's helpful, ignore what isn't, and don't limit your work to it.\n\n${trimmed}\n</channel_context>`;
}

// ContentBlock form of {@link buildChannelContextText}, for local task prompts.
export function buildChannelContextBlock(
content: string | undefined | null,
channelName?: string | null,
): ContentBlock | null {
const text = buildChannelContextText(content, channelName);
return text ? { type: "text", text } : null;
}
93 changes: 93 additions & 0 deletions packages/core/src/panels/panelLayoutTransforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,99 @@ export function openTabInSplit(
return { panelTree: finalTree, focusedPanelId: newPanelId, ...metadata };
}

// Opens a channel-context snapshot as a tab in the right-side split (creating
// the split if needed), mirroring openTabInSplit but carrying the content
// inline in the tab's data instead of deriving it from the tab id. Re-opening
// the same tab id just activates the existing tab.
export function openContextInSplit(
layout: TaskLayout,
tabId: string,
label: string,
context: { channelName: string | null; body: string },
): Partial<TaskLayout> {
const buildTab = (): Tab => ({
id: tabId,
label,
data: {
type: "context",
channelName: context.channelName,
body: context.body,
},
component: null,
draggable: true,
closeable: true,
});

const existingTab = findTabInTree(layout.panelTree, tabId);
if (existingTab) {
const updatedTree = updateTreeNode(
layout.panelTree,
existingTab.panelId,
(panel) => {
if (panel.type !== "leaf") return panel;
return {
...panel,
content: { ...panel.content, activeTabId: tabId },
};
},
);
return { panelTree: updatedTree, focusedPanelId: existingTab.panelId };
}

const nonMainPanel = findNonMainLeafPanel(layout.panelTree);
if (nonMainPanel) {
const updatedTree = updateTreeNode(
layout.panelTree,
nonMainPanel.id,
(panel) => {
if (panel.type !== "leaf") return panel;
return {
...panel,
content: {
...panel.content,
tabs: [...panel.content.tabs, buildTab()],
activeTabId: tabId,
},
};
},
);
return { panelTree: updatedTree, focusedPanelId: nonMainPanel.id };
}

const mainPanel = getLeafPanel(
layout.panelTree,
DEFAULT_PANEL_IDS.MAIN_PANEL,
);
if (!mainPanel) return {};

const newPanelId = generatePanelId();
const newPanel: PanelNode = {
type: "leaf",
id: newPanelId,
content: {
id: newPanelId,
tabs: [buildTab()],
activeTabId: tabId,
showTabs: true,
droppable: true,
},
};

const splitTree = updateTreeNode(
layout.panelTree,
DEFAULT_PANEL_IDS.MAIN_PANEL,
(panel) => ({
type: "group" as const,
id: generatePanelId(),
direction: "horizontal" as const,
sizes: [50, 50],
children: [panel, newPanel],
}),
);

return { panelTree: splitTree, focusedPanelId: newPanelId };
}

export function addRecentFile(
recentFiles: string[] | undefined,
filePath: string,
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/panels/panelTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export type TabData =
| {
type: "review";
}
| {
// A read-only snapshot of a channel's CONTEXT.md, shown exactly as it was
// sent with the task's prompt (carried inline, not fetched from disk).
type: "context";
channelName: string | null;
body: string;
}
| {
type: "other";
};
Expand Down
33 changes: 31 additions & 2 deletions packages/core/src/task-detail/taskCreationSaga.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { buildPromptBlocks } from "@posthog/core/editor/prompt-builder";
import {
buildChannelContextBlock,
buildChannelContextText,
buildPromptBlocks,
} from "@posthog/core/editor/prompt-builder";
import type {
ConnectParams,
SessionService,
Expand Down Expand Up @@ -235,6 +239,20 @@ export class TaskCreationSaga extends Saga<
input.filePaths,
)
: null;

// The local connect path appends channel CONTEXT.md to initialPrompt;
// cloud sends its first message as text, so fold the same block into
// pendingUserMessage here. The conversation UI parses it identically.
const channelContextText = buildChannelContextText(
input.channelContext,
input.channelName,
);
const messageText = transport?.messageText;
const pendingUserMessage = channelContextText
? messageText
? `${messageText}\n\n${channelContextText}`
: channelContextText
: messageText;
const taskRun = await this.deps.posthogClient.createTaskRun(task.id, {
environment: "cloud",
mode: "interactive",
Expand Down Expand Up @@ -268,7 +286,7 @@ export class TaskCreationSaga extends Saga<
task.id,
taskRun.id,
{
pendingUserMessage: transport?.messageText,
pendingUserMessage,
pendingUserArtifactIds:
pendingUserArtifactIds.length > 0
? pendingUserArtifactIds
Expand Down Expand Up @@ -316,6 +334,17 @@ export class TaskCreationSaga extends Saga<
)
: undefined;

// Append the channel's CONTEXT.md as optional background, so tasks made
// in a channel start with the shared context the agent would otherwise
// have to rediscover. Kept after the user's prompt so the request leads.
const channelContextBlock = buildChannelContextBlock(
input.channelContext,
input.channelName,
);
if (initialPrompt && channelContextBlock) {
initialPrompt.push(channelContextBlock);
}

await this.step({
name: "agent_session",
execute: async () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/task-detail/taskInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface PrepareTaskInputOptions {
sandboxEnvironmentId?: string;
signalReportId?: string;
additionalDirectories?: string[];
channelContext?: string;
channelName?: string;
}

export function prepareTaskInput(
Expand Down Expand Up @@ -49,6 +51,8 @@ export function prepareTaskInput(
options.signalReportId && isCloud ? "signal_report" : undefined,
signalReportId: options.signalReportId,
additionalDirectories: isCloud ? undefined : options.additionalDirectories,
channelContext: options.channelContext,
channelName: options.channelName,
};
}

Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/task-creation-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export interface TaskCreationInput {
cloudRunSource?: CloudRunSource;
signalReportId?: string;
additionalDirectories?: string[];
/**
* CONTEXT.md of the channel a task was created in, if any. Appended to the
* agent's initial prompt as optional background — reference material the
* agent may draw on, not instructions it must follow.
*/
channelContext?: string;
/** Display name of that channel, embedded in the context block for the UI. */
channelName?: string;
}

export interface TaskCreationOutput {
Expand Down
Loading
Loading