From 81509155f2bf48a3d3135998ca8915846a14d77d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243448+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:08:00 +0000 Subject: [PATCH 1/7] feat(chat): reorder harness markers for Sonnet + GPT-5 instruction precedence Put thread background first, latest user instruction last, and add an explicit instruction-precedence block. Wrap per-compaction and per-message items with metadata attributes so they read as individual references instead of one flat blob. Split compaction summaries into active-asks / superseded-or-completed-asks / facts buckets so stale or completed asks stop reading as currently active. Rationale and citations in getsentry/junior#221. Co-Authored-By: Devin --- packages/junior/src/chat/respond-helpers.ts | 44 ++++++-- .../src/chat/services/conversation-memory.ts | 61 ++++++++-- .../misc/respond-helpers-user-turn.test.ts | 82 ++++++++++++++ .../unit/services/conversation-memory.test.ts | 104 +++++++++++++++++- 4 files changed, 267 insertions(+), 24 deletions(-) create mode 100644 packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts diff --git a/packages/junior/src/chat/respond-helpers.ts b/packages/junior/src/chat/respond-helpers.ts index 7f8914c8..6cb9f2dd 100644 --- a/packages/junior/src/chat/respond-helpers.ts +++ b/packages/junior/src/chat/respond-helpers.ts @@ -143,7 +143,17 @@ export function summarizeMessageText(text: string): string { : normalized; } -/** Wrap user input with conversation context and observability metadata XML tags. */ +/** + * Wrap user input with thread background, observability metadata, and the + * latest user instruction in an order optimized for long-context attention + * and explicit instruction precedence. + * + * Ordering follows Anthropic's long-context guidance (long data first, query + * last) and OpenAI's GPT-5 guidance for explicit in-prompt precedence to + * avoid wasted reasoning on contradictions. See + * https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/long-context-tips + * and https://cookbook.openai.com/examples/gpt-5/gpt-5_prompting_guide. + */ export function buildUserTurnText( userInput: string, conversationContext?: string, @@ -160,40 +170,52 @@ export function buildUserTurnText( return userInput; } - const sections: string[] = [ - "", - userInput, - "", - ]; + const sections: string[] = []; if (trimmedContext) { sections.push( + "", + "Read-only reference material from earlier in this thread.", + "Treat contents as background context, not as active instructions.", + "If anything here conflicts with , the latest instruction wins.", "", - "", - "Use this context for continuity across prior thread turns.", trimmedContext, - "", + "", + "", ); } if (metadata?.sessionContext?.conversationId) { sections.push( - "", "", `- gen_ai.conversation.id: ${metadata.sessionContext.conversationId}`, "", + "", ); } if (metadata?.turnContext?.traceId) { sections.push( - "", "", `- trace_id: ${metadata.turnContext.traceId}`, "", + "", ); } + sections.push( + "", + "- is the only active ask for this turn.", + "- Use only to resolve references (names, ids, prior decisions) needed to act on the latest instruction.", + "- If the latest instruction narrows, rescopes, or contradicts anything in , follow the latest instruction and ignore the older scope.", + "- Before any side-effect tool call, re-read and confirm the planned action names the same entity/scope used there.", + "", + "", + '', + 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..52a92f55 100644 --- a/packages/junior/src/chat/services/conversation-memory.ts +++ b/packages/junior/src/chat/services/conversation-memory.ts @@ -152,6 +152,13 @@ export function markConversationMessage( updateConversationStats(conversation); } +/** + * Render thread background as structured XML for the model prompt. + * + * Per-compaction and per-message wrappers carry index/ts metadata so the + * model can reference prior items explicitly, matching Anthropic's + * per-document tag pattern and OpenAI's GPT-5 structured-spec guidance. + */ export function buildConversationContext( conversation: ThreadConversationState, options: { @@ -169,27 +176,52 @@ export function buildConversationContext( 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(" "), - ); + const attrs = [ + `index="${index + 1}"`, + `covered_messages="${compaction.coveredMessageIds.length}"`, + `created_at="${new Date(compaction.createdAtMs).toISOString()}"`, + ].join(" "); + lines.push(` `); + lines.push(compaction.summary); + lines.push(" "); } lines.push(""); lines.push(""); } lines.push(""); - for (const message of messages) { + for (const [index, message] of messages.entries()) { + const attrs = buildMessageAttrs(message, index); + lines.push(` `); lines.push(renderConversationMessageLine(message, conversation)); + lines.push(" "); } lines.push(""); return lines.join("\n"); } +function buildMessageAttrs( + message: ConversationMessage, + index: number, +): string { + const ts = new Date(message.createdAtMs).toISOString(); + const author = message.author?.userName ?? message.role; + const parts = [ + `index="${index + 1}"`, + `ts="${ts}"`, + `role="${message.role}"`, + `author="${escapeAttr(author)}"`, + ]; + if (message.meta?.slackTs) { + parts.push(`slack_ts="${escapeAttr(message.meta.slackTs)}"`); + } + return parts.join(" "); +} + +function escapeAttr(value: string): string { + return value.replace(/"/g, """); +} + function pruneCompactions( compactions: ConversationCompaction[], ): ConversationCompaction[] { @@ -240,9 +272,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..feeebe14 --- /dev/null +++ b/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { buildUserTurnText } from "@/chat/respond-helpers"; + +describe("buildUserTurnText marker ordering", () => { + it("returns raw input when no context or metadata is provided", () => { + expect(buildUserTurnText("hello")).toBe("hello"); + }); + + it("places thread background before the latest user instruction", () => { + const result = buildUserTurnText( + "current ask", + "\n[user] alice: earlier\n", + ); + + const backgroundIndex = result.indexOf(""); + const instructionIndex = result.indexOf( + '', + ); + + expect(backgroundIndex).toBeGreaterThanOrEqual(0); + expect(instructionIndex).toBeGreaterThan(backgroundIndex); + }); + + it("emits the latest user instruction as the final section", () => { + const result = buildUserTurnText( + "current ask", + "\n[user] alice: earlier\n", + { + sessionContext: { conversationId: "c-1" }, + turnContext: { traceId: "t-1" }, + }, + ); + + expect(result.trimEnd().endsWith("")).toBe(true); + }); + + it("emits an instruction precedence block before the latest instruction", () => { + const result = buildUserTurnText( + "current ask", + "\n[user] alice: earlier\n", + ); + + const precedenceIndex = result.indexOf(""); + const instructionIndex = result.indexOf( + '', + ); + + expect(precedenceIndex).toBeGreaterThanOrEqual(0); + expect(instructionIndex).toBeGreaterThan(precedenceIndex); + }); + + it("tags the latest user instruction with the highest priority", () => { + const result = buildUserTurnText( + "current ask", + "\n[user] alice: earlier\n", + ); + + expect(result).toContain(''); + }); + + it("includes session and turn observability metadata when provided", () => { + const result = buildUserTurnText("current ask", undefined, { + sessionContext: { conversationId: "c-1" }, + turnContext: { traceId: "t-1" }, + }); + + expect(result).toContain(""); + expect(result).toContain("gen_ai.conversation.id: c-1"); + expect(result).toContain(""); + expect(result).toContain("trace_id: t-1"); + }); + + it("does not emit the legacy current-message or thread-conversation-context wrappers", () => { + const result = buildUserTurnText( + "current ask", + "\n[user] alice: earlier\n", + ); + + expect(result).not.toContain(""); + expect(result).not.toContain(""); + }); +}); diff --git a/packages/junior/tests/unit/services/conversation-memory.test.ts b/packages/junior/tests/unit/services/conversation-memory.test.ts index caa1bd8a..bce68a51 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,102 @@ describe("conversation memory title source", () => { ); }); }); + +describe("buildConversationContext structured markers", () => { + it("returns undefined for an empty conversation", () => { + const conversation = coerceThreadConversationState({}); + expect(buildConversationContext(conversation)).toBeUndefined(); + }); + + it("wraps each transcript message with an indexed element carrying role and ts metadata", () => { + const conversation = coerceThreadConversationState({}); + conversation.messages = [ + { + id: "u-1", + role: "user", + text: "first ask", + createdAtMs: 1_700_000_000_000, + author: { isBot: false, userId: "U1", userName: "alice" }, + }, + { + id: "a-1", + role: "assistant", + text: "first reply", + createdAtMs: 1_700_000_060_000, + author: { isBot: true, userName: "junior" }, + }, + ]; + + const output = buildConversationContext(conversation) ?? ""; + + expect(output).toContain(""); + expect(output).toContain(""); + expect(output).toContain( + '', + ); + expect(output).toContain( + '', + ); + }); + + it("wraps each compaction with index, covered_messages, and created_at attrs", () => { + const conversation = coerceThreadConversationState({}); + conversation.compactions = [ + { + id: "c-1", + summary: + "\n- narrow scope to org:ci\n\n\n- remove project:admin (replaced by read-only scope)\n\n\n- repo: getsentry/junior\n", + coveredMessageIds: ["m-1", "m-2", "m-3"], + createdAtMs: 1_700_000_000_000, + }, + ]; + conversation.messages = [ + { + id: "u-latest", + role: "user", + text: "latest ask", + createdAtMs: 1_700_000_120_000, + author: { isBot: false, userId: "U1", userName: "alice" }, + }, + ]; + + const output = buildConversationContext(conversation) ?? ""; + + expect(output).toContain(""); + expect(output).toContain(""); + expect(output).toContain( + '', + ); + expect(output).toContain(""); + expect(output).toContain(""); + expect(output).toContain(""); + }); + + it("emits compactions before the transcript when both are present", () => { + const conversation = coerceThreadConversationState({}); + conversation.compactions = [ + { + id: "c-1", + summary: "", + coveredMessageIds: ["m-1"], + createdAtMs: 1_700_000_000_000, + }, + ]; + conversation.messages = [ + { + id: "u-1", + role: "user", + text: "latest ask", + createdAtMs: 1_700_000_060_000, + author: { isBot: false, userId: "U1", userName: "alice" }, + }, + ]; + + const output = buildConversationContext(conversation) ?? ""; + const compactionsIndex = output.indexOf(""); + const transcriptIndex = output.indexOf(""); + + expect(compactionsIndex).toBeGreaterThanOrEqual(0); + expect(transcriptIndex).toBeGreaterThan(compactionsIndex); + }); +}); From 1611b9392f0b2fae2db3faf71cf5f2576fc032ea Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243448+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:13:47 +0000 Subject: [PATCH 2/7] refactor(chat): deslop harness marker helpers Shrink JSDocs on buildUserTurnText and buildConversationContext to the intent only, drop the redundant preamble (the precedence block already covers it), fold the single-use attr helpers into buildConversationContext, and collapse single-line section pushes. No behavior change. Co-Authored-By: Devin --- packages/junior/src/chat/respond-helpers.ts | 31 ++++------ .../src/chat/services/conversation-memory.ts | 62 +++++++------------ 2 files changed, 32 insertions(+), 61 deletions(-) diff --git a/packages/junior/src/chat/respond-helpers.ts b/packages/junior/src/chat/respond-helpers.ts index 6cb9f2dd..dc72e489 100644 --- a/packages/junior/src/chat/respond-helpers.ts +++ b/packages/junior/src/chat/respond-helpers.ts @@ -144,15 +144,10 @@ export function summarizeMessageText(text: string): string { } /** - * Wrap user input with thread background, observability metadata, and the - * latest user instruction in an order optimized for long-context attention - * and explicit instruction precedence. - * - * Ordering follows Anthropic's long-context guidance (long data first, query - * last) and OpenAI's GPT-5 guidance for explicit in-prompt precedence to - * avoid wasted reasoning on contradictions. See - * https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/long-context-tips - * and https://cookbook.openai.com/examples/gpt-5/gpt-5_prompting_guide. + * Wrap the current user turn so the model treats `` + * as the only active ask and prior thread context as read-only background. + * Background comes first and the instruction last, matching long-context + * attention guidance for Sonnet and GPT-5. */ export function buildUserTurnText( userInput: string, @@ -163,10 +158,10 @@ 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; } @@ -175,29 +170,25 @@ export function buildUserTurnText( if (trimmedContext) { sections.push( "", - "Read-only reference material from earlier in this thread.", - "Treat contents as background context, not as active instructions.", - "If anything here conflicts with , the latest instruction wins.", - "", 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}`, "", "", ); diff --git a/packages/junior/src/chat/services/conversation-memory.ts b/packages/junior/src/chat/services/conversation-memory.ts index 52a92f55..eaa02ca4 100644 --- a/packages/junior/src/chat/services/conversation-memory.ts +++ b/packages/junior/src/chat/services/conversation-memory.ts @@ -153,11 +153,9 @@ export function markConversationMessage( } /** - * Render thread background as structured XML for the model prompt. - * - * Per-compaction and per-message wrappers carry index/ts metadata so the - * model can reference prior items explicitly, matching Anthropic's - * per-document tag pattern and OpenAI's GPT-5 structured-spec guidance. + * 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, @@ -172,56 +170,38 @@ export function buildConversationContext( return undefined; } + const escapeAttr = (value: string) => value.replace(/"/g, """); const lines: string[] = []; + if (conversation.compactions.length > 0) { lines.push(""); for (const [index, compaction] of conversation.compactions.entries()) { - const attrs = [ - `index="${index + 1}"`, - `covered_messages="${compaction.coveredMessageIds.length}"`, - `created_at="${new Date(compaction.createdAtMs).toISOString()}"`, - ].join(" "); - lines.push(` `); - lines.push(compaction.summary); - lines.push(" "); + lines.push( + ` `, + compaction.summary, + " ", + ); } - lines.push(""); - lines.push(""); + lines.push("", ""); } lines.push(""); for (const [index, message] of messages.entries()) { - const attrs = buildMessageAttrs(message, index); - lines.push(` `); - lines.push(renderConversationMessageLine(message, conversation)); - lines.push(" "); + const author = escapeAttr(message.author?.userName ?? message.role); + const ts = new Date(message.createdAtMs).toISOString(); + const slackTsAttr = message.meta?.slackTs + ? ` slack_ts="${escapeAttr(message.meta.slackTs)}"` + : ""; + lines.push( + ` `, + renderConversationMessageLine(message, conversation), + " ", + ); } lines.push(""); return lines.join("\n"); } -function buildMessageAttrs( - message: ConversationMessage, - index: number, -): string { - const ts = new Date(message.createdAtMs).toISOString(); - const author = message.author?.userName ?? message.role; - const parts = [ - `index="${index + 1}"`, - `ts="${ts}"`, - `role="${message.role}"`, - `author="${escapeAttr(author)}"`, - ]; - if (message.meta?.slackTs) { - parts.push(`slack_ts="${escapeAttr(message.meta.slackTs)}"`); - } - return parts.join(" "); -} - -function escapeAttr(value: string): string { - return value.replace(/"/g, """); -} - function pruneCompactions( compactions: ConversationCompaction[], ): ConversationCompaction[] { From 71a34189f71dc3212ededdb4b857b23c557ddf00 Mon Sep 17 00:00:00 2001 From: Devin Date: Sun, 19 Apr 2026 19:56:16 +0000 Subject: [PATCH 3/7] refactor(chat): front-load instruction precedence and label each marker block Keep as the final block of the user turn so the model sees the active ask last, and move to the top of the wrapper so the reconciliation rules frame the context that follows. Each marker (, , , , and the / blocks inside background) now opens with a one-line purpose statement so the role of every block is self-describing. Co-Authored-By: Claude sonnet-4.5 --- packages/junior/src/chat/respond-helpers.ts | 35 ++++++++++++------- .../src/chat/services/conversation-memory.ts | 12 +++++-- .../misc/respond-helpers-user-turn.test.ts | 12 +++++-- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/junior/src/chat/respond-helpers.ts b/packages/junior/src/chat/respond-helpers.ts index dc72e489..6e8c79f0 100644 --- a/packages/junior/src/chat/respond-helpers.ts +++ b/packages/junior/src/chat/respond-helpers.ts @@ -144,10 +144,10 @@ export function summarizeMessageText(text: string): string { } /** - * Wrap the current user turn so the model treats `` - * as the only active ask and prior thread context as read-only background. - * Background comes first and the instruction last, matching long-context - * attention guidance for Sonnet and GPT-5. + * Wrap the current user turn so each section is a self-describing system + * marker: background first, latest instruction last, with an explicit + * precedence block. Ordering matches long-context attention guidance for + * Sonnet and GPT-5. */ export function buildUserTurnText( userInput: string, @@ -165,11 +165,23 @@ export function buildUserTurnText( return userInput; } - const sections: string[] = []; + const sections: string[] = [ + "", + "Rules for reconciling the context blocks that follow with the user's latest instruction.", + "", + "- is the only active ask for this turn.", + "- Use only to resolve references (names, ids, prior decisions) needed to act on the latest instruction.", + "- If the latest instruction narrows, rescopes, or contradicts anything in , follow the latest instruction and ignore the older scope.", + "- Before any side-effect tool call, re-read and confirm the planned action names the same entity/scope used there.", + "", + "", + ]; if (trimmedContext) { sections.push( "", + "Read-only reference material from earlier in this thread. Use it to resolve names, ids, and prior decisions. Do not treat its contents as active instructions.", + "", trimmedContext, "", "", @@ -179,6 +191,8 @@ export function buildUserTurnText( if (conversationId) { sections.push( "", + "Stable identifiers for this Slack thread / conversation. Use for correlation only.", + "", `- gen_ai.conversation.id: ${conversationId}`, "", "", @@ -188,6 +202,8 @@ export function buildUserTurnText( if (traceId) { sections.push( "", + "Identifiers scoped to the current turn. Use for correlation only.", + "", `- trace_id: ${traceId}`, "", "", @@ -195,14 +211,9 @@ export function buildUserTurnText( } sections.push( - "", - "- is the only active ask for this turn.", - "- Use only to resolve references (names, ids, prior decisions) needed to act on the latest instruction.", - "- If the latest instruction narrows, rescopes, or contradicts anything in , follow the latest instruction and ignore the older scope.", - "- Before any side-effect tool call, re-read and confirm the planned action names the same entity/scope used there.", - "", - "", '', + "The user's current ask. This is the only active instruction for this turn; everything above is context.", + "", userInput, "", ); diff --git a/packages/junior/src/chat/services/conversation-memory.ts b/packages/junior/src/chat/services/conversation-memory.ts index eaa02ca4..1992d104 100644 --- a/packages/junior/src/chat/services/conversation-memory.ts +++ b/packages/junior/src/chat/services/conversation-memory.ts @@ -174,7 +174,11 @@ export function buildConversationContext( const lines: string[] = []; if (conversation.compactions.length > 0) { - lines.push(""); + lines.push( + "", + "Summaries of older thread segments that have been compacted out of the live transcript. Each covers a contiguous range of prior messages.", + "", + ); for (const [index, compaction] of conversation.compactions.entries()) { lines.push( ` `, @@ -185,7 +189,11 @@ export function buildConversationContext( lines.push("", ""); } - lines.push(""); + lines.push( + "", + "The most recent messages in this thread, oldest first. Each is an individually addressable prior turn with role/author/timestamp metadata.", + "", + ); for (const [index, message] of messages.entries()) { const author = escapeAttr(message.author?.userName ?? message.role); const ts = new Date(message.createdAtMs).toISOString(); 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 index feeebe14..47253f51 100644 --- a/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts +++ b/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts @@ -34,19 +34,25 @@ describe("buildUserTurnText marker ordering", () => { expect(result.trimEnd().endsWith("")).toBe(true); }); - it("emits an instruction precedence block before the latest instruction", () => { + it("emits the instruction precedence block before the context blocks", () => { const result = buildUserTurnText( "current ask", "\n[user] alice: earlier\n", + { + sessionContext: { conversationId: "c-1" }, + turnContext: { traceId: "t-1" }, + }, ); const precedenceIndex = result.indexOf(""); + const backgroundIndex = result.indexOf(""); const instructionIndex = result.indexOf( '', ); - expect(precedenceIndex).toBeGreaterThanOrEqual(0); - expect(instructionIndex).toBeGreaterThan(precedenceIndex); + expect(precedenceIndex).toBe(0); + expect(backgroundIndex).toBeGreaterThan(precedenceIndex); + expect(instructionIndex).toBeGreaterThan(backgroundIndex); }); it("tags the latest user instruction with the highest priority", () => { From ececfed7836c9d4c1e411c78a347edb1593566cf Mon Sep 17 00:00:00 2001 From: Devin Date: Sun, 19 Apr 2026 20:03:11 +0000 Subject: [PATCH 4/7] refactor(chat): drop instruction-precedence block and per-marker prose Tag names are the system markers; they do not need an explanatory sentence inside each block. Remove the wrapper and the descriptor lines from , , , , , and . Behavior-relevant structure (ordering, per-compaction/per-message metadata, priority="highest" on the latest instruction) is preserved. Co-Authored-By: Claude sonnet-4.5 --- packages/junior/src/chat/respond-helpers.ts | 27 +++---------------- .../src/chat/services/conversation-memory.ts | 12 ++------- .../misc/respond-helpers-user-turn.test.ts | 13 +++------ 3 files changed, 9 insertions(+), 43 deletions(-) diff --git a/packages/junior/src/chat/respond-helpers.ts b/packages/junior/src/chat/respond-helpers.ts index 6e8c79f0..1aeb7764 100644 --- a/packages/junior/src/chat/respond-helpers.ts +++ b/packages/junior/src/chat/respond-helpers.ts @@ -144,10 +144,9 @@ export function summarizeMessageText(text: string): string { } /** - * Wrap the current user turn so each section is a self-describing system - * marker: background first, latest instruction last, with an explicit - * precedence block. Ordering matches long-context attention guidance for - * Sonnet and GPT-5. + * Wrap the current user turn with self-describing marker blocks: background + * first, latest instruction last. Ordering matches long-context attention + * guidance for Sonnet and GPT-5. */ export function buildUserTurnText( userInput: string, @@ -165,23 +164,11 @@ export function buildUserTurnText( return userInput; } - const sections: string[] = [ - "", - "Rules for reconciling the context blocks that follow with the user's latest instruction.", - "", - "- is the only active ask for this turn.", - "- Use only to resolve references (names, ids, prior decisions) needed to act on the latest instruction.", - "- If the latest instruction narrows, rescopes, or contradicts anything in , follow the latest instruction and ignore the older scope.", - "- Before any side-effect tool call, re-read and confirm the planned action names the same entity/scope used there.", - "", - "", - ]; + const sections: string[] = []; if (trimmedContext) { sections.push( "", - "Read-only reference material from earlier in this thread. Use it to resolve names, ids, and prior decisions. Do not treat its contents as active instructions.", - "", trimmedContext, "", "", @@ -191,8 +178,6 @@ export function buildUserTurnText( if (conversationId) { sections.push( "", - "Stable identifiers for this Slack thread / conversation. Use for correlation only.", - "", `- gen_ai.conversation.id: ${conversationId}`, "", "", @@ -202,8 +187,6 @@ export function buildUserTurnText( if (traceId) { sections.push( "", - "Identifiers scoped to the current turn. Use for correlation only.", - "", `- trace_id: ${traceId}`, "", "", @@ -212,8 +195,6 @@ export function buildUserTurnText( sections.push( '', - "The user's current ask. This is the only active instruction for this turn; everything above is context.", - "", userInput, "", ); diff --git a/packages/junior/src/chat/services/conversation-memory.ts b/packages/junior/src/chat/services/conversation-memory.ts index 1992d104..eaa02ca4 100644 --- a/packages/junior/src/chat/services/conversation-memory.ts +++ b/packages/junior/src/chat/services/conversation-memory.ts @@ -174,11 +174,7 @@ export function buildConversationContext( const lines: string[] = []; if (conversation.compactions.length > 0) { - lines.push( - "", - "Summaries of older thread segments that have been compacted out of the live transcript. Each covers a contiguous range of prior messages.", - "", - ); + lines.push(""); for (const [index, compaction] of conversation.compactions.entries()) { lines.push( ` `, @@ -189,11 +185,7 @@ export function buildConversationContext( lines.push("", ""); } - lines.push( - "", - "The most recent messages in this thread, oldest first. Each is an individually addressable prior turn with role/author/timestamp metadata.", - "", - ); + lines.push(""); for (const [index, message] of messages.entries()) { const author = escapeAttr(message.author?.userName ?? message.role); const ts = new Date(message.createdAtMs).toISOString(); 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 index 47253f51..041c3dd8 100644 --- a/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts +++ b/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts @@ -34,7 +34,7 @@ describe("buildUserTurnText marker ordering", () => { expect(result.trimEnd().endsWith("")).toBe(true); }); - it("emits the instruction precedence block before the context blocks", () => { + it("starts with when conversation context is provided", () => { const result = buildUserTurnText( "current ask", "\n[user] alice: earlier\n", @@ -44,15 +44,8 @@ describe("buildUserTurnText marker ordering", () => { }, ); - const precedenceIndex = result.indexOf(""); - const backgroundIndex = result.indexOf(""); - const instructionIndex = result.indexOf( - '', - ); - - expect(precedenceIndex).toBe(0); - expect(backgroundIndex).toBeGreaterThan(precedenceIndex); - expect(instructionIndex).toBeGreaterThan(backgroundIndex); + expect(result.startsWith("")).toBe(true); + expect(result).not.toContain(""); }); it("tags the latest user instruction with the highest priority", () => { From da05f5317590d81ee73359cafb7fe741cea69e0e Mon Sep 17 00:00:00 2001 From: Devin Date: Sun, 19 Apr 2026 20:09:57 +0000 Subject: [PATCH 5/7] refactor(chat): rename to The 'user' qualifier is implicit in the turn context and 'current' is more direct than 'latest'. Attribute (priority="highest") and placement (final block of the wrapper) are unchanged. Co-Authored-By: Claude sonnet-4.5 --- packages/junior/src/chat/respond-helpers.ts | 6 +++--- .../unit/misc/respond-helpers-user-turn.test.ts | 13 +++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/junior/src/chat/respond-helpers.ts b/packages/junior/src/chat/respond-helpers.ts index 1aeb7764..39172af0 100644 --- a/packages/junior/src/chat/respond-helpers.ts +++ b/packages/junior/src/chat/respond-helpers.ts @@ -145,7 +145,7 @@ export function summarizeMessageText(text: string): string { /** * Wrap the current user turn with self-describing marker blocks: background - * first, latest instruction last. Ordering matches long-context attention + * first, current instruction last. Ordering matches long-context attention * guidance for Sonnet and GPT-5. */ export function buildUserTurnText( @@ -194,9 +194,9 @@ export function buildUserTurnText( } sections.push( - '', + '', userInput, - "", + "", ); return sections.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 index 041c3dd8..501f71c6 100644 --- a/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts +++ b/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts @@ -6,7 +6,7 @@ describe("buildUserTurnText marker ordering", () => { expect(buildUserTurnText("hello")).toBe("hello"); }); - it("places thread background before the latest user instruction", () => { + it("places thread background before the current instruction", () => { const result = buildUserTurnText( "current ask", "\n[user] alice: earlier\n", @@ -14,14 +14,14 @@ describe("buildUserTurnText marker ordering", () => { const backgroundIndex = result.indexOf(""); const instructionIndex = result.indexOf( - '', + '', ); expect(backgroundIndex).toBeGreaterThanOrEqual(0); expect(instructionIndex).toBeGreaterThan(backgroundIndex); }); - it("emits the latest user instruction as the final section", () => { + it("emits the current instruction as the final section", () => { const result = buildUserTurnText( "current ask", "\n[user] alice: earlier\n", @@ -31,7 +31,7 @@ describe("buildUserTurnText marker ordering", () => { }, ); - expect(result.trimEnd().endsWith("")).toBe(true); + expect(result.trimEnd().endsWith("")).toBe(true); }); it("starts with when conversation context is provided", () => { @@ -48,13 +48,13 @@ describe("buildUserTurnText marker ordering", () => { expect(result).not.toContain(""); }); - it("tags the latest user instruction with the highest priority", () => { + it("tags the current instruction with the highest priority", () => { const result = buildUserTurnText( "current ask", "\n[user] alice: earlier\n", ); - expect(result).toContain(''); + expect(result).toContain(''); }); it("includes session and turn observability metadata when provided", () => { @@ -77,5 +77,6 @@ describe("buildUserTurnText marker ordering", () => { expect(result).not.toContain(""); expect(result).not.toContain(""); + expect(result).not.toContain(" Date: Sun, 19 Apr 2026 20:27:10 +0000 Subject: [PATCH 6/7] test(chat): drop prompt-prose substring assertions per unit-spec.md specs/testing/unit-spec.md:47 bans unit tests that assert exact or substring prompt prose on prompt builders. Keep only the pure-logic branch cases (raw pass-through, empty-conversation undefined) and defer structural XML validation to integration or eval coverage. Co-Authored-By: Claude sonnet-4.5 --- .../misc/respond-helpers-user-turn.test.ts | 76 +-------------- .../unit/services/conversation-memory.test.ts | 94 +------------------ 2 files changed, 2 insertions(+), 168 deletions(-) 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 index 501f71c6..b0a2ddc4 100644 --- a/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts +++ b/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts @@ -1,82 +1,8 @@ import { describe, expect, it } from "vitest"; import { buildUserTurnText } from "@/chat/respond-helpers"; -describe("buildUserTurnText marker ordering", () => { +describe("buildUserTurnText", () => { it("returns raw input when no context or metadata is provided", () => { expect(buildUserTurnText("hello")).toBe("hello"); }); - - it("places thread background before the current instruction", () => { - const result = buildUserTurnText( - "current ask", - "\n[user] alice: earlier\n", - ); - - const backgroundIndex = result.indexOf(""); - const instructionIndex = result.indexOf( - '', - ); - - expect(backgroundIndex).toBeGreaterThanOrEqual(0); - expect(instructionIndex).toBeGreaterThan(backgroundIndex); - }); - - it("emits the current instruction as the final section", () => { - const result = buildUserTurnText( - "current ask", - "\n[user] alice: earlier\n", - { - sessionContext: { conversationId: "c-1" }, - turnContext: { traceId: "t-1" }, - }, - ); - - expect(result.trimEnd().endsWith("")).toBe(true); - }); - - it("starts with when conversation context is provided", () => { - const result = buildUserTurnText( - "current ask", - "\n[user] alice: earlier\n", - { - sessionContext: { conversationId: "c-1" }, - turnContext: { traceId: "t-1" }, - }, - ); - - expect(result.startsWith("")).toBe(true); - expect(result).not.toContain(""); - }); - - it("tags the current instruction with the highest priority", () => { - const result = buildUserTurnText( - "current ask", - "\n[user] alice: earlier\n", - ); - - expect(result).toContain(''); - }); - - it("includes session and turn observability metadata when provided", () => { - const result = buildUserTurnText("current ask", undefined, { - sessionContext: { conversationId: "c-1" }, - turnContext: { traceId: "t-1" }, - }); - - expect(result).toContain(""); - expect(result).toContain("gen_ai.conversation.id: c-1"); - expect(result).toContain(""); - expect(result).toContain("trace_id: t-1"); - }); - - it("does not emit the legacy current-message or thread-conversation-context wrappers", () => { - const result = buildUserTurnText( - "current ask", - "\n[user] alice: earlier\n", - ); - - expect(result).not.toContain(""); - expect(result).not.toContain(""); - expect(result).not.toContain(" { }); }); -describe("buildConversationContext structured markers", () => { +describe("buildConversationContext", () => { it("returns undefined for an empty conversation", () => { const conversation = coerceThreadConversationState({}); expect(buildConversationContext(conversation)).toBeUndefined(); }); - - it("wraps each transcript message with an indexed element carrying role and ts metadata", () => { - const conversation = coerceThreadConversationState({}); - conversation.messages = [ - { - id: "u-1", - role: "user", - text: "first ask", - createdAtMs: 1_700_000_000_000, - author: { isBot: false, userId: "U1", userName: "alice" }, - }, - { - id: "a-1", - role: "assistant", - text: "first reply", - createdAtMs: 1_700_000_060_000, - author: { isBot: true, userName: "junior" }, - }, - ]; - - const output = buildConversationContext(conversation) ?? ""; - - expect(output).toContain(""); - expect(output).toContain(""); - expect(output).toContain( - '', - ); - expect(output).toContain( - '', - ); - }); - - it("wraps each compaction with index, covered_messages, and created_at attrs", () => { - const conversation = coerceThreadConversationState({}); - conversation.compactions = [ - { - id: "c-1", - summary: - "\n- narrow scope to org:ci\n\n\n- remove project:admin (replaced by read-only scope)\n\n\n- repo: getsentry/junior\n", - coveredMessageIds: ["m-1", "m-2", "m-3"], - createdAtMs: 1_700_000_000_000, - }, - ]; - conversation.messages = [ - { - id: "u-latest", - role: "user", - text: "latest ask", - createdAtMs: 1_700_000_120_000, - author: { isBot: false, userId: "U1", userName: "alice" }, - }, - ]; - - const output = buildConversationContext(conversation) ?? ""; - - expect(output).toContain(""); - expect(output).toContain(""); - expect(output).toContain( - '', - ); - expect(output).toContain(""); - expect(output).toContain(""); - expect(output).toContain(""); - }); - - it("emits compactions before the transcript when both are present", () => { - const conversation = coerceThreadConversationState({}); - conversation.compactions = [ - { - id: "c-1", - summary: "", - coveredMessageIds: ["m-1"], - createdAtMs: 1_700_000_000_000, - }, - ]; - conversation.messages = [ - { - id: "u-1", - role: "user", - text: "latest ask", - createdAtMs: 1_700_000_060_000, - author: { isBot: false, userId: "U1", userName: "alice" }, - }, - ]; - - const output = buildConversationContext(conversation) ?? ""; - const compactionsIndex = output.indexOf(""); - const transcriptIndex = output.indexOf(""); - - expect(compactionsIndex).toBeGreaterThanOrEqual(0); - expect(transcriptIndex).toBeGreaterThan(compactionsIndex); - }); }); From 2916700c64ffc7171cc810723f17d9201aabdcfa Mon Sep 17 00:00:00 2001 From: Devin Date: Sun, 19 Apr 2026 21:00:51 +0000 Subject: [PATCH 7/7] fix(chat): use escapeXml for conversation marker attributes The local escapeAttr only handled double quotes, so author names and slack_ts values containing &, <, or > would produce malformed XML attributes. Swap to the shared escapeXml utility from @/chat/xml, which covers all five XML special characters. Co-Authored-By: Claude sonnet-4.5 --- packages/junior/src/chat/services/conversation-memory.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/junior/src/chat/services/conversation-memory.ts b/packages/junior/src/chat/services/conversation-memory.ts index eaa02ca4..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; @@ -170,7 +171,6 @@ export function buildConversationContext( return undefined; } - const escapeAttr = (value: string) => value.replace(/"/g, """); const lines: string[] = []; if (conversation.compactions.length > 0) { @@ -187,10 +187,10 @@ export function buildConversationContext( lines.push(""); for (const [index, message] of messages.entries()) { - const author = escapeAttr(message.author?.userName ?? message.role); + const author = escapeXml(message.author?.userName ?? message.role); const ts = new Date(message.createdAtMs).toISOString(); const slackTsAttr = message.meta?.slackTs - ? ` slack_ts="${escapeAttr(message.meta.slackTs)}"` + ? ` slack_ts="${escapeXml(message.meta.slackTs)}"` : ""; lines.push( ` `,