Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/src/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,7 @@ describe('auth.loader', () => {
},
],
},
{ role: 'user', content: 'follow up' },
],
}),
})
Expand Down
106 changes: 104 additions & 2 deletions packages/opencode/src/tests/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,7 @@ describe('rewriteRequestBody', () => {
{ type: 'text', text: 'Let me check' },
],
},
{ role: 'user', content: 'Thanks' },
],
system: [
{ type: 'text', text: systemPrompt },
Expand All @@ -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 () => {
Expand Down Expand Up @@ -995,6 +997,7 @@ describe('rewriteRequestBody', () => {
},
],
},
{ role: 'user', content: 'follow up' },
],
})

Expand Down Expand 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' },
],
})

Expand All @@ -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 () => {
Expand Down Expand Up @@ -1247,6 +1248,7 @@ describe('rewriteRequestBody', () => {
},
],
},
{ role: 'user', content: 'follow up' },
],
})

Expand All @@ -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')
})
})

// ---------------------------------------------------------------------------
Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
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.
*/
Expand All @@ -608,6 +624,7 @@ export async function rewriteRequestBody(
): Promise<string> {
try {
const parsed = JSON.parse(body)
stripTrailingAssistantMessages(parsed)
const billingHeader =
Array.isArray(parsed.messages) &&
parsed.messages.some(
Expand Down
Loading