Skip to content

Commit 264b42d

Browse files
JPeer264claude
andcommitted
test(e2e): Add a cloudflare-pi-durable end-to-end application
Runs pi-durable in a Durable Object through the PiHarness of the Cloudflare Agents SDK, with @sentry/cloudflare/vite as the only Sentry setup. The tests check the run trace with its chat, execute_tool and provider spans, a throwing tool, and a run that resumes in a new trace after the Durable Object resets during a tool call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1f1d4d4 commit 264b42d

12 files changed

Lines changed: 400 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
"typescript": "^5.5.2",
26+
"vite": "7.3.5",
27+
"wrangler": "^4.136.2"
28+
},
29+
"volta": {
30+
"extends": "../../package.json"
31+
},
32+
"sentryTest": {
33+
"optional": true,
34+
"optionalVariants": [
35+
{
36+
"build-command": "pnpm test:build-latest",
37+
"label": "cloudflare-pi-durable (latest)"
38+
}
39+
]
40+
}
41+
}
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: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { expect, test } from '@playwright/test';
2+
import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils';
3+
import { newAgentId, prompt, submitPrompt, waitForOperation } from './utils';
4+
5+
const APP = 'cloudflare-pi-durable';
6+
7+
test('traces a PiHarness prompt as invoke_agent with chat, execute_tool and provider spans', async ({ baseURL }) => {
8+
// A run is its own trace, and its `invoke_agent` segment ends last. Only this test calls get_weather.
9+
const spansPromise = collectStreamedSpans(
10+
APP,
11+
spansOfTrace =>
12+
spansOfTrace.some(span => span.is_segment && getSpanOp(span) === 'gen_ai.invoke_agent') &&
13+
spansOfTrace.some(span => span.attributes['gen_ai.tool.name']?.value === 'get_weather'),
14+
);
15+
16+
const answer = await prompt(
17+
baseURL!,
18+
newAgentId('weather'),
19+
'What is the weather in Vienna? Use the get_weather tool.',
20+
);
21+
expect(answer.status, answer.reason).toBe('done');
22+
23+
const spans = await spansPromise;
24+
const agent = spans.find(span => span.is_segment)!;
25+
const chats = spans.filter(span => getSpanOp(span) === 'gen_ai.chat');
26+
// The manual span of the tool is the one way to pick the call that ran, should the model call twice.
27+
const manualSpan = spans.find(span => span.name === 'resolve-weather')!;
28+
const tool = spans.find(span => span.span_id === manualSpan.parent_span_id)!;
29+
const providerCalls = spans.filter(span => getSpanOp(span) === 'http.client');
30+
31+
expect(agent.attributes['sentry.origin']?.value).toBe('auto.ai.pi_durable');
32+
// The PiHarness root session is pi conversation 1, in a Harness of its own per Durable Object.
33+
expect(agent.attributes['gen_ai.conversation.id']?.value).toMatch(/^[0-9a-f]{32}:1$/);
34+
// The request that submitted the prompt is a trace of its own: the scheduler runs the work.
35+
expect(spans.some(span => getSpanOp(span) === 'http.server')).toBe(false);
36+
// pi-durable keeps its state in the `pi_` tables of `PiHarness`, whose statements start no span.
37+
expect(spans.filter(span => getSpanOp(span) === 'db.query' && /\bpi_/.test(String(span.name)))).toEqual([]);
38+
// pi-ai sends the requests through `@anthropic-ai/sdk`, whose own integration must stay out so
39+
// each request is reported once.
40+
expect(spans.filter(span => String(span.attributes['sentry.origin']?.value).startsWith('auto.ai.'))).toEqual(
41+
spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ai.pi_durable'),
42+
);
43+
44+
// One tool-calling response, then the answer.
45+
expect(chats.length).toBeGreaterThanOrEqual(2);
46+
for (const chat of chats) {
47+
expect(chat.parent_span_id).toBe(agent.span_id);
48+
expect(chat.attributes['gen_ai.provider.name']?.value).toBe('openrouter');
49+
expect(chat.attributes['gen_ai.request.model']?.value).toBe('anthropic/claude-haiku-4.5');
50+
expect(chat.attributes['gen_ai.conversation.id']?.value).toBe(agent.attributes['gen_ai.conversation.id']?.value);
51+
// pi-durable retries a provider error inside the run; such a request has no usage and no HTTP span
52+
// of its own to assert on.
53+
if (chat.status === 'ok') {
54+
expect(typeof chat.attributes['gen_ai.usage.input_tokens']?.value).toBe('number');
55+
expect(typeof chat.attributes['gen_ai.usage.output_tokens']?.value).toBe('number');
56+
expect(providerCalls.some(providerCall => providerCall.parent_span_id === chat.span_id)).toBe(true);
57+
}
58+
}
59+
60+
expect(tool.attributes['gen_ai.tool.name']?.value).toBe('get_weather');
61+
expect(tool.parent_span_id).toBe(agent.span_id);
62+
expect(tool.status).toBe('ok');
63+
expect(tool.attributes['gen_ai.tool.call.arguments']?.value).toContain('Vienna');
64+
expect(tool.attributes['gen_ai.tool.call.result']?.value).toContain('21 degrees and sunny in');
65+
66+
// The provider's HTTP calls nest inside the `chat` span that sent them.
67+
expect(providerCalls.length).toBeGreaterThan(0);
68+
for (const providerCall of providerCalls) {
69+
expect(chats.map(chat => chat.span_id)).toContain(providerCall.parent_span_id);
70+
expect(providerCall.attributes['server.address']?.value).toBe('openrouter.ai');
71+
}
72+
});
73+
74+
test('reports a throwing tool as an error on its execute_tool span', async ({ baseURL }) => {
75+
const errorPromise = waitForError(
76+
APP,
77+
event => event.exception?.values?.[0]?.value === 'Intentional pi-durable tool failure',
78+
);
79+
const spansPromise = collectStreamedSpans(
80+
APP,
81+
spansOfTrace =>
82+
spansOfTrace.some(span => span.is_segment && getSpanOp(span) === 'gen_ai.invoke_agent') &&
83+
spansOfTrace.some(span => span.attributes['gen_ai.tool.name']?.value === 'fail_now'),
84+
);
85+
86+
const answer = await prompt(baseURL!, newAgentId('fail'), 'Call the fail_now tool, then tell me what happened.');
87+
expect(answer.status, answer.reason).toBe('done');
88+
89+
const [error, spans] = await Promise.all([errorPromise, spansPromise]);
90+
// The error is reported on the span of the call that threw.
91+
const tool = spans.find(span => span.span_id === error.contexts?.trace?.span_id)!;
92+
93+
expect(tool.attributes['gen_ai.tool.name']?.value).toBe('fail_now');
94+
expect(tool.status).toBe('error');
95+
expect(tool.trace_id).toBe(error.contexts?.trace?.trace_id);
96+
expect(error.exception?.values?.[0]?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: true });
97+
});
98+
99+
test('resumes a run in a new trace after the Durable Object resets during a tool call', async ({ baseURL }) => {
100+
const agentId = newAgentId('reset');
101+
const operationId = crypto.randomUUID();
102+
// The tool span of the reset object never ends, so a finished `crash_once` span can only come
103+
// from the rerun after the restart.
104+
const spansPromise = collectStreamedSpans(
105+
APP,
106+
spansOfTrace =>
107+
spansOfTrace.some(span => span.is_segment && getSpanOp(span) === 'gen_ai.invoke_agent') &&
108+
spansOfTrace.some(span => span.attributes['gen_ai.tool.name']?.value === 'crash_once' && span.status === 'ok'),
109+
);
110+
111+
// The reset fails the request that submitted the prompt, but pi has stored the input already.
112+
await submitPrompt(
113+
baseURL!,
114+
agentId,
115+
'Run the crash_once tool for job "nightly", then reply with the word DONE.',
116+
operationId,
117+
);
118+
const answer = await waitForOperation(baseURL!, agentId, operationId);
119+
expect(answer.status, answer.reason).toBe('done');
120+
121+
const spans = await spansPromise;
122+
const agent = spans.find(span => span.is_segment)!;
123+
const tool = spans.find(span => span.attributes['gen_ai.tool.name']?.value === 'crash_once')!;
124+
const chats = spans.filter(span => getSpanOp(span) === 'gen_ai.chat');
125+
const finalAnswer = chats.find(span => span.attributes['gen_ai.response.finish_reasons']?.value === '["stop"]');
126+
127+
expect(getSpanOp(agent)).toBe('gen_ai.invoke_agent');
128+
expect(tool.parent_span_id).toBe(agent.span_id);
129+
expect(finalAnswer?.parent_span_id).toBe(agent.span_id);
130+
// The request that called the tool ran before the reset, so this trace starts with the rerun of the
131+
// tool. A run that never reset would start with that request.
132+
expect(chats.every(chat => chat.start_timestamp >= tool.start_timestamp)).toBe(true);
133+
});
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)