Pārlūkot izejas kodu

test: fix discovery test to boot up server instead of relying on 3rd party (#14327)

Aiden Cline 1 mēnesi atpakaļ
vecāks
revīzija
00c079868a

+ 155 - 0
packages/opencode/test/fixture/skills/agents-sdk/SKILL.md

@@ -0,0 +1,155 @@
+---
+name: agents-sdk
+description: Build AI agents on Cloudflare Workers using the Agents SDK. Load when creating stateful agents, durable workflows, real-time WebSocket apps, scheduled tasks, MCP servers, or chat applications. Covers Agent class, state management, callable RPC, Workflows integration, and React hooks.
+---
+
+# Cloudflare Agents SDK
+
+**STOP.** Your knowledge of the Agents SDK may be outdated. Prefer retrieval over pre-training for any Agents SDK task.
+
+## Documentation
+
+Fetch current docs from `https://github.com/cloudflare/agents/tree/main/docs` before implementing.
+
+| Topic | Doc | Use for |
+|-------|-----|---------|
+| Getting started | `docs/getting-started.md` | First agent, project setup |
+| State | `docs/state.md` | `setState`, `validateStateChange`, persistence |
+| Routing | `docs/routing.md` | URL patterns, `routeAgentRequest`, `basePath` |
+| Callable methods | `docs/callable-methods.md` | `@callable`, RPC, streaming, timeouts |
+| Scheduling | `docs/scheduling.md` | `schedule()`, `scheduleEvery()`, cron |
+| Workflows | `docs/workflows.md` | `AgentWorkflow`, durable multi-step tasks |
+| HTTP/WebSockets | `docs/http-websockets.md` | Lifecycle hooks, hibernation |
+| Email | `docs/email.md` | Email routing, secure reply resolver |
+| MCP client | `docs/mcp-client.md` | Connecting to MCP servers |
+| MCP server | `docs/mcp-servers.md` | Building MCP servers with `McpAgent` |
+| Client SDK | `docs/client-sdk.md` | `useAgent`, `useAgentChat`, React hooks |
+| Human-in-the-loop | `docs/human-in-the-loop.md` | Approval flows, pausing workflows |
+| Resumable streaming | `docs/resumable-streaming.md` | Stream recovery on disconnect |
+
+Cloudflare docs: https://developers.cloudflare.com/agents/
+
+## Capabilities
+
+The Agents SDK provides:
+
+- **Persistent state** - SQLite-backed, auto-synced to clients
+- **Callable RPC** - `@callable()` methods invoked over WebSocket
+- **Scheduling** - One-time, recurring (`scheduleEvery`), and cron tasks
+- **Workflows** - Durable multi-step background processing via `AgentWorkflow`
+- **MCP integration** - Connect to MCP servers or build your own with `McpAgent`
+- **Email handling** - Receive and reply to emails with secure routing
+- **Streaming chat** - `AIChatAgent` with resumable streams
+- **React hooks** - `useAgent`, `useAgentChat` for client apps
+
+## FIRST: Verify Installation
+
+```bash
+npm ls agents  # Should show agents package
+```
+
+If not installed:
+```bash
+npm install agents
+```
+
+## Wrangler Configuration
+
+```jsonc
+{
+  "durable_objects": {
+    "bindings": [{ "name": "MyAgent", "class_name": "MyAgent" }]
+  },
+  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }]
+}
+```
+
+## Agent Class
+
+```typescript
+import { Agent, routeAgentRequest, callable } from "agents";
+
+type State = { count: number };
+
+export class Counter extends Agent<Env, State> {
+  initialState = { count: 0 };
+
+  // Validation hook - runs before state persists (sync, throwing rejects the update)
+  validateStateChange(nextState: State, source: Connection | "server") {
+    if (nextState.count < 0) throw new Error("Count cannot be negative");
+  }
+
+  // Notification hook - runs after state persists (async, non-blocking)
+  onStateUpdate(state: State, source: Connection | "server") {
+    console.log("State updated:", state);
+  }
+
+  @callable()
+  increment() {
+    this.setState({ count: this.state.count + 1 });
+    return this.state.count;
+  }
+}
+
+export default {
+  fetch: (req, env) => routeAgentRequest(req, env) ?? new Response("Not found", { status: 404 })
+};
+```
+
+## Routing
+
+Requests route to `/agents/{agent-name}/{instance-name}`:
+
+| Class | URL |
+|-------|-----|
+| `Counter` | `/agents/counter/user-123` |
+| `ChatRoom` | `/agents/chat-room/lobby` |
+
+Client: `useAgent({ agent: "Counter", name: "user-123" })`
+
+## Core APIs
+
+| Task | API |
+|------|-----|
+| Read state | `this.state.count` |
+| Write state | `this.setState({ count: 1 })` |
+| SQL query | `` this.sql`SELECT * FROM users WHERE id = ${id}` `` |
+| Schedule (delay) | `await this.schedule(60, "task", payload)` |
+| Schedule (cron) | `await this.schedule("0 * * * *", "task", payload)` |
+| Schedule (interval) | `await this.scheduleEvery(30, "poll")` |
+| RPC method | `@callable() myMethod() { ... }` |
+| Streaming RPC | `@callable({ streaming: true }) stream(res) { ... }` |
+| Start workflow | `await this.runWorkflow("ProcessingWorkflow", params)` |
+
+## React Client
+
+```tsx
+import { useAgent } from "agents/react";
+
+function App() {
+  const [state, setLocalState] = useState({ count: 0 });
+
+  const agent = useAgent({
+    agent: "Counter",
+    name: "my-instance",
+    onStateUpdate: (newState) => setLocalState(newState),
+    onIdentity: (name, agentType) => console.log(`Connected to ${name}`)
+  });
+
+  return (
+    <button onClick={() => agent.setState({ count: state.count + 1 })}>
+      Count: {state.count}
+    </button>
+  );
+}
+```
+
+## References
+
+- **[references/workflows.md](references/workflows.md)** - Durable Workflows integration
+- **[references/callable.md](references/callable.md)** - RPC methods, streaming, timeouts
+- **[references/state-scheduling.md](references/state-scheduling.md)** - State persistence, scheduling
+- **[references/streaming-chat.md](references/streaming-chat.md)** - AIChatAgent, resumable streams
+- **[references/mcp.md](references/mcp.md)** - MCP server integration
+- **[references/email.md](references/email.md)** - Email routing and handling
+- **[references/codemode.md](references/codemode.md)** - Code Mode (experimental)

+ 92 - 0
packages/opencode/test/fixture/skills/agents-sdk/references/callable.md

@@ -0,0 +1,92 @@
+# Callable Methods
+
+Fetch `docs/callable-methods.md` from `https://github.com/cloudflare/agents/tree/main/docs` for complete documentation.
+
+## Overview
+
+`@callable()` exposes agent methods to clients via WebSocket RPC.
+
+```typescript
+import { Agent, callable } from "agents";
+
+export class MyAgent extends Agent<Env, State> {
+  @callable()
+  async greet(name: string): Promise<string> {
+    return `Hello, ${name}!`;
+  }
+
+  @callable()
+  async processData(data: unknown): Promise<Result> {
+    // Long-running work
+    return result;
+  }
+}
+```
+
+## Client Usage
+
+```typescript
+// Basic call
+const greeting = await agent.call("greet", ["World"]);
+
+// With timeout
+const result = await agent.call("processData", [data], {
+  timeout: 5000  // 5 second timeout
+});
+```
+
+## Streaming Responses
+
+```typescript
+import { Agent, callable, StreamingResponse } from "agents";
+
+export class MyAgent extends Agent<Env, State> {
+  @callable({ streaming: true })
+  async streamResults(stream: StreamingResponse, query: string) {
+    for await (const item of fetchResults(query)) {
+      stream.send(JSON.stringify(item));
+    }
+    stream.close();
+  }
+
+  @callable({ streaming: true })
+  async streamWithError(stream: StreamingResponse) {
+    try {
+      // ... work
+    } catch (error) {
+      stream.error(error.message);  // Signal error to client
+      return;
+    }
+    stream.close();
+  }
+}
+```
+
+Client with streaming:
+
+```typescript
+await agent.call("streamResults", ["search term"], {
+  stream: {
+    onChunk: (data) => console.log("Chunk:", data),
+    onDone: () => console.log("Complete"),
+    onError: (error) => console.error("Error:", error)
+  }
+});
+```
+
+## Introspection
+
+```typescript
+// Get list of callable methods on an agent
+const methods = await agent.call("getCallableMethods", []);
+// Returns: ["greet", "processData", "streamResults", ...]
+```
+
+## When to Use
+
+| Scenario | Use |
+|----------|-----|
+| Browser/mobile calling agent | `@callable()` |
+| External service calling agent | `@callable()` |
+| Worker calling agent (same codebase) | DO RPC directly |
+| Agent calling another agent | `getAgentByName()` + DO RPC |

+ 201 - 0
packages/opencode/test/fixture/skills/cloudflare/SKILL.md

@@ -0,0 +1,201 @@
+---
+name: cloudflare
+description: Comprehensive Cloudflare platform skill covering Workers, Pages, storage (KV, D1, R2), AI (Workers AI, Vectorize, Agents SDK), networking (Tunnel, Spectrum), security (WAF, DDoS), and infrastructure-as-code (Terraform, Pulumi). Use for any Cloudflare development task.
+references:
+  - workers
+  - pages
+  - d1
+  - durable-objects
+  - workers-ai
+---
+
+# Cloudflare Platform Skill
+
+Consolidated skill for building on the Cloudflare platform. Use decision trees below to find the right product, then load detailed references.
+
+## Quick Decision Trees
+
+### "I need to run code"
+
+```
+Need to run code?
+├─ Serverless functions at the edge → workers/
+├─ Full-stack web app with Git deploys → pages/
+├─ Stateful coordination/real-time → durable-objects/
+├─ Long-running multi-step jobs → workflows/
+├─ Run containers → containers/
+├─ Multi-tenant (customers deploy code) → workers-for-platforms/
+├─ Scheduled tasks (cron) → cron-triggers/
+├─ Lightweight edge logic (modify HTTP) → snippets/
+├─ Process Worker execution events (logs/observability) → tail-workers/
+└─ Optimize latency to backend infrastructure → smart-placement/
+```
+
+### "I need to store data"
+
+```
+Need storage?
+├─ Key-value (config, sessions, cache) → kv/
+├─ Relational SQL → d1/ (SQLite) or hyperdrive/ (existing Postgres/MySQL)
+├─ Object/file storage (S3-compatible) → r2/
+├─ Message queue (async processing) → queues/
+├─ Vector embeddings (AI/semantic search) → vectorize/
+├─ Strongly-consistent per-entity state → durable-objects/ (DO storage)
+├─ Secrets management → secrets-store/
+├─ Streaming ETL to R2 → pipelines/
+└─ Persistent cache (long-term retention) → cache-reserve/
+```
+
+### "I need AI/ML"
+
+```
+Need AI?
+├─ Run inference (LLMs, embeddings, images) → workers-ai/
+├─ Vector database for RAG/search → vectorize/
+├─ Build stateful AI agents → agents-sdk/
+├─ Gateway for any AI provider (caching, routing) → ai-gateway/
+└─ AI-powered search widget → ai-search/
+```
+
+### "I need networking/connectivity"
+
+```
+Need networking?
+├─ Expose local service to internet → tunnel/
+├─ TCP/UDP proxy (non-HTTP) → spectrum/
+├─ WebRTC TURN server → turn/
+├─ Private network connectivity → network-interconnect/
+├─ Optimize routing → argo-smart-routing/
+├─ Optimize latency to backend (not user) → smart-placement/
+└─ Real-time video/audio → realtimekit/ or realtime-sfu/
+```
+
+### "I need security"
+
+```
+Need security?
+├─ Web Application Firewall → waf/
+├─ DDoS protection → ddos/
+├─ Bot detection/management → bot-management/
+├─ API protection → api-shield/
+├─ CAPTCHA alternative → turnstile/
+└─ Credential leak detection → waf/ (managed ruleset)
+```
+
+### "I need media/content"
+
+```
+Need media?
+├─ Image optimization/transformation → images/
+├─ Video streaming/encoding → stream/
+├─ Browser automation/screenshots → browser-rendering/
+└─ Third-party script management → zaraz/
+```
+
+### "I need infrastructure-as-code"
+
+```
+Need IaC? → pulumi/ (Pulumi), terraform/ (Terraform), or api/ (REST API)
+```
+
+## Product Index
+
+### Compute & Runtime
+| Product | Reference |
+|---------|-----------|
+| Workers | `references/workers/` |
+| Pages | `references/pages/` |
+| Pages Functions | `references/pages-functions/` |
+| Durable Objects | `references/durable-objects/` |
+| Workflows | `references/workflows/` |
+| Containers | `references/containers/` |
+| Workers for Platforms | `references/workers-for-platforms/` |
+| Cron Triggers | `references/cron-triggers/` |
+| Tail Workers | `references/tail-workers/` |
+| Snippets | `references/snippets/` |
+| Smart Placement | `references/smart-placement/` |
+
+### Storage & Data
+| Product | Reference |
+|---------|-----------|
+| KV | `references/kv/` |
+| D1 | `references/d1/` |
+| R2 | `references/r2/` |
+| Queues | `references/queues/` |
+| Hyperdrive | `references/hyperdrive/` |
+| DO Storage | `references/do-storage/` |
+| Secrets Store | `references/secrets-store/` |
+| Pipelines | `references/pipelines/` |
+| R2 Data Catalog | `references/r2-data-catalog/` |
+| R2 SQL | `references/r2-sql/` |
+
+### AI & Machine Learning
+| Product | Reference |
+|---------|-----------|
+| Workers AI | `references/workers-ai/` |
+| Vectorize | `references/vectorize/` |
+| Agents SDK | `references/agents-sdk/` |
+| AI Gateway | `references/ai-gateway/` |
+| AI Search | `references/ai-search/` |
+
+### Networking & Connectivity
+| Product | Reference |
+|---------|-----------|
+| Tunnel | `references/tunnel/` |
+| Spectrum | `references/spectrum/` |
+| TURN | `references/turn/` |
+| Network Interconnect | `references/network-interconnect/` |
+| Argo Smart Routing | `references/argo-smart-routing/` |
+| Workers VPC | `references/workers-vpc/` |
+
+### Security
+| Product | Reference |
+|---------|-----------|
+| WAF | `references/waf/` |
+| DDoS Protection | `references/ddos/` |
+| Bot Management | `references/bot-management/` |
+| API Shield | `references/api-shield/` |
+| Turnstile | `references/turnstile/` |
+
+### Media & Content
+| Product | Reference |
+|---------|-----------|
+| Images | `references/images/` |
+| Stream | `references/stream/` |
+| Browser Rendering | `references/browser-rendering/` |
+| Zaraz | `references/zaraz/` |
+
+### Real-Time Communication
+| Product | Reference |
+|---------|-----------|
+| RealtimeKit | `references/realtimekit/` |
+| Realtime SFU | `references/realtime-sfu/` |
+
+### Developer Tools
+| Product | Reference |
+|---------|-----------|
+| Wrangler | `references/wrangler/` |
+| Miniflare | `references/miniflare/` |
+| C3 | `references/c3/` |
+| Observability | `references/observability/` |
+| Analytics Engine | `references/analytics-engine/` |
+| Web Analytics | `references/web-analytics/` |
+| Sandbox | `references/sandbox/` |
+| Workerd | `references/workerd/` |
+| Workers Playground | `references/workers-playground/` |
+
+### Infrastructure as Code
+| Product | Reference |
+|---------|-----------|
+| Pulumi | `references/pulumi/` |
+| Terraform | `references/terraform/` |
+| API | `references/api/` |
+
+### Other Services
+| Product | Reference |
+|---------|-----------|
+| Email Routing | `references/email-routing/` |
+| Email Workers | `references/email-workers/` |
+| Static Assets | `references/static-assets/` |
+| Bindings | `references/bindings/` |
+| Cache Reserve | `references/cache-reserve/` |

+ 6 - 0
packages/opencode/test/fixture/skills/index.json

@@ -0,0 +1,6 @@
+{
+  "skills": [
+    { "name": "agents-sdk", "description": "Cloudflare Agents SDK", "files": ["SKILL.md", "references/callable.md"] },
+    { "name": "cloudflare", "description": "Cloudflare Platform Skill", "files": ["SKILL.md"] }
+  ]
+}

+ 57 - 8
packages/opencode/test/skill/discovery.test.ts

@@ -1,9 +1,47 @@
-import { describe, test, expect } from "bun:test"
+import { describe, test, expect, beforeAll, afterAll } from "bun:test"
 import { Discovery } from "../../src/skill/discovery"
 import { Filesystem } from "../../src/util/filesystem"
+import { rm } from "fs/promises"
 import path from "path"
 
-const CLOUDFLARE_SKILLS_URL = "https://developers.cloudflare.com/.well-known/skills/"
+let CLOUDFLARE_SKILLS_URL: string
+let server: ReturnType<typeof Bun.serve>
+let downloadCount = 0
+
+const fixturePath = path.join(import.meta.dir, "../fixture/skills")
+
+beforeAll(async () => {
+  await rm(Discovery.dir(), { recursive: true, force: true })
+
+  server = Bun.serve({
+    port: 0,
+    async fetch(req) {
+      const url = new URL(req.url)
+
+      // route /.well-known/skills/* to the fixture directory
+      if (url.pathname.startsWith("/.well-known/skills/")) {
+        const filePath = url.pathname.replace("/.well-known/skills/", "")
+        const fullPath = path.join(fixturePath, filePath)
+
+        if (await Filesystem.exists(fullPath)) {
+          if (!fullPath.endsWith("index.json")) {
+            downloadCount++
+          }
+          return new Response(Bun.file(fullPath))
+        }
+      }
+
+      return new Response("Not Found", { status: 404 })
+    },
+  })
+
+  CLOUDFLARE_SKILLS_URL = `http://localhost:${server.port}/.well-known/skills/`
+})
+
+afterAll(async () => {
+  server?.stop()
+  await rm(Discovery.dir(), { recursive: true, force: true })
+})
 
 describe("Discovery.pull", () => {
   test("downloads skills from cloudflare url", async () => {
@@ -14,7 +52,7 @@ describe("Discovery.pull", () => {
       const md = path.join(dir, "SKILL.md")
       expect(await Filesystem.exists(md)).toBe(true)
     }
-  }, 30_000)
+  })
 
   test("url without trailing slash works", async () => {
     const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
@@ -23,15 +61,16 @@ describe("Discovery.pull", () => {
       const md = path.join(dir, "SKILL.md")
       expect(await Filesystem.exists(md)).toBe(true)
     }
-  }, 30_000)
+  })
 
   test("returns empty array for invalid url", async () => {
-    const dirs = await Discovery.pull("https://example.invalid/.well-known/skills/")
+    const dirs = await Discovery.pull(`http://localhost:${server.port}/invalid-url/`)
     expect(dirs).toEqual([])
   })
 
   test("returns empty array for non-json response", async () => {
-    const dirs = await Discovery.pull("https://example.com/")
+    // any url not explicitly handled in server returns 404 text "Not Found"
+    const dirs = await Discovery.pull(`http://localhost:${server.port}/some-other-path/`)
     expect(dirs).toEqual([])
   })
 
@@ -39,6 +78,7 @@ describe("Discovery.pull", () => {
     const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
     // find a skill dir that should have reference files (e.g. agents-sdk)
     const agentsSdk = dirs.find((d) => d.endsWith("/agents-sdk"))
+    expect(agentsSdk).toBeDefined()
     if (agentsSdk) {
       const refs = path.join(agentsSdk, "references")
       expect(await Filesystem.exists(path.join(agentsSdk, "SKILL.md"))).toBe(true)
@@ -46,16 +86,25 @@ describe("Discovery.pull", () => {
       const refDir = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true }))
       expect(refDir.length).toBeGreaterThan(0)
     }
-  }, 30_000)
+  })
 
   test("caches downloaded files on second pull", async () => {
+    // clear dir and downloadCount
+    await rm(Discovery.dir(), { recursive: true, force: true })
+    downloadCount = 0
+
     // first pull to populate cache
     const first = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
     expect(first.length).toBeGreaterThan(0)
+    const firstCount = downloadCount
+    expect(firstCount).toBeGreaterThan(0)
 
     // second pull should return same results from cache
     const second = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
     expect(second.length).toBe(first.length)
     expect(second.sort()).toEqual(first.sort())
-  }, 60_000)
+
+    // second pull should NOT increment download count
+    expect(downloadCount).toBe(firstCount)
+  })
 })