supermemory/packages/tools/test/ai-sdk-compat/run.ts

179 lines
6.1 KiB
TypeScript

// Packs the built package and installs it next to each supported AI SDK major, then typechecks and runs a consumer app.
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { $ } from "bun"
const ROOT = path.resolve(import.meta.dir, "../..")
const FINISHED = { unified: "stop", raw: "stop" }
const TOOL_CALLS = { unified: "tool-calls", raw: "tool_calls" }
const USAGE = {
inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 1, text: 1, reasoning: 0 },
}
const VERSIONS = [
{
ai: "5",
mock: "MockLanguageModelV2",
stop: "stop",
toolCalls: "tool-calls",
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
},
{
ai: "6",
mock: "MockLanguageModelV3",
stop: FINISHED,
toolCalls: TOOL_CALLS,
usage: USAGE,
},
{
ai: "7",
mock: "MockLanguageModelV4",
stop: FINISHED,
toolCalls: TOOL_CALLS,
usage: USAGE,
},
]
const fixture = (v: (typeof VERSIONS)[number]) => `
import { generateText, stepCountIs, streamText } from "ai"
import { ${v.mock} } from "ai/test"
import { supermemoryTools, withSupermemory } from "@supermemory/tools/ai-sdk"
let searchCalls = 0
const saved: string[] = []
const server = Bun.serve({
port: 0,
async fetch(req) {
const route = new URL(req.url).pathname
if (route.endsWith("/document")) saved.push(String((await req.json()).content))
if (route.endsWith("/profile"))
return Response.json({ profile: { static: [{ id: "s0", memory: "User prefers dark mode" }], dynamic: [], buckets: {} } })
if (route.endsWith("/search")) {
searchCalls++
return Response.json({ results: [], searchTime: 1 })
}
return Response.json({ id: "doc_1", status: "queued" })
},
})
const baseUrl = \`http://127.0.0.1:\${server.port}\`
const prompts: string[] = []
let calls = 0
const model = new ${v.mock}({
supportedUrls: { "image/*": [/^https:/] },
doGenerate: async (options) => {
prompts.push(JSON.stringify(options.prompt))
const callTool = calls++ === 0 && (options.tools?.length ?? 0) > 0
return {
content: callTool
? [{ type: "tool-call", toolCallId: "c1", toolName: "searchMemories", input: JSON.stringify({ informationToGet: "theme" }) }]
: [{ type: "text", text: "ok" }],
finishReason: callTool ? ${JSON.stringify(v.toolCalls)} : ${JSON.stringify(v.stop)},
usage: ${JSON.stringify(v.usage)},
warnings: [],
} as never
},
})
const tools = supermemoryTools("sm_test", { baseUrl })
const withTools = await generateText({ model, tools, prompt: "what theme?", stopWhen: stepCountIs(3) })
if (withTools.text !== "ok" || searchCalls !== 1) throw new Error(\`tools: text=\${withTools.text} searchCalls=\${searchCalls}\`)
const wrapped = withSupermemory(model, { apiKey: "sm_test", namespace: "user_1", id: "conv_1", baseUrl })
const withMemory = await generateText({ model: wrapped, prompt: "hi" })
if (withMemory.text !== "ok" || !prompts.at(-1)?.includes("User prefers dark mode"))
throw new Error("withSupermemory: memory was not injected into the prompt")
// Saving is fire-and-forget, so wait for the document request to land.
const savedAfter = async (count: number) => {
for (let i = 0; i < 50 && saved.length < count; i++) await Bun.sleep(20)
return saved[count - 1] ?? ""
}
await savedAfter(1)
await generateText({ model: wrapped, messages: [{ role: "user", content: [{ type: "image", image: new URL("https://example.com/cat.png") }] }] })
if (!(await savedAfter(saved.length + 1)).includes("[image")) throw new Error("withSupermemory: image-only turn was not saved")
await generateText({ model: wrapped, messages: [{ role: "user", content: [{ type: "text", text: "look" }, { type: "image", image: new Uint8Array([137, 80, 78, 71]), mediaType: "image/png" }] }] })
if (!(await savedAfter(saved.length + 1)).includes("[image")) throw new Error("withSupermemory: image was dropped from a mixed turn")
// Type-only: the wrapped model and tools must also be accepted by streamText.
const _typecheckStream = () => streamText({ model: wrapped, tools, prompt: "hi" })
server.stop(true)
console.log("ai@${v.ai}: tools and withSupermemory OK")
`
const work = await mkdtemp(path.join(tmpdir(), "sm-tools-compat-"))
try {
await $`bun run build`.cwd(ROOT).quiet()
const packed = await $`npm pack --silent --pack-destination ${work}`
.cwd(ROOT)
.text()
const tarball = path.join(work, packed.trim().split("\n").at(-1) ?? "")
for (const v of VERSIONS) {
const app = path.join(work, `ai-v${v.ai}`)
await Bun.write(
path.join(app, "package.json"),
JSON.stringify({
type: "module",
dependencies: {
"@supermemory/tools": `file:${tarball}`,
ai: `^${v.ai}`,
typescript: "^5.9.2",
zod: "^4.1.5",
"@types/bun": "^1.2.21",
},
}),
)
await Bun.write(
path.join(app, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
target: "ES2022",
module: "ESNext",
moduleResolution: "bundler",
strict: true,
noEmit: true,
skipLibCheck: true,
types: ["bun"],
},
files: ["app.ts"],
}),
)
await Bun.write(path.join(app, "app.ts"), fixture(v))
await $`npm install --silent --no-audit --no-fund`.cwd(app).quiet()
const installed = (
await Bun.file(path.join(app, "node_modules/ai/package.json")).json()
).version
const nested = await Bun.file(
path.join(
app,
"node_modules/@supermemory/tools/node_modules/ai/package.json",
),
).exists()
if (nested)
throw new Error(
`ai@${v.ai}: a second copy of ai was installed under @supermemory/tools`,
)
const aiProvider = (
await Bun.file(path.join(app, "node_modules/ai/package.json")).json()
).dependencies["@ai-sdk/provider"]
const topProvider = (
await Bun.file(
path.join(app, "node_modules/@ai-sdk/provider/package.json"),
).json()
).version
if (aiProvider.split(".")[0] !== topProvider.split(".")[0])
throw new Error(
`ai@${installed}: @ai-sdk/provider@${topProvider} was installed, but ai uses ${aiProvider}`,
)
const tsc = await $`npx tsc -p .`.cwd(app).nothrow().quiet()
if (tsc.exitCode !== 0)
throw new Error(`ai@${installed}: typecheck failed\n${tsc.stdout}`)
await $`bun app.ts`.cwd(app)
}
} finally {
await rm(work, { recursive: true, force: true })
}