Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "cloudflare-think",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview --port 38788",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test:prod",
"test:prod": "TEST_ENV=production playwright test",
"test:dev": "TEST_ENV=development playwright test",
"test:build-ai-v6": "pnpm install && pnpm add ai@^6.0.0 @openrouter/ai-sdk-provider@^2.9.1 && pnpm build",
"test:assert-ai-v6": "AI_SDK_MAJOR=6 pnpm test:prod"
},
"dependencies": {
"@cloudflare/think": "^0.19.0",
"@openrouter/ai-sdk-provider": "^3.1.0",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"agents": "^0.24.0",
"ai": "^7.0.112",
"dataloader": "^2.2.3",
"zod": "^4.0.0"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.57.2",
"@cloudflare/workers-types": "^4.20260426.0",
"@playwright/test": "~1.63.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"typescript": "^5.5.2",
"vite": "7.3.5",
"wrangler": "^4.136.2",
"ws": "^8.18.3"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
},
"sentryTest": {
"optional": true,
"optionalVariants": [
{
"build-command": "pnpm test:build-ai-v6",
"assert-command": "pnpm test:assert-ai-v6",
"label": "cloudflare-think (ai v6)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const testEnv = process.env.TEST_ENV;

if (!testEnv) {
throw new Error('No test env defined');
}

const APP_PORT = 38788;

const config = getPlaywrightConfig(
{
startCommand: `pnpm preview`,
port: APP_PORT,
},
// Each test drives a real OpenRouter tool-calling turn (up to two model calls) and then waits for
// the spans to flush, which does not fit the default 30s timeout when the provider is slow. The
// serial default from `getPlaywrightConfig` is kept: these turns share one worker and one event
// proxy, so running them in parallel only makes traces harder to tell apart.
{ timeout: 90_000 },
);

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
declare namespace Cloudflare {
interface Env {
E2E_TEST_DSN: string;
E2E_OPENROUTER_API_KEY: string;
ThinkAgent: DurableObjectNamespace;
}
}

interface Env extends Cloudflare.Env {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { Think } from '@cloudflare/think';
import * as Sentry from '@sentry/cloudflare';
import { routeAgentRequest } from 'agents';
import type { LanguageModel, ToolSet } from 'ai';
import { tool } from 'ai';
import DataLoader from 'dataloader';
import { z } from 'zod';

/**
* Not wrapped by hand: `@sentry/cloudflare/vite` detects `extends Think` and rewrites this export
* into `instrumentAgentWithSentry(...)` at build time. Wrapping it here would prove nothing about
* the zero-config path.
*/
export class ThinkAgent extends Think<Env> {
public getModel(): LanguageModel {
// Call OpenRouter directly (rather than the default Vercel AI Gateway) so the e2e test needs
// only a single OpenRouter key, reusing `E2E_OPENROUTER_API_KEY` like the other AI apps. A real
// provider is also what makes the outgoing model request observable as an `http.client` span.
const openrouter = createOpenRouter({ apiKey: this.env.E2E_OPENROUTER_API_KEY ?? '' });

return openrouter('openai/gpt-4o-mini');
}

public getSystemPrompt(): string {
return [
'You are a concise assistant used by an automated end-to-end test.',
'When the user asks about the weather in a place, call the `get_weather` tool for that place and answer in one short sentence using its result.',
'When the user asks you to trigger a failure, call the `fail_now` tool.',
'Do not ask follow-up questions.',
].join('\n');
}

public getTools(): ToolSet {
return {
get_weather: tool({
description: 'Get the current weather for a location',
inputSchema: z.object({ location: z.string() }),
execute: async ({ location }: { location: string }) => {
// A manual span raised inside a tool must nest under that tool's `gen_ai.execute_tool`
// span, which only holds if Think runs the tool inside the SDK's async context.
return Sentry.startSpan({ name: 'lookup-forecast', op: 'gen_ai.tool.manual' }, async () => {
// `dataloader` is instrumented through the orchestrion module transform rather than by
// patching a global, so its spans are the probe for whether channel injection reached
// this bundled worker at all.
const loader = new DataLoader<string, string>(async keys => keys.map(key => `forecast:${key}`));
await Promise.all([loader.load(location), loader.load(location)]);

return { city: location, condition: 'Sunny', temperatureC: 22 };
});
},
}),
fail_now: tool({
description: 'Always throws an error. Call this when the user asks to trigger a failure.',
// A nominal argument rather than `z.object({})`, and an explicit return type: the AI SDK
// infers `never` for an empty input schema, and a body that only throws infers
// `Promise<never>`. Either one alone makes the `tool()` overload unresolvable.
inputSchema: z.object({ reason: z.string() }),
execute: async (_input: { reason: string }): Promise<string> => {
throw new Error('Think tool failed on purpose');
Comment on lines +59 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The runTurn method can return a result where continuation is undefined due to a race condition. This value is used without a check, leading to an incorrect API response.
Severity: MEDIUM

Suggested Fix

Add a check to ensure result and result.continuation are not undefined before returning the response. If result.continuation is missing, consider throwing an error or returning a more explicit error response to the client instead of an empty object. For example: if (!result?.continuation) { throw new Error('Failed to get continuation from agent turn.'); } return Response.json({ continuation: result.continuation });.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: dev-packages/e2e-tests/test-applications/cloudflare-think/src/index.ts#L59-L60

Potential issue: In
`dev-packages/e2e-tests/test-applications/cloudflare-think/src/index.ts`, the
`onRequest` method calls `runTurn` and then directly accesses `result.continuation` to
build the JSON response. Due to a known race condition in the `@cloudflare/think`
library, concurrent agent turns can cause `runTurn` to resolve with a result where the
`continuation` property is `undefined`. When this happens, `Response.json({
continuation: undefined })` serializes to an empty object `{}`, breaking the API
contract and causing silent failures for clients that expect the `continuation` field.
This race condition is reproducible in the e2e test environment.

},
}),
};
}

public async onRequest(request: Request): Promise<Response> {
const message = new URL(request.url).searchParams.get('message') ?? 'What is the weather in Paris?';

const result = await this.runTurn({ input: message });

return Response.json({ continuation: result.continuation });
}
}

export default {
async fetch(request, env): Promise<Response> {
return (await routeAgentRequest(request, env)) ?? new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { CloudflareOptions } from '@sentry/cloudflare';

export default (env: Env): CloudflareOptions => ({
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-think',
envelopeDumpPath: process.env.SENTRY_ENVELOPE_DUMP_PATH,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
import { attr, isTurnOf, newAgentId, runAgentTurn, type StreamedSpan } from './utils';

const APP = 'cloudflare-think';

const isDataloaderSpan = (span: { attributes?: Record<string, { value?: unknown }> }): boolean =>
span.attributes?.['sentry.origin']?.value === 'auto.db.dataloader';

/**
* `dataloader` is instrumented by the orchestrion module transform rather than by patching anything
* at runtime, so its spans are the probe for whether channel injection reached the worker at all.
* Libraries that do not need the transform (`node:http`, and the AI spans themselves) would pass
* even if injection were broken.
*
* On Cloudflare the injection is done at build time by `sentryCloudflareVitePlugin()`, so unlike the
* Node apps this needs no `--import` bootstrap. A Think worker is heavily bundled, which is exactly
* the situation where a transform can silently stop applying, so this is the test that catches it.
*
* The loader runs inside a tool so its spans land in the turn's trace rather than one of their own.
*/
test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async ({ baseURL }) => {
const agentId = newAgentId('dataloader');
const ofThisTurn = isTurnOf(agentId);

const spansPromise = collectStreamedSpans(
APP,
spansOfTrace =>
ofThisTurn(spansOfTrace as StreamedSpan[]) &&
spansOfTrace.some(span => attr(span as StreamedSpan, 'gen_ai.tool.name') === 'get_weather') &&
spansOfTrace.some(isDataloaderSpan),
);

await runAgentTurn(baseURL!, agentId, 'What is the weather in Paris?');

const spans = await spansPromise;
const dataloaderSpan = spans.find(isDataloaderSpan);
const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'get_weather');

// Sharing the trace is the point. Not asserting the exact parent: the model may call the tool more
// than once, so the tool span found here is not reliably the one that ran this loader.
expect(getSpanOp(dataloaderSpan!)).toBe('cache.get');
expect(dataloaderSpan?.trace_id).toBe(toolSpan?.trace_id);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils';
import { attr, isTurnOf, newAgentId, runAgentTurn, type StreamedSpan } from './utils';

const APP = 'cloudflare-think';

/**
* A tool throw is not an agent failure: the AI SDK catches it, hands the error back to the model as
* a tool result, and the turn carries on and usually answers. So the error has to reach Sentry from
* the tool span itself, and the spans above it stay `ok` — the model calls did succeed.
*/
test('captures an error thrown inside a Think tool and marks its span errored', async ({ baseURL }) => {
const agentId = newAgentId('tool-error');
const ofThisTurn = isTurnOf(agentId);

const errorPromise = waitForError(
APP,
event => event.exception?.values?.[0]?.value === 'Think tool failed on purpose',
);

const spansPromise = collectStreamedSpans(
APP,
spansOfTrace =>
ofThisTurn(spansOfTrace as StreamedSpan[]) &&
spansOfTrace.some(span => attr(span as StreamedSpan, 'gen_ai.tool.name') === 'fail_now'),
);

await runAgentTurn(baseURL!, agentId, 'Please trigger a failure now.');

const error = await errorPromise;
const spans = await spansPromise;

const exception = error.exception?.values?.[0];
expect(exception?.type).toBe('Error');
expect(exception?.value).toBe('Think tool failed on purpose');
expect(exception?.mechanism?.type).toBe('auto.vercelai.channel');

const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now');
expect(getSpanOp(toolSpan!)).toBe('gen_ai.execute_tool');
expect(toolSpan?.status).toBe('error');

// The issue belongs to the same trace as the turn that produced it.
expect(error.contexts?.trace?.trace_id).toBe(toolSpan?.trace_id);

// The model calls around the failing tool succeeded, so only the tool span is errored.
const modelCalls = spans.filter(span => getSpanOp(span) === 'gen_ai.generate_content');
expect(modelCalls.length).toBeGreaterThan(0);
for (const modelCall of modelCalls) {
expect(modelCall.status).toBe('ok');
}
});
Loading
Loading