Skip to content
Open
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
68 changes: 68 additions & 0 deletions packages/common/src/base/__tests__/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ describe('formatters', () => {
describe('formatters.ui', () => {
it.each([
['content', [{ type: 'text', text: 'Visible prompt' }]],
[
'content',
[{ type: 'data-pasted-text', data: { text: 'large pasted text' } }],
],
['compact', [{ type: 'text', text: '<compact>Summary</compact>' }]],
[
'hidden',
Expand Down Expand Up @@ -623,6 +627,70 @@ describe('formatters', () => {
});

describe('formatters.llm', () => {
it('converts pasted text data into model-visible text without mutating the source message', () => {
const messages = [
{
id: 'user-pasted-text',
role: 'user',
parts: [
{
type: 'data-pasted-text',
data: { text: 'const answer = 42;' },
},
],
},
] as UIMessage[];

expect(formatters.llm(messages)).toEqual([
{
id: 'user-pasted-text',
role: 'user',
parts: [{ type: 'text', text: 'const answer = 42;' }],
},
]);
expect(messages[0].parts).toEqual([
{
type: 'data-pasted-text',
data: { text: 'const answer = 42;' },
},
]);
});

it('does not treat compact tags inside pasted text as a compaction boundary', () => {
const messages = [
{
id: 'old-assistant',
role: 'assistant',
metadata: { kind: 'assistant' },
parts: [{ type: 'text', text: 'old response' }],
},
{
id: 'user-pasted-text',
role: 'user',
parts: [
{
type: 'data-pasted-text',
data: { text: '<compact>literal user content</compact>' },
},
],
},
{
id: 'new-assistant',
role: 'assistant',
metadata: { kind: 'assistant' },
parts: [{ type: 'text', text: 'new response' }],
},
] as UIMessage[];

const formatted = formatters.llm(messages);

expect(formatted.map((message) => message.id)).toEqual([
'old-assistant',
'user-pasted-text',
'new-assistant',
]);
});

it('should keep reasoning parts by default', () => {
const formatted = formatters.llm(clone(baseMessages));
const assistantMsg = formatted.find((m) => m.id === 'assistant-1');
Expand Down
16 changes: 16 additions & 0 deletions packages/common/src/base/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function getUIUserMessageKind(message: UIMessage): UIUserMessageKind {
}

if (
part.type === "data-pasted-text" ||
part.type === "data-reviews" ||
part.type === "data-bash-outputs" ||
part.type === "data-background-job-notification" ||
Expand Down Expand Up @@ -745,6 +746,20 @@ function resolvePendingToolCallsForShareUI(messages: UIMessage[]) {

type FormatOp = (messages: UIMessage[]) => UIMessage[];

function convertPastedTextPartsForLLM(messages: UIMessage[]): UIMessage[] {
return messages.map((message) => {
message.parts = message.parts.map((part) => {
if (part.type !== "data-pasted-text") return part;

return {
type: "text",
text: (part as { data: { text: string } }).data.text,
};
});
return message;
});
}

function removePendingTodoAttemptCompletion(
messages: UIMessage[],
): UIMessage[] {
Expand Down Expand Up @@ -793,6 +808,7 @@ const LLMFormatOps: FormatOp[] = [
removeEmptyMessages,
refineDetectedNewPromblems,
extractCompactMessages,
convertPastedTextPartsForLLM,
removeMessagesWithoutTextOrToolCall,
replaceAttemptTodoCompletionForLLM,
resolvePendingToolCalls,
Expand Down
28 changes: 28 additions & 0 deletions packages/common/src/base/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,34 @@ export const MessageMetadata = z.discriminatedUnion("kind", [

export type MessageMetadata = z.infer<typeof MessageMetadata>;

export function getPastedTextTitle(text: string): string | undefined {
const maxLength = 80;
const title: string[] = [];
let pendingSpace = false;

for (const character of text) {
if (character === "\n" || character === "\r") {
if (title.length > 0) break;
pendingSpace = false;
continue;
}
if (/\s/.test(character)) {
pendingSpace = title.length > 0;
continue;
}
if (pendingSpace) {
title.push(" ");
pendingSpace = false;
}
title.push(character);
if (title.length > maxLength) {
return `${title.slice(0, maxLength - 1).join("")}…`;
}
}

return title.join("") || undefined;
}

export const BackgroundJobNotification = z.object({
notificationId: z.string(),
backgroundJobId: z.string(),
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/vscode-webui-bridge/types/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type PochiTaskParams = { cwd: string } & (
type: "new-task";
uid?: string;
prompt?: string;
pastedTexts?: string[];
todos?: Todo[];
files?: FileUIPart[];
activeSelection?: ActiveSelection;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,50 @@ describe("auto-memory adaptor", () => {
expect(transcript.length).toBeLessThan(24_000);
});

it("keeps pasted text content in the bounded transcript", async () => {
const store = new FakeStore([
makeTask({
id: "parent",
status: "completed",
background: false,
title: "Analyze pasted logs",
}),
]);
const manager = makeAutoMemoryManager();
const adaptor = new AutoMemoryAdaptor({
store: store as unknown as LiveKitStore,
backgroundTask: createTestBackgroundTask({
store: store as unknown as LiveKitStore,
stateStore: new BackgroundTaskStateStore(),
}),
parentTaskId: "parent",
parentCwd: "/repo",
manager,
});

await adaptor.update({
messages: [
{
id: "u1",
role: "user",
parts: [
{
type: "data-pasted-text",
data: { text: "important pasted context" },
},
],
},
] as Message[],
status: "completed",
});

const transcript = vi.mocked(manager.writeTaskTranscript).mock.calls[0]?.[0]
.transcript;
expect(transcript).toContain(
JSON.stringify({ type: "text", text: "important pasted context" }),
);
});

it("starts a dream background task after extraction completes and finishes the dream lock", async () => {
const store = new FakeStore([
makeTask({
Expand Down
6 changes: 6 additions & 0 deletions packages/livekit/src/background-task/memory/auto-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,12 @@ function isWindowsAbsolutePath(inputPath: string): boolean {

function sanitizePart(part: UIMessage["parts"][number]) {
if (part.type === "text") return part;
if (part.type === "data-pasted-text") {
return {
type: "text",
text: (part as { data: { text: string } }).data.text,
};
}
if (part.type.startsWith("data-")) return { type: part.type };
if (isStaticToolUIPart(part)) {
return {
Expand Down
127 changes: 127 additions & 0 deletions packages/livekit/src/chat/llm/generate-task-title.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import type { BlobStore } from "../../blob-store";
import type { LiveKitStore, Message } from "../../types";
import { generateTaskTitle } from "./generate-task-title";

const generateTextMock = vi.hoisted(() => vi.fn());

vi.mock("ai", async (importOriginal) => {
const original = await importOriginal<typeof import("ai")>();
return {
...original,
generateText: generateTextMock,
};
});

const unusedStore = {} as LiveKitStore;
const unusedBlobStore = {} as BlobStore;

describe("generateTaskTitle pasted text fallback", () => {
it("uses a bounded pasted-text preview when there is no typed prompt", async () => {
const text = ` ${"x".repeat(100)} `;
const messages: Message[] = [
{
id: "user-1",
role: "user",
parts: [{ type: "data-pasted-text", data: { text } }],
},
];

const title = await generateTaskTitle({
store: unusedStore,
blobStore: unusedBlobStore,
taskId: "task-1",
title: null,
messages,
getModel: vi.fn(),
});

expect(title).toBe(`${"x".repeat(79)}…`);
});

it("does not split a Unicode character in the pasted-text preview", async () => {
const messages: Message[] = [
{
id: "user-1",
role: "user",
parts: [
{ type: "data-pasted-text", data: { text: "😀".repeat(100) } },
],
},
];

const title = await generateTaskTitle({
store: unusedStore,
blobStore: unusedBlobStore,
taskId: "task-1",
title: null,
messages,
getModel: vi.fn(),
});

expect(title).toBe(`${"😀".repeat(79)}…`);
});

it("prefers the typed prompt over pasted text", async () => {
const messages: Message[] = [
{
id: "user-1",
role: "user",
parts: [
{ type: "text", text: "Analyze these logs" },
{
type: "data-pasted-text",
data: { text: "serialized message list" },
},
],
},
];

const title = await generateTaskTitle({
store: unusedStore,
blobStore: unusedBlobStore,
taskId: "task-1",
title: null,
messages,
getModel: vi.fn(),
});

expect(title).toBe("Analyze these logs");
});

it("sends only a bounded pasted-text preview to the title model", async () => {
const text = "x".repeat(6_000);
const fallbackTitle = `${"x".repeat(79)}…`;
const messages: Message[] = [
{
id: "user-1",
role: "user",
parts: [{ type: "data-pasted-text", data: { text } }],
},
...Array.from({ length: 4 }, (_, index) => ({
id: `assistant-${index}`,
role: "assistant" as const,
metadata: {
kind: "assistant" as const,
totalTokens: 1,
finishReason: "stop" as const,
},
parts: [{ type: "text" as const, text: `response ${index}` }],
})),
];
generateTextMock.mockResolvedValueOnce({ text: "Generated title" });

await generateTaskTitle({
store: unusedStore,
blobStore: unusedBlobStore,
taskId: "task-1",
title: fallbackTitle,
messages,
getModel: vi.fn(() => ({}) as never),
});

const prompt = generateTextMock.mock.calls[0]?.[0]?.prompt;
expect(JSON.stringify(prompt)).not.toContain(text);
expect(JSON.stringify(prompt)).toContain(fallbackTitle);
});
});
Loading
Loading