@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.1
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/README.md +19 -0
- package/dist/bun-proxy.mjs +282 -55
- package/dist/opencode-anthropic-auth-cli.mjs +194 -55
- package/dist/opencode-anthropic-auth-plugin.js +1816 -594
- package/package.json +1 -1
- package/src/__tests__/billing-edge-cases.test.ts +84 -0
- package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
- package/src/__tests__/debug-gating.test.ts +76 -0
- package/src/__tests__/decomposition-smoke.test.ts +92 -0
- package/src/__tests__/fingerprint-regression.test.ts +1 -1
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
- package/src/__tests__/helpers/conversation-history.ts +376 -0
- package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
- package/src/__tests__/helpers/deferred.ts +122 -0
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
- package/src/__tests__/helpers/in-memory-storage.ts +152 -0
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
- package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
- package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
- package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
- package/src/__tests__/helpers/sse.ts +288 -0
- package/src/__tests__/index.parallel.test.ts +711 -0
- package/src/__tests__/sanitization-regex.test.ts +65 -0
- package/src/__tests__/state-bounds.test.ts +110 -0
- package/src/account-identity.test.ts +213 -0
- package/src/account-identity.ts +108 -0
- package/src/accounts.dedup.test.ts +696 -0
- package/src/accounts.test.ts +2 -1
- package/src/accounts.ts +485 -191
- package/src/bun-fetch.test.ts +379 -0
- package/src/bun-fetch.ts +447 -174
- package/src/bun-proxy.ts +289 -57
- package/src/circuit-breaker.test.ts +274 -0
- package/src/circuit-breaker.ts +235 -0
- package/src/cli.test.ts +1 -0
- package/src/cli.ts +37 -18
- package/src/commands/router.ts +25 -5
- package/src/env.ts +1 -0
- package/src/headers/billing.ts +31 -13
- package/src/index.ts +224 -247
- package/src/oauth.ts +7 -1
- package/src/parent-pid-watcher.test.ts +219 -0
- package/src/parent-pid-watcher.ts +99 -0
- package/src/plugin-helpers.ts +112 -0
- package/src/refresh-helpers.ts +169 -0
- package/src/refresh-lock.test.ts +36 -9
- package/src/refresh-lock.ts +2 -2
- package/src/request/body.history.test.ts +398 -0
- package/src/request/body.ts +200 -13
- package/src/request/metadata.ts +6 -2
- package/src/response/index.ts +1 -1
- package/src/response/mcp.ts +60 -31
- package/src/response/streaming.test.ts +382 -0
- package/src/response/streaming.ts +403 -76
- package/src/storage.test.ts +127 -104
- package/src/storage.ts +152 -62
- package/src/system-prompt/builder.ts +33 -3
- package/src/system-prompt/sanitize.ts +12 -2
- package/src/token-refresh.test.ts +84 -1
- package/src/token-refresh.ts +14 -8
package/src/refresh-lock.test.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
|
|
6
6
|
const baseDir = join(tmpdir(), `opencode-refresh-lock-test-${process.pid}`);
|
|
7
7
|
const storagePath = join(baseDir, "anthropic-accounts.json");
|
|
8
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 15_000;
|
|
9
|
+
const DEFAULT_STALE_LOCK_MS = 90_000;
|
|
8
10
|
|
|
9
11
|
vi.mock("./storage.js", () => ({
|
|
10
12
|
getStoragePath: () => storagePath,
|
|
@@ -26,13 +28,14 @@ describe("refresh-lock", () => {
|
|
|
26
28
|
const lock = await acquireRefreshLock("acc-1");
|
|
27
29
|
expect(lock.acquired).toBe(true);
|
|
28
30
|
expect(lock.lockPath).toBeTruthy();
|
|
31
|
+
const lockPath = lock.lockPath!;
|
|
29
32
|
|
|
30
|
-
await releaseRefreshLock({ lockPath
|
|
33
|
+
await releaseRefreshLock({ lockPath, owner: "wrong-owner" });
|
|
31
34
|
|
|
32
|
-
await expect(fs.stat(
|
|
35
|
+
await expect(fs.stat(lockPath)).resolves.toBeTruthy();
|
|
33
36
|
|
|
34
37
|
await releaseRefreshLock(lock);
|
|
35
|
-
await expect(fs.stat(
|
|
38
|
+
await expect(fs.stat(lockPath)).rejects.toMatchObject({
|
|
36
39
|
code: "ENOENT",
|
|
37
40
|
});
|
|
38
41
|
});
|
|
@@ -43,9 +46,10 @@ describe("refresh-lock", () => {
|
|
|
43
46
|
staleMs: 10_000,
|
|
44
47
|
});
|
|
45
48
|
expect(first.acquired).toBe(true);
|
|
49
|
+
const firstLockPath = first.lockPath!;
|
|
46
50
|
|
|
47
51
|
const old = Date.now() / 1000 - 120;
|
|
48
|
-
await fs.utimes(
|
|
52
|
+
await fs.utimes(firstLockPath, old, old);
|
|
49
53
|
|
|
50
54
|
const second = await acquireRefreshLock("acc-2", {
|
|
51
55
|
timeoutMs: 200,
|
|
@@ -65,28 +69,51 @@ describe("refresh-lock", () => {
|
|
|
65
69
|
const second = await acquireRefreshLock("acc-3", {
|
|
66
70
|
timeoutMs: 30,
|
|
67
71
|
backoffMs: 5,
|
|
68
|
-
staleMs:
|
|
72
|
+
staleMs: DEFAULT_STALE_LOCK_MS,
|
|
69
73
|
});
|
|
70
74
|
expect(second.acquired).toBe(false);
|
|
71
75
|
|
|
72
76
|
await releaseRefreshLock(first);
|
|
73
77
|
});
|
|
74
78
|
|
|
79
|
+
it("stale reaper does NOT steal a lock held for 60s", async () => {
|
|
80
|
+
const first = await acquireRefreshLock("acc-stable-refresh", {
|
|
81
|
+
timeoutMs: DEFAULT_LOCK_TIMEOUT_MS,
|
|
82
|
+
});
|
|
83
|
+
expect(first.acquired).toBe(true);
|
|
84
|
+
expect(first.lockPath).toBeTruthy();
|
|
85
|
+
const firstLockPath = first.lockPath!;
|
|
86
|
+
|
|
87
|
+
const sixtySecondsAgo = Date.now() / 1000 - 60;
|
|
88
|
+
await fs.utimes(firstLockPath, sixtySecondsAgo, sixtySecondsAgo);
|
|
89
|
+
|
|
90
|
+
const second = await acquireRefreshLock("acc-stable-refresh", {
|
|
91
|
+
timeoutMs: 30,
|
|
92
|
+
backoffMs: 5,
|
|
93
|
+
staleMs: DEFAULT_STALE_LOCK_MS,
|
|
94
|
+
});
|
|
95
|
+
expect(second.acquired).toBe(false);
|
|
96
|
+
|
|
97
|
+
await expect(fs.stat(firstLockPath)).resolves.toBeTruthy();
|
|
98
|
+
await releaseRefreshLock(first);
|
|
99
|
+
});
|
|
100
|
+
|
|
75
101
|
it("does not release when inode changed even if owner matches", async () => {
|
|
76
102
|
const first = await acquireRefreshLock("acc-4");
|
|
77
103
|
expect(first.acquired).toBe(true);
|
|
104
|
+
const firstLockPath = first.lockPath!;
|
|
78
105
|
|
|
79
106
|
// Replace lock file with a new inode that reuses owner text.
|
|
80
|
-
await fs.unlink(
|
|
81
|
-
await fs.writeFile(
|
|
107
|
+
await fs.unlink(firstLockPath);
|
|
108
|
+
await fs.writeFile(firstLockPath, JSON.stringify({ owner: first.owner, createdAt: Date.now() }), {
|
|
82
109
|
encoding: "utf-8",
|
|
83
110
|
mode: 0o600,
|
|
84
111
|
});
|
|
85
112
|
|
|
86
113
|
await releaseRefreshLock(first);
|
|
87
114
|
|
|
88
|
-
await expect(fs.stat(
|
|
115
|
+
await expect(fs.stat(firstLockPath)).resolves.toBeTruthy();
|
|
89
116
|
|
|
90
|
-
await fs.unlink(
|
|
117
|
+
await fs.unlink(firstLockPath);
|
|
91
118
|
});
|
|
92
119
|
});
|
package/src/refresh-lock.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { promises as fs } from "node:fs";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { getStoragePath } from "./storage.js";
|
|
5
5
|
|
|
6
|
-
const DEFAULT_LOCK_TIMEOUT_MS =
|
|
6
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 15_000;
|
|
7
7
|
const DEFAULT_LOCK_BACKOFF_MS = 50;
|
|
8
|
-
const DEFAULT_STALE_LOCK_MS =
|
|
8
|
+
const DEFAULT_STALE_LOCK_MS = 90_000;
|
|
9
9
|
|
|
10
10
|
function delay(ms: number): Promise<void> {
|
|
11
11
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Body transformation tests - TDD RED phase
|
|
3
|
+
// Tests for tool name drift defense and body handling edge cases
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
transformRequestBody,
|
|
9
|
+
validateBodyType,
|
|
10
|
+
cloneBodyForRetry,
|
|
11
|
+
detectDoublePrefix,
|
|
12
|
+
extractToolNamesFromBody,
|
|
13
|
+
} from "./body.js";
|
|
14
|
+
import type { RuntimeContext, SignatureConfig } from "../types.js";
|
|
15
|
+
|
|
16
|
+
const mockRuntime: RuntimeContext = {
|
|
17
|
+
persistentUserId: "user-123",
|
|
18
|
+
accountId: "acc-456",
|
|
19
|
+
sessionId: "sess-789",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const mockSignature: SignatureConfig = {
|
|
23
|
+
enabled: true,
|
|
24
|
+
claudeCliVersion: "0.2.45",
|
|
25
|
+
promptCompactionMode: "minimal",
|
|
26
|
+
};
|
|
27
|
+
|
|
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
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
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
|
+
});
|
|
120
|
+
|
|
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" }],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const result1 = transformRequestBody(originalBody, mockSignature, mockRuntime);
|
|
130
|
+
const result2 = transformRequestBody(originalBody, mockSignature, mockRuntime);
|
|
131
|
+
|
|
132
|
+
// Both calls should produce identical results from same input
|
|
133
|
+
expect(result1).toBe(result2);
|
|
134
|
+
|
|
135
|
+
// Original should be unchanged
|
|
136
|
+
const parsedOriginal = JSON.parse(originalBody);
|
|
137
|
+
expect(parsedOriginal.tools[0].name).toBe("read_file");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should return empty bodies unchanged", () => {
|
|
141
|
+
expect(transformRequestBody("", mockSignature, mockRuntime)).toBe("");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should handle retry with same body multiple times", () => {
|
|
145
|
+
const body = JSON.stringify({
|
|
146
|
+
model: "claude-sonnet-4-20250514",
|
|
147
|
+
messages: [{ role: "user", content: "test" }],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// First attempt
|
|
151
|
+
const result1 = transformRequestBody(body, mockSignature, mockRuntime);
|
|
152
|
+
expect(result1).toBeDefined();
|
|
153
|
+
|
|
154
|
+
// Retry attempt - should work with same body
|
|
155
|
+
const result2 = transformRequestBody(body, mockSignature, mockRuntime);
|
|
156
|
+
expect(result2).toBeDefined();
|
|
157
|
+
expect(result1).toBe(result2);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("transformRequestBody - tool name handling", () => {
|
|
162
|
+
it("should add mcp_ prefix to unprefixed tool names", () => {
|
|
163
|
+
const body = JSON.stringify({
|
|
164
|
+
model: "claude-sonnet-4-20250514",
|
|
165
|
+
tools: [
|
|
166
|
+
{ name: "read_file", description: "Read a file" },
|
|
167
|
+
{ name: "write_file", description: "Write a file" },
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const result = transformRequestBody(body, mockSignature, mockRuntime);
|
|
172
|
+
const parsed = JSON.parse(result!);
|
|
173
|
+
|
|
174
|
+
expect(parsed.tools[0].name).toBe("mcp_read_file");
|
|
175
|
+
expect(parsed.tools[1].name).toBe("mcp_write_file");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should handle historical tool_use.name with prefix correctly", () => {
|
|
179
|
+
const body = JSON.stringify({
|
|
180
|
+
model: "claude-sonnet-4-20250514",
|
|
181
|
+
messages: [
|
|
182
|
+
{
|
|
183
|
+
role: "assistant",
|
|
184
|
+
content: [{ type: "tool_use", name: "mcp_read_file", input: { path: "/test" } }],
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const result = transformRequestBody(body, mockSignature, mockRuntime);
|
|
190
|
+
const parsed = JSON.parse(result!);
|
|
191
|
+
|
|
192
|
+
// Should preserve the prefixed name in historical context
|
|
193
|
+
expect(parsed.messages[0].content[0].name).toBe("mcp_read_file");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("should handle mixed prefixed and unprefixed tools", () => {
|
|
197
|
+
const body = JSON.stringify({
|
|
198
|
+
model: "claude-sonnet-4-20250514",
|
|
199
|
+
tools: [
|
|
200
|
+
{ name: "read_file", description: "Read" },
|
|
201
|
+
{ name: "mcp_existing_tool", description: "Existing" },
|
|
202
|
+
{ name: "write_file", description: "Write" },
|
|
203
|
+
],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const result = transformRequestBody(body, mockSignature, mockRuntime);
|
|
207
|
+
const parsed = JSON.parse(result!);
|
|
208
|
+
|
|
209
|
+
expect(parsed.tools[0].name).toBe("mcp_read_file");
|
|
210
|
+
expect(parsed.tools[1].name).toBe("mcp_mcp_existing_tool");
|
|
211
|
+
expect(parsed.tools[2].name).toBe("mcp_write_file");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("transformRequestBody - structure preservation", () => {
|
|
216
|
+
it("should preserve all non-tool fields during transformation", () => {
|
|
217
|
+
const body = JSON.stringify({
|
|
218
|
+
model: "claude-sonnet-4-20250514",
|
|
219
|
+
max_tokens: 4096,
|
|
220
|
+
temperature: 0.7,
|
|
221
|
+
system: [{ type: "text", text: "You are helpful" }],
|
|
222
|
+
messages: [
|
|
223
|
+
{ role: "user", content: "Hello" },
|
|
224
|
+
{ role: "assistant", content: "Hi there" },
|
|
225
|
+
],
|
|
226
|
+
metadata: { user_id: "test-user" },
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const result = transformRequestBody(body, mockSignature, mockRuntime);
|
|
230
|
+
const parsed = JSON.parse(result!);
|
|
231
|
+
|
|
232
|
+
expect(parsed.model).toBe("claude-sonnet-4-20250514");
|
|
233
|
+
expect(parsed.max_tokens).toBe(4096);
|
|
234
|
+
expect(parsed.temperature).toBe(0.7);
|
|
235
|
+
expect(parsed.system.some((block: { text?: string }) => block.text === "You are helpful")).toBe(true);
|
|
236
|
+
expect(parsed.messages).toHaveLength(2);
|
|
237
|
+
expect(parsed.metadata.user_id).toContain('"device_id":"user-123"');
|
|
238
|
+
expect(parsed.metadata.user_id).toContain('"account_uuid":"acc-456"');
|
|
239
|
+
expect(parsed.metadata.user_id).toContain('"session_id":"sess-789"');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("should handle request with body in input correctly", () => {
|
|
243
|
+
const body = JSON.stringify({
|
|
244
|
+
model: "claude-sonnet-4-20250514",
|
|
245
|
+
messages: [
|
|
246
|
+
{
|
|
247
|
+
role: "user",
|
|
248
|
+
content: [
|
|
249
|
+
{ type: "text", text: "Process this" },
|
|
250
|
+
{
|
|
251
|
+
type: "tool_result",
|
|
252
|
+
tool_use_id: "tool_123",
|
|
253
|
+
content: "some result",
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const result = transformRequestBody(body, mockSignature, mockRuntime);
|
|
261
|
+
const parsed = JSON.parse(result!);
|
|
262
|
+
|
|
263
|
+
expect(parsed.messages[0].content).toHaveLength(2);
|
|
264
|
+
expect(parsed.messages[0].content[0].type).toBe("text");
|
|
265
|
+
expect(parsed.messages[0].content[1].type).toBe("tool_result");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("should preserve nested structures in tool input", () => {
|
|
269
|
+
const body = JSON.stringify({
|
|
270
|
+
model: "claude-sonnet-4-20250514",
|
|
271
|
+
messages: [
|
|
272
|
+
{
|
|
273
|
+
role: "assistant",
|
|
274
|
+
content: [
|
|
275
|
+
{
|
|
276
|
+
type: "tool_use",
|
|
277
|
+
name: "complex_tool",
|
|
278
|
+
input: {
|
|
279
|
+
nested: {
|
|
280
|
+
deep: {
|
|
281
|
+
value: "test",
|
|
282
|
+
array: [1, 2, 3],
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const result = transformRequestBody(body, mockSignature, mockRuntime);
|
|
293
|
+
const parsed = JSON.parse(result!);
|
|
294
|
+
|
|
295
|
+
const toolUse = parsed.messages[0].content[0];
|
|
296
|
+
expect(toolUse.input.nested.deep.value).toBe("test");
|
|
297
|
+
expect(toolUse.input.nested.deep.array).toEqual([1, 2, 3]);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("validateBodyType", () => {
|
|
302
|
+
it("should return true for valid string body", () => {
|
|
303
|
+
expect(validateBodyType('{"test": true}')).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("should return false for undefined", () => {
|
|
307
|
+
expect(validateBodyType(undefined)).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should return false for null", () => {
|
|
311
|
+
expect(validateBodyType(null as unknown as string)).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should return false for non-string types", () => {
|
|
315
|
+
expect(validateBodyType(123 as unknown as string)).toBe(false);
|
|
316
|
+
expect(validateBodyType({} as unknown as string)).toBe(false);
|
|
317
|
+
expect(validateBodyType([] as unknown as string)).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("should throw with descriptive message when throwOnInvalid is true", () => {
|
|
321
|
+
expect(() => validateBodyType(123 as unknown as string, true)).toThrow(
|
|
322
|
+
"opencode-anthropic-auth: expected string body, got number. This plugin does not support stream bodies. Please file a bug with the OpenCode version.",
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("cloneBodyForRetry", () => {
|
|
328
|
+
it("should return the same string value for retry", () => {
|
|
329
|
+
const original = '{"test": true}';
|
|
330
|
+
const cloned = cloneBodyForRetry(original);
|
|
331
|
+
|
|
332
|
+
expect(cloned).toBe(original);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("should allow empty string bodies", () => {
|
|
336
|
+
expect(() => cloneBodyForRetry("")).not.toThrow();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("should handle empty but valid body", () => {
|
|
340
|
+
expect(() => cloneBodyForRetry("{}")).not.toThrow();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe("detectDoublePrefix", () => {
|
|
345
|
+
it("should detect mcp_mcp_ prefix", () => {
|
|
346
|
+
expect(detectDoublePrefix("mcp_mcp_read_file")).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("should not detect single mcp_ prefix", () => {
|
|
350
|
+
expect(detectDoublePrefix("mcp_read_file")).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("should not detect unprefixed names", () => {
|
|
354
|
+
expect(detectDoublePrefix("read_file")).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("should detect triple prefix", () => {
|
|
358
|
+
expect(detectDoublePrefix("mcp_mcp_mcp_read_file")).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("extractToolNamesFromBody", () => {
|
|
363
|
+
it("should extract tool names from tools array", () => {
|
|
364
|
+
const body = JSON.stringify({
|
|
365
|
+
tools: [{ name: "tool1" }, { name: "tool2" }],
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const names = extractToolNamesFromBody(body);
|
|
369
|
+
expect(names).toEqual(["tool1", "tool2"]);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("should extract tool names from tool_use blocks", () => {
|
|
373
|
+
const body = JSON.stringify({
|
|
374
|
+
messages: [
|
|
375
|
+
{
|
|
376
|
+
content: [
|
|
377
|
+
{ type: "tool_use", name: "tool1" },
|
|
378
|
+
{ type: "text", text: "hello" },
|
|
379
|
+
{ type: "tool_use", name: "tool2" },
|
|
380
|
+
],
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const names = extractToolNamesFromBody(body);
|
|
386
|
+
expect(names).toEqual(["tool1", "tool2"]);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("should return empty array for body without tools", () => {
|
|
390
|
+
const body = JSON.stringify({ messages: [] });
|
|
391
|
+
const names = extractToolNamesFromBody(body);
|
|
392
|
+
expect(names).toEqual([]);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("should throw for invalid JSON", () => {
|
|
396
|
+
expect(() => extractToolNamesFromBody("not json")).toThrow();
|
|
397
|
+
});
|
|
398
|
+
});
|