diff --git a/packages/junior/src/chat/respond-helpers.ts b/packages/junior/src/chat/respond-helpers.ts index 7f8914c8..39172af0 100644 --- a/packages/junior/src/chat/respond-helpers.ts +++ b/packages/junior/src/chat/respond-helpers.ts @@ -143,7 +143,11 @@ export function summarizeMessageText(text: string): string { : normalized; } -/** Wrap user input with conversation context and observability metadata XML tags. */ +/** + * Wrap the current user turn with self-describing marker blocks: background + * first, current instruction last. Ordering matches long-context attention + * guidance for Sonnet and GPT-5. + */ export function buildUserTurnText( userInput: string, conversationContext?: string, @@ -153,47 +157,48 @@ export function buildUserTurnText( }, ): string { const trimmedContext = conversationContext?.trim(); - const hasSessionContext = Boolean(metadata?.sessionContext?.conversationId); - const hasTurnContext = Boolean(metadata?.turnContext?.traceId); + const conversationId = metadata?.sessionContext?.conversationId; + const traceId = metadata?.turnContext?.traceId; - if (!trimmedContext && !hasSessionContext && !hasTurnContext) { + if (!trimmedContext && !conversationId && !traceId) { return userInput; } - const sections: string[] = [ - "", - userInput, - "", - ]; + const sections: string[] = []; if (trimmedContext) { sections.push( - "", - "", - "Use this context for continuity across prior thread turns.", + "", trimmedContext, - "", + "", + "", ); } - if (metadata?.sessionContext?.conversationId) { + if (conversationId) { sections.push( - "", "", - `- gen_ai.conversation.id: ${metadata.sessionContext.conversationId}`, + `- gen_ai.conversation.id: ${conversationId}`, "", + "", ); } - if (metadata?.turnContext?.traceId) { + if (traceId) { sections.push( - "", "", - `- trace_id: ${metadata.turnContext.traceId}`, + `- trace_id: ${traceId}`, "", + "", ); } + sections.push( + '', + userInput, + "", + ); + return sections.join("\n"); } diff --git a/packages/junior/src/chat/services/conversation-memory.ts b/packages/junior/src/chat/services/conversation-memory.ts index db96be8c..60f6b78e 100644 --- a/packages/junior/src/chat/services/conversation-memory.ts +++ b/packages/junior/src/chat/services/conversation-memory.ts @@ -9,6 +9,7 @@ import type { } from "@/chat/state/conversation"; import { toOptionalString } from "@/chat/coerce"; import { logWarn, setSpanAttributes } from "@/chat/logging"; +import { escapeXml } from "@/chat/xml"; const CONTEXT_COMPACTION_TRIGGER_TOKENS = 9000; const CONTEXT_COMPACTION_TARGET_TOKENS = 7000; @@ -152,6 +153,11 @@ export function markConversationMessage( updateConversationStats(conversation); } +/** + * Render thread history as structured XML. Each compaction and message is + * wrapped with index/ts metadata so the model can reference prior items + * individually instead of treating the whole block as one flat narrative. + */ export function buildConversationContext( conversation: ThreadConversationState, options: { @@ -166,25 +172,31 @@ export function buildConversationContext( } const lines: string[] = []; + if (conversation.compactions.length > 0) { lines.push(""); for (const [index, compaction] of conversation.compactions.entries()) { lines.push( - [ - `summary_${index + 1}:`, - compaction.summary, - `covered_messages: ${compaction.coveredMessageIds.length}`, - `created_at: ${new Date(compaction.createdAtMs).toISOString()}`, - ].join(" "), + ` `, + compaction.summary, + " ", ); } - lines.push(""); - lines.push(""); + lines.push("", ""); } lines.push(""); - for (const message of messages) { - lines.push(renderConversationMessageLine(message, conversation)); + for (const [index, message] of messages.entries()) { + const author = escapeXml(message.author?.userName ?? message.role); + const ts = new Date(message.createdAtMs).toISOString(); + const slackTsAttr = message.meta?.slackTs + ? ` slack_ts="${escapeXml(message.meta.slackTs)}"` + : ""; + lines.push( + ` `, + renderConversationMessageLine(message, conversation), + " ", + ); } lines.push(""); return lines.join("\n"); @@ -240,9 +252,14 @@ async function summarizeConversationChunk( role: "user", content: [ "Summarize the following older Slack thread transcript segment for future assistant turns.", - "Keep the summary factual and concise.", - "Preserve decisions, commitments, constraints, locations, hiring criteria, and unresolved asks.", - "Do not invent details.", + "Keep the summary factual and concise. Do not invent details.", + "", + "Output exactly three XML sections in this order:", + " one bullet per outstanding user ask that has not been narrowed, answered, or superseded by a later turn. Omit the section body if none. ", + " one bullet per ask that has been rescoped, narrowed, answered, or already acted on in this segment. Include the replacement/outcome inline. Omit the section body if none. ", + " one bullet per durable fact useful regardless of scope: names, ids, URLs, decisions, locations, preferences, constraints that remain true. Omit the section body if none. ", + "", + "Do not output any text outside the three sections.", "", transcript, ].join("\n"), diff --git a/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts b/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts new file mode 100644 index 00000000..b0a2ddc4 --- /dev/null +++ b/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { buildUserTurnText } from "@/chat/respond-helpers"; + +describe("buildUserTurnText", () => { + it("returns raw input when no context or metadata is provided", () => { + expect(buildUserTurnText("hello")).toBe("hello"); + }); +}); diff --git a/packages/junior/tests/unit/services/conversation-memory.test.ts b/packages/junior/tests/unit/services/conversation-memory.test.ts index caa1bd8a..fbf80fd1 100644 --- a/packages/junior/tests/unit/services/conversation-memory.test.ts +++ b/packages/junior/tests/unit/services/conversation-memory.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { getThreadTitleSourceMessage } from "@/chat/services/conversation-memory"; +import { + buildConversationContext, + getThreadTitleSourceMessage, +} from "@/chat/services/conversation-memory"; import { coerceThreadConversationState } from "@/chat/state/conversation"; describe("conversation memory title source", () => { @@ -58,3 +61,10 @@ describe("conversation memory title source", () => { ); }); }); + +describe("buildConversationContext", () => { + it("returns undefined for an empty conversation", () => { + const conversation = coerceThreadConversationState({}); + expect(buildConversationContext(conversation)).toBeUndefined(); + }); +});