Skip to content
Draft
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
93 changes: 93 additions & 0 deletions packages/core/src/sessions/sessionQueueEditing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { AgentSession, QueuedMessage } from "@posthog/shared";
import { afterEach, describe, expect, it } from "vitest";
import { sessionStore, sessionStoreSetters } from "./sessionStore";

const RUN = "run-queue";
const TASK = "task-queue";

function seedQueue(messages: QueuedMessage[]) {
sessionStoreSetters.setSession({
taskRunId: RUN,
taskId: TASK,
events: [],
messageQueue: messages,
pendingPermissions: new Map(),
status: "connected",
} as unknown as AgentSession);
}

function queue(): QueuedMessage[] {
return sessionStore.getState().sessions[RUN].messageQueue;
}

function msg(id: string, content: string): QueuedMessage {
return { id, content, queuedAt: 1 };
}

afterEach(() => sessionStoreSetters.removeSession(RUN));

describe("moveQueuedMessage", () => {
it("moves a message to a later position, preserving the others' order", () => {
seedQueue([msg("a", "A"), msg("b", "B"), msg("c", "C")]);

sessionStoreSetters.moveQueuedMessage(TASK, 0, 2);

expect(queue().map((m) => m.id)).toEqual(["b", "c", "a"]);
});

it("moves a message to an earlier position", () => {
seedQueue([msg("a", "A"), msg("b", "B"), msg("c", "C")]);

sessionStoreSetters.moveQueuedMessage(TASK, 2, 0);

expect(queue().map((m) => m.id)).toEqual(["c", "a", "b"]);
});

it.each([
["same index", 1, 1],
["from out of range", 5, 0],
["to out of range", 0, 9],
["negative index", -1, 0],
])("is a no-op for %s", (_label, from, to) => {
seedQueue([msg("a", "A"), msg("b", "B"), msg("c", "C")]);

sessionStoreSetters.moveQueuedMessage(TASK, from, to);

expect(queue().map((m) => m.id)).toEqual(["a", "b", "c"]);
});
});

describe("updateQueuedMessage", () => {
it("replaces content and rawPrompt in place, keeping id and position", () => {
seedQueue([msg("a", "A"), msg("b", "B")]);

sessionStoreSetters.updateQueuedMessage(TASK, "a", {
content: "edited A",
rawPrompt: "edited A raw",
});

expect(queue().map((m) => m.id)).toEqual(["a", "b"]);
expect(queue()[0].content).toBe("edited A");
expect(queue()[0].rawPrompt).toBe("edited A raw");
expect(queue()[1].content).toBe("B");
});

it("clears a stale rawPrompt when the patch omits it (local edit)", () => {
seedQueue([{ id: "a", content: "A", rawPrompt: "old raw", queuedAt: 1 }]);

sessionStoreSetters.updateQueuedMessage(TASK, "a", { content: "edited" });

expect(queue()[0].content).toBe("edited");
expect(queue()[0].rawPrompt).toBeUndefined();
});

it("is a no-op when the target id is not in the queue", () => {
seedQueue([msg("a", "A")]);

sessionStoreSetters.updateQueuedMessage(TASK, "missing", {
content: "edited",
});

expect(queue()[0].content).toBe("A");
});
});
37 changes: 37 additions & 0 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@ export interface ISessionStore {
rawPrompt?: string | ContentBlock[],
): void;
removeQueuedMessage(taskId: string, messageId: string): void;
updateQueuedMessage(
taskId: string,
messageId: string,
patch: { content: string; rawPrompt?: string | ContentBlock[] },
): void;
clearMessageQueue(taskId: string): void;
dequeueMessagesAsText(taskId: string): string | null;
dequeueMessages(taskId: string): QueuedMessage[];
Expand Down Expand Up @@ -2546,6 +2551,38 @@ export class SessionService {
}
}

