Skip to content
Open
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
4 changes: 2 additions & 2 deletions .github/workflows/pr-standards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ jobs:
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
issuesReferences(first: 1) {
closingIssuesReferences(first: 1) {
totalCount
}
}
Expand All @@ -119,7 +119,7 @@ jobs:
number: pr.number
});

const linkedIssues = result.repository.pullRequest.issuesReferences.totalCount;
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;

if (linkedIssues === 0) {
await addLabel('needs:issue');
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,15 @@ export namespace ProviderTransform {
return msg
})
}
if (model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral")) {
// Check if this is a Mistral model (including Devstral variants)
const isMistralModel =
model.providerID === "mistral" ||
model.api.id.toLowerCase().includes("mistral") ||
model.api.id.toLowerCase().includes("devstral") ||
model.id.toLowerCase().includes("mistral") ||
model.id.toLowerCase().includes("devstral")

if (isMistralModel) {
const result: ModelMessage[] = []
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
Expand All @@ -82,6 +90,7 @@ export namespace ProviderTransform {
result.push(msg)

// Fix message sequence: tool messages cannot be followed by user messages
// This is required by Mistral's strict message ordering protocol
if (msg.role === "tool" && nextMsg?.role === "user") {
result.push({
role: "assistant",
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,9 @@ export namespace LLM {
middleware: [
{
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model)
if (args.type === "stream" && args.params.messages) {
// @ts-expect-error - AI SDK types don't expose messages in transformParams
args.params.messages = ProviderTransform.message(args.params.messages, input.model)
}
return args.params
},
Expand Down
279 changes: 279 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,285 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
})
})

describe("ProviderTransform.message - Mistral tool→user ordering", () => {
const mistralModel = {
id: "mistral/mistral-large",
providerID: "mistral",
api: {
id: "mistral-large-latest",
url: "https://api.mistral.com",
npm: "@ai-sdk/mistral",
},
name: "Mistral Large",
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0.002,
output: 0.006,
cache: { read: 0.0002, write: 0.0006 },
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
} as any

test("inserts assistant message between tool and user messages", () => {
const msgs = [
{ role: "user", content: "What is 2+2?" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call123",
toolName: "calculator",
args: { expression: "2+2" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call123",
toolName: "calculator",
result: "4",
},
],
},
{ role: "user", content: "Thanks!" },
] as any[]

const result = ProviderTransform.message(msgs, mistralModel)

// Should have 5 messages now (original 4 + 1 inserted assistant)
expect(result).toHaveLength(5)
expect(result[3].role).toBe("assistant")
expect(result[3].content).toEqual([{ type: "text", text: "Done." }])
expect(result[4].role).toBe("user")
})

test("normalizes Mistral tool call IDs to 9 alphanumeric characters", () => {
const msgs = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call_abc-123-def",
toolName: "bash",
args: { command: "ls" },
},
],
},
] as any[]

const result = ProviderTransform.message(msgs, mistralModel)

expect(result).toHaveLength(1)
const toolCall = result[0].content[0] as any
expect(toolCall.toolCallId).toHaveLength(9)
expect(toolCall.toolCallId).toMatch(/^[a-zA-Z0-9]{9}$/)
expect(toolCall.toolCallId).toBe("callabc12")
})

test("handles multiple consecutive tool results followed by user message", () => {
const msgs = [
{ role: "user", content: "Run two commands" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call1",
toolName: "bash",
args: { command: "ls" },
},
{
type: "tool-call",
toolCallId: "call2",
toolName: "bash",
args: { command: "pwd" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call1",
toolName: "bash",
result: "file1.txt\nfile2.txt",
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call2",
toolName: "bash",
result: "/home/user",
},
],
},
{ role: "user", content: "Great!" },
] as any[]

const result = ProviderTransform.message(msgs, mistralModel)

// Should insert assistant message only after the last tool message
expect(result).toHaveLength(6) // 5 original + 1 inserted
expect(result[4].role).toBe("assistant")
expect(result[4].content).toEqual([{ type: "text", text: "Done." }])
expect(result[5].role).toBe("user")
})

test("does not insert assistant message if already present", () => {
const msgs = [
{ role: "user", content: "What is 2+2?" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call123",
toolName: "calculator",
args: { expression: "2+2" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call123",
toolName: "calculator",
result: "4",
},
],
},
{ role: "assistant", content: "The answer is 4" },
{ role: "user", content: "Thanks!" },
] as any[]

const result = ProviderTransform.message(msgs, mistralModel)

// Should NOT insert additional assistant message
expect(result).toHaveLength(5)
expect(result[3].role).toBe("assistant")
expect(result[3].content).toBe("The answer is 4")
expect(result[4].role).toBe("user")
})

test("does not affect non-Mistral providers", () => {
const openaiModel = {
...mistralModel,
id: "openai/gpt-4",
providerID: "openai",
api: {
id: "gpt-4",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
}

const msgs = [
{ role: "user", content: "Hello" },
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call123",
toolName: "bash",
result: "output",
},
],
},
{ role: "user", content: "Thanks" },
] as any[]

const result = ProviderTransform.message(msgs, openaiModel)

// Should NOT insert assistant message for non-Mistral
expect(result).toHaveLength(3)
})

test("detects Devstral models via openai-compatible provider", () => {
const devstralModel = {
id: "custom/Devstral-Small-2-24B-Instruct-2512",
providerID: "openai-compatible",
api: {
id: "Devstral-Small-2-24B-Instruct-2512",
url: "https://vllm-devstral-small-2.llm-1.m3-dev.services",
npm: "@ai-sdk/openai-compatible",
},
name: "Devstral Small 2 24B",
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0.002,
output: 0.006,
cache: { read: 0.0002, write: 0.0006 },
},
limit: {
context: 262144,
output: 32768,
},
status: "active",
options: {},
headers: {},
} as any

const msgs = [
{ role: "user", content: "Hello" },
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call123",
toolName: "bash",
result: "output",
},
],
},
{ role: "user", content: "Thanks" },
] as any[]

const result = ProviderTransform.message(msgs, devstralModel)

// SHOULD insert assistant message for Devstral (it's a Mistral variant)
expect(result).toHaveLength(4)
expect(result[2].role).toBe("assistant")
expect(result[2].content).toEqual([{ type: "text", text: "Done." }])
expect(result[3].role).toBe("user")
})
})

describe("ProviderTransform.variants", () => {
const createMockModel = (overrides: Partial<any> = {}): any => ({
id: "test/test-model",
Expand Down