@cloudflare/ai-chat 0.0.1 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ import { createExecutionContext, env } from "cloudflare:test";
2
+ import { expect } from "vitest";
3
+ import { MessageType, type OutgoingMessage } from "../types";
4
+ import worker from "./worker";
5
+
6
+ /**
7
+ * Connects to the chat agent and returns the WebSocket and execution context
8
+ */
9
+ export async function connectChatWS(
10
+ path: string
11
+ ): Promise<{ ws: WebSocket; ctx: ExecutionContext }> {
12
+ const ctx = createExecutionContext();
13
+ const req = new Request(`http://example.com${path}`, {
14
+ headers: { Upgrade: "websocket" }
15
+ });
16
+ const res = await worker.fetch(req, env, ctx);
17
+ expect(res.status).toBe(101);
18
+ const ws = res.webSocket as WebSocket;
19
+ expect(ws).toBeDefined();
20
+ ws.accept();
21
+ return { ws, ctx };
22
+ }
23
+
24
+ /**
25
+ * Type guard for CF_AGENT_USE_CHAT_RESPONSE messages
26
+ */
27
+ export function isUseChatResponseMessage(
28
+ m: unknown
29
+ ): m is Extract<
30
+ OutgoingMessage,
31
+ { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE }
32
+ > {
33
+ return (
34
+ typeof m === "object" &&
35
+ m !== null &&
36
+ "type" in m &&
37
+ m.type === MessageType.CF_AGENT_USE_CHAT_RESPONSE
38
+ );
39
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "types": [
4
+ "@cloudflare/workers-types/experimental",
5
+ "@cloudflare/vitest-pool-workers"
6
+ ]
7
+ },
8
+ "extends": "../../../../tsconfig.base.json",
9
+ "include": ["./**/*.ts"]
10
+ }
@@ -0,0 +1,28 @@
1
+ import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
2
+
3
+ export default defineWorkersConfig({
4
+ test: {
5
+ deps: {
6
+ optimizer: {
7
+ ssr: {
8
+ include: [
9
+ // vitest can't seem to properly import
10
+ // `require('./path/to/anything.json')` files,
11
+ // which ajv uses (by way of @modelcontextprotocol/sdk)
12
+ // the workaround is to add the package to the include list
13
+ "ajv"
14
+ ]
15
+ }
16
+ }
17
+ },
18
+ poolOptions: {
19
+ workers: {
20
+ isolatedStorage: false,
21
+ singleWorker: true,
22
+ wrangler: {
23
+ configPath: "./wrangler.jsonc"
24
+ }
25
+ }
26
+ }
27
+ }
28
+ });
@@ -0,0 +1,258 @@
1
+ import { AIChatAgent } from "../";
2
+ import type { UIMessage as ChatMessage } from "ai";
3
+ import { callable, getCurrentAgent, routeAgentRequest } from "agents";
4
+
5
+ // Type helper for tool call parts - extracts from ChatMessage parts
6
+ type TestToolCallPart = Extract<
7
+ ChatMessage["parts"][number],
8
+ { type: `tool-${string}` }
9
+ >;
10
+
11
+ export type Env = {
12
+ TestChatAgent: DurableObjectNamespace<TestChatAgent>;
13
+ };
14
+
15
+ export class TestChatAgent extends AIChatAgent<Env> {
16
+ observability = undefined;
17
+ // Store captured context for testing
18
+ private _capturedContext: {
19
+ hasAgent: boolean;
20
+ hasConnection: boolean;
21
+ connectionId: string | undefined;
22
+ } | null = null;
23
+ // Store context captured from nested async function (simulates tool execute)
24
+ private _nestedContext: {
25
+ hasAgent: boolean;
26
+ hasConnection: boolean;
27
+ connectionId: string | undefined;
28
+ } | null = null;
29
+
30
+ async onChatMessage() {
31
+ // Capture getCurrentAgent() context for testing
32
+ const { agent, connection } = getCurrentAgent();
33
+ this._capturedContext = {
34
+ hasAgent: agent !== undefined,
35
+ hasConnection: connection !== undefined,
36
+ connectionId: connection?.id
37
+ };
38
+
39
+ // Simulate what happens inside a tool's execute function:
40
+ // It's a nested async function called from within onChatMessage
41
+ await this._simulateToolExecute();
42
+
43
+ // Simple echo response for testing
44
+ return new Response("Hello from chat agent!", {
45
+ headers: { "Content-Type": "text/plain" }
46
+ });
47
+ }
48
+
49
+ // This simulates an AI SDK tool's execute function being called
50
+ private async _simulateToolExecute(): Promise<void> {
51
+ // Add a small delay to ensure we're in a new microtask (like real tool execution)
52
+ await Promise.resolve();
53
+
54
+ // Capture context inside the "tool execute" function
55
+ const { agent, connection } = getCurrentAgent();
56
+ this._nestedContext = {
57
+ hasAgent: agent !== undefined,
58
+ hasConnection: connection !== undefined,
59
+ connectionId: connection?.id
60
+ };
61
+ }
62
+
63
+ @callable()
64
+ getCapturedContext(): {
65
+ hasAgent: boolean;
66
+ hasConnection: boolean;
67
+ connectionId: string | undefined;
68
+ } | null {
69
+ return this._capturedContext;
70
+ }
71
+
72
+ @callable()
73
+ getNestedContext(): {
74
+ hasAgent: boolean;
75
+ hasConnection: boolean;
76
+ connectionId: string | undefined;
77
+ } | null {
78
+ return this._nestedContext;
79
+ }
80
+
81
+ @callable()
82
+ clearCapturedContext(): void {
83
+ this._capturedContext = null;
84
+ this._nestedContext = null;
85
+ }
86
+
87
+ @callable()
88
+ getPersistedMessages(): ChatMessage[] {
89
+ const rawMessages = (
90
+ this.sql`select * from cf_ai_chat_agent_messages order by created_at` ||
91
+ []
92
+ ).map((row) => {
93
+ return JSON.parse(row.message as string);
94
+ });
95
+ return rawMessages;
96
+ }
97
+
98
+ @callable()
99
+ async testPersistToolCall(messageId: string, toolName: string) {
100
+ const toolCallPart: TestToolCallPart = {
101
+ type: `tool-${toolName}`,
102
+ toolCallId: `call_${messageId}`,
103
+ state: "input-available",
104
+ input: { location: "London" }
105
+ };
106
+
107
+ const messageWithToolCall: ChatMessage = {
108
+ id: messageId,
109
+ role: "assistant",
110
+ parts: [toolCallPart] as ChatMessage["parts"]
111
+ };
112
+ await this.persistMessages([messageWithToolCall]);
113
+ return messageWithToolCall;
114
+ }
115
+
116
+ @callable()
117
+ async testPersistToolResult(
118
+ messageId: string,
119
+ toolName: string,
120
+ output: string
121
+ ) {
122
+ const toolResultPart: TestToolCallPart = {
123
+ type: `tool-${toolName}`,
124
+ toolCallId: `call_${messageId}`,
125
+ state: "output-available",
126
+ input: { location: "London" },
127
+ output
128
+ };
129
+
130
+ const messageWithToolOutput: ChatMessage = {
131
+ id: messageId,
132
+ role: "assistant",
133
+ parts: [toolResultPart] as ChatMessage["parts"]
134
+ };
135
+ await this.persistMessages([messageWithToolOutput]);
136
+ return messageWithToolOutput;
137
+ }
138
+
139
+ // Resumable streaming test helpers
140
+
141
+ @callable()
142
+ testStartStream(requestId: string): string {
143
+ return this._startStream(requestId);
144
+ }
145
+
146
+ @callable()
147
+ testStoreStreamChunk(streamId: string, body: string): void {
148
+ this._storeStreamChunk(streamId, body);
149
+ }
150
+
151
+ @callable()
152
+ testFlushChunkBuffer(): void {
153
+ this._flushChunkBuffer();
154
+ }
155
+
156
+ @callable()
157
+ testCompleteStream(streamId: string): void {
158
+ this._completeStream(streamId);
159
+ }
160
+
161
+ @callable()
162
+ testMarkStreamError(streamId: string): void {
163
+ this._markStreamError(streamId);
164
+ }
165
+
166
+ @callable()
167
+ getActiveStreamId(): string | null {
168
+ return this._activeStreamId;
169
+ }
170
+
171
+ @callable()
172
+ getActiveRequestId(): string | null {
173
+ return this._activeRequestId;
174
+ }
175
+
176
+ @callable()
177
+ getStreamChunks(
178
+ streamId: string
179
+ ): Array<{ body: string; chunk_index: number }> {
180
+ return (
181
+ this.sql<{ body: string; chunk_index: number }>`
182
+ select body, chunk_index from cf_ai_chat_stream_chunks
183
+ where stream_id = ${streamId}
184
+ order by chunk_index asc
185
+ ` || []
186
+ );
187
+ }
188
+
189
+ @callable()
190
+ getStreamMetadata(
191
+ streamId: string
192
+ ): { status: string; request_id: string } | null {
193
+ const result = this.sql<{ status: string; request_id: string }>`
194
+ select status, request_id from cf_ai_chat_stream_metadata
195
+ where id = ${streamId}
196
+ `;
197
+ return result && result.length > 0 ? result[0] : null;
198
+ }
199
+
200
+ @callable()
201
+ getAllStreamMetadata(): Array<{
202
+ id: string;
203
+ status: string;
204
+ request_id: string;
205
+ created_at: number;
206
+ }> {
207
+ return (
208
+ this.sql<{
209
+ id: string;
210
+ status: string;
211
+ request_id: string;
212
+ created_at: number;
213
+ }>`select id, status, request_id, created_at from cf_ai_chat_stream_metadata` ||
214
+ []
215
+ );
216
+ }
217
+
218
+ @callable()
219
+ testInsertStaleStream(
220
+ streamId: string,
221
+ requestId: string,
222
+ ageMs: number
223
+ ): void {
224
+ const createdAt = Date.now() - ageMs;
225
+ this.sql`
226
+ insert into cf_ai_chat_stream_metadata (id, request_id, status, created_at)
227
+ values (${streamId}, ${requestId}, 'streaming', ${createdAt})
228
+ `;
229
+ }
230
+
231
+ @callable()
232
+ testRestoreActiveStream(): void {
233
+ this._restoreActiveStream();
234
+ }
235
+ }
236
+
237
+ export default {
238
+ async fetch(request: Request, env: Env, _ctx: ExecutionContext) {
239
+ const url = new URL(request.url);
240
+
241
+ if (url.pathname === "/500") {
242
+ return new Response("Internal Server Error", { status: 500 });
243
+ }
244
+
245
+ return (
246
+ (await routeAgentRequest(request, env)) ||
247
+ new Response("Not found", { status: 404 })
248
+ );
249
+ },
250
+
251
+ async email(
252
+ _message: ForwardableEmailMessage,
253
+ _env: Env,
254
+ _ctx: ExecutionContext
255
+ ) {
256
+ // Bring this in when we write tests for the complete email handler flow
257
+ }
258
+ };
@@ -0,0 +1,26 @@
1
+ {
2
+ "compatibility_date": "2025-04-17",
3
+ "compatibility_flags": [
4
+ "nodejs_compat",
5
+ // adding these flags since the vitest runner needs them
6
+ "enable_nodejs_tty_module",
7
+ "enable_nodejs_fs_module",
8
+ "enable_nodejs_http_modules",
9
+ "enable_nodejs_perf_hooks_module"
10
+ ],
11
+ "durable_objects": {
12
+ "bindings": [
13
+ {
14
+ "class_name": "TestChatAgent",
15
+ "name": "TestChatAgent"
16
+ }
17
+ ]
18
+ },
19
+ "main": "worker.ts",
20
+ "migrations": [
21
+ {
22
+ "new_sqlite_classes": ["TestChatAgent"],
23
+ "tag": "v1"
24
+ }
25
+ ]
26
+ }
package/src/types.ts ADDED
@@ -0,0 +1,122 @@
1
+ import type { UIMessage } from "ai";
2
+
3
+ /**
4
+ * Enum for message types to improve type safety and maintainability
5
+ */
6
+ export enum MessageType {
7
+ CF_AGENT_CHAT_MESSAGES = "cf_agent_chat_messages",
8
+ CF_AGENT_USE_CHAT_REQUEST = "cf_agent_use_chat_request",
9
+ CF_AGENT_USE_CHAT_RESPONSE = "cf_agent_use_chat_response",
10
+ CF_AGENT_CHAT_CLEAR = "cf_agent_chat_clear",
11
+ CF_AGENT_CHAT_REQUEST_CANCEL = "cf_agent_chat_request_cancel",
12
+
13
+ /** Sent by server when client connects and there's an active stream to resume */
14
+ CF_AGENT_STREAM_RESUMING = "cf_agent_stream_resuming",
15
+ /** Sent by client to acknowledge stream resuming notification and request chunks */
16
+ CF_AGENT_STREAM_RESUME_ACK = "cf_agent_stream_resume_ack",
17
+
18
+ /** Client sends tool result to server (for client-side tools) */
19
+ CF_AGENT_TOOL_RESULT = "cf_agent_tool_result",
20
+ /** Server notifies client that a message was updated (e.g., tool result applied) */
21
+ CF_AGENT_MESSAGE_UPDATED = "cf_agent_message_updated"
22
+ }
23
+
24
+ /**
25
+ * Types of messages sent from the Agent to clients
26
+ */
27
+ export type OutgoingMessage<ChatMessage extends UIMessage = UIMessage> =
28
+ | {
29
+ /** Indicates this message is a command to clear chat history */
30
+ type: MessageType.CF_AGENT_CHAT_CLEAR;
31
+ }
32
+ | {
33
+ /** Indicates this message contains updated chat messages */
34
+ type: MessageType.CF_AGENT_CHAT_MESSAGES;
35
+ /** Array of chat messages */
36
+ messages: ChatMessage[];
37
+ }
38
+ | {
39
+ /** Indicates this message is a response to a chat request */
40
+ type: MessageType.CF_AGENT_USE_CHAT_RESPONSE;
41
+ /** Unique ID of the request this response corresponds to */
42
+ id: string;
43
+ /** Content body of the response */
44
+ body: string;
45
+ /** Whether this is the final chunk of the response */
46
+ done: boolean;
47
+ /** Whether this response contains an error */
48
+ error?: boolean;
49
+ /** Whether this is a continuation (append to last assistant message) */
50
+ continuation?: boolean;
51
+ }
52
+ | {
53
+ /** Indicates the server is resuming an active stream */
54
+ type: MessageType.CF_AGENT_STREAM_RESUMING;
55
+ /** The request ID of the stream being resumed */
56
+ id: string;
57
+ }
58
+ | {
59
+ /** Server notifies client that a message was updated (e.g., tool result applied) */
60
+ type: MessageType.CF_AGENT_MESSAGE_UPDATED;
61
+ /** The updated message */
62
+ message: ChatMessage;
63
+ };
64
+
65
+ /**
66
+ * Types of messages sent from clients to the Agent
67
+ */
68
+ export type IncomingMessage<ChatMessage extends UIMessage = UIMessage> =
69
+ | {
70
+ /** Indicates this message is a command to clear chat history */
71
+ type: MessageType.CF_AGENT_CHAT_CLEAR;
72
+ }
73
+ | {
74
+ /** Indicates this message is a request to the chat API */
75
+ type: MessageType.CF_AGENT_USE_CHAT_REQUEST;
76
+ /** Unique ID for this request */
77
+ id: string;
78
+ /** Request initialization options */
79
+ init: Pick<
80
+ RequestInit,
81
+ | "method"
82
+ | "keepalive"
83
+ | "headers"
84
+ | "body"
85
+ | "redirect"
86
+ | "integrity"
87
+ | "credentials"
88
+ | "mode"
89
+ | "referrer"
90
+ | "referrerPolicy"
91
+ | "window"
92
+ >;
93
+ }
94
+ | {
95
+ /** Indicates this message contains updated chat messages */
96
+ type: MessageType.CF_AGENT_CHAT_MESSAGES;
97
+ /** Array of chat messages */
98
+ messages: ChatMessage[];
99
+ }
100
+ | {
101
+ /** Indicates the user wants to stop generation of this message */
102
+ type: MessageType.CF_AGENT_CHAT_REQUEST_CANCEL;
103
+ id: string;
104
+ }
105
+ | {
106
+ /** Client acknowledges stream resuming notification and is ready to receive chunks */
107
+ type: MessageType.CF_AGENT_STREAM_RESUME_ACK;
108
+ /** The request ID of the stream being resumed */
109
+ id: string;
110
+ }
111
+ | {
112
+ /** Client sends tool result to server (for client-side tools) */
113
+ type: MessageType.CF_AGENT_TOOL_RESULT;
114
+ /** The tool call ID this result is for */
115
+ toolCallId: string;
116
+ /** The name of the tool */
117
+ toolName: string;
118
+ /** The output from the tool execution */
119
+ output: unknown;
120
+ /** Whether server should auto-continue the conversation after applying result */
121
+ autoContinue?: boolean;
122
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "exclude": ["src/tests/**/*.ts", "src/e2e/**/*.ts"],
3
+ "extends": "../../tsconfig.base.json"
4
+ }