/**
* Update a queued message in place from an edited composer prompt, keeping it
* in the queue at its current position. Mirrors the enqueue normalization so
* the stored `content`/`rawPrompt` match what a freshly-queued prompt would
* hold (cloud recomputes the transport display + raw payload; local stores
* the serialized text). Returns false when the target is no longer queued so
* the caller can fall back to sending it as a new message.
*/
async updateQueuedMessage(
taskId: string,
messageId: string,
prompt: string | ContentBlock[],
): Promise<boolean> {
const session = this.d.store.getSessionByTaskId(taskId);
if (!session) return false;
if (!session.messageQueue.some((m) => m.id === messageId)) return false;

if (session.isCloud) {
const normalizedPrompt = await this.resolveCloudPrompt(prompt);
const transport = this.d.h.getCloudPromptTransport(normalizedPrompt);
this.d.store.updateQueuedMessage(taskId, messageId, {
content: transport.promptText,
rawPrompt: normalizedPrompt,
});
} else {
this.d.store.updateQueuedMessage(taskId, messageId, {
content: extractPromptText(prompt),
});
}
return true;
}

/**
* Cancel the current prompt.
*/
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/sessions/sessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,54 @@ export const sessionStoreSetters = {
});
},

/**
* Move a queued message to a new position, preserving its identity. Used by
* the drag-to-reorder affordance: the queue drains in array order, so the
* order the user sees is the order the messages send.
*/
moveQueuedMessage: (taskId: string, fromIndex: number, toIndex: number) => {
sessionStore.setState((state) => {
const taskRunId = state.taskIdIndex[taskId];
if (!taskRunId) return;
const session = state.sessions[taskRunId];
if (!session) return;
const queue = session.messageQueue;
if (
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= queue.length ||
toIndex >= queue.length ||
fromIndex === toIndex
) {
return;
}
const [moved] = queue.splice(fromIndex, 1);
if (moved) queue.splice(toIndex, 0, moved);
});
},

/**
* Replace a queued message's content in place, keeping its id, position, and
* queue timestamp. Used by edit-in-place: the message is edited in the
* composer and re-serialized here without leaving the queue.
*/
updateQueuedMessage: (
taskId: string,
messageId: string,
patch: { content: string; rawPrompt?: string | ContentBlock[] },
) => {
sessionStore.setState((state) => {
const taskRunId = state.taskIdIndex[taskId];
if (!taskRunId) return;
const session = state.sessions[taskRunId];
if (!session) return;
const message = session.messageQueue.find((m) => m.id === messageId);
if (!message) return;
message.content = patch.content;
message.rawPrompt = patch.rawPrompt;
});
},

dequeueMessagesAsText: (taskId: string): string | null => {
// Read the queue from the frozen committed state BEFORE entering the
// immer draft — same rationale as `dequeueMessages`: anything captured
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,24 @@ vi.mock("@posthog/ui/features/sessions/hooks/useMessagingMode", () => ({
useSupportsNativeSteer: () => false,
}));

vi.mock(
"@posthog/ui/features/sessions/hooks/useReturnQueuedMessageToEditor",
() => ({
useReturnQueuedMessageToEditor: () => vi.fn(),
}),
);
vi.mock("@posthog/ui/features/sessions/hooks/useEditQueuedMessage", () => ({
useEditQueuedMessage: () => vi.fn(),
useCancelQueuedMessageEdit: () => vi.fn(),
}));

vi.mock("@posthog/ui/features/sessions/sessionStore", () => ({
sessionStoreSetters: { removeQueuedMessage: vi.fn() },
sessionStoreSetters: {
removeQueuedMessage: vi.fn(),
moveQueuedMessage: vi.fn(),
},
useSessionIsCloud: () => false,
useSessionSelector: <T,>(
_taskId: string,
select: (session: undefined) => T,
) => select(undefined),
useSessionStore: {
getState: () => ({ taskIdIndex: {}, sessions: {} }),
},
}));

vi.mock("@posthog/ui/primitives/toast", () => ({
Expand Down
Loading
Loading