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", diff --git a/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts b/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts index f8dd8ab23..e98c9ea97 100644 --- a/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts @@ -50,6 +50,28 @@ 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).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", () => { 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, ];