From ad10a30206f3375921655f6dce506aa0afff5f63 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:35:44 +0000 Subject: [PATCH 1/4] fix(prompt): force max message size on all replies Steer every platform reply with the real inline budget from slackOutputPolicy so Slack and local output share one hard max. Keep the soft 800-character target, and prefer a canvas or short summary over multi-message dumps. Co-Authored-By: David Cramer --- packages/junior/src/chat/prompt.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index a76cb4f00..851a39560 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -23,6 +23,7 @@ import { sandboxSkillDir, } from "@/chat/sandbox/paths"; import type { SlackConversationContext } from "@/chat/slack/conversation-context"; +import { slackOutputPolicy } from "@/chat/slack/output"; import type { SkillMetadata } from "@/chat/skills"; import type { ActiveMcpCatalogSummary } from "@/chat/tool-support/skill/mcp-tool-summary"; import { escapeXml } from "@/chat/xml"; @@ -376,10 +377,27 @@ function buildBehaviorSection(platform: PromptPlatform): string { return sections.join("\n\n"); } +const DEFAULT_REPLY_TARGET_CHARS = 800; + +function buildMaxMessageSizeRule(platform: PromptPlatform): string { + const maxChars = slackOutputPolicy.maxInlineChars; + const maxLines = slackOutputPolicy.maxInlineLines; + const destination = + platform === "slack" + ? "one Slack message" + : "one final reply"; + const overflow = + platform === "slack" + ? " Prefer a Slack canvas link over multi-message overflow." + : " Prefer a linked artifact or short summary over a long dump."; + return `- Default to the shortest complete reply—usually 1–5 sentences and under ${DEFAULT_REPLY_TARGET_CHARS} characters. Include only the outcome, decisive evidence, and any blocker or required next action. Hard max is ${destination} (≤${maxChars} characters and ≤${maxLines} lines).${overflow} An explicit user request for detail may exceed the ${DEFAULT_REPLY_TARGET_CHARS}-character target, but still prefer staying inside the hard max.`; +} + function buildOutputSection(platform: PromptPlatform): string { if (platform === "local") { return [ ``, + buildMaxMessageSizeRule("local"), "- Start with the answer or result, not internal process narration.", "- Use concise Markdown suitable for terminal and web output: short paragraphs, bullets, links, fenced code blocks, and GFM tables when a grid is clearer than bullets.", "- End every turn with a final user-facing response.", @@ -389,7 +407,7 @@ function buildOutputSection(platform: PromptPlatform): string { return [ ``, - "- Default to the shortest complete reply—usually 1–5 sentences and under 800 characters. Include only the outcome, decisive evidence, and any blocker or required next action. If useful detail would exceed that, put it in a Slack canvas and reply with the link. An explicit user request for detail overrides this target.", + buildMaxMessageSizeRule("slack"), "- Start with the answer or result, not internal process narration.", "- Use Slack-flavored Markdown: **bold** section labels, `code`, [text](url) links, bullet lists, and fenced code blocks. No hash-prefixed headings and no tables. When the answer primarily lists several URLs, show each URL bare instead of as a labeled link.", "- End every turn with a final user-facing markdown response unless the Slack action rules allow a no-reply completion.", From 3543e30575bd26ed5603f8f0b99748d1eeb3cb23 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:54:40 +0000 Subject: [PATCH 2/4] fix(slack): tighten max reply size for chat Cut the hard inline budget from 2200/45 to 1200 chars and 12 lines so Slack replies stay short. Prompt steering reads the same policy for every platform. Co-Authored-By: David Cramer --- packages/junior/src/chat/slack/output.ts | 4 +-- .../component/slack/reply-delivery.test.ts | 27 +++++++++---------- .../junior/tests/unit/misc/output.test.ts | 5 +++- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/junior/src/chat/slack/output.ts b/packages/junior/src/chat/slack/output.ts index 089e7101e..8b976f3ed 100644 --- a/packages/junior/src/chat/slack/output.ts +++ b/packages/junior/src/chat/slack/output.ts @@ -2,8 +2,8 @@ import type { PostableMessage } from "chat"; import { getInterruptionMarker } from "@/chat/interruption-marker"; import { normalizeSlackReplyMarkdown } from "@/chat/slack/mrkdwn"; -const MAX_INLINE_CHARS = 2200; -const MAX_INLINE_LINES = 45; +const MAX_INLINE_CHARS = 1200; +const MAX_INLINE_LINES = 12; const CONTINUED_MARKER = "\n\n[Continued below]"; function countSlackLines(text: string): number { diff --git a/packages/junior/tests/component/slack/reply-delivery.test.ts b/packages/junior/tests/component/slack/reply-delivery.test.ts index ce0899c88..ca2557bea 100644 --- a/packages/junior/tests/component/slack/reply-delivery.test.ts +++ b/packages/junior/tests/component/slack/reply-delivery.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { slackOutputPolicy } from "@/chat/slack/output"; import { sendSlackReply } from "@/chat/slack/reply"; import { getCapturedSlackApiCalls, @@ -63,14 +64,14 @@ describe("sendSlackReply", () => { const messageTs = await sendSlackReply({ channelId: "C123", conversationId: "agent-dispatch:dispatch-1", - text: "a".repeat(4_500), + text: "a".repeat(slackOutputPolicy.maxInlineChars * 2 + 100), }); - expect(messageTs).toHaveLength(3); + expect(messageTs.length).toBeGreaterThan(1); expect(messageTs.every(Boolean)).toBe(true); const posts = getCapturedSlackApiCalls("chat.postMessage"); - expect(posts).toHaveLength(3); + expect(posts).toHaveLength(messageTs.length); expect(posts[0]?.params).toEqual( expect.objectContaining({ channel: "C123", @@ -78,18 +79,14 @@ describe("sendSlackReply", () => { ); expect(posts[0]?.params).not.toHaveProperty("thread_ts"); // Later chunks reply under the first posted message. - expect(posts[1]?.params).toEqual( - expect.objectContaining({ - channel: "C123", - thread_ts: messageTs[0], - }), - ); - expect(posts[2]?.params).toEqual( - expect.objectContaining({ - channel: "C123", - thread_ts: messageTs[0], - }), - ); + for (const post of posts.slice(1)) { + expect(post.params).toEqual( + expect.objectContaining({ + channel: "C123", + thread_ts: messageTs[0], + }), + ); + } }); it("keeps the original thread for every chunk", async () => { diff --git a/packages/junior/tests/unit/misc/output.test.ts b/packages/junior/tests/unit/misc/output.test.ts index 4d0accad0..f320cea90 100644 --- a/packages/junior/tests/unit/misc/output.test.ts +++ b/packages/junior/tests/unit/misc/output.test.ts @@ -267,9 +267,12 @@ describe("splitSlackReplyText", () => { (_, i) => `const value${i + 1} = ${i + 1};`, ).join("\n"); const chunks = splitSlackReplyText(`\`\`\`ts\n${code}\n\`\`\``); + const firstBody = chunks[0]?.endsWith(getSlackContinuationMarker()) + ? chunks[0].slice(0, -getSlackContinuationMarker().length) + : chunks[0]; expect(chunks.length).toBeGreaterThan(1); - expect(chunks[0]?.endsWith("```")).toBe(true); + expect(firstBody?.endsWith("```")).toBe(true); expect(chunks[1]?.startsWith("```ts\n")).toBe(true); expect(chunks.every((chunk) => fitsSlackInlineBudget(chunk))).toBe(true); }); From 72174b84175a64bb0477581cd86dca06e69bbf17 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:01:08 +0000 Subject: [PATCH 3/4] test(slack): stop hardcoding resume reply chunk counts The tighter inline budget splits long resumed replies into more posts. Drive the resume continuation test from slackOutputPolicy instead of a fixed 5-message expectation. --- .../integration/oauth-resume-slack.test.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/junior/tests/integration/oauth-resume-slack.test.ts b/packages/junior/tests/integration/oauth-resume-slack.test.ts index 43c2bbb17..38044a7b6 100644 --- a/packages/junior/tests/integration/oauth-resume-slack.test.ts +++ b/packages/junior/tests/integration/oauth-resume-slack.test.ts @@ -4,6 +4,7 @@ import { fauxAssistantMessage } from "@earendil-works/pi-ai/providers/faux"; import { getSlackContinuationMarker, getSlackInterruptionMarker, + slackOutputPolicy, } from "@/chat/slack/output"; import { disconnectStateAdapter } from "@/chat/state/adapter"; import { getCapturedSlackApiCalls } from "../msw/handlers/slack-api"; @@ -281,8 +282,9 @@ describe("oauth resume slack integration", () => { it("chunks long resumed replies into explicit continuation messages", async () => { const { resumeSlackTurn } = await import("@/chat/runtime/slack-resume"); + const lineCount = slackOutputPolicy.maxInlineLines * 4; const longReply = Array.from( - { length: 80 }, + { length: lineCount }, (_, i) => `line ${i + 1}`, ).join("\n"); @@ -305,27 +307,26 @@ describe("oauth resume slack integration", () => { }); const postCalls = getCapturedSlackApiCalls("chat.postMessage"); - expect(postCalls).toHaveLength(5); + expect(postCalls.length).toBeGreaterThan(2); expect(postCalls[0]?.params).toMatchObject({ channel: "C123", thread_ts: "1700000000.002", text: "Connected. Continuing...", }); - expect(postCalls[1]?.params.text).toContain(getSlackContinuationMarker()); - expect(postCalls[2]?.params.text).toContain(getSlackContinuationMarker()); - expect(postCalls[3]?.params.text).toContain(getSlackContinuationMarker()); - expect(postCalls[4]?.params.text).not.toContain( - getSlackContinuationMarker(), - ); - expect(postCalls[4]?.params.text).toContain("line 80"); - // Continuations keep body blocks only; the conversation footer is final-chunk only. - for (const call of postCalls.slice(1, 4)) { + const replyPosts = postCalls.slice(1); + expect(replyPosts.length).toBeGreaterThan(1); + for (const call of replyPosts.slice(0, -1)) { + expect(call.params.text).toContain(getSlackContinuationMarker()); + // Continuations keep body blocks only; the conversation footer is final-chunk only. expect(JSON.stringify(call.params.blocks ?? [])).not.toContain( "slack:C123:1700000000.002", ); } + const finalReply = replyPosts.at(-1)!; + expect(finalReply.params.text).not.toContain(getSlackContinuationMarker()); + expect(finalReply.params.text).toContain(`line ${lineCount}`); expectBlocksIncludeConversationId( - postCalls[4]!.params, + finalReply.params, "slack:C123:1700000000.002", ); }); From aadcdb03c373f6caa8aae2298de829eb33fa0562 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:06:09 +0000 Subject: [PATCH 4/4] fix(prompt): keep max message size steering short One shared length rule for every platform. Soft target stays under 800 characters; hard max still comes from slackOutputPolicy. Co-Authored-By: David Cramer --- packages/junior/src/chat/prompt.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index 851a39560..5b65004a1 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -379,25 +379,16 @@ function buildBehaviorSection(platform: PromptPlatform): string { const DEFAULT_REPLY_TARGET_CHARS = 800; -function buildMaxMessageSizeRule(platform: PromptPlatform): string { - const maxChars = slackOutputPolicy.maxInlineChars; - const maxLines = slackOutputPolicy.maxInlineLines; - const destination = - platform === "slack" - ? "one Slack message" - : "one final reply"; - const overflow = - platform === "slack" - ? " Prefer a Slack canvas link over multi-message overflow." - : " Prefer a linked artifact or short summary over a long dump."; - return `- Default to the shortest complete reply—usually 1–5 sentences and under ${DEFAULT_REPLY_TARGET_CHARS} characters. Include only the outcome, decisive evidence, and any blocker or required next action. Hard max is ${destination} (≤${maxChars} characters and ≤${maxLines} lines).${overflow} An explicit user request for detail may exceed the ${DEFAULT_REPLY_TARGET_CHARS}-character target, but still prefer staying inside the hard max.`; +/** One short length rule shared by every platform. */ +function buildMaxMessageSizeRule(): string { + return `- Keep replies short: usually 1–5 sentences and under ${DEFAULT_REPLY_TARGET_CHARS} characters. Stay inside one message (≤${slackOutputPolicy.maxInlineChars} characters / ≤${slackOutputPolicy.maxInlineLines} lines). Prefer a canvas or linked artifact for longer detail.`; } function buildOutputSection(platform: PromptPlatform): string { if (platform === "local") { return [ ``, - buildMaxMessageSizeRule("local"), + buildMaxMessageSizeRule(), "- Start with the answer or result, not internal process narration.", "- Use concise Markdown suitable for terminal and web output: short paragraphs, bullets, links, fenced code blocks, and GFM tables when a grid is clearer than bullets.", "- End every turn with a final user-facing response.", @@ -407,7 +398,7 @@ function buildOutputSection(platform: PromptPlatform): string { return [ ``, - buildMaxMessageSizeRule("slack"), + buildMaxMessageSizeRule(), "- Start with the answer or result, not internal process narration.", "- Use Slack-flavored Markdown: **bold** section labels, `code`, [text](url) links, bullet lists, and fenced code blocks. No hash-prefixed headings and no tables. When the answer primarily lists several URLs, show each URL bare instead of as a labeled link.", "- End every turn with a final user-facing markdown response unless the Slack action rules allow a no-reply completion.",