From 9e88a1be3d91f373ebdab06194a6faa8d6159c7e Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Sat, 20 Jun 2026 13:41:57 -0700 Subject: [PATCH 1/3] fix(channels): dedupe cloud channel context echo so prompt shows once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In cloud mode the channel's CONTEXT.md *is* delivered to the agent (folded into pendingUserMessage at task creation), but the conversation rendered the user's prompt twice: first the optimistic placeholder (seeded from the bare task description, no context), then the echoed session/prompt that carries the appended block. mergeConversationItems deduped optimistic↔echo by exact content equality, so the context-bearing echo never matched its placeholder and both rendered — reading as "context shows up a second time / wasn't included the first time". Local sessions don't hit this (they swap optimistic→real by id). Compare on the channel-context-stripped text instead, and upgrade the pinned bubble to the richer echoed copy so the CONTEXT.md chip renders in place, without a duplicate. Generated-By: PostHog Code Task-Id: e2321274-b00c-4451-8411-a80a515a8965 --- .../components/mergeConversationItems.test.ts | 21 ++++++++++ .../components/mergeConversationItems.ts | 40 +++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts b/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts index f8dd8ab23..e70007504 100644 --- a/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts @@ -50,6 +50,27 @@ describe("mergeConversationItems", () => { expect(result.map((i) => i.id)).toEqual(["opt", "other"]); }); + it("cloud: dedupes the echoed prompt even when it carries an appended channel CONTEXT.md", () => { + const echoedWithContext = + 'hello\n\nbackground'; + const result = mergeConversationItems({ + conversationItems: [ + userMessage("echo", echoedWithContext), + userMessage("other", "different"), + ], + optimisticItems: [userMessage("opt", "hello")], + isCloud: true, + }); + // No duplicate: the echo is dropped... + expect(result.map((i) => i.id)).toEqual(["opt", "other"]); + // ...and the pinned bubble is upgraded to the context-bearing copy so the + // CONTEXT.md chip renders in place instead of as a second message. + const pinned = result.find((i) => i.id === "opt"); + expect(pinned?.type === "user_message" && pinned.content).toBe( + echoedWithContext, + ); + }); + it("cloud: dedupe is no-op when there are no optimistic items", () => { const result = mergeConversationItems({ conversationItems: [ diff --git a/packages/ui/src/features/sessions/components/mergeConversationItems.ts b/packages/ui/src/features/sessions/components/mergeConversationItems.ts index 4e7613b7c..774cf9ab3 100644 --- a/packages/ui/src/features/sessions/components/mergeConversationItems.ts +++ b/packages/ui/src/features/sessions/components/mergeConversationItems.ts @@ -1,4 +1,5 @@ import type { ConversationItem } from "./buildConversationItems"; +import { extractChannelContext } from "./session-update/channelContext"; interface MergeConversationItemsArgs { conversationItems: ConversationItem[]; @@ -6,6 +7,15 @@ interface MergeConversationItemsArgs { isCloud: boolean; } +// The pinned optimistic bubble is seeded from the bare task description, but the +// echoed `session/prompt` that streams back from the sandbox may additionally +// carry the channel's CONTEXT.md, folded into the prompt at task creation (see +// buildChannelContextText in @posthog/core). Dedupe and upgrade compare on the +// channel-context-stripped text so the echo still matches its placeholder. +function strippedUserContent(content: string): string { + return extractChannelContext(content)?.stripped ?? content; +} + // Cloud's initial optimistic is pinned to the top so the user's prompt stays // visible above setup progress. Follow-up optimistics render at the tail until // the streamed `session/prompt` arrives and replaces them. @@ -33,17 +43,41 @@ export function mergeConversationItems({ (item): item is Extract => item.type === "user_message", ) - .map((item) => item.content), + .map((item) => strippedUserContent(item.content)), ); + + // When the echoed prompt matches a pinned optimistic placeholder, drop the + // echo but remember its content: it may carry the channel CONTEXT.md block the + // placeholder lacks, so we surface the richer copy on the pinned bubble below. + const echoedContentByKey = new Map(); const dedupedConversation = pinnedOptimisticUserContents.size === 0 ? conversationItems : conversationItems.filter((item) => { if (item.type !== "user_message") return true; - return !pinnedOptimisticUserContents.has(item.content); + const key = strippedUserContent(item.content); + if (!pinnedOptimisticUserContents.has(key)) return true; + if (!echoedContentByKey.has(key)) { + echoedContentByKey.set(key, item.content); + } + return false; }); + + const resolvedPinnedItems = + echoedContentByKey.size === 0 + ? pinnedOptimisticItems + : pinnedOptimisticItems.map((item) => { + if (item.type !== "user_message") return item; + const echoed = echoedContentByKey.get( + strippedUserContent(item.content), + ); + return echoed !== undefined && echoed !== item.content + ? { ...item, content: echoed } + : item; + }); + return [ - ...pinnedOptimisticItems, + ...resolvedPinnedItems, ...dedupedConversation, ...tailOptimisticItems, ]; From c77c1095c5fbed9daceb3d7e982b6deb75c25aae Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Sat, 20 Jun 2026 13:58:18 -0700 Subject: [PATCH 2/3] fix(channels): seed cloud placeholder with channel context so chip shows immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cloud sandbox takes seconds to boot and echo the prompt back, so the optimistic placeholder was seeded from the bare task description and showed no CONTEXT.md chip until the echo landed. Task creation now hands the context-bearing pendingUserMessage to the session service (rememberInitialCloudPrompt), which seeds the placeholder with it — so the chip renders the instant the user submits, with the block stripped to a chip by UserMessage as usual (never raw XML). Best-effort and in-memory: lost on a reload during the boot window, where the merge-layer dedupe still renders the echo correctly (chip, no duplicate). Generated-By: PostHog Code Task-Id: e2321274-b00c-4451-8411-a80a515a8965 --- packages/core/src/sessions/sessionService.ts | 36 ++++++++++++-- .../src/task-detail/taskCreationSaga.test.ts | 47 +++++++++++++++++++ .../core/src/task-detail/taskCreationSaga.ts | 11 +++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index c8eb99c1d..6672cbbaa 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -439,6 +439,14 @@ export class SessionService { string, Promise >(); + /** + * Initial cloud prompt text (user message + any channel CONTEXT.md block), + * stashed by task creation keyed by taskId. The cloud sandbox takes seconds to + * boot and echo this back, so the optimistic placeholder would otherwise show + * the bare task description with no CONTEXT.md chip until the echo lands. Seed + * the placeholder with this richer text instead, then drop it once consumed. + */ + private initialCloudOptimisticPrompt = new Map(); constructor(private readonly d: SessionServiceDeps) { this.cloudRunIdleTracker = new CloudRunIdleTracker(); @@ -3321,6 +3329,20 @@ export class SessionService { return () => {}; } + /** + * Stash the initial cloud prompt (user message plus any channel CONTEXT.md + * block) so the optimistic placeholder can render it — and its CONTEXT.md + * chip — immediately, instead of waiting for the sandbox to boot and echo it + * back. Best-effort: lost on reload, where the merge layer dedupes the echo + * against the bare placeholder instead. + */ + rememberInitialCloudPrompt(taskId: string, content: string): void { + const trimmed = content.trim(); + if (trimmed) { + this.initialCloudOptimisticPrompt.set(taskId, content); + } + } + private hydrateCloudTaskSessionFromLogs( taskId: string, taskRunId: string, @@ -3347,14 +3369,22 @@ export class SessionService { // Seed the optimistic user-message bubble whenever the agent has // not yet recorded an initial `session/prompt` request — covers the // brand-new task case as well as "agent has emitted lifecycle - // notifications but hasn't received its first prompt yet". - if (!hasUserPrompt && taskDescription?.trim()) { + // notifications but hasn't received its first prompt yet". Prefer the + // stashed initial prompt (which carries the channel CONTEXT.md block, so + // its chip renders right away) over the bare task description. + const seedContent = + this.initialCloudOptimisticPrompt.get(taskId) ?? taskDescription; + if (!hasUserPrompt && seedContent?.trim()) { this.d.store.appendOptimisticItem(taskRunId, { type: "user_message", - content: taskDescription, + content: seedContent, timestamp: Date.now(), }); } + if (hasUserPrompt) { + // The real prompt has landed; the stash is no longer needed. + this.initialCloudOptimisticPrompt.delete(taskId); + } if (rawEntries.length === 0) { return; diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index 33e0799e3..3729cfa27 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -33,6 +33,7 @@ const host = mockHost as unknown as ITaskCreationHost; const sessionService = { connectToTask: vi.fn(), disconnectFromTask: vi.fn(), + rememberInitialCloudPrompt: vi.fn(), } as unknown as SessionService; const createTask = (overrides: Partial = {}): Task => ({ @@ -157,6 +158,52 @@ describe("TaskCreationSaga", () => { ); }); + it("folds channel CONTEXT.md into the cloud prompt and stashes it for the optimistic placeholder", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + vi.mocked(sessionService.rememberInitialCloudPrompt).mockClear(); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: vi.fn().mockResolvedValue(createdTask), + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + } as never, + host, + sessionService, + track: vi.fn(), + }); + + const result = await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + channelContext: "# project-bluebird\n\nReference material.", + channelName: "project-bluebird", + }); + + expect(result.success).toBe(true); + const sentMessage = startTaskRunMock.mock.calls[0][2] + .pendingUserMessage as string; + // Prompt leads, channel context follows as a tagged block. + expect(sentMessage).toContain("Ship the fix"); + expect(sentMessage).toContain( + '', + ); + // The same context-bearing message is stashed so the optimistic placeholder + // can show its CONTEXT.md chip immediately, before the sandbox echoes back. + expect(sessionService.rememberInitialCloudPrompt).toHaveBeenCalledWith( + "task-123", + sentMessage, + ); + }); + it("starts a repo-less channel task in a scratch dir (allowNoRepo)", async () => { const createdTask = createTask({ repository: undefined }); const createTaskMock = vi.fn().mockResolvedValue(createdTask); diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 174d510ef..af55f63b1 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -270,6 +270,17 @@ export class TaskCreationSaga extends Saga< ? `${messageText}\n\n${channelContextText}` : channelContextText : messageText; + + // The sandbox echoes pendingUserMessage back once it boots; until then + // the optimistic placeholder would show the bare task description with + // no CONTEXT.md chip. Hand the channel-context-bearing message to the + // session service so it seeds the placeholder right away. + if (channelContextText && pendingUserMessage) { + this.deps.sessionService.rememberInitialCloudPrompt( + task.id, + pendingUserMessage, + ); + } const taskRun = await this.deps.posthogClient.createTaskRun(task.id, { environment: "cloud", mode: "interactive", From 7c80b9d756713f63f5b5fa7d3ecef2b8e64b1c57 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Sat, 20 Jun 2026 14:00:39 -0700 Subject: [PATCH 3/3] test(channels): split combined assertion for clearer failure messages Addresses Greptile review: the combined `pinned?.type === "user_message" && pinned.content` expression reported `false` on failure instead of the actual content. Assert the type, narrow, then assert the content separately. Generated-By: PostHog Code Task-Id: e2321274-b00c-4451-8411-a80a515a8965 --- .../sessions/components/mergeConversationItems.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts b/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts index e70007504..e98c9ea97 100644 --- a/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts @@ -66,9 +66,10 @@ describe("mergeConversationItems", () => { // ...and the pinned bubble is upgraded to the context-bearing copy so the // CONTEXT.md chip renders in place instead of as a second message. const pinned = result.find((i) => i.id === "opt"); - expect(pinned?.type === "user_message" && pinned.content).toBe( - echoedWithContext, - ); + expect(pinned?.type).toBe("user_message"); + if (pinned?.type !== "user_message") + throw new Error("expected user_message"); + expect(pinned.content).toBe(echoedWithContext); }); it("cloud: dedupe is no-op when there are no optimistic items", () => {