opencode-thinking-fix 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/proxy/proxy.js ADDED
@@ -0,0 +1,333 @@
1
+ 'use strict'
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // OpenCode Reasoning Cache Proxy
5
+ //
6
+ // What it does:
7
+ // - Sits between OpenCode and DeepSeek/Kimi/GLM/MiMo on localhost:3457
8
+ // - On every response: extracts real reasoning_content from the stream
9
+ // and stores it in memory, keyed by sessionID + message index
10
+ // - On every request: replays the cached real reasoning_content into
11
+ // assistant history turns instead of empty strings
12
+ //
13
+ // What it does NOT do:
14
+ // - Touch non-reasoning providers (Qwen/GPT/Claude pass through untouched)
15
+ // - Persist cache to disk (in-memory only, resets on restart)
16
+ // - Add any dashboard, UI, or monitoring endpoints
17
+ //
18
+ // Deployment:
19
+ // node proxy.js
20
+ // Set UPSTREAM_URL env to change target (default: https://api.deepseek.com)
21
+ // Set PORT env to change listen port (default: 3457)
22
+ // In opencode.json: set baseURL to http://127.0.0.1:3457/v1
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ const http = require('http')
26
+ const https = require('https')
27
+ const url = require('url')
28
+
29
+ const PORT = parseInt(process.env.PORT || '3457', 10)
30
+
31
+ // Optional fixed upstream override. When set, ALL requests are routed here
32
+ // instead of using the model-based routing table. Used for OpenCode Go:
33
+ // UPSTREAM_URL=https://opencode.ai/zen/go/v1 PORT=3458 node proxy.js
34
+ const UPSTREAM_URL = process.env.UPSTREAM_URL || ''
35
+
36
+ // ── Model routing table ────────────────────────────────────────────────────
37
+ // Each entry: { base: upstream API base URL, reasoning: does this model emit reasoning_content? }
38
+ // Prefix-based matching: "deepseek-v4-pro" matches the "deepseek" prefix
39
+ const ROUTES = {
40
+ deepseek: { base: 'https://api.deepseek.com', reasoning: true },
41
+ kimi: { base: 'https://api.moonshot.ai/v1', reasoning: true },
42
+ moonshot: { base: 'https://api.moonshot.ai/v1', reasoning: true },
43
+ glm: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoning: true },
44
+ zhipu: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoning: true },
45
+ minimax: { base: 'https://api.minimax.io/v1', reasoning: true },
46
+ mimo: { base: 'https://api.minimax.io/v1', reasoning: true },
47
+ gpt: { base: 'https://api.openai.com', reasoning: false },
48
+ o1: { base: 'https://api.openai.com', reasoning: false },
49
+ claude: { base: 'https://api.anthropic.com', reasoning: false },
50
+ anthropic: { base: 'https://api.anthropic.com', reasoning: false },
51
+ qwen: { base: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', reasoning: false },
52
+ gemini: { base: 'https://generativelanguage.googleapis.com/v1beta/openai', reasoning: false },
53
+ llama: { base: 'https://api.together.xyz', reasoning: false },
54
+ mistral: { base: 'https://api.mistral.ai', reasoning: false },
55
+ }
56
+
57
+ // Default: fall back to DeepSeek for unknown models
58
+ const DEFAULT_ROUTE = { base: 'https://api.deepseek.com', reasoning: false }
59
+
60
+ function route(modelName) {
61
+ if (!modelName) return DEFAULT_ROUTE
62
+ const lower = modelName.toLowerCase()
63
+ for (const [prefix, entry] of Object.entries(ROUTES)) {
64
+ if (lower.startsWith(prefix)) return entry
65
+ }
66
+ return DEFAULT_ROUTE
67
+ }
68
+
69
+ // ── In-memory reasoning cache ─────────────────────────────────────────────
70
+ // Structure: Map<sessionID, Map<assistantIndex, reasoning_content_string>>
71
+ // sessionID comes from x-session-id header forwarded by OpenCode
72
+ // assistantIndex is the position of the assistant message in the history array
73
+ const cache = new Map()
74
+
75
+ function getSessionCache(sessionId) {
76
+ if (!cache.has(sessionId)) cache.set(sessionId, new Map())
77
+ return cache.get(sessionId)
78
+ }
79
+
80
+ // ── Request body patching ─────────────────────────────────────────────────
81
+ // Injects cached real reasoning_content into assistant history turns
82
+ // Falls back to "" if no cache exists for that turn yet
83
+ function patchRequestBody(body, sessionId) {
84
+ let parsed
85
+ try {
86
+ parsed = JSON.parse(body)
87
+ } catch {
88
+ return body // not JSON, forward as-is
89
+ }
90
+
91
+ if (!Array.isArray(parsed.messages)) return body
92
+
93
+ const sessionCache = getSessionCache(sessionId)
94
+ let assistantIndex = 0
95
+ let modified = false
96
+
97
+ for (const msg of parsed.messages) {
98
+ if (msg.role !== 'assistant') continue
99
+
100
+ // Fix content if missing
101
+ if (!msg.content && msg.content !== '') {
102
+ msg.content = msg.tool_calls?.length ? 'call tool' : ''
103
+ modified = true
104
+ }
105
+
106
+ // Inject real cached reasoning if available, otherwise ""
107
+ // Check VALUE (not key existence) — plugin sets "" so key always exists
108
+ if (!msg.reasoning_content) {
109
+ const cached = sessionCache.get(assistantIndex)
110
+ msg.reasoning_content = cached ?? ''
111
+ if (cached) {
112
+ console.log(`[Cache] session ${sessionId.slice(-8)}: replayed reasoning_content for turn ${assistantIndex} (${cached.length} chars)`)
113
+ }
114
+ modified = true
115
+ }
116
+
117
+ // OpenCode Go uses reasoning instead of reasoning_content
118
+ if (!msg.reasoning) {
119
+ const cached = sessionCache.get(assistantIndex)
120
+ msg.reasoning = cached ?? ''
121
+ if (cached) {
122
+ console.log(`[Cache] session ${sessionId.slice(-8)}: replayed reasoning for turn ${assistantIndex} (${cached.length} chars)`)
123
+ }
124
+ modified = true
125
+ }
126
+
127
+ assistantIndex++
128
+ }
129
+
130
+ return modified ? JSON.stringify(parsed) : body
131
+ }
132
+
133
+ // ── SSE stream parser ─────────────────────────────────────────────────────
134
+ // Parses streaming response chunks to extract reasoning_content
135
+ // Accumulates across chunks since a single reasoning block can span many
136
+ function createStreamParser(sessionId, onComplete) {
137
+ let buffer = ''
138
+ let reasoningBuffer = ''
139
+ let assistantIndex = 0 // tracks which assistant turn we're building
140
+ let inReasoning = false
141
+
142
+ return {
143
+ feed(chunk) {
144
+ buffer += chunk.toString()
145
+ const lines = buffer.split('\n')
146
+ buffer = lines.pop() // keep incomplete last line
147
+
148
+ for (const line of lines) {
149
+ if (!line.startsWith('data: ')) continue
150
+ const data = line.slice(6).trim()
151
+ if (data === '[DONE]') {
152
+ // Flush accumulated reasoning for this turn
153
+ if (reasoningBuffer) {
154
+ onComplete(assistantIndex, reasoningBuffer)
155
+ reasoningBuffer = ''
156
+ }
157
+ continue
158
+ }
159
+ try {
160
+ const parsed = JSON.parse(data)
161
+ const delta = parsed.choices?.[0]?.delta
162
+ if (!delta) continue
163
+
164
+ // reasoning_content delta (DeepSeek/Kimi/GLM native)
165
+ if (typeof delta.reasoning_content === 'string') {
166
+ reasoningBuffer += delta.reasoning_content
167
+ inReasoning = true
168
+ }
169
+
170
+ // OpenCode Go uses delta.reasoning instead of reasoning_content
171
+ if (typeof delta.reasoning === 'string') {
172
+ reasoningBuffer += delta.reasoning
173
+ inReasoning = true
174
+ }
175
+
176
+ // When content starts after reasoning, the reasoning block is done
177
+ if (typeof delta.content === 'string' && delta.content && inReasoning) {
178
+ if (reasoningBuffer) {
179
+ onComplete(assistantIndex, reasoningBuffer)
180
+ reasoningBuffer = ''
181
+ inReasoning = false
182
+ }
183
+ }
184
+
185
+ // finish_reason means the assistant turn is complete
186
+ if (parsed.choices?.[0]?.finish_reason) {
187
+ if (reasoningBuffer) {
188
+ onComplete(assistantIndex, reasoningBuffer)
189
+ reasoningBuffer = ''
190
+ inReasoning = false
191
+ }
192
+ assistantIndex++
193
+ }
194
+ } catch {
195
+ // malformed SSE chunk, skip
196
+ }
197
+ }
198
+ },
199
+ flush() {
200
+ if (reasoningBuffer) {
201
+ onComplete(assistantIndex, reasoningBuffer)
202
+ reasoningBuffer = ''
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ // ── Main proxy handler ────────────────────────────────────────────────────
209
+ const server = http.createServer((req, res) => {
210
+ // Health check — plugin uses this to detect running proxy
211
+ if (req.method === 'GET' && req.url === '/health') {
212
+ res.writeHead(200, { 'Content-Type': 'application/json' })
213
+ res.end(JSON.stringify({ ok: true, uptime: Math.floor(process.uptime()) }))
214
+ return
215
+ }
216
+
217
+ // Collect request body
218
+ const chunks = []
219
+ req.on('data', chunk => chunks.push(chunk))
220
+ req.on('end', () => {
221
+ const rawBody = Buffer.concat(chunks)
222
+ const sessionId = req.headers['x-session-id'] || 'unknown'
223
+ const isPost = req.method === 'POST'
224
+ const isJson = (req.headers['content-type'] || '').includes('application/json')
225
+
226
+ // Determine upstream route
227
+ let modelName = ''
228
+ let routeTarget = DEFAULT_ROUTE
229
+ if (UPSTREAM_URL) {
230
+ // Fixed upstream mode (e.g. OpenCode Go)
231
+ routeTarget = { base: UPSTREAM_URL, reasoning: true }
232
+ } else if (isPost && isJson && rawBody.length > 0) {
233
+ // Model-based routing mode
234
+ try {
235
+ const bodyJson = JSON.parse(rawBody.toString('utf8'))
236
+ modelName = bodyJson.model || ''
237
+ routeTarget = route(modelName)
238
+ } catch { /* body not JSON yet, use defaults */ }
239
+ }
240
+ const upstream = url.parse(routeTarget.base)
241
+ const shouldPatch = isPost && isJson && routeTarget.reasoning
242
+
243
+ // Patch request body if this is a reasoning provider
244
+ let bodyToSend = rawBody
245
+ if (shouldPatch && rawBody.length > 0) {
246
+ const patched = patchRequestBody(rawBody.toString('utf8'), sessionId)
247
+ bodyToSend = Buffer.from(patched, 'utf8')
248
+ }
249
+
250
+ // Build upstream request options
251
+ // If base URL has a path prefix (e.g. /api/paas/v4), prepend it to req.url
252
+ let upstreamPath = req.url
253
+ const basePath = upstream.path || '/'
254
+ if (basePath !== '/' && basePath !== '/v1') {
255
+ upstreamPath = basePath + req.url.replace(/^\/v1/, '')
256
+ }
257
+ const upstreamOptions = {
258
+ hostname: upstream.hostname,
259
+ port: upstream.port || (upstream.protocol === 'https:' ? 443 : 80),
260
+ path: upstreamPath,
261
+ method: req.method,
262
+ headers: {
263
+ ...req.headers,
264
+ host: upstream.hostname,
265
+ 'content-length': bodyToSend.length,
266
+ },
267
+ }
268
+
269
+ // Use http or https depending on upstream
270
+ const transport = upstream.protocol === 'https:' ? https : http
271
+
272
+ const proxyReq = transport.request(upstreamOptions, (proxyRes) => {
273
+ // Forward response headers
274
+ res.writeHead(proxyRes.statusCode, proxyRes.headers)
275
+
276
+ const isStream = (proxyRes.headers['content-type'] || '').includes('text/event-stream')
277
+ const isOk = (proxyRes.statusCode || 500) < 400
278
+
279
+ if (shouldPatch && isStream && isOk) {
280
+ // Parse SSE stream to extract and cache reasoning_content
281
+ const parser = createStreamParser(sessionId, (index, reasoning) => {
282
+ const sc = getSessionCache(sessionId)
283
+ sc.set(index, reasoning)
284
+ console.log(`[Cache] session ${sessionId.slice(-8)}: stored reasoning turn ${index} (${reasoning.length} chars)`)
285
+ })
286
+
287
+ proxyRes.on('data', chunk => {
288
+ parser.feed(chunk)
289
+ res.write(chunk) // forward chunk immediately (no buffering)
290
+ })
291
+
292
+ proxyRes.on('end', () => {
293
+ parser.flush()
294
+ res.end()
295
+ })
296
+ } else {
297
+ // Non-streaming or non-reasoning: pipe directly
298
+ proxyRes.pipe(res)
299
+ }
300
+ })
301
+
302
+ proxyReq.on('error', (err) => {
303
+ console.error('[Proxy] upstream error:', err.message)
304
+ if (!res.headersSent) {
305
+ res.writeHead(502)
306
+ res.end(JSON.stringify({ error: 'upstream error', detail: err.message }))
307
+ }
308
+ })
309
+
310
+ proxyReq.write(bodyToSend)
311
+ proxyReq.end()
312
+ })
313
+
314
+ req.on('error', (err) => {
315
+ console.error('[Proxy] request error:', err.message)
316
+ })
317
+ })
318
+
319
+ server.listen(PORT, '127.0.0.1', () => {
320
+ if (UPSTREAM_URL) {
321
+ console.log(`[Proxy] fixed-upstream proxy on http://127.0.0.1:${PORT}`)
322
+ console.log(`[Proxy] upstream: ${UPSTREAM_URL}`)
323
+ } else {
324
+ console.log(`[Proxy] universal model-routing proxy on http://127.0.0.1:${PORT}`)
325
+ console.log(`[Proxy] ${Object.keys(ROUTES).length} model prefixes loaded`)
326
+ }
327
+ console.log(`[Proxy] in opencode.json set baseURL to http://127.0.0.1:${PORT}/v1`)
328
+ })
329
+
330
+ server.on('error', (err) => {
331
+ console.error('[Proxy] server error:', err.message)
332
+ process.exit(1)
333
+ })
@@ -0,0 +1,17 @@
1
+ [Unit]
2
+ Description=OpenCode Reasoning Cache Proxy — Go Provider
3
+ After=network-online.target reasoning-cache.service
4
+
5
+ [Service]
6
+ Type=simple
7
+ WorkingDirectory=%h/reasoning-cache-proxy
8
+ ExecStart=/usr/bin/node %h/reasoning-cache-proxy/proxy.js
9
+ Restart=on-failure
10
+ RestartSec=5
11
+ Environment=PORT=3458
12
+ Environment=UPSTREAM_URL=https://opencode.ai/zen/go/v1
13
+ StandardOutput=journal
14
+ StandardError=journal
15
+
16
+ [Install]
17
+ WantedBy=default.target
@@ -0,0 +1,16 @@
1
+ [Unit]
2
+ Description=OpenCode Reasoning Cache Proxy
3
+ After=network-online.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ WorkingDirectory=%h/reasoning-cache-proxy
8
+ ExecStart=/usr/bin/node %h/reasoning-cache-proxy/proxy.js
9
+ Restart=on-failure
10
+ RestartSec=5
11
+ Environment=PORT=3457
12
+ StandardOutput=journal
13
+ StandardError=journal
14
+
15
+ [Install]
16
+ WantedBy=default.target
@@ -0,0 +1,14 @@
1
+ [Unit]
2
+ Description=OpenCode Proxy Watchdog — auto-restart if proxies die
3
+ After=network-online.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ ExecStart=%h/reasoning-cache-proxy/watchdog.sh
8
+ Restart=on-failure
9
+ RestartSec=10
10
+ StandardOutput=journal
11
+ StandardError=journal
12
+
13
+ [Install]
14
+ WantedBy=default.target
@@ -0,0 +1,228 @@
1
+ 'use strict'
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // patchMessages — ported from opencode-thinking-fix-universal.ts (lines 31-77)
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ function patchMessages(messages) {
7
+ let count = 0
8
+
9
+ // Step 1: Self-detect reasoning model
10
+ const isReasoningModel = messages.some((wrapper) => {
11
+ const msg = wrapper?.info ?? wrapper
12
+ return msg?.role === 'assistant' && ('reasoning_content' in msg || 'reasoning' in msg)
13
+ })
14
+
15
+ // Step 2: Non-reasoning model — exit immediately
16
+ if (!isReasoningModel) return 0
17
+
18
+ // Step 3: Patch all assistant turns missing required fields
19
+ for (const wrapper of messages) {
20
+ const msg = wrapper?.info ?? wrapper
21
+ if (!msg || msg.role !== 'assistant') continue
22
+
23
+ if (!msg.content && msg.content !== '') {
24
+ msg.content = msg.tool_calls?.length ? 'call tool' : ''
25
+ count++
26
+ }
27
+
28
+ if (!('reasoning_content' in msg)) {
29
+ msg.reasoning_content = ''
30
+ count++
31
+ }
32
+
33
+ if (!('reasoning' in msg)) {
34
+ msg.reasoning = ''
35
+ count++
36
+ }
37
+ }
38
+
39
+ return count
40
+ }
41
+
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+ // Helpers
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ function deepClone(o) { return JSON.parse(JSON.stringify(o)) }
46
+
47
+ let passed = 0
48
+ let failed = 0
49
+ const failures = []
50
+
51
+ function test(name, fn) {
52
+ try {
53
+ fn()
54
+ passed++
55
+ console.log(` PASS: ${name}`)
56
+ } catch (e) {
57
+ failed++
58
+ const msg = `${name} — ${e.message}`
59
+ failures.push(msg)
60
+ console.log(` FAIL: ${msg}`)
61
+ }
62
+ }
63
+
64
+ function assert(cond, msg) {
65
+ if (!cond) throw new Error(msg || 'assertion failed')
66
+ }
67
+
68
+ // ─────────────────────────────────────────────────────────────────────────────
69
+ // Test Cases
70
+ // ─────────────────────────────────────────────────────────────────────────────
71
+ console.log('\n=== patchMessages Regression Tests ===\n')
72
+
73
+ // ── TC1: Native reasoning model (reasoning_content) ─────────────────────────
74
+ test('TC1: Native reasoning model detects via reasoning_content', () => {
75
+ const msgs = deepClone([
76
+ { role: 'user', content: 'hello' },
77
+ { role: 'assistant', content: 'hi there', reasoning_content: 'step 1...' },
78
+ ])
79
+ const result = patchMessages(msgs)
80
+ assert(result === 1, `expected 1 patch, got ${result}`)
81
+ assert(msgs[1].reasoning === '', 'reasoning should be added as empty string')
82
+ assert(msgs[1].reasoning_content === 'step 1...', 'should not overwrite existing reasoning_content')
83
+ assert(msgs[0].role === 'user' && msgs[0].content === 'hello', 'user message must not be touched')
84
+ })
85
+
86
+ // ── TC2: OpenCode Go model (reasoning, NO reasoning_content) ─────────────────
87
+ test('TC2: OpenCode Go model detected via reasoning field', () => {
88
+ const msgs = deepClone([
89
+ { role: 'assistant', content: 'ok', reasoning: 'step 1...' },
90
+ ])
91
+ const result = patchMessages(msgs)
92
+ assert(result === 1, `expected 1 patch, got ${result}`)
93
+ assert(msgs[0].reasoning_content === '', 'reasoning_content should be added as empty string')
94
+ assert(msgs[0].reasoning === 'step 1...', 'should not overwrite existing reasoning')
95
+ })
96
+
97
+ // ── TC3: Non-reasoning model (Qwen/GPT/Claude) ──────────────────────────────
98
+ test('TC3: Non-reasoning model returns 0 with zero modifications', () => {
99
+ const msgs = deepClone([
100
+ { role: 'user', content: 'hello' },
101
+ { role: 'assistant', content: 'hi' },
102
+ ])
103
+ const result = patchMessages(msgs)
104
+ assert(result === 0, `expected 0 patches, got ${result}`)
105
+ assert(!('reasoning_content' in msgs[1]), 'reasoning_content must not be injected')
106
+ assert(!('reasoning' in msgs[1]), 'reasoning must not be injected')
107
+ })
108
+
109
+ // ── TC4: Mixed messages (user + assistant + user) ───────────────────────────
110
+ test('TC4: Only assistant messages are patched, user messages untouched', () => {
111
+ const msgs = deepClone([
112
+ { role: 'user', content: 'q1' },
113
+ { role: 'assistant', content: 'a1', reasoning_content: 'thinking' },
114
+ { role: 'user', content: 'q2' },
115
+ ])
116
+ const result = patchMessages(msgs)
117
+ assert(result === 1, `expected 1 patch, got ${result}`)
118
+ assert(msgs[1].reasoning === '', 'assistant reasoning should be added')
119
+ assert(!('reasoning_content' in msgs[0]), 'user message must not get reasoning_content')
120
+ assert(!('reasoning' in msgs[0]), 'user message must not get reasoning')
121
+ assert(!('reasoning_content' in msgs[2]), 'second user message must not get reasoning_content')
122
+ assert(msgs[2].content === 'q2', 'second user message content unchanged')
123
+ })
124
+
125
+ // ── TC5: Multiple assistant turns ───────────────────────────────────────────
126
+ test('TC5: Both assistant messages patched when at least one has reasoning', () => {
127
+ const msgs = deepClone([
128
+ { role: 'assistant', content: 'first', tool_calls: [{ function: { name: 'f' } }] },
129
+ { role: 'assistant', content: 'second', reasoning: 'thinking' },
130
+ ])
131
+ const result = patchMessages(msgs)
132
+ // msgs[0]: +reasoning_content, +reasoning (2). msgs[1]: +reasoning_content (1). Total = 3.
133
+ // msgs[0].content is 'first' (truthy) so no content patch.
134
+ assert(result === 3, `expected 3 patches, got ${result}`)
135
+ assert(msgs[0].reasoning_content === '', 'first assistant should get reasoning_content')
136
+ assert(msgs[0].reasoning === '', 'first assistant should get reasoning')
137
+ // first assistant content is 'first' (truthy) so NOT patched
138
+ assert(msgs[0].content === 'first', 'first assistant content unchanged')
139
+ assert(!('content' in Object.getOwnPropertyDescriptor(msgs[1], 'content') || true) || msgs[1].content === 'second', 'second assistant content unchanged')
140
+ assert(msgs[1].reasoning_content === '', 'second assistant should get reasoning_content')
141
+ assert(msgs[1].reasoning === 'thinking', 'second assistant reasoning unchanged')
142
+ })
143
+
144
+ // ── TC6: Already complete message ───────────────────────────────────────────
145
+ test('TC6: Already complete message — no changes', () => {
146
+ const msgs = deepClone([
147
+ { role: 'assistant', content: 'done', reasoning_content: 'done', reasoning: 'done' },
148
+ ])
149
+ const result = patchMessages(msgs)
150
+ assert(result === 0, `expected 0 patches, got ${result}`)
151
+ assert(msgs[0].reasoning_content === 'done', 'reasoning_content unchanged')
152
+ assert(msgs[0].reasoning === 'done', 'reasoning unchanged')
153
+ })
154
+
155
+ // ── TC7: Wrapper format ({ info: Message }) ─────────────────────────────────
156
+ test('TC7: Wrapper format correctly handled', () => {
157
+ const msgs = deepClone([
158
+ { info: { role: 'user', content: 'hello' } },
159
+ { info: { role: 'assistant', content: 'reply', reasoning: 'thinking' } },
160
+ ])
161
+ const result = patchMessages(msgs)
162
+ assert(result === 1, `expected 1 patch, got ${result}`)
163
+ assert(msgs[1].info.reasoning_content === '', 'wrapped assistant should get reasoning_content')
164
+ assert(msgs[1].info.reasoning === 'thinking', 'wrapped assistant reasoning unchanged')
165
+ assert(!('reasoning_content' in msgs[0].info), 'wrapped user must not get reasoning_content')
166
+ assert(msgs[0].info.content === 'hello', 'wrapped user content unchanged')
167
+ })
168
+
169
+ // ── TC8: Empty reasoning_content string ─────────────────────────────────────
170
+ test('TC8: Empty reasoning_content still triggers detection', () => {
171
+ const msgs = deepClone([
172
+ { role: 'assistant', content: 'ok', reasoning_content: '' },
173
+ ])
174
+ const result = patchMessages(msgs)
175
+ assert(result === 1, `expected 1 patch (reasoning), got ${result}`)
176
+ assert(msgs[0].reasoning === '', 'reasoning should be added')
177
+ assert(msgs[0].reasoning_content === '', 'reasoning_content stays empty')
178
+ })
179
+
180
+ // ── TC9: Empty messages array ───────────────────────────────────────────────
181
+ test('TC9: Empty messages array returns 0', () => {
182
+ const result = patchMessages([])
183
+ assert(result === 0, `expected 0, got ${result}`)
184
+ })
185
+
186
+ // ── TC10: Non-reasoning model with tool_calls ────────────────────────────────
187
+ test('TC10: Non-reasoning model with tool_calls — no injection', () => {
188
+ const msgs = deepClone([
189
+ { role: 'assistant', content: '', tool_calls: [{ function: { name: 'f' } }] },
190
+ ])
191
+ const result = patchMessages(msgs)
192
+ assert(result === 0, `expected 0, got ${result}`)
193
+ assert(!('reasoning_content' in msgs[0]), 'must not inject reasoning_content')
194
+ })
195
+
196
+ // ── TC11: Reasoning model with empty content + tool_calls ───────────────────
197
+ test('TC11: Reasoning model patches content to "call tool"', () => {
198
+ const msgs = deepClone([
199
+ { role: 'assistant', content: '', tool_calls: [{ function: { name: 'f' } }], reasoning_content: 'thinking' },
200
+ ])
201
+ const result = patchMessages(msgs)
202
+ // content is '' (empty string) — guard `!msg.content && msg.content !== ''` skips it
203
+ // because '' !== '' is false. The original code treats '' as valid content.
204
+ // Only reasoning is patched (reasoning_content and content are already present/invalid).
205
+ assert(result === 1, `expected 1 patch (reasoning), got ${result}`)
206
+ assert(msgs[0].content === '', 'content stays empty string')
207
+ assert(msgs[0].reasoning === '', 'reasoning added')
208
+ assert(msgs[0].reasoning_content === 'thinking', 'reasoning_content preserved')
209
+ })
210
+
211
+ // ── TC12: Null wrapper ──────────────────────────────────────────────────────
212
+ test('TC12: Null/undefined wrapper in array is skipped gracefully', () => {
213
+ const msgs = deepClone([
214
+ null,
215
+ { role: 'assistant', content: 'hi', reasoning_content: 'think' },
216
+ undefined,
217
+ ])
218
+ const result = patchMessages(msgs)
219
+ assert(result >= 1, `expected at least 1 patch, got ${result}`)
220
+ })
221
+
222
+ // ─────────────────────────────────────────────────────────────────────────────
223
+ // Summary
224
+ // ─────────────────────────────────────────────────────────────────────────────
225
+ const total = passed + failed
226
+ console.log(`\n=== Results: ${passed}/${total} passed, ${failed} failed ===\n`)
227
+
228
+ process.exit(failed > 0 ? 1 : 0)