diff --git a/src/core/pin.ts b/src/core/pin.ts index 108e9aaab..4d77c068e 100644 --- a/src/core/pin.ts +++ b/src/core/pin.ts @@ -378,8 +378,9 @@ function isPinReply(m: Message | undefined): boolean { const blocks = Array.isArray(m.content) ? m.content : []; if (typeof m.content === 'string') return m.content.startsWith(PIN_REPLY_MARK); if (blocks.length !== 1) return false; - const blk = blocks[0]!; - return (blk as { type?: string }).type === 'text' + const blk = blocks[0]; + return !!blk && typeof blk === 'object' + && (blk as { type?: string }).type === 'text' && typeof (blk as TextBlock).text === 'string' && (blk as TextBlock).text.startsWith(PIN_REPLY_MARK); } diff --git a/tests/pin.test.ts b/tests/pin.test.ts index 10e7eca7d..55d1625b0 100644 --- a/tests/pin.test.ts +++ b/tests/pin.test.ts @@ -206,6 +206,19 @@ describe('Claude Code path is unaffected', () => { }); }); +describe('stripPinCommands: malformed input', () => { + it('does not throw on a null assistant content block after a pin command', () => { + // A null block passes isPinReply's length check; without the element guard + // (that every sibling has) blocks[0].type throws, silently disabling pinning + // for the whole request instead of passing the malformed turn through. + const messages: Message[] = [ + { role: 'user', content: '@pxpipe pin be concise' }, + { role: 'assistant', content: [null as unknown as TextBlock] }, + ]; + expect(() => stripPinCommands(messages)).not.toThrow(); + }); +}); + describe('emission', () => { it('renders file pins unbulleted, preserving the user’s own markdown', () => { // Pinning is per line and opt-in: unmarked prose stays in the file, and a