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
134 changes: 130 additions & 4 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,123 @@ describe("ProviderCommandReactor", () => {
expect(thread?.titleRegeneration).toBeNull();
});

it("pins the first user message when regeneration context is truncated", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";
const firstUserMessage = `Review subagent monitoring risks. ${"Opening context. ".repeat(200)}`;
const recentUserMessage = `LATEST FINDING: ${"implementation detail ".repeat(320)}`;
harness.generateThreadTitle.mockReturnValue(
Effect.succeed({ title: "Review subagent monitoring risks" }),
);

await harness.runEffect(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-thread-title-existing-long"),
threadId: ThreadId.make("thread-1"),
title: "Generic PR review",
}),
);
await harness.runEffect(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-before-long-title-regeneration"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-before-long-title-regeneration"),
role: "user",
text: firstUserMessage,
attachments: [
{
type: "image",
id: "opening-context-image",
name: "image.png",
mimeType: "image/png",
sizeBytes: 5,
},
],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);
await harness.runEffect(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-middle-turn-before-long-title-regeneration"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("middle-message-before-long-title-regeneration"),
role: "user",
text: "Temporary handoff details.",
attachments: [
{
type: "image",
id: "middle-context-image",
name: "image.png",
mimeType: "image/png",
sizeBytes: 5,
},
],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: "2026-01-01T00:00:01.000Z",
}),
);
await harness.runEffect(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-recent-turn-before-long-title-regeneration"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("recent-message-before-long-title-regeneration"),
role: "user",
text: recentUserMessage,
attachments: [
{
type: "image",
id: "recent-context-image",
name: "image.png",
mimeType: "image/png",
sizeBytes: 5,
},
],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: "2026-01-01T00:00:02.000Z",
}),
);
await harness.runEffect(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-thread-title-regenerate-long"),
threadId: ThreadId.make("thread-1"),
regenerateTitle: true,
}),
);

await harness.drain();

expect(harness.generateThreadTitle).toHaveBeenCalledTimes(1);
const input = harness.generateThreadTitle.mock.calls[0]?.[0];
if (!input) {
throw new Error("Expected a title generation input");
}
const message = input.message;
expect(message.startsWith("USER:\nReview subagent monitoring risks.")).toBe(true);
expect(message).toContain("[First user message truncated]");
expect(message).toContain("[Earlier content truncated]");
expect(message).toContain("image.png");
expect(message).toHaveLength(8_000);
expect(input.attachments?.map((attachment) => attachment.id)).toEqual([
"opening-context-image",
"recent-context-image",
]);
});

