@tangle-network/create-agent-app 0.46.43 → 0.46.44
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.
- package/package.json +1 -1
- package/template/_package.json +7 -7
- package/template-chat/AGENTS.md +8 -5
- package/template-chat/CUSTOMIZE.md +10 -3
- package/template-chat/README.md +4 -1
- package/template-chat/_package.json +7 -5
- package/template-chat/agent.config.ts +24 -1
- package/template-chat/migrations/0002_agent_gateway.sql +65 -0
- package/template-chat/public/index.html +20 -1
- package/template-chat/src/chat.ts +6 -0
- package/template-chat/src/gateway.ts +162 -0
- package/template-chat/src/sandbox.ts +19 -1
- package/template-chat/src/worker.ts +113 -38
- package/template-chat/tests/chat-turn.e2e.test.ts +430 -13
package/package.json
CHANGED
package/template/_package.json
CHANGED
|
@@ -21,17 +21,17 @@
|
|
|
21
21
|
"@tangle-network/agent-app": "__AGENT_APP_VERSION__"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"@tangle-network/agent-eval": "0.
|
|
24
|
+
"@tangle-network/agent-eval": "0.173.1",
|
|
25
25
|
"@tangle-network/agent-integrations": ">=0.53.55 <0.54.0",
|
|
26
|
-
"@tangle-network/agent-interface": "2.
|
|
27
|
-
"@tangle-network/agent-runtime": "0.
|
|
26
|
+
"@tangle-network/agent-interface": "2.3.0",
|
|
27
|
+
"@tangle-network/agent-runtime": "0.191.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@tangle-network/agent-eval": "0.
|
|
30
|
+
"@tangle-network/agent-eval": "0.173.1",
|
|
31
31
|
"@tangle-network/agent-integrations": "0.53.55",
|
|
32
|
-
"@tangle-network/agent-interface": "2.
|
|
33
|
-
"@tangle-network/agent-knowledge": "13.0.
|
|
34
|
-
"@tangle-network/agent-runtime": "0.
|
|
32
|
+
"@tangle-network/agent-interface": "2.3.0",
|
|
33
|
+
"@tangle-network/agent-knowledge": "13.0.1",
|
|
34
|
+
"@tangle-network/agent-runtime": "0.191.0",
|
|
35
35
|
"@tangle-network/sandbox": "0.36.4",
|
|
36
36
|
"@types/node": "^22.20.1",
|
|
37
37
|
"typescript": "^7.0.2",
|
package/template-chat/AGENTS.md
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
You are a coding agent working in a project generated by `create-agent-app --chat`.
|
|
4
4
|
This project is a thin customization layer on top of `@tangle-network/agent-app`
|
|
5
5
|
(the shell): the whole server chat vertical — auth, thread/message persistence,
|
|
6
|
-
streaming turns with buffered replay, multimodal uploads, human-in-the-loop asks
|
|
7
|
-
is ASSEMBLED from shell factories, not written here.
|
|
8
|
-
|
|
6
|
+
streaming turns with buffered replay, multimodal uploads, human-in-the-loop asks,
|
|
7
|
+
and API access — is ASSEMBLED from shell factories, not written here.
|
|
8
|
+
Walk this contract before you touch anything.
|
|
9
|
+
It is a checklist, not prose — follow it in order.
|
|
9
10
|
|
|
10
11
|
## 0. Orient
|
|
11
12
|
|
|
@@ -30,14 +31,16 @@ touch anything. It is a checklist, not prose — follow it in order.
|
|
|
30
31
|
## 2. DATA vs CODE — know which file you're in
|
|
31
32
|
|
|
32
33
|
- [ ] DATA → `agent.config.ts`: name, system prompt, model default + effort,
|
|
33
|
-
harness, renderable ask kinds.
|
|
34
|
-
you're in the wrong file.
|
|
34
|
+
provider input limit, harness, gateway pricing, and renderable ask kinds.
|
|
35
|
+
Plain values. If you're writing an `if`, you're in the wrong file.
|
|
35
36
|
- [ ] DATA → `prompts/system.md`: the persona. State intents and hard rules,
|
|
36
37
|
never implementations — no shell commands, CLI flags, or install scripts.
|
|
37
38
|
The executing agent chooses tools at execution time.
|
|
38
39
|
- [ ] CODE → `src/chat.ts`: the COMPOSER. Wires config + env into the shell's
|
|
39
40
|
factories. Extend seams here (billing hooks, `transformFinalText`,
|
|
40
41
|
`onTurnComplete`); never re-implement what a factory already does.
|
|
42
|
+
- [ ] CODE → `src/gateway.ts`: API keys and OpenAI-compatible requests enter
|
|
43
|
+
the same owned thread and chat route.
|
|
41
44
|
- [ ] CODE → `src/sandbox.ts`: the sandbox lane (box naming, credentials,
|
|
42
45
|
profile). All agent intelligence lives IN the sandbox; this file only
|
|
43
46
|
reaches it.
|
|
@@ -26,6 +26,8 @@ Discovery: **Whose job does this agent do, in whose voice, under what hard rules
|
|
|
26
26
|
Discovery: **Which model answers by default, at what effort, on which harness?**
|
|
27
27
|
|
|
28
28
|
- [ ] Set `model.default` to a model your Tangle Router key can reach.
|
|
29
|
+
- [ ] Set `gateway.maxProviderInputTokens` to that model's full input limit.
|
|
30
|
+
This covers retained tool and sidecar history that the transcript omits.
|
|
29
31
|
- [ ] Pick `harness` (`opencode` default; vendor-locked harnesses like
|
|
30
32
|
`claude-code` must pair with their own provider's models).
|
|
31
33
|
- [ ] Define the selectable profile catalog for the UI and map each selected
|
|
@@ -43,9 +45,11 @@ Discovery: **Where does this app live and what may it spend?**
|
|
|
43
45
|
- [ ] `wrangler d1 create <name>` → paste `database_id` into `wrangler.toml`.
|
|
44
46
|
- [ ] Copy `.dev.vars.example` → `.dev.vars`; fill `BETTER_AUTH_SECRET`,
|
|
45
47
|
`TANGLE_API_KEY`, `SANDBOX_API_KEY`, `SANDBOX_GATEWAY_URL`.
|
|
46
|
-
- [ ] `pnpm db:migrate:local` — applies
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
- [ ] `pnpm db:migrate:local` — applies both migrations (auth, chat, turn
|
|
49
|
+
buffer, API keys, usage, and request limits).
|
|
50
|
+
The e2e test executes these same files, so they cannot drift from the schema.
|
|
51
|
+
- [ ] Existing generated app: copy and apply `0002_agent_gateway.sql`.
|
|
52
|
+
Never edit its already-applied `0001_init.sql`.
|
|
49
53
|
- [ ] Existing app only: add `sandbox_prewarm_claims` in a new migration.
|
|
50
54
|
Do not edit an applied `0001_init.sql`; Wrangler will not run it again.
|
|
51
55
|
- [ ] R2 stays commented out unless the product stores artifacts.
|
|
@@ -59,6 +63,9 @@ Discovery: **Does a real message round-trip through a real box?**
|
|
|
59
63
|
and a usage receipt, and a second turn continues the same agent session.
|
|
60
64
|
- [ ] Kill the tab mid-turn, reopen the thread — the persisted row is intact
|
|
61
65
|
(the turn keeps running server-side and buffers for replay).
|
|
66
|
+
- [ ] Create a key through the signed-in `/api/keys` route.
|
|
67
|
+
- [ ] Call `/v1/agents/<slug>/chat/completions` with that key.
|
|
68
|
+
Open the returned `X-Tangle-Thread-Url` and confirm it shows the same durable conversation.
|
|
62
69
|
|
|
63
70
|
## ⑤ The product UI — replace the dev page
|
|
64
71
|
|
package/template-chat/README.md
CHANGED
|
@@ -4,7 +4,8 @@ A multimodal chat agent product scaffolded with `create-agent-app --chat`, built
|
|
|
4
4
|
on [`@tangle-network/agent-app`](https://github.com/tangle-network/agent-app):
|
|
5
5
|
the whole server chat vertical — better-auth sessions, thread/message
|
|
6
6
|
persistence with typed parts + usage receipts, streaming turns with buffered
|
|
7
|
-
replay, file uploads, human-in-the-loop asks
|
|
7
|
+
replay, file uploads, human-in-the-loop asks, personal API keys, and
|
|
8
|
+
OpenAI-compatible access — assembled from shell factories.
|
|
8
9
|
The agent itself runs in a Tangle sandbox (a full harness: skills, tools, bash,
|
|
9
10
|
MCP); this app coordinates UI, durability, and access around it.
|
|
10
11
|
|
|
@@ -15,9 +16,11 @@ MCP); this app coordinates UI, durability, and access around it.
|
|
|
15
16
|
| `agent.config.ts` | DATA — name, model default, harness, ask kinds | defining the agent |
|
|
16
17
|
| `prompts/system.md` | DATA — the persona (imported as a Text module) | shaping behavior |
|
|
17
18
|
| `src/chat.ts` | CODE — the composer (factories → the chat vertical) | extending seams |
|
|
19
|
+
| `src/gateway.ts` | CODE — API keys → the same chat vertical | API pricing or publication changes |
|
|
18
20
|
| `src/sandbox.ts` | CODE — the sandbox lane (boxes, credentials, profile) | provisioning changes |
|
|
19
21
|
| `src/worker.ts` | CODE — HTTP routing only | adding an endpoint |
|
|
20
22
|
| `src/db/schema.ts` | CODE — auth tables + `createChatTables()` | schema changes (+ migration) |
|
|
23
|
+
| `migrations/0002_agent_gateway.sql` | CODE — API keys, usage, and request claims | upgrading an existing generated app |
|
|
21
24
|
| `migrations/` | SQL the e2e test executes for real | schema changes |
|
|
22
25
|
| `public/index.html` | the dev chat page (not the product UI) | never — replace it (CUSTOMIZE ⑤) |
|
|
23
26
|
| `tests/` | the e2e turn gate this app ships with | extending coverage |
|
|
@@ -20,21 +20,23 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@tangle-network/agent-app": "__AGENT_APP_VERSION__",
|
|
23
|
-
"@tangle-network/agent-
|
|
24
|
-
"@tangle-network/agent-
|
|
23
|
+
"@tangle-network/agent-gateway": "0.8.16",
|
|
24
|
+
"@tangle-network/agent-interface": "2.3.0",
|
|
25
|
+
"@tangle-network/agent-runtime": "0.191.0",
|
|
25
26
|
"@tangle-network/sandbox": "0.36.4",
|
|
26
27
|
"better-auth": "^1.7.2",
|
|
27
28
|
"drizzle-orm": "^0.45.2",
|
|
29
|
+
"hono": "^4.13.5",
|
|
28
30
|
"viem": "^2.0.0"
|
|
29
31
|
},
|
|
30
32
|
"peerDependencies": {
|
|
31
|
-
"@tangle-network/agent-eval": "0.
|
|
33
|
+
"@tangle-network/agent-eval": "0.173.1",
|
|
32
34
|
"@tangle-network/agent-integrations": ">=0.53.55 <0.54.0"
|
|
33
35
|
},
|
|
34
36
|
"devDependencies": {
|
|
35
|
-
"@tangle-network/agent-eval": "0.
|
|
37
|
+
"@tangle-network/agent-eval": "0.173.1",
|
|
36
38
|
"@tangle-network/agent-integrations": "0.53.55",
|
|
37
|
-
"@tangle-network/agent-knowledge": "13.0.
|
|
39
|
+
"@tangle-network/agent-knowledge": "13.0.1",
|
|
38
40
|
"@cloudflare/workers-types": "^5.20260827.1",
|
|
39
41
|
"@types/better-sqlite3": "^9.6.0",
|
|
40
42
|
"@types/node": "^22.20.1",
|
|
@@ -44,7 +44,7 @@ export const config = {
|
|
|
44
44
|
* and review it with your default: a stale ladder degrades to today's
|
|
45
45
|
* behavior (the turn fails), never to a silent wrong-model answer.
|
|
46
46
|
*/
|
|
47
|
-
fallbacks: ['gemini-
|
|
47
|
+
fallbacks: ['gemini-3.7-flash', 'glm-5.3'],
|
|
48
48
|
/** Default reasoning effort for turns that don't specify one. */
|
|
49
49
|
effort: 'auto',
|
|
50
50
|
},
|
|
@@ -62,12 +62,35 @@ export const config = {
|
|
|
62
62
|
* a card no client will show.
|
|
63
63
|
*/
|
|
64
64
|
interactions: { question: true, plan: true },
|
|
65
|
+
|
|
66
|
+
/** API-key access to this workspace agent. The browser and API share one
|
|
67
|
+
* thread store and one sandbox turn path. */
|
|
68
|
+
gateway: {
|
|
69
|
+
enabled: true,
|
|
70
|
+
description: '__PROJECT_NAME__ workspace agent',
|
|
71
|
+
pricePerTokenUsd: 0.00002,
|
|
72
|
+
platformFeePercent: 0.20,
|
|
73
|
+
/** Maximum provider input for the configured model. Include retained
|
|
74
|
+
* sidecar history and tool output that is absent from the transcript. */
|
|
75
|
+
maxProviderInputTokens: 1_000_000,
|
|
76
|
+
defaultOutputTokens: 1024,
|
|
77
|
+
maxOutputTokens: 4096,
|
|
78
|
+
},
|
|
65
79
|
} as const satisfies {
|
|
66
80
|
name: string
|
|
67
81
|
systemPrompt: string
|
|
68
82
|
model: { default: string; fallbacks: readonly string[]; effort: 'auto' | 'low' | 'medium' | 'high' }
|
|
69
83
|
harness: Harness
|
|
70
84
|
interactions: { question?: boolean; permission?: boolean; plan?: boolean }
|
|
85
|
+
gateway: {
|
|
86
|
+
enabled: boolean
|
|
87
|
+
description: string
|
|
88
|
+
pricePerTokenUsd: number
|
|
89
|
+
platformFeePercent: number
|
|
90
|
+
maxProviderInputTokens: number
|
|
91
|
+
defaultOutputTokens: number
|
|
92
|
+
maxOutputTokens: number
|
|
93
|
+
}
|
|
71
94
|
}
|
|
72
95
|
|
|
73
96
|
export type Config = typeof config
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- External OpenAI-compatible API storage from @tangle-network/agent-gateway.
|
|
2
|
+
-- Keep this separate from 0001 so an existing generated app can apply it.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS agent_api_key (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
user_id TEXT NOT NULL,
|
|
7
|
+
name TEXT NOT NULL,
|
|
8
|
+
key_hash TEXT NOT NULL,
|
|
9
|
+
key_prefix TEXT NOT NULL,
|
|
10
|
+
scopes TEXT NOT NULL,
|
|
11
|
+
rate_limit INTEGER NOT NULL,
|
|
12
|
+
daily_limit INTEGER NOT NULL,
|
|
13
|
+
spending_limit_cents BIGINT,
|
|
14
|
+
spent_cents BIGINT NOT NULL DEFAULT 0,
|
|
15
|
+
last_used_at BIGINT,
|
|
16
|
+
expires_at BIGINT,
|
|
17
|
+
created_at BIGINT NOT NULL
|
|
18
|
+
);
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_agent_api_key_user ON agent_api_key (user_id, created_at);
|
|
20
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_api_key_hash_unique ON agent_api_key (key_hash);
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS agent_api_key_usage (
|
|
23
|
+
request_id TEXT PRIMARY KEY,
|
|
24
|
+
key_id TEXT NOT NULL,
|
|
25
|
+
cost_cents BIGINT NOT NULL,
|
|
26
|
+
created_at BIGINT NOT NULL,
|
|
27
|
+
FOREIGN KEY (key_id) REFERENCES agent_api_key(id) ON DELETE CASCADE
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_agent_api_key_usage_key ON agent_api_key_usage (key_id, created_at);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS agent_api_key_request (
|
|
32
|
+
request_id TEXT PRIMARY KEY,
|
|
33
|
+
key_id TEXT NOT NULL,
|
|
34
|
+
claim_sequence BIGINT NOT NULL,
|
|
35
|
+
day_bucket BIGINT NOT NULL,
|
|
36
|
+
created_at BIGINT NOT NULL,
|
|
37
|
+
FOREIGN KEY (key_id) REFERENCES agent_api_key(id) ON DELETE CASCADE
|
|
38
|
+
);
|
|
39
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_api_key_request_sequence
|
|
40
|
+
ON agent_api_key_request (key_id, claim_sequence);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_agent_api_key_request_window
|
|
42
|
+
ON agent_api_key_request (key_id, created_at);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_agent_api_key_request_day
|
|
44
|
+
ON agent_api_key_request (key_id, day_bucket);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS agent_gateway_usage (
|
|
47
|
+
request_id TEXT PRIMARY KEY,
|
|
48
|
+
agent_id TEXT NOT NULL,
|
|
49
|
+
agent_slug TEXT NOT NULL,
|
|
50
|
+
consumer_id TEXT NOT NULL,
|
|
51
|
+
payment_method TEXT NOT NULL,
|
|
52
|
+
input_tokens INTEGER NOT NULL,
|
|
53
|
+
output_tokens INTEGER NOT NULL,
|
|
54
|
+
reasoning_tokens INTEGER,
|
|
55
|
+
tool_tokens INTEGER,
|
|
56
|
+
tool_call_count INTEGER,
|
|
57
|
+
provider_cost_nanodollars BIGINT,
|
|
58
|
+
total_cost_nanodollars BIGINT NOT NULL,
|
|
59
|
+
owner_earned_nanodollars BIGINT NOT NULL,
|
|
60
|
+
platform_fee_nanodollars BIGINT NOT NULL,
|
|
61
|
+
duration_ms BIGINT NOT NULL,
|
|
62
|
+
settlement_basis TEXT,
|
|
63
|
+
created_at BIGINT NOT NULL
|
|
64
|
+
);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_agent_gateway_usage_agent ON agent_gateway_usage (agent_id, created_at);
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
|
|
74
74
|
<script type="module">
|
|
75
75
|
const $ = (id) => document.getElementById(id)
|
|
76
|
+
const initialThreadId = new URL(location.href).searchParams.get('threadId')
|
|
76
77
|
const state = { threadId: null, parts: [], signUp: false }
|
|
77
78
|
|
|
78
79
|
const api = async (path, init) => {
|
|
@@ -148,6 +149,9 @@ function renderParts(container, parts = []) {
|
|
|
148
149
|
|
|
149
150
|
async function openThread(id) {
|
|
150
151
|
state.threadId = id
|
|
152
|
+
const url = new URL(location.href)
|
|
153
|
+
url.searchParams.set('threadId', id)
|
|
154
|
+
history.replaceState(null, '', url)
|
|
151
155
|
const { messages } = await api(`/api/threads/${id}/messages`)
|
|
152
156
|
$('transcript').replaceChildren()
|
|
153
157
|
for (const m of messages) {
|
|
@@ -227,10 +231,25 @@ $('composer').onsubmit = async (e) => {
|
|
|
227
231
|
await runTurn(content, parts).catch((err) => { $('error').textContent = err.message })
|
|
228
232
|
}
|
|
229
233
|
|
|
230
|
-
$('new-thread').onclick = () => {
|
|
234
|
+
$('new-thread').onclick = () => {
|
|
235
|
+
state.threadId = null
|
|
236
|
+
const url = new URL(location.href)
|
|
237
|
+
url.searchParams.delete('threadId')
|
|
238
|
+
history.replaceState(null, '', url)
|
|
239
|
+
$('transcript').replaceChildren()
|
|
240
|
+
}
|
|
231
241
|
|
|
232
242
|
await ensureSession()
|
|
233
243
|
await loadThreads()
|
|
244
|
+
if (initialThreadId) {
|
|
245
|
+
await openThread(initialThreadId).catch((err) => {
|
|
246
|
+
state.threadId = null
|
|
247
|
+
$('error').textContent = err.message
|
|
248
|
+
const url = new URL(location.href)
|
|
249
|
+
url.searchParams.delete('threadId')
|
|
250
|
+
history.replaceState(null, '', url)
|
|
251
|
+
})
|
|
252
|
+
}
|
|
234
253
|
</script>
|
|
235
254
|
</body>
|
|
236
255
|
</html>
|
|
@@ -58,6 +58,9 @@ export interface ChatAppOverrides {
|
|
|
58
58
|
produce?: (args: ChatTurnProduceArgs<void>) => ChatTurnRouteProducer | Promise<ChatTurnRouteProducer>
|
|
59
59
|
/** Test seam: where large uploads land. Default: the workspace box's fs. */
|
|
60
60
|
uploadSink?: (scope: { workspaceId: string; userId: string }) => Promise<SandboxUploadSink | null>
|
|
61
|
+
/** Server-trusted identity for a private gateway chat assembly. Never read
|
|
62
|
+
* this value from a request body or header. */
|
|
63
|
+
trustedUserId?: string
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
export interface ChatApp {
|
|
@@ -94,6 +97,9 @@ export function buildChatApp(env: AppEnv, overrides: ChatAppOverrides = {}): Cha
|
|
|
94
97
|
/** Session → identity + thread access, for both routes and seams. Guards
|
|
95
98
|
* throw JSON Responses; `guardResolution` adapts them to `{ ok, response }`. */
|
|
96
99
|
async function requireUser(request: Request) {
|
|
100
|
+
if (overrides.trustedUserId) {
|
|
101
|
+
return { ok: true as const, value: { user: { id: overrides.trustedUserId } } }
|
|
102
|
+
}
|
|
97
103
|
return guardResolution(() => auth.requireApiUser(request))
|
|
98
104
|
}
|
|
99
105
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API-key access to the same persisted chat path the browser uses.
|
|
3
|
+
*
|
|
4
|
+
* The gateway translates OpenAI-compatible requests. The chat route still owns
|
|
5
|
+
* the sandbox turn, transcript, replay buffer, and failure handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { config } from '../agent.config'
|
|
9
|
+
import { streamChatRouteAsSandboxEvents } from '@tangle-network/agent-app/chat-routes'
|
|
10
|
+
import {
|
|
11
|
+
createAgentGateway,
|
|
12
|
+
createApiKeyRequestClaim,
|
|
13
|
+
createApiKeyRoutes,
|
|
14
|
+
createApiKeyUsageSettlement,
|
|
15
|
+
d1ToSqlAdapter,
|
|
16
|
+
SqlApiKeyStore,
|
|
17
|
+
SqlGatewayUsageStore,
|
|
18
|
+
verifyApiKeyFromStore,
|
|
19
|
+
type SqlAdapter,
|
|
20
|
+
} from '@tangle-network/agent-gateway'
|
|
21
|
+
import { Hono } from 'hono'
|
|
22
|
+
import { buildChatApp, type ChatApp } from './chat'
|
|
23
|
+
import type { AppEnv } from './env'
|
|
24
|
+
import { appSlug } from './sandbox'
|
|
25
|
+
|
|
26
|
+
export interface GatewayAppOptions {
|
|
27
|
+
/** Keep the persisted chat turn alive after an API client disconnects. */
|
|
28
|
+
waitUntil?: (promise: Promise<unknown>) => void
|
|
29
|
+
/** Test override. Production uses the D1 adapter from agent-gateway. */
|
|
30
|
+
sql?: SqlAdapter
|
|
31
|
+
/** Test override. Production builds one private, trusted chat assembly. */
|
|
32
|
+
createTrustedChatApp?: (ownerId: string) => ChatApp
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function sessionUserId(app: ChatApp, request: Request): Promise<string | null> {
|
|
36
|
+
try {
|
|
37
|
+
return (await app.auth.getSession(request))?.user.id ?? null
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function ensureOwnedThread(
|
|
44
|
+
app: ChatApp,
|
|
45
|
+
threadId: string,
|
|
46
|
+
ownerId: string,
|
|
47
|
+
firstMessage: string,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
const existing = await app.store.getThread(threadId)
|
|
50
|
+
if (existing) {
|
|
51
|
+
if (existing.workspaceId !== ownerId) throw new Error('Gateway thread is unavailable')
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await app.store.createThread({ id: threadId, workspaceId: ownerId, firstMessage })
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// Two requests can create the same caller-supplied conversation at once.
|
|
59
|
+
// The primary key chooses one winner; the loser adopts that owned row.
|
|
60
|
+
const raced = await app.store.getThread(threadId)
|
|
61
|
+
if (!raced || raced.workspaceId !== ownerId) throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Build the external API surface. The Worker mounts only these Hono routes. */
|
|
66
|
+
export function buildGatewayApp(
|
|
67
|
+
env: AppEnv,
|
|
68
|
+
chatApp: ChatApp,
|
|
69
|
+
options: GatewayAppOptions = {},
|
|
70
|
+
) {
|
|
71
|
+
const sql = options.sql ?? d1ToSqlAdapter(env.DB)
|
|
72
|
+
const apiKeys = new SqlApiKeyStore(sql)
|
|
73
|
+
const usage = new SqlGatewayUsageStore(sql)
|
|
74
|
+
const trustedChatApp = options.createTrustedChatApp
|
|
75
|
+
?? ((ownerId: string) => buildChatApp(env, { trustedUserId: ownerId }))
|
|
76
|
+
|
|
77
|
+
const verifyKey = (authorization: string) =>
|
|
78
|
+
verifyApiKeyFromStore(authorization, apiKeys)
|
|
79
|
+
|
|
80
|
+
const gateway = createAgentGateway({
|
|
81
|
+
resolveAgent: async (slug) => slug === appSlug && config.gateway.enabled
|
|
82
|
+
? {
|
|
83
|
+
id: appSlug,
|
|
84
|
+
ownerId: appSlug,
|
|
85
|
+
slug: appSlug,
|
|
86
|
+
systemPrompt: config.systemPrompt,
|
|
87
|
+
pricePerTokenUsd: config.gateway.pricePerTokenUsd,
|
|
88
|
+
platformFeePercent: config.gateway.platformFeePercent,
|
|
89
|
+
sandboxEndpoint: null,
|
|
90
|
+
remoteSandboxId: null,
|
|
91
|
+
remoteBearerToken: null,
|
|
92
|
+
enabled: true,
|
|
93
|
+
harness: config.harness,
|
|
94
|
+
harnessModel: config.model.default,
|
|
95
|
+
description: config.gateway.description,
|
|
96
|
+
}
|
|
97
|
+
: null,
|
|
98
|
+
verifyApiKey: verifyKey,
|
|
99
|
+
claimApiKeyRequest: createApiKeyRequestClaim(apiKeys),
|
|
100
|
+
apiKeyPrefix: 'ak_',
|
|
101
|
+
conversationMode: 'thread',
|
|
102
|
+
// A2A task control remains off until the shared gateway owns durable
|
|
103
|
+
// cross-isolate cancel and replay. OpenAI-compatible calls are independent.
|
|
104
|
+
a2a: false,
|
|
105
|
+
authorizeConsumer: async (_agent, consumer) => {
|
|
106
|
+
if (consumer.method !== 'apikey' || !consumer.ownerId || !consumer.threadId) {
|
|
107
|
+
return { allow: false, reason: 'API key owner is unavailable', code: 'owner_unavailable' }
|
|
108
|
+
}
|
|
109
|
+
const thread = await chatApp.store.getThread(consumer.threadId)
|
|
110
|
+
if (thread && thread.workspaceId !== consumer.ownerId) {
|
|
111
|
+
return { allow: false, reason: 'Thread is unavailable', code: 'thread_unavailable' }
|
|
112
|
+
}
|
|
113
|
+
return { allow: true }
|
|
114
|
+
},
|
|
115
|
+
// A sandbox session can retain private tool output that is absent from the
|
|
116
|
+
// visible transcript. The model input limit is the only complete bound.
|
|
117
|
+
unauthenticatedInputTokenBound: config.gateway.maxProviderInputTokens,
|
|
118
|
+
getSandbox: async (_agent, context) => {
|
|
119
|
+
const ownerId = context?.keyInfo?.ownerId
|
|
120
|
+
const threadId = context?.threadId
|
|
121
|
+
if (!ownerId || !threadId) throw new Error('Gateway request has no verified owner or thread')
|
|
122
|
+
|
|
123
|
+
const firstMessage = context.messages
|
|
124
|
+
.filter((message) => message.role === 'user')
|
|
125
|
+
.at(-1)?.content ?? ''
|
|
126
|
+
const app = trustedChatApp(ownerId)
|
|
127
|
+
await ensureOwnedThread(app, threadId, ownerId, firstMessage)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
streamPrompt: (message, streamOptions) => streamChatRouteAsSandboxEvents({
|
|
131
|
+
routes: app.routes,
|
|
132
|
+
request: new Request(`${env.BETTER_AUTH_URL}/api/chat`, {
|
|
133
|
+
headers: { 'X-Gateway-Request-Id': context.requestId },
|
|
134
|
+
}),
|
|
135
|
+
payload: {
|
|
136
|
+
workspaceId: ownerId,
|
|
137
|
+
threadId,
|
|
138
|
+
content: message,
|
|
139
|
+
turnId: context.requestId,
|
|
140
|
+
},
|
|
141
|
+
waitUntil: options.waitUntil,
|
|
142
|
+
signal: streamOptions?.signal,
|
|
143
|
+
executionLimits: streamOptions?.executionBudget,
|
|
144
|
+
}),
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
recordUsage: usage.recordUsage,
|
|
148
|
+
...(options.waitUntil ? { continueOnDisconnect: options.waitUntil } : {}),
|
|
149
|
+
settlePayment: createApiKeyUsageSettlement(apiKeys),
|
|
150
|
+
defaultOutputTokens: config.gateway.defaultOutputTokens,
|
|
151
|
+
maxOutputTokens: config.gateway.maxOutputTokens,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const app = new Hono()
|
|
155
|
+
app.route('/api/keys', createApiKeyRoutes({
|
|
156
|
+
store: apiKeys,
|
|
157
|
+
prefix: 'ak_',
|
|
158
|
+
getAuthUserId: (request) => sessionUserId(chatApp, request),
|
|
159
|
+
}))
|
|
160
|
+
app.route('/v1/agents', gateway)
|
|
161
|
+
return app
|
|
162
|
+
}
|
|
@@ -107,6 +107,7 @@ export function createSandboxProduce(env: AppEnv) {
|
|
|
107
107
|
identity,
|
|
108
108
|
prompt,
|
|
109
109
|
executionId,
|
|
110
|
+
executionLimits,
|
|
110
111
|
}: ChatTurnProduceArgs<void>): Promise<ChatTurnRouteProducer> => {
|
|
111
112
|
const box = await ensureForegroundWorkspaceSandbox(env, shell, {
|
|
112
113
|
workspaceId: identity.tenantId,
|
|
@@ -131,7 +132,24 @@ export function createSandboxProduce(env: AppEnv) {
|
|
|
131
132
|
executionId: attempt === 1 ? executionId : `${executionId}-f${attempt}`,
|
|
132
133
|
model: attemptModel,
|
|
133
134
|
signal,
|
|
134
|
-
|
|
135
|
+
// A zero reasoning allowance maps to the profile's supported
|
|
136
|
+
// no-reasoning control. Positive limits use token ceilings below.
|
|
137
|
+
effort: executionLimits?.maxReasoningTokens === 0
|
|
138
|
+
? 'none'
|
|
139
|
+
: (body.effort ?? config.model.effort),
|
|
140
|
+
...(executionLimits?.maxOutputTokens
|
|
141
|
+
? { maxOutputTokens: executionLimits.maxOutputTokens }
|
|
142
|
+
: {}),
|
|
143
|
+
...(executionLimits?.maxReasoningTokens
|
|
144
|
+
? { maxReasoningTokens: executionLimits.maxReasoningTokens }
|
|
145
|
+
: {}),
|
|
146
|
+
...(executionLimits?.maxOutputTokens !== undefined
|
|
147
|
+
&& executionLimits.maxReasoningTokens !== undefined
|
|
148
|
+
? {
|
|
149
|
+
maxTotalOutputTokens:
|
|
150
|
+
executionLimits.maxOutputTokens + executionLimits.maxReasoningTokens,
|
|
151
|
+
}
|
|
152
|
+
: {}),
|
|
135
153
|
harness: config.harness,
|
|
136
154
|
systemPrompt: config.systemPrompt,
|
|
137
155
|
// Durable by default: the run keeps executing server-side if the operator
|
|
@@ -14,45 +14,120 @@
|
|
|
14
14
|
* POST /api/chat/upload multipart upload → prompt parts
|
|
15
15
|
* GET /api/chat/interactions outstanding agent asks (?threadId=)
|
|
16
16
|
* POST /api/chat/interactions answer an ask
|
|
17
|
+
* CRUD /api/keys manage personal API keys
|
|
18
|
+
* POST /v1/agents/:slug/chat/completions OpenAI-compatible API
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
|
-
import {
|
|
21
|
+
import { config } from '../agent.config'
|
|
22
|
+
import { buildChatApp, type ChatApp } from './chat'
|
|
20
23
|
import type { AppEnv } from './env'
|
|
24
|
+
import {
|
|
25
|
+
buildGatewayApp,
|
|
26
|
+
type GatewayAppOptions,
|
|
27
|
+
} from './gateway'
|
|
21
28
|
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
29
|
+
export interface WorkerAssembly {
|
|
30
|
+
buildChatApp(env: AppEnv): ChatApp
|
|
31
|
+
buildGatewayApp(
|
|
32
|
+
env: AppEnv,
|
|
33
|
+
app: ChatApp,
|
|
34
|
+
options?: GatewayAppOptions,
|
|
35
|
+
): ReturnType<typeof buildGatewayApp>
|
|
36
|
+
/** Test override. Production follows agent.config.ts. */
|
|
37
|
+
gatewayEnabled?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function attachThreadUrl(response: Response, request: Request): Response {
|
|
41
|
+
const threadId = response.headers.get('X-Tangle-Thread-Id')
|
|
42
|
+
if (!threadId) return response
|
|
43
|
+
const threadUrl = new URL('/', request.url)
|
|
44
|
+
threadUrl.searchParams.set('threadId', threadId)
|
|
45
|
+
const headers = new Headers(response.headers)
|
|
46
|
+
headers.set('X-Tangle-Thread-Url', threadUrl.toString())
|
|
47
|
+
return new Response(response.body, {
|
|
48
|
+
status: response.status,
|
|
49
|
+
statusText: response.statusText,
|
|
50
|
+
headers,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const defaultAssembly: WorkerAssembly = {
|
|
55
|
+
buildChatApp,
|
|
56
|
+
buildGatewayApp,
|
|
57
|
+
gatewayEnabled: config.gateway.enabled,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build the Worker around one app assembly. Tests inject the real app with a test database. */
|
|
61
|
+
export function createWorker(assembly: WorkerAssembly = defaultAssembly): ExportedHandler<AppEnv> {
|
|
62
|
+
// Reuse the database-backed chat assembly within one Worker environment.
|
|
63
|
+
// Build the gateway per request so its background work binds to that
|
|
64
|
+
// request's ExecutionContext. Durable stores remain authoritative.
|
|
65
|
+
const instances = new WeakMap<object, ChatApp>()
|
|
66
|
+
|
|
67
|
+
const resolveApp = (env: AppEnv) => {
|
|
68
|
+
const key = env as object
|
|
69
|
+
const existing = instances.get(key)
|
|
70
|
+
if (existing) return existing
|
|
71
|
+
const app = assembly.buildChatApp(env)
|
|
72
|
+
instances.set(key, app)
|
|
73
|
+
return app
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
async fetch(request: Request, env: AppEnv, ctx: ExecutionContext): Promise<Response> {
|
|
78
|
+
const url = new URL(request.url)
|
|
79
|
+
const { pathname } = url
|
|
80
|
+
const method = request.method
|
|
81
|
+
|
|
82
|
+
const isGatewayPath = pathname === '/api/keys'
|
|
83
|
+
|| pathname.startsWith('/api/keys/')
|
|
84
|
+
|| pathname === '/v1/agents'
|
|
85
|
+
|| pathname.startsWith('/v1/agents/')
|
|
86
|
+
const isUnsupportedA2APath = /^\/v1\/agents\/[^/]+(?:\/\.well-known\/agent\.json)?$/.test(pathname)
|
|
87
|
+
if (isGatewayPath && assembly.gatewayEnabled === false) {
|
|
88
|
+
return Response.json({ error: 'Not found' }, { status: 404 })
|
|
89
|
+
}
|
|
90
|
+
// Long-running A2A task control is not mounted until agent-gateway owns
|
|
91
|
+
// durable cross-isolate cancel and replay. OpenAI-compatible calls do
|
|
92
|
+
// not depend on that unfinished path.
|
|
93
|
+
if (isUnsupportedA2APath) return Response.json({ error: 'Not found' }, { status: 404 })
|
|
94
|
+
|
|
95
|
+
const app = resolveApp(env)
|
|
96
|
+
if (isGatewayPath) {
|
|
97
|
+
const gateway = assembly.buildGatewayApp(env, app, {
|
|
98
|
+
waitUntil: (promise) => ctx.waitUntil(promise),
|
|
99
|
+
})
|
|
100
|
+
return attachThreadUrl(await gateway.fetch(request), request)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (pathname.startsWith('/api/auth/')) return app.auth.auth.handler(request)
|
|
104
|
+
|
|
105
|
+
if (pathname === '/api/chat' && method === 'POST') {
|
|
106
|
+
// Pass waitUntil so the turn keeps running (and buffering for replay)
|
|
107
|
+
// after a client disconnect.
|
|
108
|
+
return app.routes.turn(request, ctx)
|
|
109
|
+
}
|
|
110
|
+
const replay = pathname.match(/^\/api\/chat\/replay\/([^/]+)$/)
|
|
111
|
+
if (replay && method === 'GET') return app.routes.replay(request, { turnId: replay[1]! })
|
|
112
|
+
// Reconnect discovery: which turns are still live on a thread, so a page
|
|
113
|
+
// reloaded mid-turn re-attaches via /replay instead of losing the run.
|
|
114
|
+
if (pathname === '/api/chat/running' && method === 'GET') return app.routes.running(request)
|
|
115
|
+
if (pathname === '/api/chat/upload' && method === 'POST') return app.upload(request)
|
|
116
|
+
if (pathname === '/api/chat/interactions' && app.routes.interactions) {
|
|
117
|
+
if (method === 'GET') return app.routes.interactions.list(request)
|
|
118
|
+
if (method === 'POST') return app.routes.interactions.answer(request)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (pathname === '/api/threads' && method === 'POST') return app.routes.createThread(request)
|
|
122
|
+
if (pathname === '/api/threads' && method === 'GET') return app.routes.listThreads(request)
|
|
123
|
+
const transcript = pathname.match(/^\/api\/threads\/([^/]+)\/messages$/)
|
|
124
|
+
if (transcript && method === 'GET') {
|
|
125
|
+
return app.routes.threadMessages(request, { threadId: transcript[1]! })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return Response.json({ error: 'Not found' }, { status: 404 })
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export default createWorker()
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* contract (or the migration from the schema). Fix the drift, not the test.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
20
20
|
import { dirname, join } from 'node:path'
|
|
21
21
|
import { fileURLToPath } from 'node:url'
|
|
22
22
|
import Database from 'better-sqlite3'
|
|
@@ -26,6 +26,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
26
26
|
import {
|
|
27
27
|
createSandboxChatProducer,
|
|
28
28
|
normalizeChatPromptForSandbox,
|
|
29
|
+
type ChatTurnProduceArgs,
|
|
29
30
|
type ChatTurnRouteProducer,
|
|
30
31
|
} from '@tangle-network/agent-app/chat-routes'
|
|
31
32
|
import type { ChatDatabase } from '@tangle-network/agent-app/chat-store'
|
|
@@ -34,28 +35,59 @@ import {
|
|
|
34
35
|
createMemoryTurnEventStore,
|
|
35
36
|
TURN_EVENTS_MIGRATION_SQL,
|
|
36
37
|
} from '@tangle-network/agent-app/stream'
|
|
38
|
+
import {
|
|
39
|
+
sqlApiKeyStoreSchemaStatements,
|
|
40
|
+
sqlGatewayUsageStoreSchemaStatements,
|
|
41
|
+
type SqlAdapter,
|
|
42
|
+
} from '@tangle-network/agent-gateway'
|
|
37
43
|
|
|
38
44
|
import { config } from '../agent.config'
|
|
39
45
|
import { buildChatApp, type ChatApp } from '../src/chat'
|
|
40
46
|
import type { AppEnv } from '../src/env'
|
|
47
|
+
import { buildGatewayApp } from '../src/gateway'
|
|
48
|
+
import { appSlug } from '../src/sandbox'
|
|
49
|
+
import { createWorker } from '../src/worker'
|
|
41
50
|
|
|
42
51
|
const BASE = 'http://localhost:8787'
|
|
43
52
|
const MODEL = 'test/model-1'
|
|
44
53
|
|
|
45
54
|
// ── fixtures ────────────────────────────────────────────────────────────────
|
|
46
55
|
|
|
47
|
-
const
|
|
56
|
+
const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
|
57
|
+
const MIGRATIONS = readdirSync(MIGRATIONS_DIR)
|
|
58
|
+
.filter((name) => /^\d+.*\.sql$/.test(name))
|
|
59
|
+
.sort()
|
|
60
|
+
.map((name) => join(MIGRATIONS_DIR, name))
|
|
61
|
+
const BASE_MIGRATION = join(MIGRATIONS_DIR, '0001_init.sql')
|
|
62
|
+
const GATEWAY_MIGRATION = join(MIGRATIONS_DIR, '0002_agent_gateway.sql')
|
|
48
63
|
|
|
49
64
|
/** The real migration, executed against a real SQLite database. Every query
|
|
50
65
|
* the test makes afterwards runs over THESE tables — schema drift between
|
|
51
66
|
* `migrations/` and `src/db/schema.ts` fails here, not in production. */
|
|
52
|
-
function openMigratedDb():
|
|
67
|
+
function openMigratedDb(migrations = MIGRATIONS): {
|
|
68
|
+
db: ChatDatabase
|
|
69
|
+
sql: SqlAdapter
|
|
70
|
+
applyMigration(path: string): void
|
|
71
|
+
} {
|
|
53
72
|
const sqlite = new Database(':memory:')
|
|
54
73
|
sqlite.pragma('foreign_keys = ON')
|
|
55
|
-
sqlite.exec(readFileSync(
|
|
74
|
+
for (const migration of migrations) sqlite.exec(readFileSync(migration, 'utf8'))
|
|
56
75
|
// better-sqlite3's sync drizzle handle narrows the driver generic; the store
|
|
57
76
|
// treats sync and async drivers identically (builders are awaited).
|
|
58
|
-
return
|
|
77
|
+
return {
|
|
78
|
+
db: drizzle(sqlite) as unknown as ChatDatabase,
|
|
79
|
+
sql: {
|
|
80
|
+
async exec(statement, params = []) {
|
|
81
|
+
return { rowsAffected: sqlite.prepare(statement).run(...params).changes }
|
|
82
|
+
},
|
|
83
|
+
async query<TRow>(statement: string, params: readonly unknown[] = []) {
|
|
84
|
+
return sqlite.prepare(statement).all(...params) as TRow[]
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
applyMigration(path) {
|
|
88
|
+
sqlite.exec(readFileSync(path, 'utf8'))
|
|
89
|
+
},
|
|
90
|
+
}
|
|
59
91
|
}
|
|
60
92
|
|
|
61
93
|
/** Raw sidecar events, exactly as `streamSandboxPrompt` would yield them from
|
|
@@ -84,18 +116,28 @@ const env: AppEnv = {
|
|
|
84
116
|
|
|
85
117
|
interface Harness {
|
|
86
118
|
app: ChatApp
|
|
119
|
+
workerFetch(request: Request): Promise<Response>
|
|
120
|
+
sql: SqlAdapter
|
|
87
121
|
cookie: string
|
|
122
|
+
gatewayBuilds(): number
|
|
88
123
|
settle(): Promise<unknown>
|
|
89
124
|
}
|
|
90
125
|
|
|
91
126
|
async function createHarness(
|
|
92
|
-
produce: () => ChatTurnRouteProducer = () =>
|
|
127
|
+
produce: (args: ChatTurnProduceArgs<void>) => ChatTurnRouteProducer = () =>
|
|
93
128
|
createSandboxChatProducer({ events: feed(RAW_TURN_EVENTS), model: MODEL }),
|
|
94
129
|
): Promise<Harness> {
|
|
95
|
-
const
|
|
96
|
-
|
|
130
|
+
const database = openMigratedDb()
|
|
131
|
+
const pending: Promise<unknown>[] = []
|
|
132
|
+
let gatewayBuildCount = 0
|
|
133
|
+
const chatOverrides = {
|
|
134
|
+
db: database.db,
|
|
97
135
|
turnStore: createMemoryTurnEventStore(),
|
|
98
136
|
produce,
|
|
137
|
+
uploadSink: async () => null,
|
|
138
|
+
}
|
|
139
|
+
const app = buildChatApp(env, {
|
|
140
|
+
...chatOverrides,
|
|
99
141
|
uploadSink: async () => null, // inline uploads only; no box in tests
|
|
100
142
|
})
|
|
101
143
|
// Real sign-up through better-auth; the returned cookie is what a browser
|
|
@@ -113,11 +155,42 @@ async function createHarness(
|
|
|
113
155
|
.map((c) => c.split(';')[0]!)
|
|
114
156
|
.join('; ')
|
|
115
157
|
|
|
116
|
-
const pending: Promise<unknown>[] = []
|
|
117
158
|
const originalTurn = app.routes.turn
|
|
118
159
|
app.routes.turn = (request) =>
|
|
119
160
|
originalTurn(request, { waitUntil: (p) => void pending.push(p) })
|
|
120
|
-
|
|
161
|
+
const worker = createWorker({
|
|
162
|
+
buildChatApp: () => app,
|
|
163
|
+
buildGatewayApp: (_env, chatApp, options) => {
|
|
164
|
+
gatewayBuildCount += 1
|
|
165
|
+
return buildGatewayApp(env, chatApp, {
|
|
166
|
+
...options,
|
|
167
|
+
sql: database.sql,
|
|
168
|
+
createTrustedChatApp: (ownerId) => buildChatApp(env, {
|
|
169
|
+
...chatOverrides,
|
|
170
|
+
trustedUserId: ownerId,
|
|
171
|
+
}),
|
|
172
|
+
})
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
const executionContext = {
|
|
176
|
+
waitUntil: (promise: Promise<unknown>) => void pending.push(promise),
|
|
177
|
+
passThroughOnException: () => undefined,
|
|
178
|
+
props: {},
|
|
179
|
+
} as ExecutionContext
|
|
180
|
+
const workerHandler = worker.fetch
|
|
181
|
+
if (!workerHandler) throw new Error('Generated Worker has no fetch handler')
|
|
182
|
+
return {
|
|
183
|
+
app,
|
|
184
|
+
workerFetch: async (request) => workerHandler(
|
|
185
|
+
request as Parameters<typeof workerHandler>[0],
|
|
186
|
+
env,
|
|
187
|
+
executionContext,
|
|
188
|
+
),
|
|
189
|
+
sql: database.sql,
|
|
190
|
+
cookie,
|
|
191
|
+
gatewayBuilds: () => gatewayBuildCount,
|
|
192
|
+
settle: () => Promise.all(pending),
|
|
193
|
+
}
|
|
121
194
|
}
|
|
122
195
|
|
|
123
196
|
function post(path: string, cookie: string, body: unknown): Request {
|
|
@@ -142,6 +215,22 @@ function eventsOf(lines: Array<Record<string, unknown>>): Array<Record<string, u
|
|
|
142
215
|
return lines.map((l) => (l.kind === 'event' ? (l.event as Record<string, unknown>) : l))
|
|
143
216
|
}
|
|
144
217
|
|
|
218
|
+
async function readGatewayText(response: Response): Promise<string> {
|
|
219
|
+
const body = await response.text()
|
|
220
|
+
return body
|
|
221
|
+
.split('\n')
|
|
222
|
+
.filter((line) => line.startsWith('data: ') && line !== 'data: [DONE]')
|
|
223
|
+
.map((line) => JSON.parse(line.slice(6)) as {
|
|
224
|
+
choices?: Array<{ delta?: { content?: string } }>
|
|
225
|
+
error?: { message?: string }
|
|
226
|
+
})
|
|
227
|
+
.map((frame) => {
|
|
228
|
+
if (frame.error) throw new Error(frame.error.message ?? 'gateway stream failed')
|
|
229
|
+
return frame.choices?.[0]?.delta?.content ?? ''
|
|
230
|
+
})
|
|
231
|
+
.join('')
|
|
232
|
+
}
|
|
233
|
+
|
|
145
234
|
// ── the gate ────────────────────────────────────────────────────────────────
|
|
146
235
|
|
|
147
236
|
describe('e2e: fake sandbox producer → streamed turn → persisted transcript', () => {
|
|
@@ -215,7 +304,10 @@ describe('e2e: fake sandbox producer → streamed turn → persisted transcript'
|
|
|
215
304
|
| undefined
|
|
216
305
|
expect(toolCall?.call?.toolName).toBe('record_search')
|
|
217
306
|
expect(events).toContainEqual(
|
|
218
|
-
expect.objectContaining({
|
|
307
|
+
expect.objectContaining({
|
|
308
|
+
type: 'usage',
|
|
309
|
+
usage: expect.objectContaining({ promptTokens: 40, completionTokens: 20 }),
|
|
310
|
+
}),
|
|
219
311
|
)
|
|
220
312
|
await settle()
|
|
221
313
|
|
|
@@ -272,12 +364,337 @@ describe('e2e: fake sandbox producer → streamed turn → persisted transcript'
|
|
|
272
364
|
|
|
273
365
|
it('the migration carries the turn-buffer DDL the /stream store expects, verbatim', () => {
|
|
274
366
|
const normalize = (sql: string) => sql.replace(/\s+/g, ' ').trim()
|
|
275
|
-
expect(normalize(readFileSync(
|
|
367
|
+
expect(normalize(readFileSync(BASE_MIGRATION, 'utf8'))).toContain(normalize(TURN_EVENTS_MIGRATION_SQL))
|
|
276
368
|
})
|
|
277
369
|
|
|
278
370
|
it('the migration carries the fenced sandbox claim table, verbatim', () => {
|
|
279
371
|
const normalize = (sql: string) => sql.replace(/\s+/g, ' ').replace(/;$/, '').trim()
|
|
280
|
-
expect(normalize(readFileSync(
|
|
372
|
+
expect(normalize(readFileSync(BASE_MIGRATION, 'utf8'))).toContain(normalize(PREWARM_CLAIM_TABLE_DDL))
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('the migration carries every agent-gateway SQL store statement', () => {
|
|
376
|
+
const migration = readFileSync(GATEWAY_MIGRATION, 'utf8')
|
|
377
|
+
const normalize = (sql: string) => sql.replace(/\s+/g, ' ').replace(/;$/, '').trim()
|
|
378
|
+
const statements = [
|
|
379
|
+
...sqlApiKeyStoreSchemaStatements(),
|
|
380
|
+
...sqlGatewayUsageStoreSchemaStatements(),
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
for (const statement of statements) {
|
|
384
|
+
expect(normalize(migration)).toContain(normalize(statement))
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('upgrades a database that already applied the original chat migration', async () => {
|
|
389
|
+
const migrated = openMigratedDb([BASE_MIGRATION])
|
|
390
|
+
const tableNames = async () => (await migrated.sql.query<{ name: string }>(
|
|
391
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'agent_%' ORDER BY name",
|
|
392
|
+
)).map((row) => row.name)
|
|
393
|
+
|
|
394
|
+
expect(await tableNames()).toEqual([])
|
|
395
|
+
migrated.applyMigration(GATEWAY_MIGRATION)
|
|
396
|
+
expect(await tableNames()).toEqual([
|
|
397
|
+
'agent_api_key',
|
|
398
|
+
'agent_api_key_request',
|
|
399
|
+
'agent_api_key_usage',
|
|
400
|
+
'agent_gateway_usage',
|
|
401
|
+
])
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('shares one owned thread across OpenAI-compatible API calls', async () => {
|
|
405
|
+
const { app, workerFetch, sql, cookie, gatewayBuilds, settle } = await createHarness()
|
|
406
|
+
const cardResponse = await workerFetch(new Request(
|
|
407
|
+
`${BASE}/v1/agents/${appSlug}/.well-known/agent.json`,
|
|
408
|
+
))
|
|
409
|
+
expect(cardResponse.status).toBe(404)
|
|
410
|
+
|
|
411
|
+
const keyResponse = await workerFetch(post('/api/keys', cookie, {
|
|
412
|
+
name: 'coding agent',
|
|
413
|
+
rateLimit: 2,
|
|
414
|
+
dailyLimit: 2,
|
|
415
|
+
}))
|
|
416
|
+
expect(keyResponse.status).toBe(201)
|
|
417
|
+
const { key } = (await keyResponse.json()) as { key: string }
|
|
418
|
+
|
|
419
|
+
const openAiResponse = await workerFetch(new Request(
|
|
420
|
+
`${BASE}/v1/agents/${appSlug}/chat/completions`,
|
|
421
|
+
{
|
|
422
|
+
method: 'POST',
|
|
423
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
424
|
+
body: JSON.stringify({
|
|
425
|
+
messages: [{ role: 'user', content: 'File my lease summary' }],
|
|
426
|
+
stream: true,
|
|
427
|
+
}),
|
|
428
|
+
},
|
|
429
|
+
))
|
|
430
|
+
expect(openAiResponse.status).toBe(200)
|
|
431
|
+
const threadId = openAiResponse.headers.get('X-Tangle-Thread-Id')
|
|
432
|
+
expect(threadId).toBeTruthy()
|
|
433
|
+
expect(openAiResponse.headers.get('X-Tangle-Thread-Url')).toBe(
|
|
434
|
+
`${BASE}/?threadId=${encodeURIComponent(threadId!)}`,
|
|
435
|
+
)
|
|
436
|
+
expect(await readGatewayText(openAiResponse)).toBe('Filed the summary.')
|
|
437
|
+
await settle()
|
|
438
|
+
|
|
439
|
+
const thread = await app.store.getThread(threadId!)
|
|
440
|
+
expect(thread).toMatchObject({ id: threadId, workspaceId: expect.any(String) })
|
|
441
|
+
expect(await app.store.listMessages(threadId!)).toHaveLength(2)
|
|
442
|
+
expect(await sql.query(`
|
|
443
|
+
SELECT input_tokens, output_tokens, reasoning_tokens, tool_tokens,
|
|
444
|
+
tool_call_count, provider_cost_nanodollars, total_cost_nanodollars,
|
|
445
|
+
settlement_basis
|
|
446
|
+
FROM agent_gateway_usage
|
|
447
|
+
`)).toEqual([{
|
|
448
|
+
input_tokens: 40,
|
|
449
|
+
output_tokens: 20,
|
|
450
|
+
reasoning_tokens: 5,
|
|
451
|
+
tool_tokens: 0,
|
|
452
|
+
tool_call_count: 1,
|
|
453
|
+
provider_cost_nanodollars: 12_300_000,
|
|
454
|
+
total_cost_nanodollars: 12_300_000,
|
|
455
|
+
settlement_basis: 'usage-receipt',
|
|
456
|
+
}])
|
|
457
|
+
expect(await sql.query('SELECT request_id FROM agent_api_key_usage')).toHaveLength(1)
|
|
458
|
+
expect(await sql.query('SELECT request_id FROM agent_api_key_request')).toHaveLength(1)
|
|
459
|
+
|
|
460
|
+
const continuedResponse = await workerFetch(new Request(
|
|
461
|
+
`${BASE}/v1/agents/${appSlug}/chat/completions`,
|
|
462
|
+
{
|
|
463
|
+
method: 'POST',
|
|
464
|
+
headers: {
|
|
465
|
+
Authorization: `Bearer ${key}`,
|
|
466
|
+
'Content-Type': 'application/json',
|
|
467
|
+
'X-Tangle-Thread-Id': threadId!,
|
|
468
|
+
},
|
|
469
|
+
body: JSON.stringify({
|
|
470
|
+
messages: [{ role: 'user', content: 'Continue the same work' }],
|
|
471
|
+
stream: true,
|
|
472
|
+
}),
|
|
473
|
+
},
|
|
474
|
+
))
|
|
475
|
+
expect(continuedResponse.status).toBe(200)
|
|
476
|
+
expect(continuedResponse.headers.get('X-Tangle-Thread-Id')).toBe(threadId)
|
|
477
|
+
expect(await readGatewayText(continuedResponse)).toBe('Filed the summary.')
|
|
478
|
+
await settle()
|
|
479
|
+
expect(await app.store.listMessages(threadId!)).toHaveLength(4)
|
|
480
|
+
expect(await sql.query('SELECT request_id FROM agent_api_key_request')).toHaveLength(2)
|
|
481
|
+
expect(await sql.query('SELECT request_id FROM agent_gateway_usage')).toHaveLength(2)
|
|
482
|
+
expect(await sql.query('SELECT request_id FROM agent_api_key_usage')).toHaveLength(2)
|
|
483
|
+
|
|
484
|
+
await app.store.createThread({
|
|
485
|
+
id: 'another-users-thread',
|
|
486
|
+
workspaceId: 'another-user',
|
|
487
|
+
title: 'Private',
|
|
488
|
+
})
|
|
489
|
+
const probeKeyResponse = await workerFetch(post('/api/keys', cookie, {
|
|
490
|
+
name: 'thread probe',
|
|
491
|
+
rateLimit: 1,
|
|
492
|
+
dailyLimit: 1,
|
|
493
|
+
}))
|
|
494
|
+
expect(probeKeyResponse.status).toBe(201)
|
|
495
|
+
const { key: probeKey } = (await probeKeyResponse.json()) as { key: string }
|
|
496
|
+
const denied = await workerFetch(new Request(
|
|
497
|
+
`${BASE}/v1/agents/${appSlug}/chat/completions`,
|
|
498
|
+
{
|
|
499
|
+
method: 'POST',
|
|
500
|
+
headers: {
|
|
501
|
+
Authorization: `Bearer ${probeKey}`,
|
|
502
|
+
'Content-Type': 'application/json',
|
|
503
|
+
'X-Tangle-Thread-Id': 'another-users-thread',
|
|
504
|
+
},
|
|
505
|
+
body: JSON.stringify({ messages: [{ role: 'user', content: 'Open it' }] }),
|
|
506
|
+
},
|
|
507
|
+
))
|
|
508
|
+
expect(denied.status).toBe(403)
|
|
509
|
+
expect(await sql.query('SELECT request_id FROM agent_api_key_request')).toHaveLength(2)
|
|
510
|
+
|
|
511
|
+
const rateLimited = await workerFetch(new Request(
|
|
512
|
+
`${BASE}/v1/agents/${appSlug}/chat/completions`,
|
|
513
|
+
{
|
|
514
|
+
method: 'POST',
|
|
515
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
516
|
+
body: JSON.stringify({
|
|
517
|
+
messages: [{ role: 'user', content: 'This third turn must not run' }],
|
|
518
|
+
stream: true,
|
|
519
|
+
}),
|
|
520
|
+
},
|
|
521
|
+
))
|
|
522
|
+
expect(rateLimited.status).toBe(429)
|
|
523
|
+
expect(await app.store.listMessages(threadId!)).toHaveLength(4)
|
|
524
|
+
expect(await sql.query('SELECT request_id FROM agent_api_key_request')).toHaveLength(2)
|
|
525
|
+
expect(gatewayBuilds()).toBe(6)
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('finishes the linked browser transcript after the API client disconnects', async () => {
|
|
529
|
+
let releaseTurn = () => {}
|
|
530
|
+
const canFinish = new Promise<void>((resolve) => {
|
|
531
|
+
releaseTurn = resolve
|
|
532
|
+
})
|
|
533
|
+
async function* delayedEvents(): AsyncGenerator<Record<string, unknown>> {
|
|
534
|
+
yield {
|
|
535
|
+
type: 'message.part.updated',
|
|
536
|
+
data: {
|
|
537
|
+
part: { type: 'text', id: 'answer', text: 'Started. ' },
|
|
538
|
+
delta: 'Started. ',
|
|
539
|
+
},
|
|
540
|
+
}
|
|
541
|
+
await canFinish
|
|
542
|
+
yield {
|
|
543
|
+
type: 'message.part.updated',
|
|
544
|
+
data: {
|
|
545
|
+
part: { type: 'text', id: 'answer', text: 'Started. Finished.' },
|
|
546
|
+
delta: 'Finished.',
|
|
547
|
+
},
|
|
548
|
+
}
|
|
549
|
+
yield {
|
|
550
|
+
type: 'message.part.updated',
|
|
551
|
+
data: {
|
|
552
|
+
part: {
|
|
553
|
+
type: 'step-finish',
|
|
554
|
+
reason: 'stop',
|
|
555
|
+
tokens: { input: 7, output: 3, reasoning: 1 },
|
|
556
|
+
cost: 0.00021,
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
}
|
|
560
|
+
yield { type: 'result', data: { finalText: 'Started. Finished.' } }
|
|
561
|
+
}
|
|
562
|
+
const { app, workerFetch, sql, cookie, settle } = await createHarness(() =>
|
|
563
|
+
createSandboxChatProducer({ events: delayedEvents(), model: MODEL }))
|
|
564
|
+
const keyResponse = await workerFetch(post('/api/keys', cookie, {
|
|
565
|
+
name: 'disconnect test',
|
|
566
|
+
rateLimit: 1,
|
|
567
|
+
dailyLimit: 1,
|
|
568
|
+
}))
|
|
569
|
+
const { key } = (await keyResponse.json()) as { key: string }
|
|
570
|
+
|
|
571
|
+
const response = await workerFetch(new Request(
|
|
572
|
+
`${BASE}/v1/agents/${appSlug}/chat/completions`,
|
|
573
|
+
{
|
|
574
|
+
method: 'POST',
|
|
575
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
576
|
+
body: JSON.stringify({
|
|
577
|
+
messages: [{ role: 'user', content: 'Complete this after I leave' }],
|
|
578
|
+
stream: true,
|
|
579
|
+
}),
|
|
580
|
+
},
|
|
581
|
+
))
|
|
582
|
+
expect(response.status).toBe(200)
|
|
583
|
+
const threadId = response.headers.get('X-Tangle-Thread-Id')
|
|
584
|
+
expect(response.headers.get('X-Tangle-Thread-Url')).toBe(
|
|
585
|
+
`${BASE}/?threadId=${encodeURIComponent(threadId!)}`,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
const reader = response.body!.getReader()
|
|
589
|
+
const decoder = new TextDecoder()
|
|
590
|
+
let visible = ''
|
|
591
|
+
while (!visible.includes('Started.')) {
|
|
592
|
+
const chunk = await reader.read()
|
|
593
|
+
if (chunk.done) throw new Error('Gateway stream ended before its first answer text')
|
|
594
|
+
visible += decoder.decode(chunk.value, { stream: true })
|
|
595
|
+
}
|
|
596
|
+
await reader.cancel()
|
|
597
|
+
|
|
598
|
+
const runningResponse = await app.routes.running(new Request(
|
|
599
|
+
`${BASE}/api/chat/running?threadId=${encodeURIComponent(threadId!)}`,
|
|
600
|
+
{ headers: { cookie } },
|
|
601
|
+
))
|
|
602
|
+
expect(runningResponse.status).toBe(200)
|
|
603
|
+
const { running } = (await runningResponse.json()) as { running: string[] }
|
|
604
|
+
expect(running).toHaveLength(1)
|
|
605
|
+
const replay = await app.routes.replay(
|
|
606
|
+
new Request(`${BASE}/api/chat/replay/${running[0]}?fromSeq=0`, { headers: { cookie } }),
|
|
607
|
+
{ turnId: running[0]! },
|
|
608
|
+
)
|
|
609
|
+
const replayLines = readLines(replay)
|
|
610
|
+
|
|
611
|
+
releaseTurn()
|
|
612
|
+
await settle()
|
|
613
|
+
|
|
614
|
+
const messages = await app.store.listMessages(threadId!)
|
|
615
|
+
expect(messages).toHaveLength(2)
|
|
616
|
+
expect(messages[1]).toMatchObject({ role: 'assistant', content: 'Started. Finished.' })
|
|
617
|
+
const events = eventsOf(await replayLines)
|
|
618
|
+
expect(events.filter((event) => event.type === 'text').map((event) => event.text).join(''))
|
|
619
|
+
.toBe('Started. Finished.')
|
|
620
|
+
expect(events.at(-1)).toMatchObject({ type: 'turn_status', status: 'complete' })
|
|
621
|
+
expect(await sql.query(`
|
|
622
|
+
SELECT input_tokens, output_tokens, reasoning_tokens, provider_cost_nanodollars
|
|
623
|
+
FROM agent_gateway_usage
|
|
624
|
+
`)).toEqual([{
|
|
625
|
+
input_tokens: 7,
|
|
626
|
+
output_tokens: 3,
|
|
627
|
+
reasoning_tokens: 1,
|
|
628
|
+
provider_cost_nanodollars: 210_000,
|
|
629
|
+
}])
|
|
630
|
+
expect(await sql.query('SELECT request_id FROM agent_api_key_usage')).toHaveLength(1)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('passes the complete provider budget into the shared chat turn', async () => {
|
|
634
|
+
let receivedLimits: ChatTurnProduceArgs<void>['executionLimits']
|
|
635
|
+
const { workerFetch, cookie, settle } = await createHarness((args) => {
|
|
636
|
+
receivedLimits = args.executionLimits
|
|
637
|
+
return createSandboxChatProducer({ events: feed(RAW_TURN_EVENTS), model: MODEL })
|
|
638
|
+
})
|
|
639
|
+
const keyResponse = await workerFetch(post('/api/keys', cookie, {
|
|
640
|
+
name: 'budget test',
|
|
641
|
+
rateLimit: 1,
|
|
642
|
+
dailyLimit: 1,
|
|
643
|
+
}))
|
|
644
|
+
const { key } = (await keyResponse.json()) as { key: string }
|
|
645
|
+
|
|
646
|
+
const response = await workerFetch(new Request(
|
|
647
|
+
`${BASE}/v1/agents/${appSlug}/chat/completions`,
|
|
648
|
+
{
|
|
649
|
+
method: 'POST',
|
|
650
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
651
|
+
body: JSON.stringify({
|
|
652
|
+
messages: [{ role: 'user', content: 'Use the bounded path' }],
|
|
653
|
+
max_tokens: 321,
|
|
654
|
+
stream: true,
|
|
655
|
+
}),
|
|
656
|
+
},
|
|
657
|
+
))
|
|
658
|
+
|
|
659
|
+
expect(response.status).toBe(200)
|
|
660
|
+
await readGatewayText(response)
|
|
661
|
+
await settle()
|
|
662
|
+
expect(receivedLimits).toMatchObject({
|
|
663
|
+
maxInputTokens: config.gateway.maxProviderInputTokens,
|
|
664
|
+
maxOutputTokens: 321,
|
|
665
|
+
maxReasoningTokens: 321,
|
|
666
|
+
maxToolTokens: 321,
|
|
667
|
+
maxToolCalls: 8,
|
|
668
|
+
})
|
|
669
|
+
expect(receivedLimits?.maxProviderCostUsd).toBeGreaterThan(0)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('does not mount API-key or agent routes when the gateway is disabled', async () => {
|
|
673
|
+
const database = openMigratedDb()
|
|
674
|
+
const app = buildChatApp(env, {
|
|
675
|
+
db: database.db,
|
|
676
|
+
turnStore: createMemoryTurnEventStore(),
|
|
677
|
+
produce: () => createSandboxChatProducer({ events: feed(RAW_TURN_EVENTS), model: MODEL }),
|
|
678
|
+
})
|
|
679
|
+
const worker = createWorker({
|
|
680
|
+
buildChatApp: () => app,
|
|
681
|
+
buildGatewayApp: (_env, chatApp) => buildGatewayApp(env, chatApp, { sql: database.sql }),
|
|
682
|
+
gatewayEnabled: false,
|
|
683
|
+
})
|
|
684
|
+
const fetch = worker.fetch
|
|
685
|
+
if (!fetch) throw new Error('Generated Worker has no fetch handler')
|
|
686
|
+
const context = {
|
|
687
|
+
waitUntil: () => undefined,
|
|
688
|
+
passThroughOnException: () => undefined,
|
|
689
|
+
props: {},
|
|
690
|
+
} as unknown as ExecutionContext
|
|
691
|
+
|
|
692
|
+
const [keys, agents] = await Promise.all([
|
|
693
|
+
fetch(new Request(`${BASE}/api/keys`), env, context),
|
|
694
|
+
fetch(new Request(`${BASE}/v1/agents/${appSlug}/.well-known/agent.json`), env, context),
|
|
695
|
+
])
|
|
696
|
+
expect(keys.status).toBe(404)
|
|
697
|
+
expect(agents.status).toBe(404)
|
|
281
698
|
})
|
|
282
699
|
|
|
283
700
|
it('agent.config carries a real system prompt (prompts/system.md is wired)', () => {
|