diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 9fcd1d9..92bd1b0 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -1131,6 +1131,7 @@ describe('auth.loader', () => { }, ], }, + { role: 'user', content: 'follow up' }, ], }), }) diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index 019babe..b410ad1 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -770,6 +770,7 @@ describe('rewriteRequestBody', () => { { type: 'text', text: 'Let me check' }, ], }, + { role: 'user', content: 'Thanks' }, ], system: [ { type: 'text', text: systemPrompt }, @@ -791,6 +792,7 @@ describe('rewriteRequestBody', () => { // User messages are untouched expect(result.messages[0].content).toBe('Help me fix this bug') expect(result.messages[1].content[0].name).toBe('mcp_Bash') + expect(result.messages[2].content).toBe('Thanks') }) test('handles body with no messages array', async () => { @@ -995,6 +997,7 @@ describe('rewriteRequestBody', () => { }, ], }, + { role: 'user', content: 'follow up' }, ], }) @@ -1096,7 +1099,6 @@ describe('rewriteRequestBody', () => { { role: 'user', content: 'message 0' }, { role: 'assistant', content: 'message 1' }, { role: 'user', content: 'message 2' }, - { role: 'assistant', content: 'message 3' }, ], }) @@ -1122,7 +1124,6 @@ describe('rewriteRequestBody', () => { cache_control: { type: 'ephemeral', ttl: '1h' }, }, ]) - expect(result.messages[3].content).toBe('message 3') }) test('hybrid mode keeps the system anchor when the latest user boundary is within lookback', async () => { @@ -1247,6 +1248,7 @@ describe('rewriteRequestBody', () => { }, ], }, + { role: 'user', content: 'follow up' }, ], }) @@ -1273,6 +1275,106 @@ describe('rewriteRequestBody', () => { ttl: '1h', }) }) + + // ----------------------------------------------------------------------- + // Prefill stripping — trailing assistant messages + // ----------------------------------------------------------------------- + + test('strips single trailing assistant message', async () => { + const body = JSON.stringify({ + system: 'sys', + messages: [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: [{ type: 'text', text: 'I will help' }] }, + ], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.messages.length).toBe(1) + expect(result.messages[0].role).toBe('user') + }) + + test('strips multiple trailing assistant messages', async () => { + const body = JSON.stringify({ + system: 'sys', + messages: [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: [{ type: 'text', text: 'first' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'second' }] }, + ], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.messages.length).toBe(1) + expect(result.messages[0].role).toBe('user') + }) + + test('preserves assistant message followed by user message', async () => { + const body = JSON.stringify({ + system: 'sys', + messages: [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: [{ type: 'text', text: 'response' }] }, + { role: 'user', content: 'follow up' }, + ], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.messages.length).toBe(3) + expect(result.messages[0].role).toBe('user') + expect(result.messages[1].role).toBe('assistant') + expect(result.messages[2].role).toBe('user') + }) + + test('preserves assistant tool_use followed by user tool_result', async () => { + const body = JSON.stringify({ + system: 'sys', + messages: [ + { role: 'user', content: 'do something' }, + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'tool_1', name: 'Bash', input: {} }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'tool_1', content: 'output' }, + ], + }, + ], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.messages.length).toBe(3) + expect(result.messages[1].role).toBe('assistant') + expect(result.messages[2].role).toBe('user') + }) + + test('strips trailing assistant after tool_result + assistant', async () => { + const body = JSON.stringify({ + system: 'sys', + messages: [ + { role: 'user', content: 'do something' }, + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'tool_1', name: 'Bash', input: {} }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'tool_1', content: 'output' }, + ], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'based on that output...' }], + }, + ], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.messages.length).toBe(3) + expect(result.messages[2].role).toBe('user') + }) }) // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index 1d0ca29..350bc3c 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -594,6 +594,22 @@ function applyCache1hStrategy( delete parsed.cacheControl } +/** + * Strip trailing assistant messages. Anthropic rejects assistant-message + * prefill on Claude Code OAuth models with: "This model does not support + * assistant message prefill. The conversation must end with a user message." + * A resumed/compacted session can end on an assistant turn (e.g. after a + * failed tool round); pop those before signing. + */ +function stripTrailingAssistantMessages(parsed: Record) { + if (!Array.isArray(parsed.messages)) return + while (parsed.messages.length) { + const last = parsed.messages[parsed.messages.length - 1] + if (!isRecord(last) || last.role !== 'assistant') break + parsed.messages.pop() + } +} + /** * Rewrite the full request body: sanitize system prompt and prefix tool names. */ @@ -608,6 +624,7 @@ export async function rewriteRequestBody( ): Promise { try { const parsed = JSON.parse(body) + stripTrailingAssistantMessages(parsed) const billingHeader = Array.isArray(parsed.messages) && parsed.messages.some(