@tangle-network/create-agent-app 0.44.36
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/README.md +9 -0
- package/index.mjs +183 -0
- package/package.json +34 -0
- package/template/.dev.vars.example +11 -0
- package/template/AGENTS.md +67 -0
- package/template/CLAUDE.md +6 -0
- package/template/CUSTOMIZE.md +83 -0
- package/template/KNOWLEDGE.md +73 -0
- package/template/README.md +41 -0
- package/template/_gitignore +5 -0
- package/template/_package.json +33 -0
- package/template/_tsconfig.json +16 -0
- package/template/_wrangler.toml +22 -0
- package/template/agent.config.ts +113 -0
- package/template/knowledge/.gitkeep +0 -0
- package/template/knowledge/README.md +24 -0
- package/template/scripts/knowledge-ingest.mjs +86 -0
- package/template/src/agent-app.ts +77 -0
- package/template/src/worker.ts +128 -0
- package/template/tests/agent-app.test.ts +71 -0
- package/template/vitest.config.ts +7 -0
- package/template-chat/.dev.vars.example +18 -0
- package/template-chat/AGENTS.md +73 -0
- package/template-chat/CLAUDE.md +6 -0
- package/template-chat/CUSTOMIZE.md +88 -0
- package/template-chat/README.md +50 -0
- package/template-chat/_gitignore +6 -0
- package/template-chat/_package.json +38 -0
- package/template-chat/_tsconfig.json +16 -0
- package/template-chat/_wrangler.toml +38 -0
- package/template-chat/agent.config.ts +73 -0
- package/template-chat/declarations.d.ts +9 -0
- package/template-chat/migrations/0001_init.sql +109 -0
- package/template-chat/prompts/system.md +11 -0
- package/template-chat/public/index.html +236 -0
- package/template-chat/src/chat.ts +210 -0
- package/template-chat/src/db/schema.ts +70 -0
- package/template-chat/src/env.ts +33 -0
- package/template-chat/src/sandbox.ts +159 -0
- package/template-chat/src/worker.ts +58 -0
- package/template-chat/tests/chat-turn.e2e.test.ts +277 -0
- package/template-chat/vitest.config.ts +22 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/sandbox.ts — the config-driven sandbox lane. CODE, not data.
|
|
3
|
+
*
|
|
4
|
+
* Everything agent-shaped runs in a Tangle sandbox (a full agent harness:
|
|
5
|
+
* skills, tools, bash, MCP) reached through `@tangle-network/agent-app`'s
|
|
6
|
+
* sandbox helpers. This file turns `agent.config.ts` + env into the three
|
|
7
|
+
* seams the chat vertical needs:
|
|
8
|
+
*
|
|
9
|
+
* - `createSandboxProduce` — the turn producer (`createChatTurnRoutes`'s
|
|
10
|
+
* `produce` seam): resolve the workspace box, stream the prompt, bridge
|
|
11
|
+
* raw sidecar events through `createSandboxChatProducer`.
|
|
12
|
+
* - `resolveUploadSink` — where >inline-cap uploads land (`box.fs`).
|
|
13
|
+
* - `resolveSidecarConnection` — where interaction answers go.
|
|
14
|
+
*
|
|
15
|
+
* No mock fallback: without SANDBOX_API_KEY / SANDBOX_GATEWAY_URL the turn
|
|
16
|
+
* fails loud with a clear error instead of pretending to answer.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { config } from '../agent.config'
|
|
20
|
+
import {
|
|
21
|
+
createSandboxChatProducer,
|
|
22
|
+
normalizeChatPromptForSandbox,
|
|
23
|
+
type ChatTurnProduceArgs,
|
|
24
|
+
type ChatTurnRouteProducer,
|
|
25
|
+
type SandboxUploadSink,
|
|
26
|
+
} from '@tangle-network/agent-app/chat-routes'
|
|
27
|
+
import type { SidecarInteractionsConnection } from '@tangle-network/agent-app/interactions'
|
|
28
|
+
import {
|
|
29
|
+
ensureWorkspaceSandbox,
|
|
30
|
+
streamSandboxPrompt,
|
|
31
|
+
type SandboxRuntimeConfig,
|
|
32
|
+
} from '@tangle-network/agent-app/sandbox'
|
|
33
|
+
import type { AppEnv } from './env'
|
|
34
|
+
|
|
35
|
+
/** Lowercased, non-alphanumerics collapsed: box names + projectId. */
|
|
36
|
+
export const appSlug = config.name
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
39
|
+
.replace(/^-+|-+$/g, '')
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The sandbox shell: how boxes are named, credentialed, and provisioned for
|
|
43
|
+
* this product. Extend here when you add per-workspace env, file mounts, or
|
|
44
|
+
* per-user key minting (see `resolveSandboxClientCredentials` in
|
|
45
|
+
* `@tangle-network/agent-app/sandbox` for the credential-policy helper).
|
|
46
|
+
*/
|
|
47
|
+
export function createSandboxShell(env: AppEnv): SandboxRuntimeConfig {
|
|
48
|
+
return {
|
|
49
|
+
credentials: () => {
|
|
50
|
+
const apiKey = env.SANDBOX_API_KEY?.trim()
|
|
51
|
+
const baseUrl = env.SANDBOX_GATEWAY_URL?.trim()
|
|
52
|
+
if (!apiKey || !baseUrl) return null
|
|
53
|
+
return { apiKey, baseUrl }
|
|
54
|
+
},
|
|
55
|
+
name: (workspaceId) => `${appSlug}-${workspaceId}`.slice(0, 63),
|
|
56
|
+
metadata: (harness) => ({ app: appSlug, harness }),
|
|
57
|
+
connectedIntegrationIds: async () => [],
|
|
58
|
+
env: async () => ({}),
|
|
59
|
+
files: async () => [],
|
|
60
|
+
secrets: async () => [],
|
|
61
|
+
profile: ({ systemPrompt, extraMcp }) => ({
|
|
62
|
+
name: appSlug,
|
|
63
|
+
prompt: { systemPrompt: systemPrompt ?? config.systemPrompt },
|
|
64
|
+
...(extraMcp && Object.keys(extraMcp).length > 0 ? { mcp: extraMcp } : {}),
|
|
65
|
+
}),
|
|
66
|
+
provider: {
|
|
67
|
+
...(env.TANGLE_API_KEY ? { apiKey: env.TANGLE_API_KEY } : {}),
|
|
68
|
+
...(env.TANGLE_ROUTER_URL ? { routerBaseUrl: env.TANGLE_ROUTER_URL } : {}),
|
|
69
|
+
...(env.MODEL_NAME ? { modelName: env.MODEL_NAME } : {}),
|
|
70
|
+
defaultModel: config.model.default,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The `produce` seam for `createChatTurnRoutes`: one call per turn. The
|
|
77
|
+
* chat thread id doubles as the agent session id, so follow-up turns land in
|
|
78
|
+
* the same sidecar session and keep its context.
|
|
79
|
+
*/
|
|
80
|
+
export function createSandboxProduce(env: AppEnv) {
|
|
81
|
+
const shell = createSandboxShell(env)
|
|
82
|
+
return async ({
|
|
83
|
+
body,
|
|
84
|
+
identity,
|
|
85
|
+
prompt,
|
|
86
|
+
executionId,
|
|
87
|
+
}: ChatTurnProduceArgs<void>): Promise<ChatTurnRouteProducer> => {
|
|
88
|
+
const box = await ensureWorkspaceSandbox(shell, {
|
|
89
|
+
workspaceId: identity.tenantId,
|
|
90
|
+
userId: identity.userId,
|
|
91
|
+
harness: config.harness,
|
|
92
|
+
})
|
|
93
|
+
const model = body.model ?? env.MODEL_NAME ?? config.model.default
|
|
94
|
+
return createSandboxChatProducer({
|
|
95
|
+
model,
|
|
96
|
+
// Reactive model failover, ON by default: when `model`'s upstream is
|
|
97
|
+
// dead (quota wall, 502, provider outage), the turn moves to the next
|
|
98
|
+
// model in `config.model.fallbacks` BEFORE any client-visible byte.
|
|
99
|
+
// Never silent: the persisted row and billing receipt name the model
|
|
100
|
+
// that actually served, and the transcript gets a visible notice.
|
|
101
|
+
// Opt out with `modelFailover: false` (or empty `fallbacks`).
|
|
102
|
+
fallbackModels: config.model.fallbacks,
|
|
103
|
+
openEvents: ({ model: attemptModel, attempt }) =>
|
|
104
|
+
streamSandboxPrompt(shell, box, normalizeChatPromptForSandbox(prompt), {
|
|
105
|
+
sessionId: identity.sessionId,
|
|
106
|
+
// A failover attempt is a NEW dispatch, not a reconnect to the dead
|
|
107
|
+
// one — it needs its own execution identity or the platform would
|
|
108
|
+
// resume the failed execution instead of starting a fresh run.
|
|
109
|
+
executionId: attempt === 1 ? executionId : `${executionId}-f${attempt}`,
|
|
110
|
+
model: attemptModel,
|
|
111
|
+
effort: body.effort ?? config.model.effort,
|
|
112
|
+
harness: config.harness,
|
|
113
|
+
systemPrompt: config.systemPrompt,
|
|
114
|
+
// Durable by default: the run keeps executing server-side if the operator
|
|
115
|
+
// refreshes, closes the tab, or the Worker restarts mid-turn. On reopen
|
|
116
|
+
// the client re-attaches via GET /api/chat/running → /api/chat/replay, so
|
|
117
|
+
// nothing is lost. Drop this only for a run that should stop when the tab
|
|
118
|
+
// closes (e.g. a throwaway preview).
|
|
119
|
+
detach: true,
|
|
120
|
+
interactions: config.interactions,
|
|
121
|
+
}),
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Where large uploads land: the workspace box's filesystem. Returns null when
|
|
127
|
+
* no sandbox is configured — the upload route then accepts inline files only
|
|
128
|
+
* and rejects oversized ones with an explicit 413. */
|
|
129
|
+
export async function resolveUploadSink(
|
|
130
|
+
env: AppEnv,
|
|
131
|
+
scope: { workspaceId: string; userId: string },
|
|
132
|
+
): Promise<SandboxUploadSink | null> {
|
|
133
|
+
if (!env.SANDBOX_API_KEY?.trim() || !env.SANDBOX_GATEWAY_URL?.trim()) return null
|
|
134
|
+
const shell = createSandboxShell(env)
|
|
135
|
+
const box = await ensureWorkspaceSandbox(shell, { ...scope, harness: config.harness })
|
|
136
|
+
return box.fs
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The sidecar connection interaction answers travel over. `sessionId` is the
|
|
140
|
+
* thread id — the same session the turn streams under. */
|
|
141
|
+
export async function resolveSidecarConnection(
|
|
142
|
+
env: AppEnv,
|
|
143
|
+
scope: { workspaceId: string; userId: string; threadId: string },
|
|
144
|
+
): Promise<SidecarInteractionsConnection | null> {
|
|
145
|
+
if (!env.SANDBOX_API_KEY?.trim() || !env.SANDBOX_GATEWAY_URL?.trim()) return null
|
|
146
|
+
const shell = createSandboxShell(env)
|
|
147
|
+
const box = await ensureWorkspaceSandbox(shell, {
|
|
148
|
+
workspaceId: scope.workspaceId,
|
|
149
|
+
userId: scope.userId,
|
|
150
|
+
harness: config.harness,
|
|
151
|
+
})
|
|
152
|
+
const connection = box.connection
|
|
153
|
+
if (!connection?.runtimeUrl) return null
|
|
154
|
+
return {
|
|
155
|
+
runtimeUrl: connection.runtimeUrl,
|
|
156
|
+
...(connection.authToken ? { authToken: connection.authToken } : {}),
|
|
157
|
+
sessionId: scope.threadId,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/worker.ts — the HTTP surface. Routing only; every handler is a factory
|
|
3
|
+
* product from `src/chat.ts`. Static assets (the dev chat page in `public/`)
|
|
4
|
+
* are served by the Workers assets pipeline before this fetch handler runs.
|
|
5
|
+
*
|
|
6
|
+
* Route map:
|
|
7
|
+
* ALL /api/auth/* better-auth (sign-up/sign-in/session)
|
|
8
|
+
* POST /api/threads create a thread
|
|
9
|
+
* GET /api/threads list threads
|
|
10
|
+
* GET /api/threads/:id/messages typed transcript (parts + usage)
|
|
11
|
+
* POST /api/chat run one turn (NDJSON stream)
|
|
12
|
+
* GET /api/chat/replay/:turnId replay a buffered turn (?fromSeq=)
|
|
13
|
+
* GET /api/chat/running live turn ids on a thread (?threadId=)
|
|
14
|
+
* POST /api/chat/upload multipart upload → prompt parts
|
|
15
|
+
* GET /api/chat/interactions outstanding agent asks (?threadId=)
|
|
16
|
+
* POST /api/chat/interactions answer an ask
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { buildChatApp } from './chat'
|
|
20
|
+
import type { AppEnv } from './env'
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
async fetch(request: Request, env: AppEnv, ctx: ExecutionContext): Promise<Response> {
|
|
24
|
+
// Per-request assembly is the standard Workers pattern: env only exists
|
|
25
|
+
// inside fetch, and the factories are cheap closures over it.
|
|
26
|
+
const app = buildChatApp(env)
|
|
27
|
+
const url = new URL(request.url)
|
|
28
|
+
const { pathname } = url
|
|
29
|
+
const method = request.method
|
|
30
|
+
|
|
31
|
+
if (pathname.startsWith('/api/auth/')) return app.auth.auth.handler(request)
|
|
32
|
+
|
|
33
|
+
if (pathname === '/api/chat' && method === 'POST') {
|
|
34
|
+
// Pass waitUntil so the turn keeps running (and buffering for replay)
|
|
35
|
+
// after a client disconnect.
|
|
36
|
+
return app.routes.turn(request, ctx)
|
|
37
|
+
}
|
|
38
|
+
const replay = pathname.match(/^\/api\/chat\/replay\/([^/]+)$/)
|
|
39
|
+
if (replay && method === 'GET') return app.routes.replay(request, { turnId: replay[1]! })
|
|
40
|
+
// Reconnect discovery: which turns are still live on a thread, so a page
|
|
41
|
+
// reloaded mid-turn re-attaches via /replay instead of losing the run.
|
|
42
|
+
if (pathname === '/api/chat/running' && method === 'GET') return app.routes.running(request)
|
|
43
|
+
if (pathname === '/api/chat/upload' && method === 'POST') return app.upload(request)
|
|
44
|
+
if (pathname === '/api/chat/interactions' && app.routes.interactions) {
|
|
45
|
+
if (method === 'GET') return app.routes.interactions.list(request)
|
|
46
|
+
if (method === 'POST') return app.routes.interactions.answer(request)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (pathname === '/api/threads' && method === 'POST') return app.routes.createThread(request)
|
|
50
|
+
if (pathname === '/api/threads' && method === 'GET') return app.routes.listThreads(request)
|
|
51
|
+
const transcript = pathname.match(/^\/api\/threads\/([^/]+)\/messages$/)
|
|
52
|
+
if (transcript && method === 'GET') {
|
|
53
|
+
return app.routes.threadMessages(request, { threadId: transcript[1]! })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return Response.json({ error: 'Not found' }, { status: 404 })
|
|
57
|
+
},
|
|
58
|
+
} satisfies ExportedHandler<AppEnv>
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The e2e gate this app ships with: the REAL assembly (`buildChatApp` — auth,
|
|
3
|
+
* store, turn routes, upload, replay) driven end to end against the REAL
|
|
4
|
+
* migration, with exactly one fake at the outermost seam — the sandbox event
|
|
5
|
+
* feed. `createSandboxChatProducer` (the real bridge) consumes canonical
|
|
6
|
+
* sidecar events a live box would emit, so everything below the fake is
|
|
7
|
+
* production code:
|
|
8
|
+
*
|
|
9
|
+
* sign-up (better-auth drizzle adapter over the migrated tables)
|
|
10
|
+
* → create thread → upload a file (inline `data:` part)
|
|
11
|
+
* → POST /api/chat with content + parts → consume the NDJSON stream
|
|
12
|
+
* → user + assistant rows persisted with typed parts + usage receipt
|
|
13
|
+
* → replay the buffered turn after the live stream is gone.
|
|
14
|
+
*
|
|
15
|
+
* If this file fails after an edit, the app has drifted from the framework
|
|
16
|
+
* contract (or the migration from the schema). Fix the drift, not the test.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import Database from 'better-sqlite3'
|
|
23
|
+
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
24
|
+
import { describe, expect, it } from 'vitest'
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
createSandboxChatProducer,
|
|
28
|
+
normalizeChatPromptForSandbox,
|
|
29
|
+
type ChatTurnRouteProducer,
|
|
30
|
+
} from '@tangle-network/agent-app/chat-routes'
|
|
31
|
+
import type { ChatDatabase } from '@tangle-network/agent-app/chat-store'
|
|
32
|
+
import {
|
|
33
|
+
createMemoryTurnEventStore,
|
|
34
|
+
TURN_EVENTS_MIGRATION_SQL,
|
|
35
|
+
} from '@tangle-network/agent-app/stream'
|
|
36
|
+
|
|
37
|
+
import { config } from '../agent.config'
|
|
38
|
+
import { buildChatApp, type ChatApp } from '../src/chat'
|
|
39
|
+
import type { AppEnv } from '../src/env'
|
|
40
|
+
|
|
41
|
+
const BASE = 'http://localhost:8787'
|
|
42
|
+
const MODEL = 'test/model-1'
|
|
43
|
+
|
|
44
|
+
// ── fixtures ────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const MIGRATION = join(dirname(fileURLToPath(import.meta.url)), '..', 'migrations', '0001_init.sql')
|
|
47
|
+
|
|
48
|
+
/** The real migration, executed against a real SQLite database. Every query
|
|
49
|
+
* the test makes afterwards runs over THESE tables — schema drift between
|
|
50
|
+
* `migrations/` and `src/db/schema.ts` fails here, not in production. */
|
|
51
|
+
function openMigratedDb(): ChatDatabase {
|
|
52
|
+
const sqlite = new Database(':memory:')
|
|
53
|
+
sqlite.pragma('foreign_keys = ON')
|
|
54
|
+
sqlite.exec(readFileSync(MIGRATION, 'utf8'))
|
|
55
|
+
// better-sqlite3's sync drizzle handle narrows the driver generic; the store
|
|
56
|
+
// treats sync and async drivers identically (builders are awaited).
|
|
57
|
+
return drizzle(sqlite) as unknown as ChatDatabase
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Raw sidecar events, exactly as `streamSandboxPrompt` would yield them from
|
|
61
|
+
* a live box: reasoning + text deltas, a tool round-trip, the usage receipt,
|
|
62
|
+
* and the final-text result. */
|
|
63
|
+
const RAW_TURN_EVENTS: Array<Record<string, unknown>> = [
|
|
64
|
+
{ type: 'message.part.updated', data: { part: { type: 'reasoning', id: 'r1', text: 'checking the records' }, delta: 'checking the records' } },
|
|
65
|
+
{ type: 'message.part.updated', data: { part: { type: 'text', id: 't1', text: 'Filed ' }, delta: 'Filed ' } },
|
|
66
|
+
{ type: 'message.part.updated', data: { part: { type: 'tool', id: 'call-1', tool: 'record_search', state: { status: 'running', input: { query: 'lease' } } } } },
|
|
67
|
+
{ type: 'message.part.updated', data: { part: { type: 'tool', id: 'call-1', tool: 'record_search', state: { status: 'completed', input: { query: 'lease' }, output: { hits: 2 } } } } },
|
|
68
|
+
{ type: 'message.part.updated', data: { part: { type: 'text', id: 't1', text: 'Filed the summary.' }, delta: 'the summary.' } },
|
|
69
|
+
{ type: 'message.part.updated', data: { part: { type: 'step-finish', reason: 'stop', tokens: { input: 40, output: 20, reasoning: 5, cache: { read: 10, write: 2 } }, cost: 0.0123 } } },
|
|
70
|
+
{ type: 'result', data: { finalText: 'Filed the summary.' } },
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
async function* feed(events: Array<Record<string, unknown>>): AsyncGenerator<unknown> {
|
|
74
|
+
for (const event of events) yield event
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const env: AppEnv = {
|
|
78
|
+
// The DB binding is unused when the test injects its own drizzle handle.
|
|
79
|
+
DB: null as unknown as AppEnv['DB'],
|
|
80
|
+
BETTER_AUTH_URL: BASE,
|
|
81
|
+
BETTER_AUTH_SECRET: 'e2e-test-secret-not-for-production',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Harness {
|
|
85
|
+
app: ChatApp
|
|
86
|
+
cookie: string
|
|
87
|
+
settle(): Promise<unknown>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function createHarness(
|
|
91
|
+
produce: () => ChatTurnRouteProducer = () =>
|
|
92
|
+
createSandboxChatProducer({ events: feed(RAW_TURN_EVENTS), model: MODEL }),
|
|
93
|
+
): Promise<Harness> {
|
|
94
|
+
const app = buildChatApp(env, {
|
|
95
|
+
db: openMigratedDb(),
|
|
96
|
+
turnStore: createMemoryTurnEventStore(),
|
|
97
|
+
produce,
|
|
98
|
+
uploadSink: async () => null, // inline uploads only; no box in tests
|
|
99
|
+
})
|
|
100
|
+
// Real sign-up through better-auth; the returned cookie is what a browser
|
|
101
|
+
// would replay on every API call.
|
|
102
|
+
const res = await app.auth.auth.handler(
|
|
103
|
+
new Request(`${BASE}/api/auth/sign-up/email`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'content-type': 'application/json', origin: BASE },
|
|
106
|
+
body: JSON.stringify({ email: 'e2e@example.com', password: 'correct-horse-battery', name: 'e2e' }),
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
expect(res.status).toBe(200)
|
|
110
|
+
const cookie = res.headers
|
|
111
|
+
.getSetCookie()
|
|
112
|
+
.map((c) => c.split(';')[0]!)
|
|
113
|
+
.join('; ')
|
|
114
|
+
|
|
115
|
+
const pending: Promise<unknown>[] = []
|
|
116
|
+
const originalTurn = app.routes.turn
|
|
117
|
+
app.routes.turn = (request) =>
|
|
118
|
+
originalTurn(request, { waitUntil: (p) => void pending.push(p) })
|
|
119
|
+
return { app, cookie, settle: () => Promise.all(pending) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function post(path: string, cookie: string, body: unknown): Request {
|
|
123
|
+
return new Request(`${BASE}${path}`, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'content-type': 'application/json', cookie },
|
|
126
|
+
body: JSON.stringify(body),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function readLines(res: Response): Promise<Array<Record<string, unknown>>> {
|
|
131
|
+
const text = await new Response(res.body).text()
|
|
132
|
+
return text
|
|
133
|
+
.split('\n')
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Flatten the NDJSON line vocabulary (`{kind:'event', event}` wrappers) the
|
|
139
|
+
* same way web-react's `dispatchChatStreamLine` does. */
|
|
140
|
+
function eventsOf(lines: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
|
141
|
+
return lines.map((l) => (l.kind === 'event' ? (l.event as Record<string, unknown>) : l))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── the gate ────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe('e2e: fake sandbox producer → streamed turn → persisted transcript', () => {
|
|
147
|
+
it('normalizes path-backed generic files for the sandbox prompt API', () => {
|
|
148
|
+
expect(
|
|
149
|
+
normalizeChatPromptForSandbox([
|
|
150
|
+
{ type: 'text', text: 'Read this' },
|
|
151
|
+
{
|
|
152
|
+
type: 'file',
|
|
153
|
+
filename: 'lease terms.pdf',
|
|
154
|
+
mediaType: 'application/pdf',
|
|
155
|
+
path: '/workspace/uploads/lease terms.pdf',
|
|
156
|
+
},
|
|
157
|
+
]),
|
|
158
|
+
).toEqual([
|
|
159
|
+
{ type: 'text', text: 'Read this' },
|
|
160
|
+
{
|
|
161
|
+
type: 'file',
|
|
162
|
+
filename: 'lease terms.pdf',
|
|
163
|
+
mediaType: 'application/pdf',
|
|
164
|
+
url: 'file:///workspace/uploads/lease%20terms.pdf',
|
|
165
|
+
},
|
|
166
|
+
])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('runs the full multimodal vertical: upload, turn, stream, rows, replay', async () => {
|
|
170
|
+
const { app, cookie, settle } = await createHarness()
|
|
171
|
+
|
|
172
|
+
// Thread
|
|
173
|
+
const threadRes = await app.routes.createThread(
|
|
174
|
+
post('/api/threads', cookie, { firstMessage: 'File my lease summary' }),
|
|
175
|
+
)
|
|
176
|
+
expect(threadRes.status).toBe(200)
|
|
177
|
+
const { thread } = (await threadRes.json()) as { thread: { id: string } }
|
|
178
|
+
|
|
179
|
+
// Upload → inline `data:` part (≤700 KiB stays in the turn body)
|
|
180
|
+
const form = new FormData()
|
|
181
|
+
form.append('files', new File(['%PDF-1.4 fake'], 'lease.pdf', { type: 'application/pdf' }))
|
|
182
|
+
const uploadRes = await app.upload(
|
|
183
|
+
new Request(`${BASE}/api/chat/upload`, { method: 'POST', headers: { cookie }, body: form }),
|
|
184
|
+
)
|
|
185
|
+
expect(uploadRes.status).toBe(200)
|
|
186
|
+
const { files } = (await uploadRes.json()) as {
|
|
187
|
+
files: Array<{ inline: boolean; part: Record<string, unknown> }>
|
|
188
|
+
}
|
|
189
|
+
expect(files[0]!.inline).toBe(true)
|
|
190
|
+
expect(String(files[0]!.part.url)).toMatch(/^data:application\/pdf;base64,/)
|
|
191
|
+
|
|
192
|
+
// Turn: content + the uploaded part, streamed as NDJSON
|
|
193
|
+
const turnRes = await app.routes.turn(
|
|
194
|
+
post('/api/chat', cookie, {
|
|
195
|
+
threadId: thread.id,
|
|
196
|
+
content: 'File my lease summary',
|
|
197
|
+
parts: [files[0]!.part],
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
expect(turnRes.status).toBe(200)
|
|
201
|
+
const lines = await readLines(turnRes)
|
|
202
|
+
const events = eventsOf(lines)
|
|
203
|
+
|
|
204
|
+
// The stream announced the replay handle first, then the client vocabulary.
|
|
205
|
+
const turnId = String(lines[0]!.turnId ?? '')
|
|
206
|
+
expect(lines[0]).toMatchObject({ type: 'turn' })
|
|
207
|
+
expect(turnId).toBeTruthy()
|
|
208
|
+
expect(
|
|
209
|
+
events.filter((e) => e.type === 'text').map((e) => String(e.text)).join(''),
|
|
210
|
+
).toBe('Filed the summary.')
|
|
211
|
+
expect(events.some((e) => e.type === 'reasoning')).toBe(true)
|
|
212
|
+
const toolCall = events.find((e) => e.type === 'tool_call') as
|
|
213
|
+
| { call?: { toolName?: string } }
|
|
214
|
+
| undefined
|
|
215
|
+
expect(toolCall?.call?.toolName).toBe('record_search')
|
|
216
|
+
expect(events).toContainEqual(
|
|
217
|
+
expect.objectContaining({ type: 'usage', usage: { promptTokens: 40, completionTokens: 20 } }),
|
|
218
|
+
)
|
|
219
|
+
await settle()
|
|
220
|
+
|
|
221
|
+
// A later page load reads back both rows with typed parts + the receipt.
|
|
222
|
+
const transcriptRes = await app.routes.threadMessages(
|
|
223
|
+
new Request(`${BASE}/api/threads/${thread.id}/messages`, { headers: { cookie } }),
|
|
224
|
+
{ threadId: thread.id },
|
|
225
|
+
)
|
|
226
|
+
const { messages } = (await transcriptRes.json()) as {
|
|
227
|
+
messages: Array<Record<string, unknown> & { parts?: Array<Record<string, unknown>> }>
|
|
228
|
+
}
|
|
229
|
+
expect(messages.map((m) => m.role)).toEqual(['user', 'assistant'])
|
|
230
|
+
|
|
231
|
+
const user = messages[0]!
|
|
232
|
+
expect(user.parts?.some((p) => p.type === 'file' && p.filename === 'lease.pdf')).toBe(true)
|
|
233
|
+
|
|
234
|
+
const assistant = messages[1]!
|
|
235
|
+
expect(assistant.content).toBe('Filed the summary.')
|
|
236
|
+
expect(assistant.model).toBe(MODEL)
|
|
237
|
+
expect(assistant.inputTokens).toBe(40)
|
|
238
|
+
expect(assistant.outputTokens).toBe(20)
|
|
239
|
+
expect(assistant.reasoningTokens).toBe(5)
|
|
240
|
+
expect(assistant.costUsd).toBeCloseTo(0.0123)
|
|
241
|
+
expect(assistant.parts?.some((p) => p.type === 'reasoning')).toBe(true)
|
|
242
|
+
const tool = assistant.parts?.find((p) => p.type === 'tool')
|
|
243
|
+
expect(tool).toMatchObject({ tool: 'record_search', state: { status: 'completed' } })
|
|
244
|
+
expect(assistant.parts?.some((p) => p.type === 'step-finish')).toBe(true)
|
|
245
|
+
|
|
246
|
+
// The buffered turn replays in full after the live stream is long gone.
|
|
247
|
+
const replayRes = await app.routes.replay(
|
|
248
|
+
new Request(`${BASE}/api/chat/replay/${turnId}?fromSeq=0`, { headers: { cookie } }),
|
|
249
|
+
{ turnId },
|
|
250
|
+
)
|
|
251
|
+
const replayEvents = eventsOf(await readLines(replayRes))
|
|
252
|
+
expect(
|
|
253
|
+
replayEvents.filter((e) => e.type === 'text').map((e) => String(e.text)).join(''),
|
|
254
|
+
).toBe('Filed the summary.')
|
|
255
|
+
expect(replayEvents.at(-1)).toMatchObject({ type: 'turn_status', status: 'complete' })
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('rejects an unauthenticated turn with the guard 401, before any row is written', async () => {
|
|
259
|
+
const { app, cookie } = await createHarness()
|
|
260
|
+
const threadRes = await app.routes.createThread(post('/api/threads', cookie, { firstMessage: 'seed' }))
|
|
261
|
+
const { thread } = (await threadRes.json()) as { thread: { id: string } }
|
|
262
|
+
|
|
263
|
+
const res = await app.routes.turn(post('/api/chat', '', { threadId: thread.id, content: 'hi' }))
|
|
264
|
+
expect(res.status).toBe(401)
|
|
265
|
+
expect(await app.store.listMessages(thread.id)).toEqual([])
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('the migration carries the turn-buffer DDL the /stream store expects, verbatim', () => {
|
|
269
|
+
const normalize = (sql: string) => sql.replace(/\s+/g, ' ').trim()
|
|
270
|
+
expect(normalize(readFileSync(MIGRATION, 'utf8'))).toContain(normalize(TURN_EVENTS_MIGRATION_SQL))
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('agent.config carries a real system prompt (prompts/system.md is wired)', () => {
|
|
274
|
+
expect(config.systemPrompt.length).toBeGreaterThan(0)
|
|
275
|
+
expect(config.name.length).toBeGreaterThan(0)
|
|
276
|
+
})
|
|
277
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { defineConfig } from 'vitest/config'
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [
|
|
6
|
+
{
|
|
7
|
+
// Mirror wrangler's `[[rules]]` Text modules: `.md` imports (the system
|
|
8
|
+
// prompt) resolve to plain strings under vitest too.
|
|
9
|
+
name: 'text-markdown',
|
|
10
|
+
enforce: 'pre',
|
|
11
|
+
load(id) {
|
|
12
|
+
if (id.endsWith('.md')) {
|
|
13
|
+
return `export default ${JSON.stringify(readFileSync(id, 'utf8'))}`
|
|
14
|
+
}
|
|
15
|
+
return null
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
test: {
|
|
20
|
+
include: ['tests/**/*.test.ts'],
|
|
21
|
+
},
|
|
22
|
+
})
|