@@ -1361,6 +1361,40 @@ describe("session.message-v2.toModelMessage", () => {
13611361 const texts = ( result [ 0 ] . content as any [ ] ) . filter ( ( p ) => p . type === "text" )
13621362 expect ( texts . map ( ( t ) => t . text ) ) . toStrictEqual ( [ "" , "hello" ] )
13631363 } )
1364+
1365+ test ( "strips reasoning blocks when forCompaction is true, even for same model" , async ( ) => {
1366+ // When building messages for the compaction agent, reasoning blocks must be
1367+ // unconditionally stripped (downgraded to text) regardless of model match.
1368+ // This prevents the Anthropic API error: "thinking blocks in the latest
1369+ // assistant message cannot be modified."
1370+ const assistantID = "m-assistant-compaction"
1371+ const input : SessionV1 . WithParts [ ] = [
1372+ {
1373+ info : assistantInfo ( assistantID , "m-parent" ) ,
1374+ parts : [
1375+ {
1376+ ...basePart ( assistantID , "p1" ) ,
1377+ type : "reasoning" ,
1378+ text : "deep thinking about the problem" ,
1379+ metadata : { anthropic : { signature : "sig-compaction" } } ,
1380+ } ,
1381+ { ...basePart ( assistantID , "p2" ) , type : "text" , text : "the answer" } ,
1382+ ] as SessionV1 . Part [ ] ,
1383+ } ,
1384+ ]
1385+
1386+ // Without forCompaction, same-model reasoning is preserved
1387+ const preserved = await MessageV2 . toModelMessages ( input , model )
1388+ const preservedParts = preserved [ 0 ] . content as any [ ]
1389+ expect ( preservedParts . some ( ( p ) => p . type === "reasoning" ) ) . toBe ( true )
1390+
1391+ // With forCompaction, reasoning is stripped to text
1392+ const stripped = await MessageV2 . toModelMessages ( input , model , { forCompaction : true } )
1393+ const strippedParts = stripped [ 0 ] . content as any [ ]
1394+ expect ( strippedParts . some ( ( p ) => p . type === "reasoning" ) ) . toBe ( false )
1395+ expect ( strippedParts . some ( ( p ) => p . type === "text" && p . text === "deep thinking about the problem" ) ) . toBe ( true )
1396+ expect ( strippedParts . some ( ( p ) => p . type === "text" && p . text === "the answer" ) ) . toBe ( true )
1397+ } )
13641398} )
13651399
13661400describe ( "session.message-v2.fromError" , ( ) => {
@@ -1661,6 +1695,102 @@ describe("session.message-v2.latest", () => {
16611695 } )
16621696} )
16631697
1698+ describe ( "session.message-v2.filterCompacted — orphaned compaction guard" , ( ) => {
1699+ const ANCIENT_USER = MessageID . make ( "msg_009" )
1700+ const ANCIENT_ASSISTANT = MessageID . make ( "msg_010" )
1701+ const TAIL_USER = MessageID . make ( "msg_011" )
1702+ const TAIL_ASSISTANT = MessageID . make ( "msg_012" )
1703+ const COMPACTION_USER = MessageID . make ( "msg_013" )
1704+ const ERRORED_SUMMARY = MessageID . make ( "msg_014" )
1705+
1706+ const ancientUser : SessionV1 . WithParts = {
1707+ info : userInfo ( ANCIENT_USER ) ,
1708+ parts : [ { ...basePart ( ANCIENT_USER , "p1" ) , type : "text" , text : "ancient question" } ] as SessionV1 . Part [ ] ,
1709+ }
1710+
1711+ const ancientAssistant : SessionV1 . WithParts = {
1712+ info : {
1713+ ...assistantInfo ( ANCIENT_ASSISTANT , ANCIENT_USER ) ,
1714+ finish : "stop" ,
1715+ tokens : { input : 100 , output : 100 , reasoning : 0 , cache : { read : 0 , write : 0 } , total : 200 } ,
1716+ } as SessionV1 . Assistant ,
1717+ parts : [ { ...basePart ( ANCIENT_ASSISTANT , "p1" ) , type : "text" , text : "ancient response" } ] as SessionV1 . Part [ ] ,
1718+ }
1719+
1720+ const tailUser : SessionV1 . WithParts = {
1721+ info : userInfo ( TAIL_USER ) ,
1722+ parts : [ { ...basePart ( TAIL_USER , "p1" ) , type : "text" , text : "recent question" } ] as SessionV1 . Part [ ] ,
1723+ }
1724+
1725+ const tailAssistant : SessionV1 . WithParts = {
1726+ info : {
1727+ ...assistantInfo ( TAIL_ASSISTANT , TAIL_USER ) ,
1728+ finish : "stop" ,
1729+ tokens : { input : 100 , output : 100 , reasoning : 0 , cache : { read : 0 , write : 0 } , total : 200 } ,
1730+ } as SessionV1 . Assistant ,
1731+ parts : [ { ...basePart ( TAIL_ASSISTANT , "p1" ) , type : "text" , text : "recent response" } ] as SessionV1 . Part [ ] ,
1732+ }
1733+
1734+ // tail_start_id points at TAIL_USER, meaning ancientUser + ancientAssistant
1735+ // would normally be "summarized away" — but only if the summary succeeds.
1736+ const compactionUser : SessionV1 . WithParts = {
1737+ info : userInfo ( COMPACTION_USER ) ,
1738+ parts : [
1739+ {
1740+ ...basePart ( COMPACTION_USER , "p1" ) ,
1741+ type : "compaction" ,
1742+ auto : true ,
1743+ tail_start_id : TAIL_USER ,
1744+ } ,
1745+ ] as SessionV1 . Part [ ] ,
1746+ }
1747+
1748+ test ( "errored summary does not truncate pre-tail history" , ( ) => {
1749+ // When the compaction LLM call fails (e.g. thinking block API error), the
1750+ // summary-assistant gets summary:true but also error set. filterCompacted
1751+ // must NOT use this as a valid boundary — the ancient messages must survive.
1752+ const erroredSummary : SessionV1 . WithParts = {
1753+ info : {
1754+ ...assistantInfo ( ERRORED_SUMMARY , COMPACTION_USER ) ,
1755+ summary : true ,
1756+ finish : "error" ,
1757+ error : { name : "APIError" , message : "thinking blocks cannot be modified" } as any ,
1758+ tokens : { input : 0 , output : 0 , reasoning : 0 , cache : { read : 0 , write : 0 } , total : 0 } ,
1759+ } as SessionV1 . Assistant ,
1760+ parts : [ ] ,
1761+ }
1762+
1763+ // Newest-first (DB order)
1764+ const input : SessionV1 . WithParts [ ] = [
1765+ erroredSummary ,
1766+ compactionUser ,
1767+ tailAssistant ,
1768+ tailUser ,
1769+ ancientAssistant ,
1770+ ancientUser ,
1771+ ]
1772+
1773+ const result = MessageV2 . filterCompacted ( input )
1774+
1775+ // ALL messages must be preserved — errored compaction is not a valid boundary
1776+ expect ( result ) . toHaveLength ( 6 )
1777+ // Critically: ancient messages (before tail_start_id) must still be present
1778+ expect ( result . some ( ( m ) => m . info . id === ANCIENT_USER ) ) . toBe ( true )
1779+ expect ( result . some ( ( m ) => m . info . id === ANCIENT_ASSISTANT ) ) . toBe ( true )
1780+ } )
1781+
1782+ test ( "missing summary does not truncate pre-tail history" , ( ) => {
1783+ // Compaction marker exists with tail_start_id but no summary-assistant
1784+ const input : SessionV1 . WithParts [ ] = [ compactionUser , tailAssistant , tailUser , ancientAssistant , ancientUser ]
1785+
1786+ const result = MessageV2 . filterCompacted ( input )
1787+
1788+ // All messages preserved
1789+ expect ( result ) . toHaveLength ( 5 )
1790+ expect ( result . some ( ( m ) => m . info . id === ANCIENT_USER ) ) . toBe ( true )
1791+ } )
1792+ } )
1793+
16641794describe ( "session.message-v2.toModelMessage — skill parts" , ( ) => {
16651795 test ( "expands skill part content to text for the model" , async ( ) => {
16661796 const input : SessionV1 . WithParts [ ] = [
0 commit comments