Skip to content
Merged
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
36 changes: 33 additions & 3 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,14 @@ export class SessionService {
string,
Promise<SessionConfigOption[]>
>();
/**
* 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<string, string>();

constructor(private readonly d: SessionServiceDeps) {
this.cloudRunIdleTracker = new CloudRunIdleTracker();
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/task-detail/taskCreationSaga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): Task => ({
Expand Down Expand Up @@ -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(
'<channel_context channel="project-bluebird">',
);
// 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);
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/task-detail/taskCreationSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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\n<channel_context channel="bluebird">background</channel_context>';
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: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import type { ConversationItem } from "./buildConversationItems";
import { extractChannelContext } from "./session-update/channelContext";

interface MergeConversationItemsArgs {
conversationItems: ConversationItem[];
optimisticItems: ConversationItem[];
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.
Expand Down Expand Up @@ -33,17 +43,41 @@ export function mergeConversationItems({
(item): item is Extract<typeof item, { type: "user_message" }> =>
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<string, string>();
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,
];
Expand Down
Loading