it("clears title regeneration state left pending across reactor startup", async () => {
const harness = await createHarness({
titleRegenerationBeforeStart: "one",
Expand Down Expand Up @@ -972,10 +1089,14 @@ describe("ProviderCommandReactor", () => {
expect(thread?.titleRegeneration).toBeNull();
});

it("keeps the full retained context and excludes attachments outside it", async () => {
it("pins the first user context and attachment before the retained tail", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";
const retainedContext = "x".repeat(8_000);
const firstUserContext = "USER:\nOld visual issue\n[Attachments: old-issue.png]";
const truncationMarker = "[Earlier content truncated]\n\n";
const retainedContext = "x".repeat(
8_000 - firstUserContext.length - "\n\n".length - truncationMarker.length,
);

await harness.runEffect(
harness.engine.dispatch({
Expand Down Expand Up @@ -1040,9 +1161,14 @@ describe("ProviderCommandReactor", () => {
await harness.drain();

expect(harness.generateThreadTitle.mock.calls[0]?.[0].message).toBe(
`[Earlier content truncated]\n\n${retainedContext}`,
`${firstUserContext}\n\n${truncationMarker}${retainedContext}`,
);
expect(harness.generateThreadTitle.mock.calls[0]?.[0].attachments).toBeUndefined();
expect(harness.generateThreadTitle.mock.calls[0]?.[0].attachments).toEqual([
expect.objectContaining({
id: "old-title-context-image",
name: "old-issue.png",
}),
]);
});

it("does not overwrite a manual rename while title regeneration is running", async () => {
Expand Down
111 changes: 88 additions & 23 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,41 +94,61 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
const DEFAULT_THREAD_TITLE = "New thread";
const MAX_REGENERATION_ATTACHMENTS = 4;
const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000;
const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000;
const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n";
const FIRST_USER_CONTEXT_TRUNCATION_MARKER = "\n[First user message truncated]";

function formatThreadTitleContext(
messages: ReadonlyArray<{
readonly role: "user" | "assistant" | "system";
readonly text: string;
readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
}>,
type ThreadTitleMessage = {
readonly role: "user" | "assistant" | "system";
readonly text: string;
readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
};

function formatThreadTitleSection(message: ThreadTitleMessage): string | undefined {
if (message.role === "system") {
return undefined;
}
const text = message.text.trim();
const attachmentSummary = (message.attachments ?? [])
.map((attachment) => attachment.name)
.join(", ");
const contents = [
...(text.length > 0 ? [text] : []),
...(attachmentSummary.length > 0 ? [`[Attachments: ${attachmentSummary}]`] : []),
].join("\n");
return contents.length > 0 ? `${message.role.toUpperCase()}:\n${contents}` : undefined;
}

function limitFirstUserSection(section: string): string {
if (section.length <= MAX_FIRST_USER_TITLE_CONTEXT_CHARS) {
return section;
}
return `${section.slice(
0,
MAX_FIRST_USER_TITLE_CONTEXT_CHARS - FIRST_USER_CONTEXT_TRUNCATION_MARKER.length,
)}${FIRST_USER_CONTEXT_TRUNCATION_MARKER}`;
}

function collectRecentThreadTitleContext(
messages: ReadonlyArray<ThreadTitleMessage>,
maxChars: number,
): {
readonly message: string;
readonly context: string;
readonly attachments: ReadonlyArray<ChatAttachment>;
readonly truncated: boolean;
} {
let context = "";
let truncated = false;
const retainedAttachments: Array<ChatAttachment> = [];

for (const message of messages.toReversed()) {
if (message.role === "system") {
continue;
}
const text = message.text.trim();
const attachmentSummary = (message.attachments ?? [])
.map((attachment) => attachment.name)
.join(", ");
const contents = [
...(text.length > 0 ? [text] : []),
...(attachmentSummary.length > 0 ? [`[Attachments: ${attachmentSummary}]`] : []),
].join("\n");
if (contents.length === 0) {
const section = formatThreadTitleSection(message);
if (section === undefined) {
continue;
}

const section = `${message.role.toUpperCase()}:\n${contents}`;
const separator = context.length > 0 ? "\n\n" : "";
const available = MAX_THREAD_TITLE_CONTEXT_CHARS - context.length - separator.length;
const available = maxChars - context.length - separator.length;
if (section.length > available) {
if (available > 0) {
context = `${section.slice(-available)}${separator}${context}`;
Expand All @@ -141,9 +161,54 @@ function formatThreadTitleContext(
retainedAttachments.unshift(...(message.attachments ?? []));
}

return { context, attachments: retainedAttachments, truncated };
}

function formatThreadTitleContext(messages: ReadonlyArray<ThreadTitleMessage>): {
readonly message: string;
readonly attachments: ReadonlyArray<ChatAttachment>;
} {
const recent = collectRecentThreadTitleContext(messages, MAX_THREAD_TITLE_CONTEXT_CHARS);
if (!recent.truncated) {
return {
message: recent.context,
attachments: recent.attachments.slice(-MAX_REGENERATION_ATTACHMENTS),
};
}

const firstUserMessage = messages.find(
(message) => message.role === "user" && formatThreadTitleSection(message),
);
const firstUserSection = firstUserMessage
? formatThreadTitleSection(firstUserMessage)
: undefined;
if (!firstUserMessage || !firstUserSection) {
return {
message: `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${recent.context}`,
attachments: recent.attachments.slice(-MAX_REGENERATION_ATTACHMENTS),
};
}

const pinnedSection = limitFirstUserSection(firstUserSection);
const recentContextBudget =
MAX_THREAD_TITLE_CONTEXT_CHARS -
pinnedSection.length -
"\n\n".length -
THREAD_TITLE_CONTEXT_TRUNCATION_MARKER.length;
const retainedRecent = collectRecentThreadTitleContext(messages, recentContextBudget);
const pinnedAttachment = firstUserMessage.attachments?.[0];
const recentAttachments = retainedRecent.attachments.filter(
(attachment) => attachment.id !== pinnedAttachment?.id,
);

return {
message: truncated ? `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${context}` : context,
attachments: retainedAttachments.slice(-MAX_REGENERATION_ATTACHMENTS),
message: `${pinnedSection}\n\n${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${retainedRecent.context}`,
attachments: [
...(pinnedAttachment ? [pinnedAttachment] : []),
...recentAttachments.slice(
-(MAX_REGENERATION_ATTACHMENTS - (pinnedAttachment === undefined ? 0 : 1)),
),
],
};
}

Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/textGeneration/TextGenerationPrompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,17 @@ describe("buildThreadTitlePrompt", () => {
});

expect(result.prompt).toContain(
"Generate a new title that will help the user recognize this T3 Code thread weeks later.",
"Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.",
);
expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".');
expect(result.prompt).toContain(
"Capture the current durable subject and outcome across the whole thread, not merely its initial request or latest step.",
"Read the USER messages first. Identify the latest explicit durable goal.",
);
expect(result.prompt).toContain(
"Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal.",
);
expect(result.prompt).toContain(
'A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks,"',
);
expect(result.prompt).toContain("Thread contents:");
expect(result.prompt).toContain("The remaining issue is stale session state");
Expand Down
Loading
Loading