diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md
index 87d91181474..77be2ac9c7c 100644
--- a/packages/agent/CHANGELOG.md
+++ b/packages/agent/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+### Fixed
+
+- Fixed compaction summary inputs escaping Harmony control tokens so Copilot `gpt-5.6-*` models no longer reject serialized analysis-channel markers. ([#5184](https://github.com/can1357/oh-my-pi/issues/5184))
+
## [16.4.3] - 2026-07-11
### Fixed
diff --git a/packages/agent/src/compaction/branch-summarization.ts b/packages/agent/src/compaction/branch-summarization.ts
index 93901d67022..ec1e3bb7315 100644
--- a/packages/agent/src/compaction/branch-summarization.ts
+++ b/packages/agent/src/compaction/branch-summarization.ts
@@ -27,7 +27,7 @@ import {
extractFileOpsFromMessage,
type FileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
- serializeConversation,
+ serializeConversationForSummary,
stripReadSelector,
truncateToolResultForSummary,
upsertFileOperations,
@@ -320,7 +320,7 @@ export async function generateBranchSummary(
// Transform to LLM-compatible messages, then serialize to text
// Serialization prevents the model from treating it as a conversation to continue
const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
- const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
+ const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
// Build prompt
const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts
index 37dd360ca02..2b518601067 100644
--- a/packages/agent/src/compaction/compaction.ts
+++ b/packages/agent/src/compaction/compaction.ts
@@ -66,7 +66,7 @@ import {
extractFileOpsFromMessage,
type FileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
- serializeConversation,
+ serializeConversationForSummary,
stripReadSelector,
upsertFileOperations,
} from "./utils";
@@ -816,7 +816,7 @@ export async function generateSummary(
// Serialize conversation to text so model doesn't try to continue it
// Convert to LLM messages first (handles custom app messages when caller provides a transformer).
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages);
- const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
+ const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
// Build the prompt with conversation wrapped in tags
let promptText = `\n${conversationText}\n\n\n`;
@@ -1030,7 +1030,7 @@ async function generateShortSummary(
): Promise {
const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
- const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
+ const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
let promptText = `\n${conversationText}\n\n\n`;
if (historySummary) {
@@ -1578,7 +1578,7 @@ async function generateTurnPrefixSummary(
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
- const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
+ const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
const summarizationMessages = [
{
diff --git a/packages/agent/src/compaction/utils.ts b/packages/agent/src/compaction/utils.ts
index fc3bb6965d7..286f4e7f951 100644
--- a/packages/agent/src/compaction/utils.ts
+++ b/packages/agent/src/compaction/utils.ts
@@ -207,9 +207,19 @@ export function truncateToolResultForSummary(text: string): string {
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n\n[... ${truncatedChars} more characters truncated]`;
}
+const HARMONY_CONTROL_TOKEN_RE = /<\|(start|end|message|channel|constrain|return|call)\|>/g;
+
+/**
+ * Serialize LLM messages as plain summary input without provider control tokens.
+ */
+export function serializeConversationForSummary(messages: Message[], dialect?: Dialect): string {
+ const conversation = serializeConversation(messages, dialect);
+ if (dialect !== "harmony") return conversation;
+ return conversation.replace(HARMONY_CONTROL_TOKEN_RE, "<\\|$1\\|>");
+}
+
/**
- * Serialize LLM messages to text for summarization.
- * This prevents the model from treating it as a conversation to continue.
+ * Serialize LLM messages to transcript text.
* Call convertToLlm() first to handle custom message types.
*/
export function serializeConversation(messages: Message[], dialect?: Dialect): string {
diff --git a/packages/agent/test/serialize-conversation.test.ts b/packages/agent/test/serialize-conversation.test.ts
index d91c059a0de..0c7b119e494 100644
--- a/packages/agent/test/serialize-conversation.test.ts
+++ b/packages/agent/test/serialize-conversation.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { serializeConversation } from "@oh-my-pi/pi-agent-core/compaction";
+import { serializeConversation, serializeConversationForSummary } from "@oh-my-pi/pi-agent-core/compaction";
import type { AssistantMessage, Message, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai";
const ZERO_USAGE: Usage = {
@@ -82,6 +82,41 @@ describe("serializeConversation — useless pairs", () => {
expect(out).not.toContain("[Assistant tool calls]:");
});
+ test("summary serialization escapes Harmony control tokens while preserving assistant thinking", () => {
+ const messages = [
+ assistantMessage([
+ { type: "thinking", thinking: "Need to inspect the failing compaction path." },
+ { type: "text", text: "The final answer stays visible." },
+ ]),
+ ];
+
+ const out = serializeConversationForSummary(messages, "harmony");
+
+ expect(out).not.toContain("<|channel|>analysis");
+ expect(out).not.toContain("<|message|>");
+ expect(out).toContain("<\\|channel\\|>analysis");
+ expect(out).toContain("<\\|channel\\|>final");
+ expect(out).toContain("Need to inspect the failing compaction path.");
+ expect(out).toContain("The final answer stays visible.");
+ });
+
+ test("native Harmony serialization keeps raw transcript markers", () => {
+ const out = serializeConversation(
+ [
+ assistantMessage([
+ { type: "thinking", thinking: "Native transcript includes analysis." },
+ { type: "text", text: "Native final text." },
+ ]),
+ ],
+ "harmony",
+ );
+
+ expect(out).toContain("<|channel|>analysis");
+ expect(out).toContain("<|message|>Native transcript includes analysis.");
+ expect(out).toContain("<|channel|>final");
+ expect(out).toContain("Native final text.");
+ });
+
test("native dialect serialization drops empty assistants left by useless calls", () => {
const out = serializeConversation(
[