snow-flow 10.0.185 → 10.0.186-dev.682
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/bin/index.js.map +9 -9
- package/bin/worker.js.map +7 -7
- package/mcp/servicenow-unified.js +116 -116
- package/package.json +1 -1
- package/parsers-config.ts +2 -1
- package/src/bun/index.ts +10 -9
- package/src/cli/cmd/agent.ts +3 -3
- package/src/cli/cmd/auth.ts +46 -0
- package/src/cli/cmd/import.ts +2 -2
- package/src/cli/cmd/session.ts +9 -12
- package/src/cli/cmd/tui/component/prompt/autocomplete.tsx +2 -1
- package/src/cli/cmd/tui/component/prompt/index.tsx +19 -6
- package/src/cli/cmd/tui/component/spinner.tsx +24 -0
- package/src/cli/cmd/tui/context/exit.tsx +1 -1
- package/src/cli/cmd/tui/routes/home.tsx +16 -2
- package/src/cli/cmd/tui/routes/session/index.tsx +122 -53
- package/src/cli/cmd/tui/routes/session/permission.tsx +9 -1
- package/src/cli/cmd/tui/routes/session/sidebar.tsx +9 -1
- package/src/cli/cmd/tui/thread.ts +4 -1
- package/src/cli/cmd/tui/ui/dialog-export-options.tsx +1 -1
- package/src/cli/cmd/tui/util/clipboard.ts +3 -3
- package/src/cli/cmd/tui/worker.ts +6 -1
- package/src/config/config.ts +28 -0
- package/src/context/context-db.ts +437 -0
- package/src/format/formatter.ts +14 -5
- package/src/global/index.ts +3 -4
- package/src/mcp/index.ts +7 -2
- package/src/mcp/oauth-callback.ts +7 -15
- package/src/mcp/oauth-provider.ts +34 -3
- package/src/project/project.ts +8 -4
- package/src/provider/models.ts +1 -1
- package/src/provider/provider.ts +88 -9
- package/src/provider/transform.ts +7 -2
- package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_capacity_plan.ts +20 -7
- package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_retrospective.ts +6 -8
- package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_sprint_manage.ts +46 -28
- package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_team_manage.ts +53 -41
- package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_velocity_report.ts +8 -1
- package/src/servicenow/servicenow-mcp-unified/tools/automation/snow_schedule_script_job.ts +388 -243
- package/src/session/compaction.ts +126 -23
- package/src/session/message-v2.ts +33 -10
- package/src/session/processor.ts +29 -17
- package/src/session/prompt.ts +34 -6
- package/src/share/share-next.ts +2 -2
- package/src/shell/shell.ts +2 -1
- package/src/tool/edit.ts +15 -1
- package/src/tool/registry.ts +9 -1
- package/src/tool/truncation.ts +17 -0
- package/src/tool/websearch.ts +1 -1
- package/src/tool/websearch.txt +2 -2
- package/src/tool/write.ts +3 -4
- package/src/util/filesystem.ts +36 -7
- package/src/util/keybind.ts +1 -1
- package/src/util/log.ts +8 -5
- package/src/util/token.ts +28 -0
- package/test/cli/plugin-auth-picker.test.ts +120 -0
- package/test/fixture/fixture.ts +3 -0
- package/test/mcp/oauth-auto-connect.test.ts +197 -0
- package/test/project/project.test.ts +47 -0
- package/test/provider/provider.test.ts +2 -0
- package/test/provider/transform.test.ts +32 -0
- package/test/tool/edit.test.ts +679 -0
package/src/util/filesystem.ts
CHANGED
|
@@ -1,19 +1,48 @@
|
|
|
1
|
-
import { realpathSync } from "fs"
|
|
2
|
-
import { realpath } from "fs/promises"
|
|
3
|
-
import { dirname, join, relative, resolve } from "path"
|
|
1
|
+
import { realpathSync, statSync, mkdirSync } from "fs"
|
|
2
|
+
import { readFile, writeFile, stat as fsStat, realpath, mkdir } from "fs/promises"
|
|
3
|
+
import { dirname, join, relative, resolve as pathResolve } from "path"
|
|
4
4
|
|
|
5
5
|
export namespace Filesystem {
|
|
6
6
|
export const exists = (p: string) =>
|
|
7
|
-
|
|
8
|
-
.stat()
|
|
7
|
+
fsStat(p)
|
|
9
8
|
.then(() => true)
|
|
10
9
|
.catch(() => false)
|
|
11
10
|
|
|
12
11
|
export const isDir = (p: string) =>
|
|
13
|
-
|
|
14
|
-
.stat()
|
|
12
|
+
fsStat(p)
|
|
15
13
|
.then((s) => s.isDirectory())
|
|
16
14
|
.catch(() => false)
|
|
15
|
+
|
|
16
|
+
export async function readText(p: string): Promise<string> {
|
|
17
|
+
return readFile(p, "utf-8")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readJson<T = unknown>(p: string): Promise<T> {
|
|
21
|
+
const text = await readFile(p, "utf-8")
|
|
22
|
+
return JSON.parse(text) as T
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function write(p: string, content: string | Buffer | Uint8Array): Promise<void> {
|
|
26
|
+
await mkdir(dirname(p), { recursive: true }).catch(() => {})
|
|
27
|
+
await writeFile(p, content)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function writeJson(p: string, data: unknown): Promise<void> {
|
|
31
|
+
await mkdir(dirname(p), { recursive: true }).catch(() => {})
|
|
32
|
+
await writeFile(p, JSON.stringify(data, null, 2))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function readBytes(p: string): Promise<Buffer> {
|
|
36
|
+
return readFile(p)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function stat(p: string) {
|
|
40
|
+
return fsStat(p)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolve(...segments: string[]): string {
|
|
44
|
+
return pathResolve(...segments)
|
|
45
|
+
}
|
|
17
46
|
/**
|
|
18
47
|
* On Windows, normalize a path to its canonical casing using the filesystem.
|
|
19
48
|
* This is needed because Windows paths are case-insensitive but LSP servers
|
package/src/util/keybind.ts
CHANGED
package/src/util/log.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "path"
|
|
2
2
|
import fs from "fs/promises"
|
|
3
|
+
import { createWriteStream } from "fs"
|
|
3
4
|
import { Global } from "../global"
|
|
4
5
|
import z from "zod"
|
|
5
6
|
|
|
@@ -63,13 +64,15 @@ export namespace Log {
|
|
|
63
64
|
Global.Path.log,
|
|
64
65
|
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
|
65
66
|
)
|
|
66
|
-
const logfile = Bun.file(logpath)
|
|
67
67
|
await fs.truncate(logpath).catch(() => {})
|
|
68
|
-
const
|
|
68
|
+
const stream = createWriteStream(logpath, { flags: "a" })
|
|
69
69
|
write = async (msg: any) => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
stream.write(msg, (err) => {
|
|
72
|
+
if (err) reject(err)
|
|
73
|
+
else resolve(msg.length)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
|
package/src/util/token.ts
CHANGED
|
@@ -1,7 +1,35 @@
|
|
|
1
1
|
export namespace Token {
|
|
2
2
|
const CHARS_PER_TOKEN = 4
|
|
3
3
|
|
|
4
|
+
// Provider-aware ratios for more accurate estimation.
|
|
5
|
+
// Measured against actual tokenizer output for mixed code/text.
|
|
6
|
+
const PROVIDER_RATIOS: Record<string, number> = {
|
|
7
|
+
claude: 3.5,
|
|
8
|
+
anthropic: 3.5,
|
|
9
|
+
gpt: 4.0,
|
|
10
|
+
openai: 4.0,
|
|
11
|
+
gemini: 3.8,
|
|
12
|
+
google: 3.8,
|
|
13
|
+
deepseek: 3.7,
|
|
14
|
+
mistral: 3.8,
|
|
15
|
+
default: 3.7,
|
|
16
|
+
}
|
|
17
|
+
|
|
4
18
|
export function estimate(input: string) {
|
|
5
19
|
return Math.max(0, Math.round((input || "").length / CHARS_PER_TOKEN))
|
|
6
20
|
}
|
|
21
|
+
|
|
22
|
+
export function estimateForProvider(input: string, providerID?: string) {
|
|
23
|
+
const ratio = ratioForProvider(providerID)
|
|
24
|
+
return Math.max(0, Math.round((input || "").length / ratio))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ratioForProvider(providerID?: string): number {
|
|
28
|
+
if (!providerID) return PROVIDER_RATIOS.default
|
|
29
|
+
const lower = providerID.toLowerCase()
|
|
30
|
+
for (const [key, ratio] of Object.entries(PROVIDER_RATIOS)) {
|
|
31
|
+
if (lower.includes(key)) return ratio
|
|
32
|
+
}
|
|
33
|
+
return PROVIDER_RATIOS.default
|
|
34
|
+
}
|
|
7
35
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test"
|
|
2
|
+
import { resolvePluginProviders } from "../../src/cli/cmd/auth"
|
|
3
|
+
import type { Hooks } from "@opencode-ai/plugin"
|
|
4
|
+
|
|
5
|
+
function hookWithAuth(provider: string): Hooks {
|
|
6
|
+
return {
|
|
7
|
+
auth: {
|
|
8
|
+
provider,
|
|
9
|
+
methods: [],
|
|
10
|
+
},
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hookWithoutAuth(): Hooks {
|
|
15
|
+
return {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("resolvePluginProviders", () => {
|
|
19
|
+
test("returns plugin providers not in models.dev", () => {
|
|
20
|
+
const result = resolvePluginProviders({
|
|
21
|
+
hooks: [hookWithAuth("portkey")],
|
|
22
|
+
existingProviders: {},
|
|
23
|
+
disabled: new Set(),
|
|
24
|
+
providerNames: {},
|
|
25
|
+
})
|
|
26
|
+
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("skips providers already in models.dev", () => {
|
|
30
|
+
const result = resolvePluginProviders({
|
|
31
|
+
hooks: [hookWithAuth("anthropic")],
|
|
32
|
+
existingProviders: { anthropic: {} },
|
|
33
|
+
disabled: new Set(),
|
|
34
|
+
providerNames: {},
|
|
35
|
+
})
|
|
36
|
+
expect(result).toEqual([])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("deduplicates across plugins", () => {
|
|
40
|
+
const result = resolvePluginProviders({
|
|
41
|
+
hooks: [hookWithAuth("portkey"), hookWithAuth("portkey")],
|
|
42
|
+
existingProviders: {},
|
|
43
|
+
disabled: new Set(),
|
|
44
|
+
providerNames: {},
|
|
45
|
+
})
|
|
46
|
+
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("respects disabled_providers", () => {
|
|
50
|
+
const result = resolvePluginProviders({
|
|
51
|
+
hooks: [hookWithAuth("portkey")],
|
|
52
|
+
existingProviders: {},
|
|
53
|
+
disabled: new Set(["portkey"]),
|
|
54
|
+
providerNames: {},
|
|
55
|
+
})
|
|
56
|
+
expect(result).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("respects enabled_providers when provider is absent", () => {
|
|
60
|
+
const result = resolvePluginProviders({
|
|
61
|
+
hooks: [hookWithAuth("portkey")],
|
|
62
|
+
existingProviders: {},
|
|
63
|
+
disabled: new Set(),
|
|
64
|
+
enabled: new Set(["anthropic"]),
|
|
65
|
+
providerNames: {},
|
|
66
|
+
})
|
|
67
|
+
expect(result).toEqual([])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("includes provider when in enabled set", () => {
|
|
71
|
+
const result = resolvePluginProviders({
|
|
72
|
+
hooks: [hookWithAuth("portkey")],
|
|
73
|
+
existingProviders: {},
|
|
74
|
+
disabled: new Set(),
|
|
75
|
+
enabled: new Set(["portkey"]),
|
|
76
|
+
providerNames: {},
|
|
77
|
+
})
|
|
78
|
+
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("resolves name from providerNames", () => {
|
|
82
|
+
const result = resolvePluginProviders({
|
|
83
|
+
hooks: [hookWithAuth("portkey")],
|
|
84
|
+
existingProviders: {},
|
|
85
|
+
disabled: new Set(),
|
|
86
|
+
providerNames: { portkey: "Portkey AI" },
|
|
87
|
+
})
|
|
88
|
+
expect(result).toEqual([{ id: "portkey", name: "Portkey AI" }])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("falls back to id when no name configured", () => {
|
|
92
|
+
const result = resolvePluginProviders({
|
|
93
|
+
hooks: [hookWithAuth("portkey")],
|
|
94
|
+
existingProviders: {},
|
|
95
|
+
disabled: new Set(),
|
|
96
|
+
providerNames: {},
|
|
97
|
+
})
|
|
98
|
+
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("skips hooks without auth", () => {
|
|
102
|
+
const result = resolvePluginProviders({
|
|
103
|
+
hooks: [hookWithoutAuth(), hookWithAuth("portkey"), hookWithoutAuth()],
|
|
104
|
+
existingProviders: {},
|
|
105
|
+
disabled: new Set(),
|
|
106
|
+
providerNames: {},
|
|
107
|
+
})
|
|
108
|
+
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("returns empty for no hooks", () => {
|
|
112
|
+
const result = resolvePluginProviders({
|
|
113
|
+
hooks: [],
|
|
114
|
+
existingProviders: {},
|
|
115
|
+
disabled: new Set(),
|
|
116
|
+
providerNames: {},
|
|
117
|
+
})
|
|
118
|
+
expect(result).toEqual([])
|
|
119
|
+
})
|
|
120
|
+
})
|
package/test/fixture/fixture.ts
CHANGED
|
@@ -20,6 +20,9 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
|
|
|
20
20
|
await fs.mkdir(dirpath, { recursive: true })
|
|
21
21
|
if (options?.git) {
|
|
22
22
|
await $`git init`.cwd(dirpath).quiet()
|
|
23
|
+
await $`git config core.fsmonitor false`.cwd(dirpath).quiet()
|
|
24
|
+
await $`git config user.email "test@opencode.test"`.cwd(dirpath).quiet()
|
|
25
|
+
await $`git config user.name "Test"`.cwd(dirpath).quiet()
|
|
23
26
|
await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
|
|
24
27
|
}
|
|
25
28
|
if (options?.config) {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { test, expect, mock, beforeEach } from "bun:test"
|
|
2
|
+
|
|
3
|
+
// Mock UnauthorizedError to match the SDK's class
|
|
4
|
+
class MockUnauthorizedError extends Error {
|
|
5
|
+
constructor(message?: string) {
|
|
6
|
+
super(message ?? "Unauthorized")
|
|
7
|
+
this.name = "UnauthorizedError"
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Track what options were passed to each transport constructor
|
|
12
|
+
const transportCalls: Array<{
|
|
13
|
+
type: "streamable" | "sse"
|
|
14
|
+
url: string
|
|
15
|
+
options: { authProvider?: unknown }
|
|
16
|
+
}> = []
|
|
17
|
+
|
|
18
|
+
// Controls whether the mock transport simulates a 401 that triggers the SDK
|
|
19
|
+
// auth flow (which calls provider.state()) or a simple UnauthorizedError.
|
|
20
|
+
let simulateAuthFlow = true
|
|
21
|
+
|
|
22
|
+
// Mock the transport constructors to simulate OAuth auto-auth on 401
|
|
23
|
+
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|
24
|
+
StreamableHTTPClientTransport: class MockStreamableHTTP {
|
|
25
|
+
authProvider: {
|
|
26
|
+
state?: () => Promise<string>
|
|
27
|
+
redirectToAuthorization?: (url: URL) => Promise<void>
|
|
28
|
+
saveCodeVerifier?: (v: string) => Promise<void>
|
|
29
|
+
} | undefined
|
|
30
|
+
constructor(url: URL, options?: { authProvider?: unknown }) {
|
|
31
|
+
this.authProvider = options?.authProvider as typeof this.authProvider
|
|
32
|
+
transportCalls.push({
|
|
33
|
+
type: "streamable",
|
|
34
|
+
url: url.toString(),
|
|
35
|
+
options: options ?? {},
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
async start() {
|
|
39
|
+
// Simulate what the real SDK transport does on 401:
|
|
40
|
+
// It calls auth() which eventually calls provider.state(), then
|
|
41
|
+
// provider.redirectToAuthorization(), then throws UnauthorizedError.
|
|
42
|
+
if (simulateAuthFlow && this.authProvider) {
|
|
43
|
+
// The SDK calls provider.state() to get the OAuth state parameter
|
|
44
|
+
if (this.authProvider.state) {
|
|
45
|
+
await this.authProvider.state()
|
|
46
|
+
}
|
|
47
|
+
// The SDK calls saveCodeVerifier before redirecting
|
|
48
|
+
if (this.authProvider.saveCodeVerifier) {
|
|
49
|
+
await this.authProvider.saveCodeVerifier("test-verifier")
|
|
50
|
+
}
|
|
51
|
+
// The SDK calls redirectToAuthorization to redirect the user
|
|
52
|
+
if (this.authProvider.redirectToAuthorization) {
|
|
53
|
+
await this.authProvider.redirectToAuthorization(new URL("https://auth.example.com/authorize?state=test"))
|
|
54
|
+
}
|
|
55
|
+
throw new MockUnauthorizedError()
|
|
56
|
+
}
|
|
57
|
+
throw new MockUnauthorizedError()
|
|
58
|
+
}
|
|
59
|
+
async finishAuth(_code: string) {}
|
|
60
|
+
},
|
|
61
|
+
}))
|
|
62
|
+
|
|
63
|
+
mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
|
64
|
+
SSEClientTransport: class MockSSE {
|
|
65
|
+
constructor(url: URL, options?: { authProvider?: unknown }) {
|
|
66
|
+
transportCalls.push({
|
|
67
|
+
type: "sse",
|
|
68
|
+
url: url.toString(),
|
|
69
|
+
options: options ?? {},
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
async start() {
|
|
73
|
+
throw new Error("Mock SSE transport cannot connect")
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
// Mock the MCP SDK Client
|
|
79
|
+
mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
|
80
|
+
Client: class MockClient {
|
|
81
|
+
async connect(transport: { start: () => Promise<void> }) {
|
|
82
|
+
await transport.start()
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
// Mock UnauthorizedError in the auth module so instanceof checks work
|
|
88
|
+
mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
|
89
|
+
UnauthorizedError: MockUnauthorizedError,
|
|
90
|
+
}))
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
transportCalls.length = 0
|
|
94
|
+
simulateAuthFlow = true
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Import modules after mocking
|
|
98
|
+
const { MCP } = await import("../../src/mcp/index")
|
|
99
|
+
const { Instance } = await import("../../src/project/instance")
|
|
100
|
+
const { tmpdir } = await import("../fixture/fixture")
|
|
101
|
+
|
|
102
|
+
test("first connect to OAuth server shows needs_auth instead of failed", async () => {
|
|
103
|
+
await using tmp = await tmpdir({
|
|
104
|
+
init: async (dir) => {
|
|
105
|
+
await Bun.write(
|
|
106
|
+
`${dir}/opencode.json`,
|
|
107
|
+
JSON.stringify({
|
|
108
|
+
$schema: "https://opencode.ai/config.json",
|
|
109
|
+
mcp: {
|
|
110
|
+
"test-oauth": {
|
|
111
|
+
type: "remote",
|
|
112
|
+
url: "https://example.com/mcp",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await Instance.provide({
|
|
121
|
+
directory: tmp.path,
|
|
122
|
+
fn: async () => {
|
|
123
|
+
const result = await MCP.add("test-oauth", {
|
|
124
|
+
type: "remote",
|
|
125
|
+
url: "https://example.com/mcp",
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const serverStatus = result.status as Record<string, { status: string; error?: string }>
|
|
129
|
+
|
|
130
|
+
// The server should be detected as needing auth, NOT as failed.
|
|
131
|
+
// Before the fix, provider.state() would throw a plain Error
|
|
132
|
+
// ("No OAuth state saved for MCP server: test-oauth") which was
|
|
133
|
+
// not caught as UnauthorizedError, causing status to be "failed".
|
|
134
|
+
expect(serverStatus["test-oauth"]).toBeDefined()
|
|
135
|
+
expect(serverStatus["test-oauth"].status).toBe("needs_auth")
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test("state() generates a new state when none is saved", async () => {
|
|
141
|
+
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
|
142
|
+
const { McpAuth } = await import("../../src/mcp/auth")
|
|
143
|
+
|
|
144
|
+
await using tmp = await tmpdir()
|
|
145
|
+
|
|
146
|
+
await Instance.provide({
|
|
147
|
+
directory: tmp.path,
|
|
148
|
+
fn: async () => {
|
|
149
|
+
const provider = new McpOAuthProvider(
|
|
150
|
+
"test-state-gen",
|
|
151
|
+
"https://example.com/mcp",
|
|
152
|
+
{},
|
|
153
|
+
{ onRedirect: async () => {} },
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// Ensure no state exists
|
|
157
|
+
const entryBefore = await McpAuth.get("test-state-gen")
|
|
158
|
+
expect(entryBefore?.oauthState).toBeUndefined()
|
|
159
|
+
|
|
160
|
+
// state() should generate and return a new state, not throw
|
|
161
|
+
const state = await provider.state()
|
|
162
|
+
expect(typeof state).toBe("string")
|
|
163
|
+
expect(state.length).toBe(64) // 32 bytes as hex
|
|
164
|
+
|
|
165
|
+
// The generated state should be persisted
|
|
166
|
+
const entryAfter = await McpAuth.get("test-state-gen")
|
|
167
|
+
expect(entryAfter?.oauthState).toBe(state)
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test("state() returns existing state when one is saved", async () => {
|
|
173
|
+
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
|
174
|
+
const { McpAuth } = await import("../../src/mcp/auth")
|
|
175
|
+
|
|
176
|
+
await using tmp = await tmpdir()
|
|
177
|
+
|
|
178
|
+
await Instance.provide({
|
|
179
|
+
directory: tmp.path,
|
|
180
|
+
fn: async () => {
|
|
181
|
+
const provider = new McpOAuthProvider(
|
|
182
|
+
"test-state-existing",
|
|
183
|
+
"https://example.com/mcp",
|
|
184
|
+
{},
|
|
185
|
+
{ onRedirect: async () => {} },
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
// Pre-save a state
|
|
189
|
+
const existingState = "pre-saved-state-value"
|
|
190
|
+
await McpAuth.updateOAuthState("test-state-existing", existingState)
|
|
191
|
+
|
|
192
|
+
// state() should return the existing state
|
|
193
|
+
const state = await provider.state()
|
|
194
|
+
expect(state).toBe(existingState)
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
2
|
import { Project } from "../../src/project/project"
|
|
3
|
+
import { Filesystem } from "../../src/util/filesystem"
|
|
3
4
|
import { Log } from "../../src/util/log"
|
|
4
5
|
import { Storage } from "../../src/storage/storage"
|
|
5
6
|
import { $ } from "bun"
|
|
@@ -68,6 +69,52 @@ describe("Project.fromDirectory with worktrees", () => {
|
|
|
68
69
|
await $`git worktree remove ${worktreePath}`.cwd(tmp.path).quiet()
|
|
69
70
|
})
|
|
70
71
|
|
|
72
|
+
test("worktree should share project ID with main repo", async () => {
|
|
73
|
+
const p = Project
|
|
74
|
+
await using tmp = await tmpdir({ git: true })
|
|
75
|
+
|
|
76
|
+
const { project: main } = await p.fromDirectory(tmp.path)
|
|
77
|
+
|
|
78
|
+
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
|
|
79
|
+
try {
|
|
80
|
+
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
|
|
81
|
+
|
|
82
|
+
const { project: wt } = await p.fromDirectory(worktreePath)
|
|
83
|
+
|
|
84
|
+
expect(wt.id).toBe(main.id)
|
|
85
|
+
|
|
86
|
+
// Cache should live in the common .git dir, not the worktree's .git file
|
|
87
|
+
const cache = path.join(tmp.path, ".git", "opencode")
|
|
88
|
+
const exists = await Filesystem.exists(cache)
|
|
89
|
+
expect(exists).toBe(true)
|
|
90
|
+
} finally {
|
|
91
|
+
await $`git worktree remove ${worktreePath}`
|
|
92
|
+
.cwd(tmp.path)
|
|
93
|
+
.quiet()
|
|
94
|
+
.catch(() => {})
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("separate clones of the same repo should share project ID", async () => {
|
|
99
|
+
const p = Project
|
|
100
|
+
await using tmp = await tmpdir({ git: true })
|
|
101
|
+
|
|
102
|
+
// Create a bare remote, push, then clone into a second directory
|
|
103
|
+
const bare = tmp.path + "-bare"
|
|
104
|
+
const clone = tmp.path + "-clone"
|
|
105
|
+
try {
|
|
106
|
+
await $`git clone --bare ${tmp.path} ${bare}`.quiet()
|
|
107
|
+
await $`git clone ${bare} ${clone}`.quiet()
|
|
108
|
+
|
|
109
|
+
const { project: a } = await p.fromDirectory(tmp.path)
|
|
110
|
+
const { project: b } = await p.fromDirectory(clone)
|
|
111
|
+
|
|
112
|
+
expect(b.id).toBe(a.id)
|
|
113
|
+
} finally {
|
|
114
|
+
await $`rm -rf ${bare} ${clone}`.quiet().nothrow()
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
71
118
|
test("should accumulate multiple worktrees in sandboxes", async () => {
|
|
72
119
|
await using tmp = await tmpdir({ git: true })
|
|
73
120
|
|
|
@@ -281,6 +281,7 @@ test("env variable takes precedence, config merges options", async () => {
|
|
|
281
281
|
anthropic: {
|
|
282
282
|
options: {
|
|
283
283
|
timeout: 60000,
|
|
284
|
+
chunkTimeout: 15000,
|
|
284
285
|
},
|
|
285
286
|
},
|
|
286
287
|
},
|
|
@@ -298,6 +299,7 @@ test("env variable takes precedence, config merges options", async () => {
|
|
|
298
299
|
expect(providers["anthropic"]).toBeDefined()
|
|
299
300
|
// Config options should be merged
|
|
300
301
|
expect(providers["anthropic"].options.timeout).toBe(60000)
|
|
302
|
+
expect(providers["anthropic"].options.chunkTimeout).toBe(15000)
|
|
301
303
|
},
|
|
302
304
|
})
|
|
303
305
|
})
|
|
@@ -596,6 +596,38 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
|
|
|
596
596
|
expect(result[0].content[1]).toEqual({ type: "text", text: "Result" })
|
|
597
597
|
})
|
|
598
598
|
|
|
599
|
+
test("filters empty content for bedrock provider", () => {
|
|
600
|
+
const bedrockModel = {
|
|
601
|
+
...anthropicModel,
|
|
602
|
+
id: "amazon-bedrock/anthropic.claude-opus-4-6",
|
|
603
|
+
providerID: "amazon-bedrock",
|
|
604
|
+
api: {
|
|
605
|
+
id: "anthropic.claude-opus-4-6",
|
|
606
|
+
url: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
|
607
|
+
npm: "@ai-sdk/amazon-bedrock",
|
|
608
|
+
},
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const msgs = [
|
|
612
|
+
{ role: "user", content: "Hello" },
|
|
613
|
+
{ role: "assistant", content: "" },
|
|
614
|
+
{
|
|
615
|
+
role: "assistant",
|
|
616
|
+
content: [
|
|
617
|
+
{ type: "text", text: "" },
|
|
618
|
+
{ type: "text", text: "Answer" },
|
|
619
|
+
],
|
|
620
|
+
},
|
|
621
|
+
] as any[]
|
|
622
|
+
|
|
623
|
+
const result = ProviderTransform.message(msgs, bedrockModel, {})
|
|
624
|
+
|
|
625
|
+
expect(result).toHaveLength(2)
|
|
626
|
+
expect(result[0].content).toBe("Hello")
|
|
627
|
+
expect(result[1].content).toHaveLength(1)
|
|
628
|
+
expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" })
|
|
629
|
+
})
|
|
630
|
+
|
|
599
631
|
test("does not filter for non-anthropic providers", () => {
|
|
600
632
|
const openaiModel = {
|
|
601
633
|
...anthropicModel,
|