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();
+ });
+});