Skip to content
Closed
8 changes: 8 additions & 0 deletions GIT_STATUS_SNAPSHOT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Git Status Snapshot

## Initial State

- **Detached HEAD at**: dc25669b6 (release: v1.1.1)
- **Original Target Commit**: 0e33a3011f6bef4316b6faed8269a2fd0e6cfb88
- **Branch containing commit**: fix/ramarivera_glm4.7_interleaved_thinking_fix
- **Untracked files**: packages/opencode/repro_schema.ts
1 change: 0 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 65 additions & 0 deletions packages/opencode/repro_schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import z from "zod"

const PermissionAction = z.enum(["ask", "allow", "deny"])
const PermissionObject = z.record(z.string(), PermissionAction)
const PermissionRule = z.union([PermissionAction, PermissionObject])

const Permission = z
.object({
read: PermissionRule.optional(),
edit: PermissionRule.optional(),
bash: PermissionRule.optional(),
})
.catchall(PermissionRule)
.or(PermissionAction)

const Agent = z.object({
permission: Permission.optional(),
})

const Config = z.object({
agent: z.record(z.string(), Agent).optional(),
})

// Test case 1: Valid object permission
const case1 = {
agent: {
plan: {
permission: {
edit: { "*.md": "allow" },
},
},
},
}

// Test case 2: Invalid action in object
const case2 = {
agent: {
plan: {
permission: {
edit: { "*.md": "invalid_action" },
},
},
},
}

// Test case 4: Object passed to Enum-only schema
const EnumOnly = z.object({
edit: PermissionAction,
})
const case4 = { edit: { "*.md": "allow" } }

console.log("--- Testing Case 1 (Valid Object) ---")
const result1 = Config.safeParse(case1)
if (result1.success) console.log("Success!")
else console.log(JSON.stringify(result1.error.issues, null, 2))

console.log("\n--- Testing Case 2 (Invalid Action in Object) ---")
const result2 = Config.safeParse(case2)
if (result2.success) console.log("Success!")
else console.log(JSON.stringify(result2.error.issues, null, 2))

console.log("\n--- Testing Case 4 (Object passed to Enum-only) ---")
const result4 = EnumOnly.safeParse(case4)
if (result4.success) console.log("Success!")
else console.log(JSON.stringify(result4.error.issues, null, 2))
92 changes: 92 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,98 @@ export namespace ProviderTransform {
return result
}

