Skip to content

Commit 221dd86

Browse files
JPeer264claude
andauthored
test(e2e): Add a cloudflare-pi-durable end-to-end application (#25022)
Cloudflare now hosts pi-durable in Durable Objects through the Agents SDK `PiHarness` ([changelog](https://developers.cloudflare.com/changelog/post/2026-10-02-pi-harness/)). This app runs pi-durable that way, with `@sentry/cloudflare/vite` as the only Sentry setup. It checks that a prompt becomes an `invoke_agent` trace with `chat`, `execute_tool` and provider `http.client` spans, that a throwing tool is reported on its span, and that a run resumes in a new trace after the Durable Object resets during a tool call. The reset uses `ctx.abort()`, which drops the object like an eviction; the next request starts it again, and `PiHarness` resumes pi from its SQLite state. Like `node-pi-durable` and `cloudflare-think`, it calls a real model through OpenRouter, so it is optional. The changelog example uses Workers AI (`createAI({ binding: env.AI })`), but the AI binding needs a Cloudflare account even in local dev, so that path has a unit test in #24993 instead. This app found a bug that #24993 now fixes: `@sentry/cloudflare` initializes the SDK inside each request, so the client is on the scope of that request only. The task phases ran on a copy of the default scope, had no client, and dropped every pi-durable span and error. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 966e1ff commit 221dd86

12 files changed

Lines changed: 415 additions & 0 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "cloudflare-pi-durable",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "vite dev",
7+
"build": "vite build",
8+
"preview": "vite preview --port 38789",
9+
"typecheck": "tsc --noEmit",
10+
"test:build": "pnpm install && pnpm build",
11+
"test:build-latest": "pnpm install && pnpm add agents@latest @earendil-works/pi-durable@latest @earendil-works/pi-ai@latest && pnpm build",
12+
"test:assert": "playwright test"
13+
},
14+
"dependencies": {
15+
"@earendil-works/pi-ai": "1.0.0",
16+
"@earendil-works/pi-durable": "1.0.0",
17+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
18+
"agents": "0.26.0"
19+
},
20+
"devDependencies": {
21+
"@cloudflare/vite-plugin": "1.57.2",
22+
"@cloudflare/workers-types": "^4.20260426.0",
23+
"@playwright/test": "~1.63.0",
24+
"@sentry-internal/test-utils": "link:../../../test-utils",
25+
"@sentry/conventions": "0.26.0",
26+
"typescript": "^5.5.2",
27+
"vite": "7.3.5",
28+
"wrangler": "^4.136.2"
29+
},
30+
"volta": {
31+
"extends": "../../package.json"
32+
},
33+
"sentryTest": {
34+
"optional": true,
35+
"optionalVariants": [
36+
{
37+
"build-command": "pnpm test:build-latest",
38+
"label": "cloudflare-pi-durable (latest)"
39+
}
40+
]
41+
}
42+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig(
4+
{
5+
startCommand: 'pnpm preview',
6+
port: 38789,
7+
},
8+
// Each test drives real OpenRouter tool-calling turns, and one resets the Durable Object mid-run,
9+
// which does not fit the default 30s timeout when the provider is slow.
10+
{ timeout: 120_000 },
11+
);
12+
13+
export default config;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Type } from '@earendil-works/pi-ai';
2+
import { createModels } from '@earendil-works/pi-ai/models';
3+
import { openrouterProvider } from '@earendil-works/pi-ai/providers/openrouter';
4+
import { createRegistry, defineExtension, defineTool, Harness, section } from '@earendil-works/pi-durable';
5+
import * as Sentry from '@sentry/cloudflare';
6+
import { Agent } from 'agents';
7+
import { PiHarness } from 'agents/harness/pi';
8+
9+
/**
10+
* pi-durable hosted by the Agents SDK `PiHarness`: pi keeps its state in this object's SQLite
11+
* database, and the harness reopens pi when the object restarts. Nothing wires Sentry into
12+
* pi-durable by hand, the Vite plugin injects the channel into `Harness.open()`.
13+
*/
14+
export class Assistant extends Agent<Env> {
15+
public registry = createRegistry();
16+
17+
public harness = new PiHarness({
18+
harness: ({ storage, context }) => {
19+
// pi-ai reads provider keys from `process.env` by default, so the Worker hands over its secret.
20+
const models = createModels({
21+
authContext: {
22+
env: async name => (name === 'OPENROUTER_API_KEY' ? this.env.E2E_OPENROUTER_API_KEY : undefined),
23+
fileExists: async () => false,
24+
},
25+
});
26+
models.setProvider(openrouterProvider());
27+
return Harness.open(storage, { models, registry: this.registry }, context);
28+
},
29+
defaults: { model: { provider: 'openrouter', id: 'anthropic/claude-haiku-4.5' } },
30+
});
31+
32+
public constructor(ctx: DurableObjectState, env: Env) {
33+
super(ctx, env);
34+
this.registry.install(
35+
defineExtension({
36+
name: 'e2e',
37+
sections: [
38+
section('preamble', () => 'You are a test assistant. Use the tools exactly as asked. Keep answers short.', {
39+
tag: false,
40+
}),
41+
],
42+
tools: [
43+
defineTool({
44+
name: 'get_weather',
45+
description: 'Get the current weather for a city.',
46+
parameters: Type.Object({ city: Type.String() }),
47+
// The manual span should nest under the SDK's `execute_tool` span.
48+
execute: async args =>
49+
Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => ({
50+
content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }],
51+
})),
52+
}),
53+
defineTool({
54+
name: 'fail_now',
55+
description: 'Always throws an error. Call this when the user asks to trigger a failure.',
56+
parameters: Type.Object({}),
57+
execute: async () => {
58+
throw new Error('Intentional pi-durable tool failure');
59+
},
60+
}),
61+
defineTool({
62+
name: 'crash_once',
63+
description: 'Runs one step of a job. Call this when the user asks for crash_once.',
64+
parameters: Type.Object({ job: Type.String() }),
65+
// Replay-safe, so pi-durable reruns the call after the reset instead of failing it.
66+
replay: 'safe',
67+
execute: async args => {
68+
const marker = `crashed:${args.job}`;
69+
if (await this.ctx.storage.get(marker)) {
70+
return { content: [{ type: 'text', text: `Step of job ${args.job} completed.` }] };
71+
}
72+
await this.ctx.storage.put(marker, true);
73+
await this.ctx.storage.sync();
74+
// Drops the object mid-call, like an eviction. The next request starts it again.
75+
this.ctx.abort('crash_once resets the Durable Object');
76+
throw new Error('The Durable Object did not reset');
77+
},
78+
}),
79+
],
80+
}),
81+
);
82+
this.lifecycle.use(this.harness);
83+
}
84+
85+
public async onRequest(request: Request): Promise<Response> {
86+
if (request.method === 'GET') {
87+
const operationId = new URL(request.url).searchParams.get('operation') ?? '';
88+
const { status, reason, text } = await this.harness.wait(operationId);
89+
return Response.json({ status, reason, text });
90+
}
91+
92+
const { message, operationId } = await request.json<{ message: string; operationId?: string }>();
93+
const { status, reason, text } = await this.harness.prompt(message, { operationId });
94+
return Response.json({ status, reason, text });
95+
}
96+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
declare namespace Cloudflare {
2+
interface Env {
3+
E2E_TEST_DSN: string;
4+
E2E_OPENROUTER_API_KEY: string;
5+
Assistant: DurableObjectNamespace;
6+
}
7+
}
8+
9+
interface Env extends Cloudflare.Env {}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { routeAgentRequest } from 'agents';
2+
3+
export { Assistant } from './assistant';
4+
5+
export default {
6+
async fetch(request, env): Promise<Response> {
7+
return (await routeAgentRequest(request, env)) ?? new Response('Not found', { status: 404 });
8+
},
9+
} satisfies ExportedHandler<Env>;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
// The Sentry Vite plugin picks this file up by convention, next to the worker entry named in
4+
// wrangler's `main`, and hands its default export to `withSentry`.
5+
export default defineCloudflareOptions((env: Env) => ({
6+
dsn: env.E2E_TEST_DSN,
7+
environment: 'qa',
8+
tunnel: 'http://localhost:3031/',
9+
tracesSampleRate: 1.0,
10+
}));
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'cloudflare-pi-durable',
6+
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { expect, test } from '@playwright/test';
2+
import {
3+
GEN_AI_CONVERSATION_ID,
4+
GEN_AI_PROVIDER_NAME,
5+
GEN_AI_REQUEST_MODEL,
6+
GEN_AI_RESPONSE_FINISH_REASONS,
7+
GEN_AI_TOOL_CALL_ARGUMENTS,
8+
GEN_AI_TOOL_CALL_RESULT,
9+
GEN_AI_TOOL_NAME,
10+
GEN_AI_USAGE_INPUT_TOKENS,
11+
GEN_AI_USAGE_OUTPUT_TOKENS,
12+
SENTRY_ORIGIN,
13+
SERVER_ADDRESS,
14+
} from '@sentry/conventions/attributes';
15+
import { DB_QUERY, GEN_AI_CHAT, GEN_AI_INVOKE_AGENT, HTTP_CLIENT, HTTP_SERVER } from '@sentry/conventions/op';
16+
import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils';
17+
import { newAgentId, prompt, submitPrompt, waitForOperation } from './utils';
18+
19+
const APP = 'cloudflare-pi-durable';
20+
21+
test('traces a PiHarness prompt as invoke_agent with chat, execute_tool and provider spans', async ({ baseURL }) => {
22+
// A run is its own trace, and its `invoke_agent` segment ends last. Only this test calls get_weather.
23+
const spansPromise = collectStreamedSpans(
24+
APP,
25+
spansOfTrace =>
26+
spansOfTrace.some(span => span.is_segment && getSpanOp(span) === GEN_AI_INVOKE_AGENT) &&
27+
spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'get_weather'),
28+
);
29+
30+
const answer = await prompt(
31+
baseURL!,
32+
newAgentId('weather'),
33+
'What is the weather in Vienna? Use the get_weather tool.',
34+
);
35+
expect(answer.status, answer.reason).toBe('done');
36+
37+
const spans = await spansPromise;
38+
const agent = spans.find(span => span.is_segment)!;
39+
const chats = spans.filter(span => getSpanOp(span) === GEN_AI_CHAT);
40+
// The manual span of the tool is the one way to pick the call that ran, should the model call twice.
41+
const manualSpan = spans.find(span => span.name === 'resolve-weather')!;
42+
const tool = spans.find(span => span.span_id === manualSpan.parent_span_id)!;
43+
const providerCalls = spans.filter(span => getSpanOp(span) === HTTP_CLIENT);
44+
45+
expect(agent.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable');
46+
// The PiHarness root session is pi conversation 1, in a Harness of its own per Durable Object.
47+
expect(agent.attributes[GEN_AI_CONVERSATION_ID]?.value).toMatch(/^[0-9a-f]{32}:1$/);
48+
// The request that submitted the prompt is a trace of its own: the scheduler runs the work.
49+
expect(spans.some(span => getSpanOp(span) === HTTP_SERVER)).toBe(false);
50+
// pi-durable keeps its state in the `pi_` tables of `PiHarness`, whose statements start no span.
51+
expect(spans.filter(span => getSpanOp(span) === DB_QUERY && /\bpi_/.test(String(span.name)))).toEqual([]);
52+
// pi-ai sends the requests through `@anthropic-ai/sdk`, whose own integration must stay out so
53+
// each request is reported once.
54+
expect(spans.filter(span => String(span.attributes[SENTRY_ORIGIN]?.value).startsWith('auto.ai.'))).toEqual(
55+
spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.pi_durable'),
56+
);
57+
58+
// One tool-calling response, then the answer.
59+
expect(chats.length).toBeGreaterThanOrEqual(2);
60+
for (const chat of chats) {
61+
expect(chat.parent_span_id).toBe(agent.span_id);
62+
expect(chat.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('openrouter');
63+
expect(chat.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('anthropic/claude-haiku-4.5');
64+
expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(agent.attributes[GEN_AI_CONVERSATION_ID]?.value);
65+
// pi-durable retries a provider error inside the run; such a request has no usage and no HTTP span
66+
// of its own to assert on.
67+
if (chat.status === 'ok') {
68+
expect(typeof chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe('number');
69+
expect(typeof chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe('number');
70+
expect(providerCalls.some(providerCall => providerCall.parent_span_id === chat.span_id)).toBe(true);
71+
}
72+
}
73+
74+
expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather');
75+
expect(tool.parent_span_id).toBe(agent.span_id);
76+
expect(tool.status).toBe('ok');
77+
expect(tool.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toContain('Vienna');
78+
expect(tool.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('21 degrees and sunny in');
79+
80+
// The provider's HTTP calls nest inside the `chat` span that sent them.
81+
expect(providerCalls.length).toBeGreaterThan(0);
82+
for (const providerCall of providerCalls) {
83+
expect(chats.map(chat => chat.span_id)).toContain(providerCall.parent_span_id);
84+
expect(providerCall.attributes[SERVER_ADDRESS]?.value).toBe('openrouter.ai');
85+
}
86+
});
87+
88+
test('reports a throwing tool as an error on its execute_tool span', async ({ baseURL }) => {
89+
const errorPromise = waitForError(
90+
APP,
91+
event => event.exception?.values?.[0]?.value === 'Intentional pi-durable tool failure',
92+
);
93+
const spansPromise = collectStreamedSpans(
94+
APP,
95+
spansOfTrace =>
96+
spansOfTrace.some(span => span.is_segment && getSpanOp(span) === GEN_AI_INVOKE_AGENT) &&
97+
spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'fail_now'),
98+
);
99+
100+
const answer = await prompt(baseURL!, newAgentId('fail'), 'Call the fail_now tool, then tell me what happened.');
101+
expect(answer.status, answer.reason).toBe('done');
102+
103+
const [error, spans] = await Promise.all([errorPromise, spansPromise]);
104+
// The error is reported on the span of the call that threw.
105+
const tool = spans.find(span => span.span_id === error.contexts?.trace?.span_id)!;
106+
107+
expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('fail_now');
108+
expect(tool.status).toBe('error');
109+
expect(tool.trace_id).toBe(error.contexts?.trace?.trace_id);
110+
expect(error.exception?.values?.[0]?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
111+
});
112+
113+
test('resumes a run in a new trace after the Durable Object resets during a tool call', async ({ baseURL }) => {
114+
const agentId = newAgentId('reset');
115+
const operationId = crypto.randomUUID();
116+
// The tool span of the reset object never ends, so a finished `crash_once` span can only come
117+
// from the rerun after the restart.
118+
const spansPromise = collectStreamedSpans(
119+
APP,
120+
spansOfTrace =>
121+
spansOfTrace.some(span => span.is_segment && getSpanOp(span) === GEN_AI_INVOKE_AGENT) &&
122+
spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'crash_once' && span.status === 'ok'),
123+
);
124+
125+
// The reset fails the request that submitted the prompt, but pi has stored the input already.
126+
await submitPrompt(
127+
baseURL!,
128+
agentId,
129+
'Run the crash_once tool for job "nightly", then reply with the word DONE.',
130+
operationId,
131+
);
132+
const answer = await waitForOperation(baseURL!, agentId, operationId);
133+
expect(answer.status, answer.reason).toBe('done');
134+
135+
const spans = await spansPromise;
136+
const agent = spans.find(span => span.is_segment)!;
137+
const tool = spans.find(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'crash_once')!;
138+
const chats = spans.filter(span => getSpanOp(span) === GEN_AI_CHAT);
139+
const finalAnswer = chats.find(span => span.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value === '["stop"]');
140+
141+
expect(getSpanOp(agent)).toBe(GEN_AI_INVOKE_AGENT);
142+
expect(tool.parent_span_id).toBe(agent.span_id);
143+
expect(finalAnswer?.parent_span_id).toBe(agent.span_id);
144+
// The request that called the tool ran before the reset, so this trace starts with the rerun of the
145+
// tool. A run that never reset would start with that request.
146+
expect(chats.every(chat => chat.start_timestamp >= tool.start_timestamp)).toBe(true);
147+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect } from '@playwright/test';
2+
3+
export type Operation = { status: string; reason?: string; text?: string };
4+
5+
/**
6+
* A Durable Object name nothing has used yet. The name is the `:name` path segment
7+
* `routeAgentRequest` maps to an object, so each test gets its own SQLite database and pi Harness.
8+
*/
9+
export function newAgentId(prefix: string): string {
10+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
11+
}
12+
13+
/** Submit a prompt to the root session. The response arrives once pi settles it. */
14+
export function submitPrompt(
15+
baseURL: string,
16+
agentId: string,
17+
message: string,
18+
operationId?: string,
19+
): Promise<Response> {
20+
return fetch(`${baseURL}/agents/assistant/${agentId}`, {
21+
method: 'POST',
22+
headers: { 'Content-Type': 'application/json' },
23+
body: JSON.stringify({ message, operationId }),
24+
});
25+
}
26+
27+
export async function prompt(baseURL: string, agentId: string, message: string): Promise<Operation> {
28+
const res = await submitPrompt(baseURL, agentId, message);
29+
expect(res.status).toBe(200);
30+
return (await res.json()) as Operation;
31+
}
32+
33+
/** Wait until pi settles an operation. The request starts the object again if it was reset. */
34+
export async function waitForOperation(baseURL: string, agentId: string, operationId: string): Promise<Operation> {
35+
const res = await fetch(`${baseURL}/agents/assistant/${agentId}?operation=${operationId}`);
36+
expect(res.status).toBe(200);
37+
return (await res.json()) as Operation;
38+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"compilerOptions": {
3+
"target": "es2022",
4+
"lib": ["es2022"],
5+
"module": "es2022",
6+
"moduleResolution": "Bundler",
7+
"resolveJsonModule": true,
8+
"allowJs": true,
9+
"checkJs": false,
10+
"noEmit": true,
11+
"isolatedModules": true,
12+
"allowSyntheticDefaultImports": true,
13+
"forceConsistentCasingInFileNames": true,
14+
"strict": true,
15+
"skipLibCheck": true,
16+
"types": ["@cloudflare/workers-types"]
17+
},
18+
"exclude": ["tests"],
19+
"include": ["src/**/*.ts"]
20+
}

0 commit comments

Comments
 (0)