@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9

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.
Files changed (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. package/dist/bun-proxy.mjs +0 -291
@@ -5,628 +5,636 @@
5
5
 
6
6
  import { describe, it, expect, vi } from "vitest";
7
7
  import {
8
- transformRequestBody,
9
- validateBodyType,
10
- cloneBodyForRetry,
11
- detectDoublePrefix,
12
- extractToolNamesFromBody,
8
+ transformRequestBody,
9
+ validateBodyType,
10
+ cloneBodyForRetry,
11
+ detectDoublePrefix,
12
+ extractToolNamesFromBody,
13
13
  } from "./body.js";
14
14
  import type { RuntimeContext, SignatureConfig } from "../types.js";
15
15
 
16
16
  const mockRuntime: RuntimeContext = {
17
- persistentUserId: "user-123",
18
- accountId: "acc-456",
19
- sessionId: "sess-789",
17
+ persistentUserId: "user-123",
18
+ accountId: "acc-456",
19
+ sessionId: "sess-789",
20
20
  };
21
21
 
22
22
  const mockSignature: SignatureConfig = {
23
- enabled: true,
24
- claudeCliVersion: "0.2.45",
25
- promptCompactionMode: "minimal",
23
+ enabled: true,
24
+ claudeCliVersion: "0.2.45",
25
+ promptCompactionMode: "minimal",
26
26
  };
27
27
 
28
28
  describe("transformRequestBody - type validation", () => {
29
- it("should reject undefined body without error", () => {
30
- const result = transformRequestBody(undefined, mockSignature, mockRuntime);
31
- expect(result).toBeUndefined();
32
- });
33
-
34
- it("should reject null body without error", () => {
35
- const result = transformRequestBody(null as unknown as string, mockSignature, mockRuntime);
36
- expect(result).toBeNull();
37
- });
38
-
39
- it("should reject non-string body with clear error", () => {
40
- const invalidBodies = [123, {}, [], true, () => {}];
41
-
42
- for (const body of invalidBodies) {
43
- expect(() => transformRequestBody(body as unknown as string, mockSignature, mockRuntime)).toThrow(
44
- /opencode-anthropic-auth: expected string body, got /,
45
- );
46
- }
47
- });
48
-
49
- it("should validate body type at runtime with descriptive error", () => {
50
- const debugLog = vi.fn();
51
- const body = { not: "a string" };
52
-
53
- expect(() => transformRequestBody(body as unknown as string, mockSignature, mockRuntime, true, debugLog)).toThrow(
54
- "opencode-anthropic-auth: expected string body, got object. This plugin does not support stream bodies. Please file a bug with the OpenCode version.",
55
- );
56
- });
29
+ it("should reject undefined body without error", () => {
30
+ const result = transformRequestBody(undefined, mockSignature, mockRuntime);
31
+ expect(result).toBeUndefined();
32
+ });
33
+
34
+ it("should reject null body without error", () => {
35
+ const result = transformRequestBody(null as unknown as string, mockSignature, mockRuntime);
36
+ expect(result).toBeNull();
37
+ });
38
+
39
+ it("should reject non-string body with clear error", () => {
40
+ const invalidBodies = [123, {}, [], true, () => {}];
41
+
42
+ for (const body of invalidBodies) {
43
+ expect(() => transformRequestBody(body as unknown as string, mockSignature, mockRuntime)).toThrow(
44
+ /opencode-anthropic-auth: expected string body, got /,
45
+ );
46
+ }
47
+ });
48
+
49
+ it("should validate body type at runtime with descriptive error", () => {
50
+ const debugLog = vi.fn();
51
+ const body = { not: "a string" };
52
+
53
+ expect(() =>
54
+ transformRequestBody(body as unknown as string, mockSignature, mockRuntime, true, debugLog),
55
+ ).toThrow(
56
+ "opencode-anthropic-auth: expected string body, got object. This plugin does not support stream bodies. Please file a bug with the OpenCode version.",
57
+ );
58
+ });
57
59
  });
58
60
 
59
61
  describe("transformRequestBody - double-prefix defense", () => {
60
- it("should detect and reject double-prefixed tool names (mcp_mcp_)", () => {
61
- const body = JSON.stringify({
62
- model: "claude-sonnet-4-20250514",
63
- messages: [{ role: "user", content: "test" }],
64
- tools: [
65
- { name: "mcp_mcp_read_file", description: "Read a file" },
66
- { name: "mcp_mcp_write_file", description: "Write a file" },
67
- ],
68
- });
69
-
70
- expect(() => transformRequestBody(body, mockSignature, mockRuntime)).toThrow(
71
- /Double tool prefix detected: mcp_mcp_/,
72
- );
73
- });
74
-
75
- it("should detect double-prefix in tool_use blocks", () => {
76
- const body = JSON.stringify({
77
- model: "claude-sonnet-4-20250514",
78
- messages: [
79
- {
80
- role: "assistant",
81
- content: [{ type: "tool_use", name: "mcp_mcp_read_file", input: {} }],
82
- },
83
- ],
84
- });
85
-
86
- expect(() => transformRequestBody(body, mockSignature, mockRuntime)).toThrow(
87
- /Double tool prefix detected in tool_use block/,
88
- );
89
- });
90
-
91
- it("should double-prefix literal mcp_ tool definitions to preserve round-trip names", () => {
92
- const body = JSON.stringify({
93
- model: "claude-sonnet-4-20250514",
94
- messages: [{ role: "user", content: "test" }],
95
- tools: [{ name: "mcp_read_file", description: "Read a file" }],
96
- });
97
-
98
- const result = transformRequestBody(body, mockSignature, mockRuntime);
99
- const parsed = JSON.parse(result!);
100
-
101
- expect(parsed.tools[0].name).toBe("mcp_mcp_read_file");
102
- });
103
-
104
- it("should keep literal mcp_ tool definitions round-trip safe", () => {
105
- const body = JSON.stringify({
106
- model: "claude-sonnet-4-20250514",
107
- tools: [
108
- { name: "mcp_server1__tool1", description: "Tool 1" },
109
- { name: "mcp_server2__tool2", description: "Tool 2" },
110
- ],
111
- });
112
-
113
- const result = transformRequestBody(body, mockSignature, mockRuntime);
114
- const parsed = JSON.parse(result!);
115
-
116
- expect(parsed.tools[0].name).toBe("mcp_mcp_server1__tool1");
117
- expect(parsed.tools[1].name).toBe("mcp_mcp_server2__tool2");
118
- });
119
- });
62
+ it("should detect and reject double-prefixed tool names (mcp_mcp_)", () => {
63
+ const body = JSON.stringify({
64
+ model: "claude-sonnet-4-20250514",
65
+ messages: [{ role: "user", content: "test" }],
66
+ tools: [
67
+ { name: "mcp_mcp_read_file", description: "Read a file" },
68
+ { name: "mcp_mcp_write_file", description: "Write a file" },
69
+ ],
70
+ });
71
+
72
+ expect(() => transformRequestBody(body, mockSignature, mockRuntime)).toThrow(
73
+ /Double tool prefix detected: mcp_mcp_/,
74
+ );
75
+ });
120
76
 
121
- describe("transformRequestBody - body cloning for retries", () => {
122
- it("should clone body before transformation to preserve original", () => {
123
- const originalBody = JSON.stringify({
124
- model: "claude-sonnet-4-20250514",
125
- messages: [{ role: "user", content: "test" }],
126
- tools: [{ name: "read_file", description: "Read a file" }],
77
+ it("should detect double-prefix in tool_use blocks", () => {
78
+ const body = JSON.stringify({
79
+ model: "claude-sonnet-4-20250514",
80
+ messages: [
81
+ {
82
+ role: "assistant",
83
+ content: [{ type: "tool_use", name: "mcp_mcp_read_file", input: {} }],
84
+ },
85
+ ],
86
+ });
87
+
88
+ expect(() => transformRequestBody(body, mockSignature, mockRuntime)).toThrow(
89
+ /Double tool prefix detected in tool_use block/,
90
+ );
91
+ });
92
+
93
+ it("should double-prefix literal mcp_ tool definitions to preserve round-trip names", () => {
94
+ const body = JSON.stringify({
95
+ model: "claude-sonnet-4-20250514",
96
+ messages: [{ role: "user", content: "test" }],
97
+ tools: [{ name: "mcp_read_file", description: "Read a file" }],
98
+ });
99
+
100
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
101
+ const parsed = JSON.parse(result!);
102
+
103
+ expect(parsed.tools[0].name).toBe("mcp_mcp_read_file");
127
104
  });
128
105
 
129
- const result1 = transformRequestBody(originalBody, mockSignature, mockRuntime);
130
- const result2 = transformRequestBody(originalBody, mockSignature, mockRuntime);
131
- expect(result1).toBe(result2);
106
+ it("should keep literal mcp_ tool definitions round-trip safe", () => {
107
+ const body = JSON.stringify({
108
+ model: "claude-sonnet-4-20250514",
109
+ tools: [
110
+ { name: "mcp_server1__tool1", description: "Tool 1" },
111
+ { name: "mcp_server2__tool2", description: "Tool 2" },
112
+ ],
113
+ });
132
114
 
133
- const parsedOriginal = JSON.parse(originalBody);
134
- expect(parsedOriginal.tools[0].name).toBe("read_file");
135
- });
115
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
116
+ const parsed = JSON.parse(result!);
136
117
 
137
- it("should return empty bodies unchanged", () => {
138
- expect(transformRequestBody("", mockSignature, mockRuntime)).toBe("");
139
- });
118
+ expect(parsed.tools[0].name).toBe("mcp_mcp_server1__tool1");
119
+ expect(parsed.tools[1].name).toBe("mcp_mcp_server2__tool2");
120
+ });
121
+ });
122
+
123
+ describe("transformRequestBody - body cloning for retries", () => {
124
+ it("should clone body before transformation to preserve original", () => {
125
+ const originalBody = JSON.stringify({
126
+ model: "claude-sonnet-4-20250514",
127
+ messages: [{ role: "user", content: "test" }],
128
+ tools: [{ name: "read_file", description: "Read a file" }],
129
+ });
130
+
131
+ const result1 = transformRequestBody(originalBody, mockSignature, mockRuntime);
132
+ const result2 = transformRequestBody(originalBody, mockSignature, mockRuntime);
133
+ expect(result1).toBe(result2);
134
+
135
+ const parsedOriginal = JSON.parse(originalBody);
136
+ expect(parsedOriginal.tools[0].name).toBe("read_file");
137
+ });
140
138
 
141
- it("should handle retry with same body multiple times", () => {
142
- const body = JSON.stringify({
143
- model: "claude-sonnet-4-20250514",
144
- messages: [{ role: "user", content: "test" }],
139
+ it("should return empty bodies unchanged", () => {
140
+ expect(transformRequestBody("", mockSignature, mockRuntime)).toBe("");
145
141
  });
146
142
 
147
- const result1 = transformRequestBody(body, mockSignature, mockRuntime);
148
- expect(result1).toBeDefined();
143
+ it("should handle retry with same body multiple times", () => {
144
+ const body = JSON.stringify({
145
+ model: "claude-sonnet-4-20250514",
146
+ messages: [{ role: "user", content: "test" }],
147
+ });
148
+
149
+ const result1 = transformRequestBody(body, mockSignature, mockRuntime);
150
+ expect(result1).toBeDefined();
149
151
 
150
- const result2 = transformRequestBody(body, mockSignature, mockRuntime);
151
- expect(result2).toBeDefined();
152
- expect(result1).toBe(result2);
153
- });
152
+ const result2 = transformRequestBody(body, mockSignature, mockRuntime);
153
+ expect(result2).toBeDefined();
154
+ expect(result1).toBe(result2);
155
+ });
154
156
  });
155
157
 
156
158
  describe("transformRequestBody - tool name handling", () => {
157
- it("should add mcp_ prefix to unprefixed tool names", () => {
158
- const body = JSON.stringify({
159
- model: "claude-sonnet-4-20250514",
160
- tools: [
161
- { name: "read_file", description: "Read a file" },
162
- { name: "write_file", description: "Write a file" },
163
- ],
164
- });
165
-
166
- const result = transformRequestBody(body, mockSignature, mockRuntime);
167
- const parsed = JSON.parse(result!);
168
-
169
- expect(parsed.tools[0].name).toBe("mcp_read_file");
170
- expect(parsed.tools[1].name).toBe("mcp_write_file");
171
- });
172
-
173
- it("should handle historical tool_use.name with prefix correctly", () => {
174
- const body = JSON.stringify({
175
- model: "claude-sonnet-4-20250514",
176
- messages: [
177
- {
178
- role: "assistant",
179
- content: [{ type: "tool_use", name: "mcp_read_file", input: { path: "/test" } }],
180
- },
181
- ],
182
- });
183
-
184
- const result = transformRequestBody(body, mockSignature, mockRuntime);
185
- const parsed = JSON.parse(result!);
186
-
187
- // Should preserve the prefixed name in historical context
188
- expect(parsed.messages[0].content[0].name).toBe("mcp_read_file");
189
- });
190
-
191
- it("should handle mixed prefixed and unprefixed tools", () => {
192
- const body = JSON.stringify({
193
- model: "claude-sonnet-4-20250514",
194
- tools: [
195
- { name: "read_file", description: "Read" },
196
- { name: "mcp_existing_tool", description: "Existing" },
197
- { name: "write_file", description: "Write" },
198
- ],
199
- });
200
-
201
- const result = transformRequestBody(body, mockSignature, mockRuntime);
202
- const parsed = JSON.parse(result!);
203
-
204
- expect(parsed.tools[0].name).toBe("mcp_read_file");
205
- expect(parsed.tools[1].name).toBe("mcp_mcp_existing_tool");
206
- expect(parsed.tools[2].name).toBe("mcp_write_file");
207
- });
159
+ it("should add mcp_ prefix to unprefixed tool names", () => {
160
+ const body = JSON.stringify({
161
+ model: "claude-sonnet-4-20250514",
162
+ tools: [
163
+ { name: "read_file", description: "Read a file" },
164
+ { name: "write_file", description: "Write a file" },
165
+ ],
166
+ });
167
+
168
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
169
+ const parsed = JSON.parse(result!);
170
+
171
+ expect(parsed.tools[0].name).toBe("mcp_read_file");
172
+ expect(parsed.tools[1].name).toBe("mcp_write_file");
173
+ });
174
+
175
+ it("should handle historical tool_use.name with prefix correctly", () => {
176
+ const body = JSON.stringify({
177
+ model: "claude-sonnet-4-20250514",
178
+ messages: [
179
+ {
180
+ role: "assistant",
181
+ content: [{ type: "tool_use", name: "mcp_read_file", input: { path: "/test" } }],
182
+ },
183
+ ],
184
+ });
185
+
186
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
187
+ const parsed = JSON.parse(result!);
188
+
189
+ // Should preserve the prefixed name in historical context
190
+ expect(parsed.messages[0].content[0].name).toBe("mcp_read_file");
191
+ });
192
+
193
+ it("should handle mixed prefixed and unprefixed tools", () => {
194
+ const body = JSON.stringify({
195
+ model: "claude-sonnet-4-20250514",
196
+ tools: [
197
+ { name: "read_file", description: "Read" },
198
+ { name: "mcp_existing_tool", description: "Existing" },
199
+ { name: "write_file", description: "Write" },
200
+ ],
201
+ });
202
+
203
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
204
+ const parsed = JSON.parse(result!);
205
+
206
+ expect(parsed.tools[0].name).toBe("mcp_read_file");
207
+ expect(parsed.tools[1].name).toBe("mcp_mcp_existing_tool");
208
+ expect(parsed.tools[2].name).toBe("mcp_write_file");
209
+ });
208
210
  });
209
211
 
210
212
  describe("transformRequestBody - structure preservation", () => {
211
- it("should preserve all non-tool fields during transformation", () => {
212
- const body = JSON.stringify({
213
- model: "claude-sonnet-4-20250514",
214
- max_tokens: 4096,
215
- temperature: 0.7,
216
- system: [{ type: "text", text: "You are helpful" }],
217
- messages: [
218
- { role: "user", content: "Hello" },
219
- { role: "assistant", content: "Hi there" },
220
- ],
221
- metadata: { user_id: "test-user" },
222
- });
223
-
224
- const result = transformRequestBody(body, mockSignature, mockRuntime);
225
- const parsed = JSON.parse(result!);
226
-
227
- expect(parsed.model).toBe("claude-sonnet-4-20250514");
228
- expect(parsed.max_tokens).toBe(4096);
229
- expect(parsed.temperature).toBe(0.7);
230
- // The original "You are helpful" block was relocated to the first user
231
- // message wrapper. parsed.system now only contains billing + identity.
232
- expect(parsed.system.some((block: { text?: string }) => block.text === "You are helpful")).toBe(false);
233
- const firstUserContent = parsed.messages[0].content;
234
- const wrappedText = typeof firstUserContent === "string" ? firstUserContent : firstUserContent[0].text;
235
- expect(wrappedText).toContain("<system-instructions>");
236
- expect(wrappedText).toContain("You are helpful");
237
- // Original messages are preserved alongside the prepended wrapper text.
238
- expect(parsed.messages).toHaveLength(2);
239
- expect(parsed.metadata.user_id).toContain('"device_id":"user-123"');
240
- expect(parsed.metadata.user_id).toContain('"account_uuid":"acc-456"');
241
- expect(parsed.metadata.user_id).toContain('"session_id":"sess-789"');
242
- });
243
-
244
- it("should handle request with body in input correctly", () => {
245
- const body = JSON.stringify({
246
- model: "claude-sonnet-4-20250514",
247
- messages: [
248
- {
249
- role: "user",
250
- content: [
251
- { type: "text", text: "Process this" },
252
- {
253
- type: "tool_result",
254
- tool_use_id: "tool_123",
255
- content: "some result",
256
- },
257
- ],
258
- },
259
- ],
260
- });
261
-
262
- const result = transformRequestBody(body, mockSignature, mockRuntime);
263
- const parsed = JSON.parse(result!);
264
-
265
- expect(parsed.messages[0].content).toHaveLength(2);
266
- expect(parsed.messages[0].content[0].type).toBe("text");
267
- expect(parsed.messages[0].content[1].type).toBe("tool_result");
268
- });
269
-
270
- it("should preserve nested structures in tool input", () => {
271
- const body = JSON.stringify({
272
- model: "claude-sonnet-4-20250514",
273
- messages: [
274
- {
275
- role: "assistant",
276
- content: [
277
- {
278
- type: "tool_use",
279
- name: "complex_tool",
280
- input: {
281
- nested: {
282
- deep: {
283
- value: "test",
284
- array: [1, 2, 3],
285
- },
213
+ it("should preserve all non-tool fields during transformation", () => {
214
+ const body = JSON.stringify({
215
+ model: "claude-sonnet-4-20250514",
216
+ max_tokens: 4096,
217
+ temperature: 0.7,
218
+ system: [{ type: "text", text: "You are helpful" }],
219
+ messages: [
220
+ { role: "user", content: "Hello" },
221
+ { role: "assistant", content: "Hi there" },
222
+ ],
223
+ metadata: { user_id: "test-user" },
224
+ });
225
+
226
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
227
+ const parsed = JSON.parse(result!);
228
+
229
+ expect(parsed.model).toBe("claude-sonnet-4-20250514");
230
+ expect(parsed.max_tokens).toBe(4096);
231
+ expect(parsed.temperature).toBe(0.7);
232
+ // The original "You are helpful" block was relocated to the first user
233
+ // message wrapper. parsed.system now only contains billing + identity.
234
+ expect(parsed.system.some((block: { text?: string }) => block.text === "You are helpful")).toBe(false);
235
+ const firstUserContent = parsed.messages[0].content;
236
+ const wrappedText = typeof firstUserContent === "string" ? firstUserContent : firstUserContent[0].text;
237
+ expect(wrappedText).toContain("<system-instructions>");
238
+ expect(wrappedText).toContain("You are helpful");
239
+ // Original messages are preserved alongside the prepended wrapper text.
240
+ expect(parsed.messages).toHaveLength(2);
241
+ expect(parsed.metadata.user_id).toContain('"device_id":"user-123"');
242
+ expect(parsed.metadata.user_id).toContain('"account_uuid":"acc-456"');
243
+ expect(parsed.metadata.user_id).toContain('"session_id":"sess-789"');
244
+ });
245
+
246
+ it("should handle request with body in input correctly", () => {
247
+ const body = JSON.stringify({
248
+ model: "claude-sonnet-4-20250514",
249
+ messages: [
250
+ {
251
+ role: "user",
252
+ content: [
253
+ { type: "text", text: "Process this" },
254
+ {
255
+ type: "tool_result",
256
+ tool_use_id: "tool_123",
257
+ content: "some result",
258
+ },
259
+ ],
286
260
  },
287
- },
288
- },
289
- ],
290
- },
291
- ],
261
+ ],
262
+ });
263
+
264
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
265
+ const parsed = JSON.parse(result!);
266
+
267
+ expect(parsed.messages[0].content).toHaveLength(2);
268
+ expect(parsed.messages[0].content[0].type).toBe("text");
269
+ expect(parsed.messages[0].content[1].type).toBe("tool_result");
292
270
  });
293
271
 
294
- const result = transformRequestBody(body, mockSignature, mockRuntime);
295
- const parsed = JSON.parse(result!);
272
+ it("should preserve nested structures in tool input", () => {
273
+ const body = JSON.stringify({
274
+ model: "claude-sonnet-4-20250514",
275
+ messages: [
276
+ {
277
+ role: "assistant",
278
+ content: [
279
+ {
280
+ type: "tool_use",
281
+ name: "complex_tool",
282
+ input: {
283
+ nested: {
284
+ deep: {
285
+ value: "test",
286
+ array: [1, 2, 3],
287
+ },
288
+ },
289
+ },
290
+ },
291
+ ],
292
+ },
293
+ ],
294
+ });
295
+
296
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
297
+ const parsed = JSON.parse(result!);
296
298
 
297
- const toolUse = parsed.messages[0].content[0];
298
- expect(toolUse.input.nested.deep.value).toBe("test");
299
- expect(toolUse.input.nested.deep.array).toEqual([1, 2, 3]);
300
- });
299
+ const toolUse = parsed.messages[0].content[0];
300
+ expect(toolUse.input.nested.deep.value).toBe("test");
301
+ expect(toolUse.input.nested.deep.array).toEqual([1, 2, 3]);
302
+ });
301
303
  });
302
304
 
303
305
  describe("validateBodyType", () => {
304
- it("should return true for valid string body", () => {
305
- expect(validateBodyType('{"test": true}')).toBe(true);
306
- });
307
-
308
- it("should return false for undefined", () => {
309
- expect(validateBodyType(undefined)).toBe(false);
310
- });
311
-
312
- it("should return false for null", () => {
313
- expect(validateBodyType(null as unknown as string)).toBe(false);
314
- });
315
-
316
- it("should return false for non-string types", () => {
317
- expect(validateBodyType(123 as unknown as string)).toBe(false);
318
- expect(validateBodyType({} as unknown as string)).toBe(false);
319
- expect(validateBodyType([] as unknown as string)).toBe(false);
320
- });
321
-
322
- it("should throw with descriptive message when throwOnInvalid is true", () => {
323
- expect(() => validateBodyType(123 as unknown as string, true)).toThrow(
324
- "opencode-anthropic-auth: expected string body, got number. This plugin does not support stream bodies. Please file a bug with the OpenCode version.",
325
- );
326
- });
306
+ it("should return true for valid string body", () => {
307
+ expect(validateBodyType('{"test": true}')).toBe(true);
308
+ });
309
+
310
+ it("should return false for undefined", () => {
311
+ expect(validateBodyType(undefined)).toBe(false);
312
+ });
313
+
314
+ it("should return false for null", () => {
315
+ expect(validateBodyType(null as unknown as string)).toBe(false);
316
+ });
317
+
318
+ it("should return false for non-string types", () => {
319
+ expect(validateBodyType(123 as unknown as string)).toBe(false);
320
+ expect(validateBodyType({} as unknown as string)).toBe(false);
321
+ expect(validateBodyType([] as unknown as string)).toBe(false);
322
+ });
323
+
324
+ it("should throw with descriptive message when throwOnInvalid is true", () => {
325
+ expect(() => validateBodyType(123 as unknown as string, true)).toThrow(
326
+ "opencode-anthropic-auth: expected string body, got number. This plugin does not support stream bodies. Please file a bug with the OpenCode version.",
327
+ );
328
+ });
327
329
  });
328
330
 
329
331
  describe("cloneBodyForRetry", () => {
330
- it("should return the same string value for retry", () => {
331
- const original = '{"test": true}';
332
- const cloned = cloneBodyForRetry(original);
332
+ it("should return the same string value for retry", () => {
333
+ const original = '{"test": true}';
334
+ const cloned = cloneBodyForRetry(original);
333
335
 
334
- expect(cloned).toBe(original);
335
- });
336
+ expect(cloned).toBe(original);
337
+ });
336
338
 
337
- it("should allow empty string bodies", () => {
338
- expect(() => cloneBodyForRetry("")).not.toThrow();
339
- });
339
+ it("should allow empty string bodies", () => {
340
+ expect(() => cloneBodyForRetry("")).not.toThrow();
341
+ });
340
342
 
341
- it("should handle empty but valid body", () => {
342
- expect(() => cloneBodyForRetry("{}")).not.toThrow();
343
- });
343
+ it("should handle empty but valid body", () => {
344
+ expect(() => cloneBodyForRetry("{}")).not.toThrow();
345
+ });
344
346
  });
345
347
 
346
348
  describe("detectDoublePrefix", () => {
347
- it("should detect mcp_mcp_ prefix", () => {
348
- expect(detectDoublePrefix("mcp_mcp_read_file")).toBe(true);
349
- });
349
+ it("should detect mcp_mcp_ prefix", () => {
350
+ expect(detectDoublePrefix("mcp_mcp_read_file")).toBe(true);
351
+ });
350
352
 
351
- it("should not detect single mcp_ prefix", () => {
352
- expect(detectDoublePrefix("mcp_read_file")).toBe(false);
353
- });
353
+ it("should not detect single mcp_ prefix", () => {
354
+ expect(detectDoublePrefix("mcp_read_file")).toBe(false);
355
+ });
354
356
 
355
- it("should not detect unprefixed names", () => {
356
- expect(detectDoublePrefix("read_file")).toBe(false);
357
- });
357
+ it("should not detect unprefixed names", () => {
358
+ expect(detectDoublePrefix("read_file")).toBe(false);
359
+ });
358
360
 
359
- it("should detect triple prefix", () => {
360
- expect(detectDoublePrefix("mcp_mcp_mcp_read_file")).toBe(true);
361
- });
361
+ it("should detect triple prefix", () => {
362
+ expect(detectDoublePrefix("mcp_mcp_mcp_read_file")).toBe(true);
363
+ });
362
364
  });
363
365
 
364
366
  describe("extractToolNamesFromBody", () => {
365
- it("should extract tool names from tools array", () => {
366
- const body = JSON.stringify({
367
- tools: [{ name: "tool1" }, { name: "tool2" }],
368
- });
369
-
370
- const names = extractToolNamesFromBody(body);
371
- expect(names).toEqual(["tool1", "tool2"]);
372
- });
373
-
374
- it("should extract tool names from tool_use blocks", () => {
375
- const body = JSON.stringify({
376
- messages: [
377
- {
378
- content: [
379
- { type: "tool_use", name: "tool1" },
380
- { type: "text", text: "hello" },
381
- { type: "tool_use", name: "tool2" },
382
- ],
383
- },
384
- ],
385
- });
386
-
387
- const names = extractToolNamesFromBody(body);
388
- expect(names).toEqual(["tool1", "tool2"]);
389
- });
390
-
391
- it("should return empty array for body without tools", () => {
392
- const body = JSON.stringify({ messages: [] });
393
- const names = extractToolNamesFromBody(body);
394
- expect(names).toEqual([]);
395
- });
396
-
397
- it("should throw for invalid JSON", () => {
398
- expect(() => extractToolNamesFromBody("not json")).toThrow();
399
- });
367
+ it("should extract tool names from tools array", () => {
368
+ const body = JSON.stringify({
369
+ tools: [{ name: "tool1" }, { name: "tool2" }],
370
+ });
371
+
372
+ const names = extractToolNamesFromBody(body);
373
+ expect(names).toEqual(["tool1", "tool2"]);
374
+ });
375
+
376
+ it("should extract tool names from tool_use blocks", () => {
377
+ const body = JSON.stringify({
378
+ messages: [
379
+ {
380
+ content: [
381
+ { type: "tool_use", name: "tool1" },
382
+ { type: "text", text: "hello" },
383
+ { type: "tool_use", name: "tool2" },
384
+ ],
385
+ },
386
+ ],
387
+ });
388
+
389
+ const names = extractToolNamesFromBody(body);
390
+ expect(names).toEqual(["tool1", "tool2"]);
391
+ });
392
+
393
+ it("should return empty array for body without tools", () => {
394
+ const body = JSON.stringify({ messages: [] });
395
+ const names = extractToolNamesFromBody(body);
396
+ expect(names).toEqual([]);
397
+ });
398
+
399
+ it("should throw for invalid JSON", () => {
400
+ expect(() => extractToolNamesFromBody("not json")).toThrow();
401
+ });
400
402
  });
401
403
 
402
404
  describe("transformRequestBody - aggressive system block relocation", () => {
403
- it("keeps only billing + identity blocks in parsed.system", () => {
404
- const body = JSON.stringify({
405
- model: "claude-sonnet-4-20250514",
406
- messages: [{ role: "user", content: "hi" }],
407
- system: [
408
- { type: "text", text: "You are a helpful assistant." },
409
- { type: "text", text: "Working dir: /Users/vacbo/Documents/Projects/opencode-anthropic-fix" },
410
- { type: "text", text: "Plugin: @vacbo/opencode-anthropic-fix v0.1.3" },
411
- ],
412
- });
413
-
414
- const result = transformRequestBody(body, mockSignature, mockRuntime);
415
- const parsed = JSON.parse(result!);
416
-
417
- // System contains exactly 2 blocks: billing header + identity string.
418
- expect(parsed.system).toHaveLength(2);
419
- expect(parsed.system[0].text).toMatch(/^x-anthropic-billing-header:/);
420
- expect(parsed.system[1].text).toBe("You are Claude Code, Anthropic's official CLI for Claude.");
421
-
422
- // None of the original third-party blocks survived in system.
423
- const systemTexts = parsed.system.map((b: { text: string }) => b.text);
424
- expect(systemTexts.some((t: string) => t.includes("helpful assistant"))).toBe(false);
425
- expect(systemTexts.some((t: string) => t.includes("Working dir:"))).toBe(false);
426
- expect(systemTexts.some((t: string) => t.includes("Plugin:"))).toBe(false);
427
- });
428
-
429
- it("relocates non-CC system blocks into the first user message wrapped in <system-instructions>", () => {
430
- const body = JSON.stringify({
431
- model: "claude-sonnet-4-20250514",
432
- messages: [{ role: "user", content: "what do you know about the codebase?" }],
433
- system: [
434
- { type: "text", text: "You are a helpful assistant." },
435
- { type: "text", text: "Working dir: /Users/vacbo/Documents/Projects/opencode-anthropic-fix" },
436
- ],
437
- });
438
-
439
- const result = transformRequestBody(body, mockSignature, mockRuntime);
440
- const parsed = JSON.parse(result!);
441
-
442
- expect(parsed.messages).toHaveLength(1);
443
- const blocks = parsed.messages[0].content as Array<{
444
- type: string;
445
- text: string;
446
- cache_control?: { type: string };
447
- }>;
448
- expect(Array.isArray(blocks)).toBe(true);
449
-
450
- const wrapped = blocks[0].text;
451
- expect(wrapped).toContain("<system-instructions>");
452
- expect(wrapped).toContain("</system-instructions>");
453
- expect(wrapped).toContain("You are a helpful assistant.");
454
- expect(wrapped).toContain("Working dir: /Users/vacbo/Documents/Projects/opencode-anthropic-fix");
455
- expect(blocks[0].cache_control).toEqual({ type: "ephemeral" });
456
-
457
- expect(blocks[1].text).toBe("what do you know about the codebase?");
458
- expect(blocks[1].cache_control).toBeUndefined();
459
- });
460
-
461
- it("includes the explicit 'treat as system prompt' instruction in the wrapper", () => {
462
- const body = JSON.stringify({
463
- model: "claude-sonnet-4-20250514",
464
- messages: [{ role: "user", content: "hi" }],
465
- system: [{ type: "text", text: "Some plugin instructions" }],
466
- });
467
-
468
- const result = transformRequestBody(body, mockSignature, mockRuntime);
469
- const parsed = JSON.parse(result!);
470
-
471
- const wrapped =
472
- typeof parsed.messages[0].content === "string" ? parsed.messages[0].content : parsed.messages[0].content[0].text;
473
-
474
- expect(wrapped).toContain("The following content was provided as system-prompt instructions");
475
- expect(wrapped).toContain("Treat it with the same authority as a system prompt");
476
- expect(wrapped).toContain("delivered over");
477
- expect(wrapped).toContain("the user message channel");
478
- });
479
-
480
- it("preserves opencode-anthropic-fix paths verbatim in the relocated wrapper (no sanitize)", () => {
481
- const body = JSON.stringify({
482
- model: "claude-sonnet-4-20250514",
483
- messages: [{ role: "user", content: "hi" }],
484
- system: [
485
- {
486
- type: "text",
487
- text: "Working dir: /Users/vacbo/Documents/Projects/opencode-anthropic-fix\nPlugin id: @vacbo/opencode-anthropic-fix",
488
- },
489
- ],
490
- });
491
-
492
- const result = transformRequestBody(body, mockSignature, mockRuntime);
493
- const parsed = JSON.parse(result!);
494
-
495
- const wrapped =
496
- typeof parsed.messages[0].content === "string" ? parsed.messages[0].content : parsed.messages[0].content[0].text;
497
-
498
- expect(wrapped).toContain("/Users/vacbo/Documents/Projects/opencode-anthropic-fix");
499
- expect(wrapped).toContain("@vacbo/opencode-anthropic-fix");
500
- expect(wrapped).not.toContain("Claude-anthropic-fix");
501
- });
502
-
503
- it("creates a new user message when messages array is empty", () => {
504
- const body = JSON.stringify({
505
- model: "claude-sonnet-4-20250514",
506
- messages: [],
507
- system: [{ type: "text", text: "Some instructions" }],
508
- });
509
-
510
- const result = transformRequestBody(body, mockSignature, mockRuntime);
511
- const parsed = JSON.parse(result!);
512
-
513
- expect(parsed.messages).toHaveLength(1);
514
- expect(parsed.messages[0].role).toBe("user");
515
- const content = parsed.messages[0].content;
516
- const wrapped = typeof content === "string" ? content : content[0].text;
517
- expect(wrapped).toContain("Some instructions");
518
- expect(wrapped).toContain("<system-instructions>");
519
- });
520
-
521
- it("prepends a new user message when first message is from assistant", () => {
522
- const body = JSON.stringify({
523
- model: "claude-sonnet-4-20250514",
524
- messages: [
525
- { role: "assistant", content: "previous turn" },
526
- { role: "user", content: "follow up" },
527
- ],
528
- system: [{ type: "text", text: "Plugin instructions" }],
529
- });
530
-
531
- const result = transformRequestBody(body, mockSignature, mockRuntime);
532
- const parsed = JSON.parse(result!);
533
-
534
- expect(parsed.messages).toHaveLength(3);
535
- expect(parsed.messages[0].role).toBe("user");
536
- const wrapped =
537
- typeof parsed.messages[0].content === "string" ? parsed.messages[0].content : parsed.messages[0].content[0].text;
538
- expect(wrapped).toContain("<system-instructions>");
539
- expect(wrapped).toContain("Plugin instructions");
540
- // Original turns survive in order.
541
- expect(parsed.messages[1].role).toBe("assistant");
542
- expect(parsed.messages[1].content).toBe("previous turn");
543
- expect(parsed.messages[2].role).toBe("user");
544
- expect(parsed.messages[2].content).toBe("follow up");
545
- });
546
-
547
- it("merges relocated wrapper into the first user message when content is a string", () => {
548
- const body = JSON.stringify({
549
- model: "claude-sonnet-4-20250514",
550
- messages: [{ role: "user", content: "the original user request" }],
551
- system: [{ type: "text", text: "Plugin instructions" }],
552
- });
553
-
554
- const result = transformRequestBody(body, mockSignature, mockRuntime);
555
- const parsed = JSON.parse(result!);
556
-
557
- expect(parsed.messages).toHaveLength(1);
558
- expect(Array.isArray(parsed.messages[0].content)).toBe(true);
559
- const blocks = parsed.messages[0].content as Array<{
560
- type: string;
561
- text: string;
562
- cache_control?: { type: string };
563
- }>;
564
- expect(blocks).toHaveLength(2);
565
- expect(blocks[0].text).toContain("<system-instructions>");
566
- expect(blocks[0].text).toContain("Plugin instructions");
567
- expect(blocks[0].cache_control).toEqual({ type: "ephemeral" });
568
- expect(blocks[1].text).toBe("the original user request");
569
- expect(blocks[1].cache_control).toBeUndefined();
570
- });
571
-
572
- it("merges relocated wrapper into the first user message when content is an array", () => {
573
- const body = JSON.stringify({
574
- model: "claude-sonnet-4-20250514",
575
- messages: [
576
- {
577
- role: "user",
578
- content: [{ type: "text", text: "structured user turn" }],
579
- },
580
- ],
581
- system: [{ type: "text", text: "Plugin instructions" }],
582
- });
583
-
584
- const result = transformRequestBody(body, mockSignature, mockRuntime);
585
- const parsed = JSON.parse(result!);
586
-
587
- expect(parsed.messages).toHaveLength(1);
588
- expect(Array.isArray(parsed.messages[0].content)).toBe(true);
589
- const blocks = parsed.messages[0].content as Array<{
590
- type: string;
591
- text: string;
592
- cache_control?: { type: string };
593
- }>;
594
- expect(blocks[0].type).toBe("text");
595
- expect(blocks[0].text).toContain("<system-instructions>");
596
- expect(blocks[0].text).toContain("Plugin instructions");
597
- expect(blocks[0].cache_control).toEqual({ type: "ephemeral" });
598
- expect(blocks[1].text).toBe("structured user turn");
599
- expect(blocks[1].cache_control).toBeUndefined();
600
- });
601
-
602
- it("does not relocate when signature.enabled is false (legacy passthrough)", () => {
603
- const body = JSON.stringify({
604
- model: "claude-sonnet-4-20250514",
605
- messages: [{ role: "user", content: "hi" }],
606
- system: [{ type: "text", text: "Plugin instructions" }],
607
- });
608
-
609
- const result = transformRequestBody(body, { ...mockSignature, enabled: false }, mockRuntime);
610
- const parsed = JSON.parse(result!);
611
-
612
- // Legacy mode: third-party content stays in system, no wrapper added.
613
- const systemJoined = parsed.system.map((b: { text: string }) => b.text).join("\n");
614
- expect(systemJoined).toContain("Plugin instructions");
615
- expect(parsed.messages[0].content).toBe("hi");
616
- });
617
-
618
- it("does not relocate when relocateThirdPartyPrompts arg is false", () => {
619
- const body = JSON.stringify({
620
- model: "claude-sonnet-4-20250514",
621
- messages: [{ role: "user", content: "hi" }],
622
- system: [{ type: "text", text: "Plugin instructions" }],
623
- });
624
-
625
- const result = transformRequestBody(body, mockSignature, mockRuntime, false);
626
- const parsed = JSON.parse(result!);
627
-
628
- const systemJoined = parsed.system.map((b: { text: string }) => b.text).join("\n");
629
- expect(systemJoined).toContain("Plugin instructions");
630
- expect(parsed.messages[0].content).toBe("hi");
631
- });
405
+ it("keeps only billing + identity blocks in parsed.system", () => {
406
+ const body = JSON.stringify({
407
+ model: "claude-sonnet-4-20250514",
408
+ messages: [{ role: "user", content: "hi" }],
409
+ system: [
410
+ { type: "text", text: "You are a helpful assistant." },
411
+ { type: "text", text: "Working dir: /Users/vacbo/Documents/Projects/opencode-anthropic-fix" },
412
+ { type: "text", text: "Plugin: @vacbo/opencode-anthropic-fix v0.1.3" },
413
+ ],
414
+ });
415
+
416
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
417
+ const parsed = JSON.parse(result!);
418
+
419
+ // System contains exactly 2 blocks: billing header + identity string.
420
+ expect(parsed.system).toHaveLength(2);
421
+ expect(parsed.system[0].text).toMatch(/^x-anthropic-billing-header:/);
422
+ expect(parsed.system[1].text).toBe("You are Claude Code, Anthropic's official CLI for Claude.");
423
+
424
+ // None of the original third-party blocks survived in system.
425
+ const systemTexts = parsed.system.map((b: { text: string }) => b.text);
426
+ expect(systemTexts.some((t: string) => t.includes("helpful assistant"))).toBe(false);
427
+ expect(systemTexts.some((t: string) => t.includes("Working dir:"))).toBe(false);
428
+ expect(systemTexts.some((t: string) => t.includes("Plugin:"))).toBe(false);
429
+ });
430
+
431
+ it("relocates non-CC system blocks into the first user message wrapped in <system-instructions>", () => {
432
+ const body = JSON.stringify({
433
+ model: "claude-sonnet-4-20250514",
434
+ messages: [{ role: "user", content: "what do you know about the codebase?" }],
435
+ system: [
436
+ { type: "text", text: "You are a helpful assistant." },
437
+ { type: "text", text: "Working dir: /Users/vacbo/Documents/Projects/opencode-anthropic-fix" },
438
+ ],
439
+ });
440
+
441
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
442
+ const parsed = JSON.parse(result!);
443
+
444
+ expect(parsed.messages).toHaveLength(1);
445
+ const blocks = parsed.messages[0].content as Array<{
446
+ type: string;
447
+ text: string;
448
+ cache_control?: { type: string };
449
+ }>;
450
+ expect(Array.isArray(blocks)).toBe(true);
451
+
452
+ const wrapped = blocks[0].text;
453
+ expect(wrapped).toContain("<system-instructions>");
454
+ expect(wrapped).toContain("</system-instructions>");
455
+ expect(wrapped).toContain("You are a helpful assistant.");
456
+ expect(wrapped).toContain("Working dir: /Users/vacbo/Documents/Projects/opencode-anthropic-fix");
457
+ expect(blocks[0].cache_control).toEqual({ type: "ephemeral" });
458
+
459
+ expect(blocks[1].text).toBe("what do you know about the codebase?");
460
+ expect(blocks[1].cache_control).toBeUndefined();
461
+ });
462
+
463
+ it("includes the explicit 'treat as system prompt' instruction in the wrapper", () => {
464
+ const body = JSON.stringify({
465
+ model: "claude-sonnet-4-20250514",
466
+ messages: [{ role: "user", content: "hi" }],
467
+ system: [{ type: "text", text: "Some plugin instructions" }],
468
+ });
469
+
470
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
471
+ const parsed = JSON.parse(result!);
472
+
473
+ const wrapped =
474
+ typeof parsed.messages[0].content === "string"
475
+ ? parsed.messages[0].content
476
+ : parsed.messages[0].content[0].text;
477
+
478
+ expect(wrapped).toContain("The following content was provided as system-prompt instructions");
479
+ expect(wrapped).toContain("Treat it with the same authority as a system prompt");
480
+ expect(wrapped).toContain("delivered over");
481
+ expect(wrapped).toContain("the user message channel");
482
+ });
483
+
484
+ it("preserves opencode-anthropic-fix paths verbatim in the relocated wrapper (no sanitize)", () => {
485
+ const body = JSON.stringify({
486
+ model: "claude-sonnet-4-20250514",
487
+ messages: [{ role: "user", content: "hi" }],
488
+ system: [
489
+ {
490
+ type: "text",
491
+ text: "Working dir: /Users/vacbo/Documents/Projects/opencode-anthropic-fix\nPlugin id: @vacbo/opencode-anthropic-fix",
492
+ },
493
+ ],
494
+ });
495
+
496
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
497
+ const parsed = JSON.parse(result!);
498
+
499
+ const wrapped =
500
+ typeof parsed.messages[0].content === "string"
501
+ ? parsed.messages[0].content
502
+ : parsed.messages[0].content[0].text;
503
+
504
+ expect(wrapped).toContain("/Users/vacbo/Documents/Projects/opencode-anthropic-fix");
505
+ expect(wrapped).toContain("@vacbo/opencode-anthropic-fix");
506
+ expect(wrapped).not.toContain("Claude-anthropic-fix");
507
+ });
508
+
509
+ it("creates a new user message when messages array is empty", () => {
510
+ const body = JSON.stringify({
511
+ model: "claude-sonnet-4-20250514",
512
+ messages: [],
513
+ system: [{ type: "text", text: "Some instructions" }],
514
+ });
515
+
516
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
517
+ const parsed = JSON.parse(result!);
518
+
519
+ expect(parsed.messages).toHaveLength(1);
520
+ expect(parsed.messages[0].role).toBe("user");
521
+ const content = parsed.messages[0].content;
522
+ const wrapped = typeof content === "string" ? content : content[0].text;
523
+ expect(wrapped).toContain("Some instructions");
524
+ expect(wrapped).toContain("<system-instructions>");
525
+ });
526
+
527
+ it("prepends a new user message when first message is from assistant", () => {
528
+ const body = JSON.stringify({
529
+ model: "claude-sonnet-4-20250514",
530
+ messages: [
531
+ { role: "assistant", content: "previous turn" },
532
+ { role: "user", content: "follow up" },
533
+ ],
534
+ system: [{ type: "text", text: "Plugin instructions" }],
535
+ });
536
+
537
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
538
+ const parsed = JSON.parse(result!);
539
+
540
+ expect(parsed.messages).toHaveLength(3);
541
+ expect(parsed.messages[0].role).toBe("user");
542
+ const wrapped =
543
+ typeof parsed.messages[0].content === "string"
544
+ ? parsed.messages[0].content
545
+ : parsed.messages[0].content[0].text;
546
+ expect(wrapped).toContain("<system-instructions>");
547
+ expect(wrapped).toContain("Plugin instructions");
548
+ // Original turns survive in order.
549
+ expect(parsed.messages[1].role).toBe("assistant");
550
+ expect(parsed.messages[1].content).toBe("previous turn");
551
+ expect(parsed.messages[2].role).toBe("user");
552
+ expect(parsed.messages[2].content).toBe("follow up");
553
+ });
554
+
555
+ it("merges relocated wrapper into the first user message when content is a string", () => {
556
+ const body = JSON.stringify({
557
+ model: "claude-sonnet-4-20250514",
558
+ messages: [{ role: "user", content: "the original user request" }],
559
+ system: [{ type: "text", text: "Plugin instructions" }],
560
+ });
561
+
562
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
563
+ const parsed = JSON.parse(result!);
564
+
565
+ expect(parsed.messages).toHaveLength(1);
566
+ expect(Array.isArray(parsed.messages[0].content)).toBe(true);
567
+ const blocks = parsed.messages[0].content as Array<{
568
+ type: string;
569
+ text: string;
570
+ cache_control?: { type: string };
571
+ }>;
572
+ expect(blocks).toHaveLength(2);
573
+ expect(blocks[0].text).toContain("<system-instructions>");
574
+ expect(blocks[0].text).toContain("Plugin instructions");
575
+ expect(blocks[0].cache_control).toEqual({ type: "ephemeral" });
576
+ expect(blocks[1].text).toBe("the original user request");
577
+ expect(blocks[1].cache_control).toBeUndefined();
578
+ });
579
+
580
+ it("merges relocated wrapper into the first user message when content is an array", () => {
581
+ const body = JSON.stringify({
582
+ model: "claude-sonnet-4-20250514",
583
+ messages: [
584
+ {
585
+ role: "user",
586
+ content: [{ type: "text", text: "structured user turn" }],
587
+ },
588
+ ],
589
+ system: [{ type: "text", text: "Plugin instructions" }],
590
+ });
591
+
592
+ const result = transformRequestBody(body, mockSignature, mockRuntime);
593
+ const parsed = JSON.parse(result!);
594
+
595
+ expect(parsed.messages).toHaveLength(1);
596
+ expect(Array.isArray(parsed.messages[0].content)).toBe(true);
597
+ const blocks = parsed.messages[0].content as Array<{
598
+ type: string;
599
+ text: string;
600
+ cache_control?: { type: string };
601
+ }>;
602
+ expect(blocks[0].type).toBe("text");
603
+ expect(blocks[0].text).toContain("<system-instructions>");
604
+ expect(blocks[0].text).toContain("Plugin instructions");
605
+ expect(blocks[0].cache_control).toEqual({ type: "ephemeral" });
606
+ expect(blocks[1].text).toBe("structured user turn");
607
+ expect(blocks[1].cache_control).toBeUndefined();
608
+ });
609
+
610
+ it("does not relocate when signature.enabled is false (legacy passthrough)", () => {
611
+ const body = JSON.stringify({
612
+ model: "claude-sonnet-4-20250514",
613
+ messages: [{ role: "user", content: "hi" }],
614
+ system: [{ type: "text", text: "Plugin instructions" }],
615
+ });
616
+
617
+ const result = transformRequestBody(body, { ...mockSignature, enabled: false }, mockRuntime);
618
+ const parsed = JSON.parse(result!);
619
+
620
+ // Legacy mode: third-party content stays in system, no wrapper added.
621
+ const systemJoined = parsed.system.map((b: { text: string }) => b.text).join("\n");
622
+ expect(systemJoined).toContain("Plugin instructions");
623
+ expect(parsed.messages[0].content).toBe("hi");
624
+ });
625
+
626
+ it("does not relocate when relocateThirdPartyPrompts arg is false", () => {
627
+ const body = JSON.stringify({
628
+ model: "claude-sonnet-4-20250514",
629
+ messages: [{ role: "user", content: "hi" }],
630
+ system: [{ type: "text", text: "Plugin instructions" }],
631
+ });
632
+
633
+ const result = transformRequestBody(body, mockSignature, mockRuntime, false);
634
+ const parsed = JSON.parse(result!);
635
+
636
+ const systemJoined = parsed.system.map((b: { text: string }) => b.text).join("\n");
637
+ expect(systemJoined).toContain("Plugin instructions");
638
+ expect(parsed.messages[0].content).toBe("hi");
639
+ });
632
640
  });