if (
model.providerID === "z.ai" ||
model.providerID === "zai-coding-plan" ||
model.api.id.toLowerCase().includes("glm-4.7") ||
model.api.id.toLowerCase().includes("glm-4.6")
) {
return msgs.map((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const reasoningParts = (msg.content as Array<{ type: string; text?: string }>).filter(
(part) => part.type === "reasoning",
) as Array<{ type: "reasoning"; text: string }>
const reasoningText = reasoningParts.map((part) => part.text).join("")

const invokePattern = /<invoke\s+name="([^"]+)">([\s\S]*?)<\/invoke>/g
const hasMalformedTools = invokePattern.test(reasoningText)
invokePattern.lastIndex = 0

if (hasMalformedTools) {
const toolCalls: Array<{
type: "tool-call"
toolCallId: string
toolName: string
input: Record<string, string>
}> = []
let match

while ((match = invokePattern.exec(reasoningText)) !== null) {
const toolName = match[1]
const argsText = match[2]

const argKeyPattern =
/<arg_key>([^<]+)<\/arg_key>\s*<arg_value>([^<]*(?:(?!<arg_key>)[^<]+)*)<\/arg_value>/g
const args: Record<string, string> = {}
let argMatch = argKeyPattern.exec(argsText)

if (argMatch) {
argKeyPattern.lastIndex = 0
while ((argMatch = argKeyPattern.exec(argsText)) !== null) {
const key = argMatch[1].trim()
const value = argMatch[2].trim()
args[key] = value
}
} else {
const directTagPattern = /<(\w+)>([^<]+)<\/\1>/g
let directMatch
while ((directMatch = directTagPattern.exec(argsText)) !== null) {
const key = directMatch[1]
const value = directMatch[2].trim()
if (key !== "invoke" && !key.startsWith("/")) {
args[key] = value
}
}
}

toolCalls.push({
type: "tool-call",
toolCallId: `tc_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
toolName,
input: args,
})
}

const cleanText = reasoningText
.replace(invokePattern, "")
.replace(/<arg_key>[\s\S]*?<\/arg_key>/g, "")
.replace(/<arg_value>[\s\S]*?<\/arg_value>/g, "")
.replace(/<description>[\s\S]*?<\/description>/g, "")
.replace(/<(\w+)>[^<]+<\/\1>/g, "")
.replace(/\s+/g, " ")
.trim()

const nonReasoningContent = (msg.content as Array<any>).filter((part) => part.type !== "reasoning")
const newContent = [...nonReasoningContent, ...toolCalls] as Array<any>

if (cleanText.length > 0) {
newContent.push({
type: "reasoning",
text: cleanText,
})
}

return {
...msg,
content: newContent,
}
}
}

return msg
})
}

if (
model.capabilities.interleaved &&
typeof model.capabilities.interleaved === "object" &&
Expand Down
211 changes: 211 additions & 0 deletions packages/opencode/test/provider/test_glm47_thinking_fix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { describe, expect, test } from "bun:test"
import type { ModelMessage } from "ai"
import { ProviderTransform } from "../../src/provider/transform"
import type { Provider } from "../../src/provider/provider"

const createModel = (): Provider.Model => ({
id: "z.ai/glm-4.7",
providerID: "zai-coding-plan",
api: {
id: "glm-4.7",
url: "https://api.z.ai/api/anthropic",
npm: "@ai-sdk/openai-compatible",
},
name: "GLM-4.7",
capabilities: {
temperature: true,
reasoning: true,
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: {
field: "reasoning_content",
},
},
cost: {
input: 0.001,
output: 0.002,
cache: { read: 0.0001, write: 0.0002 },
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2024-01-01",
})

describe("ProviderTransform.message - GLM-4.7 malformed thinking block", () => {
test("GLM-4.7 with tool call XML in reasoning_content should extract malformed tool calls", () => {
const malformedReasoning = `Let me think about what to do here.
<invoke name="bash">
<command>bun test packages/portal/scripts/generate/workflow/workflow.test.ts 2>&1</command>
<description>Run workflow inference unit tests</description>
</invoke>
After running the tests, I'll analyze the results.`

const msgs: ModelMessage[] = [
{
role: "assistant",
content: [{ type: "reasoning", text: malformedReasoning }],
},
]

const result = ProviderTransform.message(msgs, createModel())

// The fix should:
// 1. Extract the tool call from reasoning_content
// 2. Add it as a proper tool-call part
// 3. Clean the reasoning text to remove the XML

expect(result).toHaveLength(1)

if (typeof result[0].content === "string") {
throw new Error("Expected content to be an array, not a string")
}

// Check that tool call was extracted
const toolCalls = result[0].content.filter((part) => part.type === "tool-call")
expect(toolCalls.length).toBe(1)
expect(toolCalls[0].toolName).toBe("bash")
expect(toolCalls[0].input).toEqual({
command: "bun test packages/portal/scripts/generate/workflow/workflow.test.ts 2>&1",
description: "Run workflow inference unit tests",
})

// Check that reasoning was cleaned
const reasoningParts = result[0].content.filter((part) => part.type === "reasoning")
expect(reasoningParts.length).toBe(1)
expect(reasoningParts[0].text).not.toContain("<invoke")
expect(reasoningParts[0].text).not.toContain("bun test")
expect(reasoningParts[0].text).toContain("Let me think about what to do here.")
expect(reasoningParts[0].text).toContain("After running the tests")
})

test("GLM-4.7 with pal_thinkdeep XML in reasoning_content should extract properly", () => {
const thinkingBlock = `<invoke name="pal_thinkdeep">
<step>Reviewing Phase 1.8 implementation</step>
<step_number>1</step_number>
<total_steps>4</total_steps>
<next_step_required>true</next_step_required>
</invoke>`

const msgs: ModelMessage[] = [
{
role: "assistant",
content: [{ type: "reasoning", text: thinkingBlock }],
},
]

const result = ProviderTransform.message(msgs, createModel())

// For thinking-specific tools like pal_thinkdeep, we may want to keep them in reasoning
// or extract them - the fix should handle this case appropriately
expect(result).toHaveLength(1)

if (typeof result[0].content === "string") {
throw new Error("Expected content to be an array, not a string")
}

const toolCalls = result[0].content.filter((part) => part.type === "tool-call")
expect(toolCalls.length).toBe(1)
expect(toolCalls[0].toolName).toBe("pal_thinkdeep")
expect(toolCalls[0].input).toEqual({
step: "Reviewing Phase 1.8 implementation",
step_number: "1",
total_steps: "4",
next_step_required: "true",
})
})

test("GLM-4.7 with multiple tool calls in reasoning should extract all", () => {
const malformedReasoning = `I'll need to run several commands:
<invoke name="bash">
<command>ls -la</command>
<description>List files</description>
</invoke>
<invoke name="bash">
<command>bun install</command>
<description>Install dependencies</description>
</invoke>
<invoke name="bash">
<command>bun test</command>
<description>Run tests</description>
</invoke>`

const msgs: ModelMessage[] = [
{
role: "assistant",
content: [{ type: "reasoning", text: malformedReasoning }],
},
]

const result = ProviderTransform.message(msgs, createModel())

expect(result).toHaveLength(1)

if (typeof result[0].content === "string") {
throw new Error("Expected content to be an array, not a string")
}

const toolCalls = result[0].content.filter((part) => part.type === "tool-call")
expect(toolCalls.length).toBe(3)

expect(toolCalls[0].toolName).toBe("bash")
expect(toolCalls[0].input).toEqual({
command: "ls -la",
description: "List files",
})

expect(toolCalls[1].toolName).toBe("bash")
expect(toolCalls[1].input).toEqual({
command: "bun install",
description: "Install dependencies",
})

expect(toolCalls[2].toolName).toBe("bash")
expect(toolCalls[2].input).toEqual({
command: "bun test",
description: "Run tests",
})
})

test("Properly formatted GLM-4.7 response should not be affected", () => {
const msgs: ModelMessage[] = [
{
role: "assistant",
content: [
{ type: "reasoning", text: "Let me check the tests first." },
{
type: "tool-call",
toolCallId: "test-1",
toolName: "bash",
input: { command: "bun test" },
},
{ type: "text", text: "Tests passed!" },
],
},
]

const result = ProviderTransform.message(msgs, createModel())

expect(result).toHaveLength(1)
// Should preserve existing structure
expect(result[0].content).toHaveLength(3)

if (typeof result[0].content === "string") {
throw new Error("Expected content to be an array, not a string")
}

const reasoning = result[0].content.filter((part) => part.type === "reasoning")
expect(reasoning.length).toBe(1)
expect(reasoning[0].text).toBe("Let me check the tests first.")

const toolCalls = result[0].content.filter((part) => part.type === "tool-call")
expect(toolCalls.length).toBe(1)
expect(toolCalls[0].input).toEqual({ command: "bun test" })
})
})
Loading