experimental-a2 0.16.0 → 0.17.0
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/CHANGELOG.md +360 -0
- package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
- package/dist/actor-DBbe5xeE.d.ts.map +1 -0
- package/dist/actor-client.d.ts +2 -5
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +13 -4
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -3
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +20 -10
- package/dist/actor-react.js.map +1 -1
- package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
- package/dist/actor-shared-t7w8xIKP.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +42 -23
- package/dist/actor.js.map +1 -1
- package/dist/{ai-CrLYwNEx.js → ai-UrWfKeuv.js} +966 -541
- package/dist/ai-UrWfKeuv.js.map +1 -0
- package/dist/ai-client.d.ts +27 -0
- package/dist/ai-client.d.ts.map +1 -0
- package/dist/ai-client.js +29 -0
- package/dist/ai-client.js.map +1 -0
- package/dist/ai-react.d.ts +96 -0
- package/dist/ai-react.d.ts.map +1 -0
- package/dist/ai-react.js +142 -0
- package/dist/ai-react.js.map +1 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.browser.js.map +1 -1
- package/dist/ai-server.d.ts +80 -36
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +466 -265
- package/dist/ai-server.js.map +1 -1
- package/dist/{ai-HJ9fHfYI.d.ts → ai-vpGtcaCB.d.ts} +115 -101
- package/dist/ai-vpGtcaCB.d.ts.map +1 -0
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
- package/dist/client-COaiaDub.js.map +1 -0
- package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
- package/dist/client-qRnRUzBS.d.ts.map +1 -0
- package/dist/client.d.ts +4 -2
- package/dist/client.js +1 -1
- package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
- package/dist/contract-BYKASlCV.js.map +1 -0
- package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
- package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-BrLso5x_.d.ts} +7 -3
- package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
- package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
- package/dist/internal-CbVvEwad.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/react-BbJ-g6Uu.d.ts +86 -0
- package/dist/react-BbJ-g6Uu.d.ts.map +1 -0
- package/dist/react.d.ts +2 -122
- package/dist/react.js +107 -100
- package/dist/react.js.map +1 -1
- package/dist/reducer-BAXc8JyF.js +87 -0
- package/dist/reducer-BAXc8JyF.js.map +1 -0
- package/dist/{reducer-otzHjuJj.d.ts → reducer-BYu6DT7h.d.ts} +45 -29
- package/dist/reducer-BYu6DT7h.d.ts.map +1 -0
- package/dist/reducer-runtime-DQwz_J59.js +28 -0
- package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +3 -3
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +33 -10
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -9
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +25 -44
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
- package/dist/server-CGM8WzRG.d.ts.map +1 -0
- package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
- package/dist/server-Rj64fNvX.js.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
- package/dist/session-clear-DGI4jTQ5.js.map +1 -0
- package/dist/session-clear-Dshpuhff.d.ts +12 -0
- package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
- package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
- package/dist/store-Gpkq7WBo.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +182 -69
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
- package/dist/store-polling-4_y8lETm.js.map +1 -0
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +399 -108
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
- package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +293 -11
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis-notify-tqWNnMRu.js +2374 -0
- package/dist/store-redis-notify-tqWNnMRu.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +11 -11
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +166 -32
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-Cx7AAPGq.js +23 -0
- package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
- package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-cZH-cibj.d.ts} +2 -2
- package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
- package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
- package/dist/wire-DjfHOvdW.js.map +1 -0
- package/docs/01-quickstart.mdx +2 -2
- package/docs/actors/01-introduction.mdx +4 -0
- package/docs/actors/04-routes.mdx +14 -1
- package/docs/concepts/01-contracts.mdx +95 -0
- package/docs/concepts/04-state.mdx +3 -3
- package/docs/guides/01-timers.mdx +22 -14
- package/docs/guides/02-cancellation.mdx +1 -1
- package/docs/guides/03-react.mdx +268 -328
- package/docs/guides/04-local-first.mdx +32 -73
- package/docs/guides/05-production.mdx +40 -27
- package/docs/guides/06-ai-agents.mdx +680 -580
- package/docs/guides/09-presence.mdx +3 -7
- package/docs/guides/10-transports.mdx +5 -5
- package/docs/index.mdx +10 -18
- package/docs/reference/01-api.mdx +662 -195
- package/docs/reference/02-errors.mdx +2 -1
- package/examples/playground/DEPLOY.md +52 -6
- package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
- package/examples/playground/app/a0/[appId]/page.tsx +48 -0
- package/examples/playground/app/a0/a0.css +818 -0
- package/examples/playground/app/a0/app-list.tsx +211 -0
- package/examples/playground/app/a0/app-view.tsx +55 -0
- package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
- package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
- package/examples/playground/app/a0/apps.test.ts +116 -0
- package/examples/playground/app/a0/apps.ts +87 -0
- package/examples/playground/app/a0/chat.tsx +364 -0
- package/examples/playground/app/a0/columns.tsx +109 -0
- package/examples/playground/app/a0/context-menu.tsx +116 -0
- package/examples/playground/app/a0/cookies.ts +46 -0
- package/examples/playground/app/a0/crash-on-request.ts +72 -0
- package/examples/playground/app/a0/crashes/route.ts +3 -0
- package/examples/playground/app/a0/crashes.test.ts +33 -0
- package/examples/playground/app/a0/crashes.ts +28 -0
- package/examples/playground/app/a0/devtools.tsx +164 -0
- package/examples/playground/app/a0/events/route.ts +6 -0
- package/examples/playground/app/a0/flags.ts +30 -0
- package/examples/playground/app/a0/layout.tsx +38 -0
- package/examples/playground/app/a0/list/route.ts +10 -0
- package/examples/playground/app/a0/model.test.ts +56 -0
- package/examples/playground/app/a0/model.ts +58 -0
- package/examples/playground/app/a0/page.tsx +14 -0
- package/examples/playground/app/a0/preview-pane.tsx +145 -0
- package/examples/playground/app/a0/preview.test.ts +53 -0
- package/examples/playground/app/a0/preview.ts +26 -0
- package/examples/playground/app/a0/sandbox-log.ts +69 -0
- package/examples/playground/app/a0/sandbox.ts +191 -0
- package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
- package/examples/playground/app/a0/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/server.ts +158 -0
- package/examples/playground/app/a0/session.ts +47 -0
- package/examples/playground/app/a0/shell.tsx +150 -0
- package/examples/playground/app/a0/short/events/route.ts +9 -0
- package/examples/playground/app/a0/splitter.tsx +120 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
- package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
- package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
- package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
- package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
- package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
- package/examples/playground/app/agent/events/route.ts +5 -2
- package/examples/playground/app/agent/ingress.test.ts +98 -0
- package/examples/playground/app/agent/model-input.test.tsx +58 -0
- package/examples/playground/app/agent/model-input.tsx +115 -0
- package/examples/playground/app/agent/model.test.ts +73 -0
- package/examples/playground/app/agent/model.ts +58 -26
- package/examples/playground/app/agent/new-agent-session.tsx +37 -19
- package/examples/playground/app/agent/page.tsx +4 -4
- package/examples/playground/app/agent/server.ts +15 -14
- package/examples/playground/app/agent/session.ts +5 -6
- package/examples/playground/app/agent/telemetry.test.ts +168 -0
- package/examples/playground/app/agent/telemetry.ts +30 -0
- package/examples/playground/app/agent/trace.test.ts +23 -0
- package/examples/playground/app/agent/trace.ts +18 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +11 -11
- package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
- package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
- package/examples/playground/app/canvas/model.ts +53 -53
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
- package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
- package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
- package/examples/playground/app/chat/[chatId]/session.ts +2 -19
- package/examples/playground/app/chat/agent/route.ts +9 -0
- package/examples/playground/app/chat/model.test.ts +46 -119
- package/examples/playground/app/chat/model.ts +82 -128
- package/examples/playground/app/chat/scheduler/route.ts +2 -2
- package/examples/playground/app/chat/server.test.ts +108 -0
- package/examples/playground/app/chat/server.ts +69 -73
- package/examples/playground/app/components/activity-feed.tsx +30 -1
- package/examples/playground/app/components/activity-history.test.tsx +35 -0
- package/examples/playground/app/components/demo-icons.tsx +89 -0
- package/examples/playground/app/components/demos.tsx +33 -0
- package/examples/playground/app/components/fast-link.test.tsx +58 -0
- package/examples/playground/app/components/fast-link.tsx +55 -0
- package/examples/playground/app/components/site-header.tsx +96 -0
- package/examples/playground/app/counter/counter-client.tsx +3 -3
- package/examples/playground/app/counter/model.test.ts +3 -3
- package/examples/playground/app/counter/model.ts +11 -21
- package/examples/playground/app/counter/page.tsx +2 -15
- package/examples/playground/app/counter/session.ts +2 -2
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
- package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
- package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
- package/examples/playground/app/documents/[documentId]/session.ts +2 -2
- package/examples/playground/app/documents/model.ts +27 -27
- package/examples/playground/app/globals.css +420 -207
- package/examples/playground/app/initial-state.test.tsx +54 -0
- package/examples/playground/app/layout.tsx +7 -23
- package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
- package/examples/playground/app/orders/[orderId]/session.ts +2 -2
- package/examples/playground/app/orders/model.ts +49 -49
- package/examples/playground/app/page.tsx +13 -102
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
- package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
- package/examples/playground/app/recovery/model.ts +30 -30
- package/examples/playground/instrumentation.ts +5 -0
- package/examples/playground/package.json +10 -2
- package/examples/playground/scripts/a0-template.ts +77 -0
- package/examples/playground/vercel.json +18 -0
- package/package.json +3 -1
- package/src/actor-client.ts +16 -4
- package/src/actor-react.ts +25 -11
- package/src/actor-shared.ts +21 -6
- package/src/actor.ts +76 -31
- package/src/ai-agent-actions.ts +219 -0
- package/src/ai-agent.ts +2343 -0
- package/src/ai-app-state.ts +91 -0
- package/src/ai-client-state.ts +39 -2
- package/src/ai-client.ts +62 -0
- package/src/ai-context-schema.ts +25 -5
- package/src/ai-context.ts +90 -40
- package/src/ai-control-server.ts +94 -55
- package/src/ai-control.ts +2 -0
- package/src/ai-coordinator.ts +23 -22
- package/src/ai-message-base.ts +209 -0
- package/src/ai-message-projection.ts +382 -58
- package/src/ai-progress-batches.ts +26 -0
- package/src/ai-projector.ts +95 -14
- package/src/ai-react.ts +146 -0
- package/src/ai-server.browser.ts +2 -2
- package/src/ai-server.ts +20 -1951
- package/src/ai-stored-state.ts +48 -163
- package/src/ai.ts +343 -276
- package/src/bootstrap-stream.ts +72 -0
- package/src/bounded-cache.ts +55 -0
- package/src/client-render-session.ts +49 -0
- package/src/client.ts +688 -151
- package/src/contract.ts +92 -42
- package/src/deterministic-id.ts +37 -0
- package/src/equal-json.ts +32 -0
- package/src/errors.ts +13 -1
- package/src/index.ts +5 -1
- package/src/initial-snapshot.ts +39 -0
- package/src/internal.ts +45 -0
- package/src/postgres-notification-scope.ts +14 -3
- package/src/react.ts +171 -206
- package/src/redis-http-reply.ts +256 -0
- package/src/redis-http-stream.ts +73 -0
- package/src/redis-http-subscriptions.ts +13 -5
- package/src/reducer-runtime.ts +52 -0
- package/src/reducer.ts +92 -10
- package/src/scheduler-qstash.ts +49 -20
- package/src/scheduler-task.ts +34 -2
- package/src/scheduler-vercel.ts +32 -84
- package/src/server-fetch.ts +139 -35
- package/src/server.ts +957 -237
- package/src/session-clear.ts +23 -0
- package/src/session-socket.ts +154 -49
- package/src/sse.ts +71 -4
- package/src/store-memory.ts +220 -78
- package/src/store-polling.ts +18 -4
- package/src/store-postgres.ts +461 -114
- package/src/store-redis-core.ts +1260 -434
- package/src/store-redis-http.ts +18 -2
- package/src/store-redis-notify.ts +260 -85
- package/src/store-redis.ts +19 -9
- package/src/store-sqlite.ts +259 -45
- package/src/store.ts +46 -1
- package/src/stream-buffer.ts +43 -0
- package/src/telemetry.ts +13 -1
- package/src/wire.ts +241 -13
- package/dist/actor-ohPC-81x.d.ts.map +0 -1
- package/dist/actor-shared-B5tJfzt-.js.map +0 -1
- package/dist/ai-CrLYwNEx.js.map +0 -1
- package/dist/ai-HJ9fHfYI.d.ts.map +0 -1
- package/dist/client-BYzHjkwU.js.map +0 -1
- package/dist/client-CzyacQpJ.d.ts.map +0 -1
- package/dist/contract-CKRg_E4q.js.map +0 -1
- package/dist/errors-DCk6ch5n.js.map +0 -1
- package/dist/errors-DvhSXnxk.d.ts.map +0 -1
- package/dist/internal-Dq2qYxou.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/reducer-DEMjEY_O.js +0 -29
- package/dist/reducer-DEMjEY_O.js.map +0 -1
- package/dist/reducer-otzHjuJj.d.ts.map +0 -1
- package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
- package/dist/server-BD5ckxZb.js.map +0 -1
- package/dist/server-DbD7IJVK.d.ts.map +0 -1
- package/dist/store-DyZM6fS5.d.ts.map +0 -1
- package/dist/store-polling-CmxUbV93.js.map +0 -1
- package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
- package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
- package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
- package/dist/validate-XKT4FSNn.js +0 -30
- package/dist/validate-XKT4FSNn.js.map +0 -1
- package/dist/wire-BO5wWCb1.js.map +0 -1
- package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
- package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
- package/examples/playground/app/agent/compaction-settings.ts +0 -49
- package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
- package/examples/playground/app/agent/compaction-timeline.ts +0 -208
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ContractEvent } from 'experimental-a2'
|
|
2
|
+
import { expect, it } from 'vitest'
|
|
3
|
+
import { crashLog, crashLogId, isCrashLogId } from './crashes'
|
|
4
|
+
|
|
5
|
+
type CrashEvents = typeof crashLog.events
|
|
6
|
+
|
|
7
|
+
const pending = (...types: (keyof CrashEvents & string)[]): boolean =>
|
|
8
|
+
types.reduce(
|
|
9
|
+
(state, type, index) =>
|
|
10
|
+
crashLog.reducer.fold(state, {
|
|
11
|
+
id: `event-${index}`,
|
|
12
|
+
type,
|
|
13
|
+
payload: {},
|
|
14
|
+
index: index + 1,
|
|
15
|
+
sessionId: 'log-1',
|
|
16
|
+
createdAt: new Date(index * 1000),
|
|
17
|
+
} as ContractEvent<CrashEvents>),
|
|
18
|
+
crashLog.reducer.initialState,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
it('holds a crash from its request until it happens', () => {
|
|
22
|
+
expect(pending()).toBe(false)
|
|
23
|
+
expect(pending('crash.requested')).toBe(true)
|
|
24
|
+
expect(pending('crash.requested', 'crashed')).toBe(false)
|
|
25
|
+
expect(pending('crash.requested', 'crashed', 'crash.requested')).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("names one crash log per app, and only an app's", () => {
|
|
29
|
+
const appId = '6f1c1c3e-2f7a-4f0e-9d4e-5b8a9f1d2c3b'
|
|
30
|
+
expect(isCrashLogId(crashLogId(appId))).toBe(true)
|
|
31
|
+
expect(isCrashLogId(appId)).toBe(false)
|
|
32
|
+
expect(isCrashLogId('not-a-uuid:crashes')).toBe(false)
|
|
33
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { contract } from 'experimental-a2'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { isAppId } from './apps'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The dev tools' crashes for one app, in a log of their own so the app's
|
|
7
|
+
* log stays its conversation: a request, then the crash that carried it
|
|
8
|
+
* out. The state is whether one is still pending.
|
|
9
|
+
*/
|
|
10
|
+
export const crashLog = contract({
|
|
11
|
+
name: 'a0-crashes',
|
|
12
|
+
events: {
|
|
13
|
+
'crash.requested': z.object({}),
|
|
14
|
+
crashed: z.object({}),
|
|
15
|
+
},
|
|
16
|
+
reducer: {
|
|
17
|
+
name: 'a0-crashes.v1',
|
|
18
|
+
initialState: false as boolean,
|
|
19
|
+
fold: (_pending, event) => event.type === 'crash.requested',
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const suffix = ':crashes'
|
|
24
|
+
|
|
25
|
+
export const crashLogId = (appId: string): string => `${appId}${suffix}`
|
|
26
|
+
|
|
27
|
+
export const isCrashLogId = (id: string): boolean =>
|
|
28
|
+
id.endsWith(suffix) && isAppId(id.slice(0, -suffix.length))
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { memo, useEffect, useState } from 'react'
|
|
4
|
+
import { useSearchParams } from 'next/navigation'
|
|
5
|
+
import { X, Zap } from 'lucide-react'
|
|
6
|
+
import { crashLogId } from './crashes'
|
|
7
|
+
import { setFlag, toggleDevtools } from './flags'
|
|
8
|
+
import { logPage } from './model'
|
|
9
|
+
import { useCrashLog, useSession } from './session'
|
|
10
|
+
|
|
11
|
+
type LogEvent = ReturnType<typeof useSession>['events'][number]
|
|
12
|
+
|
|
13
|
+
const gap = (ms: number): string =>
|
|
14
|
+
ms < 1000 ? `+${Math.round(ms)}ms` : `+${(ms / 1000).toFixed(2)}s`
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The app's raw A2 log, and ways to break the function running its agent:
|
|
18
|
+
* a crash, or a 5-second time limit, so the recovery is there to watch.
|
|
19
|
+
*/
|
|
20
|
+
export function DevTools({
|
|
21
|
+
appId,
|
|
22
|
+
deployed,
|
|
23
|
+
crashesAllowed,
|
|
24
|
+
}: {
|
|
25
|
+
appId: string
|
|
26
|
+
/** Function limits and crashes only mean something on Vercel. */
|
|
27
|
+
deployed: boolean
|
|
28
|
+
crashesAllowed: boolean
|
|
29
|
+
}): ReactNode {
|
|
30
|
+
const { state, events, index, history, hydrated, loadHistory } = useSession()
|
|
31
|
+
const short = useSearchParams().get('limit') === '5s'
|
|
32
|
+
const crashes = useCrashLog({ sessionId: crashLogId(appId) })
|
|
33
|
+
const [error, setError] = useState<string | null>(null)
|
|
34
|
+
|
|
35
|
+
const load = (options: { before?: number }): void => {
|
|
36
|
+
loadHistory({ ...options, limit: logPage }).catch((cause: unknown) =>
|
|
37
|
+
setError(cause instanceof Error ? cause.message : String(cause)),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
// Opening reads the latest page. The client skips what it already holds,
|
|
41
|
+
// such as the server's seed, so this reads nothing when there's one.
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (hydrated && !history.complete) load({ before: index + 1 })
|
|
44
|
+
}, [hydrated])
|
|
45
|
+
|
|
46
|
+
const crash = (): void => {
|
|
47
|
+
setError(null)
|
|
48
|
+
crashes
|
|
49
|
+
.push({ type: 'crash.requested', payload: {} })
|
|
50
|
+
.catch((cause: unknown) =>
|
|
51
|
+
setError(cause instanceof Error ? cause.message : String(cause)),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const localOnly = deployed ? undefined : "next dev doesn't limit functions"
|
|
56
|
+
return (
|
|
57
|
+
<section aria-label="Dev tools" className="a0-devtools">
|
|
58
|
+
<header className="a0-devtools-bar">
|
|
59
|
+
<span className="a0-devtools-title">
|
|
60
|
+
Log <span>{events.length} events</span>
|
|
61
|
+
</span>
|
|
62
|
+
{error && (
|
|
63
|
+
<span role="alert" className="a0-error">
|
|
64
|
+
{error}
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
<label className="a0-devtools-switch" title={localOnly}>
|
|
68
|
+
<input
|
|
69
|
+
type="checkbox"
|
|
70
|
+
checked={short}
|
|
71
|
+
disabled={!deployed}
|
|
72
|
+
onChange={(event) =>
|
|
73
|
+
setFlag({
|
|
74
|
+
name: 'limit',
|
|
75
|
+
value: event.currentTarget.checked ? '5s' : null,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
/>
|
|
79
|
+
5-second functions
|
|
80
|
+
</label>
|
|
81
|
+
{/* A crash only means something while the agent works. */}
|
|
82
|
+
{state.active && (
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
className="a0-ghost a0-devtools-crash"
|
|
86
|
+
disabled={!crashesAllowed || crashes.state}
|
|
87
|
+
title={
|
|
88
|
+
crashesAllowed
|
|
89
|
+
? 'Ends the process running the agent, right now'
|
|
90
|
+
: 'Crashes need a preview deployment, or A0_CRASHES=on'
|
|
91
|
+
}
|
|
92
|
+
onClick={crash}
|
|
93
|
+
>
|
|
94
|
+
<Zap size={13} aria-hidden />
|
|
95
|
+
{crashes.state ? 'Crashing…' : 'Crash'}
|
|
96
|
+
</button>
|
|
97
|
+
)}
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
className="a0-icon-button"
|
|
101
|
+
aria-label="Close dev tools"
|
|
102
|
+
title="Close (⌘J)"
|
|
103
|
+
onClick={toggleDevtools}
|
|
104
|
+
>
|
|
105
|
+
<X size={14} />
|
|
106
|
+
</button>
|
|
107
|
+
</header>
|
|
108
|
+
{/* Reversed, so the view stays pinned to the newest event. */}
|
|
109
|
+
<div className="a0-log-scroll">
|
|
110
|
+
<ol className="a0-log">
|
|
111
|
+
{!history.complete && (
|
|
112
|
+
<li>
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
className="a0-ghost a0-log-older"
|
|
116
|
+
disabled={history.loading}
|
|
117
|
+
onClick={() => load({})}
|
|
118
|
+
>
|
|
119
|
+
{history.loading ? 'Loading…' : 'Load older'}
|
|
120
|
+
</button>
|
|
121
|
+
</li>
|
|
122
|
+
)}
|
|
123
|
+
{events.map((event, position) => (
|
|
124
|
+
<LogRow
|
|
125
|
+
key={event.id}
|
|
126
|
+
event={event}
|
|
127
|
+
previous={events[position - 1]}
|
|
128
|
+
/>
|
|
129
|
+
))}
|
|
130
|
+
</ol>
|
|
131
|
+
</div>
|
|
132
|
+
</section>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Rows keep their events' identity, so a new event renders only its own row.
|
|
137
|
+
const LogRow = memo(function LogRow({
|
|
138
|
+
event,
|
|
139
|
+
previous,
|
|
140
|
+
}: {
|
|
141
|
+
event: LogEvent
|
|
142
|
+
previous: LogEvent | undefined
|
|
143
|
+
}): ReactNode {
|
|
144
|
+
const payload = JSON.stringify(event.payload)
|
|
145
|
+
return (
|
|
146
|
+
<li>
|
|
147
|
+
<details className="a0-log-row">
|
|
148
|
+
<summary title={event.createdAt.toISOString()}>
|
|
149
|
+
<span className="a0-log-index">{event.index}</span>
|
|
150
|
+
<span className="a0-log-gap">
|
|
151
|
+
{previous
|
|
152
|
+
? gap(event.createdAt.getTime() - previous.createdAt.getTime())
|
|
153
|
+
: ''}
|
|
154
|
+
</span>
|
|
155
|
+
<span className="a0-log-type">{event.type}</span>
|
|
156
|
+
<code>
|
|
157
|
+
{payload.length > 240 ? `${payload.slice(0, 240)}…` : payload}
|
|
158
|
+
</code>
|
|
159
|
+
</summary>
|
|
160
|
+
<pre>{JSON.stringify(event.payload, null, 2)}</pre>
|
|
161
|
+
</details>
|
|
162
|
+
</li>
|
|
163
|
+
)
|
|
164
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev tools' state lives in the query: `?devtools` while they're open,
|
|
3
|
+
* and `limit=5s` for the 5-second switch. Setting a flag is browser-only.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A query string for a URL, with bare flags as `?devtools`, not `?devtools=`. */
|
|
7
|
+
export function queryString(params: URLSearchParams): string {
|
|
8
|
+
const query = params.toString().replace(/=(?=&|$)/g, '')
|
|
9
|
+
return query && `?${query}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Sets or clears one flag, in place, without a navigation. */
|
|
13
|
+
export function setFlag({
|
|
14
|
+
name,
|
|
15
|
+
value,
|
|
16
|
+
}: {
|
|
17
|
+
name: 'devtools' | 'limit'
|
|
18
|
+
value: string | null
|
|
19
|
+
}): void {
|
|
20
|
+
const params = new URLSearchParams(location.search)
|
|
21
|
+
if (value === null) params.delete(name)
|
|
22
|
+
else params.set(name, value)
|
|
23
|
+
history.replaceState(null, '', `${location.pathname}${queryString(params)}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const toggleDevtools = (): void =>
|
|
27
|
+
setFlag({
|
|
28
|
+
name: 'devtools',
|
|
29
|
+
value: new URLSearchParams(location.search).has('devtools') ? null : '',
|
|
30
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { cookies } from 'next/headers'
|
|
4
|
+
import { appList, isAppId } from './apps'
|
|
5
|
+
import { listCookie, parseWidths, widthsCookie } from './cookies'
|
|
6
|
+
import { listServer } from './server'
|
|
7
|
+
import { A0Shell } from './shell'
|
|
8
|
+
import './a0.css'
|
|
9
|
+
|
|
10
|
+
export const metadata: Metadata = { title: 'A0 · a2 example' }
|
|
11
|
+
|
|
12
|
+
/** The apps list and the columns stay put while the app beside them changes. */
|
|
13
|
+
export default async function A0Layout({
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
children: ReactNode
|
|
17
|
+
}): Promise<ReactNode> {
|
|
18
|
+
const jar = await cookies()
|
|
19
|
+
const saved = jar.get(listCookie)?.value
|
|
20
|
+
const listId = saved && isAppId(saved) ? saved : null
|
|
21
|
+
const list = listId
|
|
22
|
+
? { id: listId, initial: await listServer.session(listId).state() }
|
|
23
|
+
: {
|
|
24
|
+
// Becomes this browser's list with its first write.
|
|
25
|
+
id: crypto.randomUUID(),
|
|
26
|
+
initial: { state: appList.reducer.initialState, index: 0 },
|
|
27
|
+
}
|
|
28
|
+
return (
|
|
29
|
+
<A0Shell
|
|
30
|
+
list={list}
|
|
31
|
+
widths={parseWidths(jar.get(widthsCookie)?.value)}
|
|
32
|
+
renderedAt={Date.now()}
|
|
33
|
+
newAppId={crypto.randomUUID()}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</A0Shell>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isAppId } from '../apps'
|
|
2
|
+
import { listServer } from '../server'
|
|
3
|
+
|
|
4
|
+
function list(request: Request): Promise<Response> {
|
|
5
|
+
return listServer.fetch(request, {
|
|
6
|
+
authorize: (operation) => isAppId(operation.sessionId),
|
|
7
|
+
})
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { list as GET, list as POST }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ContractEvent } from 'experimental-a2'
|
|
2
|
+
import { expect, it } from 'vitest'
|
|
3
|
+
import { appAgent, defaultModelChoice } from './model'
|
|
4
|
+
|
|
5
|
+
const chosen = (payload: unknown): ContractEvent<typeof appAgent.events> =>
|
|
6
|
+
({
|
|
7
|
+
id: 'choice',
|
|
8
|
+
index: 1,
|
|
9
|
+
sessionId: 'app',
|
|
10
|
+
createdAt: new Date(0),
|
|
11
|
+
type: 'model.chosen',
|
|
12
|
+
payload,
|
|
13
|
+
}) as ContractEvent<typeof appAgent.events>
|
|
14
|
+
|
|
15
|
+
const validate = (payload: unknown) =>
|
|
16
|
+
appAgent.events['model.chosen']['~standard'].validate(payload)
|
|
17
|
+
|
|
18
|
+
it('defaults to Opus 5.5 in fast mode', () => {
|
|
19
|
+
expect(appAgent.reducer.initialState.modelChoice).toEqual({
|
|
20
|
+
model: 'anthropic/claude-opus-5.5',
|
|
21
|
+
reasoning: 'provider-default',
|
|
22
|
+
fast: true,
|
|
23
|
+
})
|
|
24
|
+
expect(defaultModelChoice).toEqual(appAgent.reducer.initialState.modelChoice)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("keeps the latest choice in the app's state", () => {
|
|
28
|
+
const choice = {
|
|
29
|
+
model: 'openai/gpt-5.6-luna',
|
|
30
|
+
reasoning: 'high',
|
|
31
|
+
fast: false,
|
|
32
|
+
}
|
|
33
|
+
const state = appAgent.reducer.fold(
|
|
34
|
+
appAgent.reducer.initialState,
|
|
35
|
+
chosen(choice),
|
|
36
|
+
)
|
|
37
|
+
expect(state.modelChoice).toEqual(choice)
|
|
38
|
+
expect(state.messages).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('fills in defaults and rejects choices a browser could write badly', () => {
|
|
42
|
+
expect(validate({ model: ' anthropic/claude-sonnet-5 ' })).toEqual({
|
|
43
|
+
value: {
|
|
44
|
+
model: 'anthropic/claude-sonnet-5',
|
|
45
|
+
reasoning: 'provider-default',
|
|
46
|
+
fast: false,
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
for (const payload of [
|
|
50
|
+
{},
|
|
51
|
+
{ model: ' ' },
|
|
52
|
+
{ model: 'openai/gpt-5.6-luna', reasoning: 'maximum' },
|
|
53
|
+
{ model: 'openai/gpt-5.6-luna', fast: 'yes' },
|
|
54
|
+
])
|
|
55
|
+
expect(validate(payload)).toHaveProperty('issues')
|
|
56
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { UIMessage } from 'ai'
|
|
2
|
+
import { agentContract } from 'experimental-a2/ai'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
/** Log events the dev tools read at a time. */
|
|
6
|
+
export const logPage = 200
|
|
7
|
+
|
|
8
|
+
/** The port the agent serves its Next.js app on, and the preview shows. */
|
|
9
|
+
export const appPort = 3000
|
|
10
|
+
|
|
11
|
+
export const reasoningSchema = z.enum([
|
|
12
|
+
'provider-default',
|
|
13
|
+
'none',
|
|
14
|
+
'minimal',
|
|
15
|
+
'low',
|
|
16
|
+
'medium',
|
|
17
|
+
'high',
|
|
18
|
+
'xhigh',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
/** What the app's next request runs with. The browser picks it and the log keeps it. */
|
|
22
|
+
const modelChoiceSchema = z.object({
|
|
23
|
+
model: z.string().trim().min(1),
|
|
24
|
+
reasoning: reasoningSchema.default('provider-default'),
|
|
25
|
+
fast: z.boolean().default(false),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export type ModelChoice = z.output<typeof modelChoiceSchema>
|
|
29
|
+
|
|
30
|
+
export const defaultModelChoice: ModelChoice = {
|
|
31
|
+
model: 'anthropic/claude-opus-5.5',
|
|
32
|
+
reasoning: 'provider-default',
|
|
33
|
+
fast: true,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type AppMessage = UIMessage
|
|
37
|
+
|
|
38
|
+
/** The coding agent's contract, shared by the server and the browser. */
|
|
39
|
+
export const appAgent = agentContract({
|
|
40
|
+
name: 'a0',
|
|
41
|
+
events: { 'model.chosen': modelChoiceSchema },
|
|
42
|
+
reducer: {
|
|
43
|
+
name: 'a0.model-choice.v1',
|
|
44
|
+
initialState: { modelChoice: defaultModelChoice },
|
|
45
|
+
fold: (state, event) =>
|
|
46
|
+
event.type === 'model.chosen' ? { modelChoice: event.payload } : state,
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export const toolLabels: Record<string, string> = {
|
|
51
|
+
bash: 'Running a command',
|
|
52
|
+
show_preview: 'Showing the app',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** While the model is still writing a call's input. */
|
|
56
|
+
export const writingLabels: Record<string, string> = {
|
|
57
|
+
bash: 'Writing a command',
|
|
58
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { redirect } from 'next/navigation'
|
|
2
|
+
import { queryString } from './flags'
|
|
3
|
+
|
|
4
|
+
/** A new app is just an id with no log yet; the query keeps the dev tools. */
|
|
5
|
+
export default async function NewApp({
|
|
6
|
+
searchParams,
|
|
7
|
+
}: {
|
|
8
|
+
searchParams: Promise<Record<string, string | string[] | undefined>>
|
|
9
|
+
}): Promise<never> {
|
|
10
|
+
const params = new URLSearchParams()
|
|
11
|
+
for (const [name, value] of Object.entries(await searchParams))
|
|
12
|
+
for (const one of [value ?? []].flat()) params.append(name, one)
|
|
13
|
+
redirect(`/a0/${crypto.randomUUID()}${queryString(params)}`)
|
|
14
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { AppWindow, LoaderCircle } from 'lucide-react'
|
|
5
|
+
import { appPort } from './model'
|
|
6
|
+
import { shownPreview } from './preview'
|
|
7
|
+
import { useSession } from './session'
|
|
8
|
+
|
|
9
|
+
// Checks come quickly until the app answers, then only as often as keeping
|
|
10
|
+
// its sandbox up needs, since each one runs a command in it.
|
|
11
|
+
const CHECK_MS = 3000
|
|
12
|
+
const KEEP_UP_MS = 2 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
/** Has the server make sure the app is up; says whether it answers. */
|
|
15
|
+
export async function checkApp({
|
|
16
|
+
appId,
|
|
17
|
+
newApp = false,
|
|
18
|
+
signal,
|
|
19
|
+
}: {
|
|
20
|
+
appId: string
|
|
21
|
+
/** An app nobody has prompted yet, whose sandbox goes if nobody does. */
|
|
22
|
+
newApp?: boolean
|
|
23
|
+
signal?: AbortSignal
|
|
24
|
+
}): Promise<boolean> {
|
|
25
|
+
const response = await fetch(`/a0/apps/${appId}/preview`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
body: JSON.stringify({ newApp }),
|
|
29
|
+
...(signal ? { signal } : {}),
|
|
30
|
+
})
|
|
31
|
+
return ((await response.json()) as { up?: boolean }).up === true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Liveness = {
|
|
35
|
+
up: boolean | null
|
|
36
|
+
/** How many times it came back after being down. */
|
|
37
|
+
returns: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether the app answers, asked while the page is visible, which keeps its
|
|
42
|
+
* sandbox up. One ask at a time.
|
|
43
|
+
*/
|
|
44
|
+
function useAppUp({
|
|
45
|
+
appId,
|
|
46
|
+
watching,
|
|
47
|
+
}: {
|
|
48
|
+
appId: string
|
|
49
|
+
watching: boolean
|
|
50
|
+
}): Liveness {
|
|
51
|
+
const [liveness, setLiveness] = useState<Liveness>({
|
|
52
|
+
up: null,
|
|
53
|
+
returns: 0,
|
|
54
|
+
})
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!watching) return
|
|
57
|
+
const controller = new AbortController()
|
|
58
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
59
|
+
const check = async (): Promise<void> => {
|
|
60
|
+
clearTimeout(timer)
|
|
61
|
+
let answering = false
|
|
62
|
+
try {
|
|
63
|
+
answering = await checkApp({ appId, signal: controller.signal })
|
|
64
|
+
} catch {}
|
|
65
|
+
if (controller.signal.aborted) return
|
|
66
|
+
setLiveness(({ up, returns }) => ({
|
|
67
|
+
up: answering,
|
|
68
|
+
returns: answering && up === false ? returns + 1 : returns,
|
|
69
|
+
}))
|
|
70
|
+
if (document.visibilityState === 'visible')
|
|
71
|
+
timer = setTimeout(check, answering ? KEEP_UP_MS : CHECK_MS)
|
|
72
|
+
}
|
|
73
|
+
const resume = (): void => {
|
|
74
|
+
if (document.visibilityState === 'visible') void check()
|
|
75
|
+
}
|
|
76
|
+
void check()
|
|
77
|
+
document.addEventListener('visibilitychange', resume)
|
|
78
|
+
return () => {
|
|
79
|
+
controller.abort()
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
document.removeEventListener('visibilitychange', resume)
|
|
82
|
+
}
|
|
83
|
+
}, [appId, watching])
|
|
84
|
+
return liveness
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The app the agent last showed the user, as its sandbox serves it. */
|
|
88
|
+
export function PreviewPane({ appId }: { appId: string }): ReactNode {
|
|
89
|
+
const { state } = useSession()
|
|
90
|
+
const shown = useMemo(() => shownPreview(state.messages), [state.messages])
|
|
91
|
+
// An app with a conversation stays up while it's on screen.
|
|
92
|
+
const { up, returns } = useAppUp({
|
|
93
|
+
appId,
|
|
94
|
+
watching: state.messages.length > 0,
|
|
95
|
+
})
|
|
96
|
+
const live = shown && up ? shown.url : null
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<section aria-label="Preview" className="a0-preview">
|
|
100
|
+
{live ? (
|
|
101
|
+
<iframe
|
|
102
|
+
// Showing the app again, or its server coming back, loads it again.
|
|
103
|
+
key={`${live}:${shown?.callId}:${returns}`}
|
|
104
|
+
title="App"
|
|
105
|
+
src={live}
|
|
106
|
+
allow="clipboard-write"
|
|
107
|
+
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads"
|
|
108
|
+
/>
|
|
109
|
+
) : (
|
|
110
|
+
<Placeholder shown={shown !== null} checking={up === null} />
|
|
111
|
+
)}
|
|
112
|
+
</section>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function Placeholder({
|
|
117
|
+
shown,
|
|
118
|
+
checking,
|
|
119
|
+
}: {
|
|
120
|
+
shown: boolean
|
|
121
|
+
checking: boolean
|
|
122
|
+
}): ReactNode {
|
|
123
|
+
return (
|
|
124
|
+
<div className="a0-placeholder">
|
|
125
|
+
{shown && checking ? (
|
|
126
|
+
<p>
|
|
127
|
+
<LoaderCircle size={14} className="a0-spin" aria-hidden />
|
|
128
|
+
Connecting to port {appPort}
|
|
129
|
+
</p>
|
|
130
|
+
) : (
|
|
131
|
+
<div>
|
|
132
|
+
<AppWindow size={24} strokeWidth={1.5} aria-hidden />
|
|
133
|
+
<p className="a0-placeholder-title">
|
|
134
|
+
{shown ? `Nothing is running on port ${appPort}` : 'No app yet'}
|
|
135
|
+
</p>
|
|
136
|
+
<p>
|
|
137
|
+
{shown
|
|
138
|
+
? 'Ask the agent to start it again.'
|
|
139
|
+
: 'It shows up here when the agent is done.'}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { expect, it } from 'vitest'
|
|
2
|
+
import type { AppMessage } from './model'
|
|
3
|
+
import { shownPreview } from './preview'
|
|
4
|
+
|
|
5
|
+
type Part = AppMessage['parts'][number]
|
|
6
|
+
|
|
7
|
+
const message = (role: AppMessage['role'], ...parts: Part[]): AppMessage => ({
|
|
8
|
+
id: crypto.randomUUID(),
|
|
9
|
+
role,
|
|
10
|
+
parts,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const shown = (callId: string, output: unknown): Part =>
|
|
14
|
+
({
|
|
15
|
+
type: 'tool-show_preview',
|
|
16
|
+
toolCallId: callId,
|
|
17
|
+
state: 'output-available',
|
|
18
|
+
input: { path: '/' },
|
|
19
|
+
output,
|
|
20
|
+
}) as Part
|
|
21
|
+
|
|
22
|
+
const url = 'https://sb-3000.vercel.run/'
|
|
23
|
+
|
|
24
|
+
it('shows nothing until the agent shows the app', () => {
|
|
25
|
+
expect(shownPreview([])).toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('follows the latest preview the agent showed', () => {
|
|
29
|
+
const messages = [
|
|
30
|
+
message('assistant', shown('p1', { url })),
|
|
31
|
+
message('assistant', shown('p2', { url: `${url}about` })),
|
|
32
|
+
message(
|
|
33
|
+
'assistant',
|
|
34
|
+
shown('p3', { error: 'Nothing is serving port 3000.' }),
|
|
35
|
+
),
|
|
36
|
+
]
|
|
37
|
+
expect(shownPreview(messages)).toEqual({ url: `${url}about`, callId: 'p2' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('ignores previews a user message carries', () => {
|
|
41
|
+
expect(shownPreview([message('user', shown('p1', { url }))])).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('frames only https sandbox hosts, and survives malformed output', () => {
|
|
45
|
+
for (const output of [
|
|
46
|
+
null,
|
|
47
|
+
url,
|
|
48
|
+
{ url: 'https://example.com/' },
|
|
49
|
+
{ url: 'http://sb-3000.vercel.run/' },
|
|
50
|
+
{ url: 'javascript:alert(1)' },
|
|
51
|
+
])
|
|
52
|
+
expect(shownPreview([message('assistant', shown('p1', output))])).toBeNull()
|
|
53
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { AppMessage } from './model'
|
|
3
|
+
|
|
4
|
+
// Only the agent's own show_preview results, which point at a sandbox's
|
|
5
|
+
// public host. Anything else in the log never reaches the iframe.
|
|
6
|
+
const previewSchema = z.object({
|
|
7
|
+
url: z.url({ protocol: /^https$/, hostname: /\.vercel\.run$/ }),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
/** The app the agent last showed the user, so a reload shows it again. */
|
|
11
|
+
export function shownPreview(
|
|
12
|
+
messages: readonly AppMessage[],
|
|
13
|
+
): { url: string; callId: string } | null {
|
|
14
|
+
let shown: { url: string; callId: string } | null = null
|
|
15
|
+
for (const message of messages) {
|
|
16
|
+
if (message.role !== 'assistant') continue
|
|
17
|
+
for (const part of message.parts) {
|
|
18
|
+
if (part.type !== 'tool-show_preview') continue
|
|
19
|
+
if (!('state' in part) || part.state !== 'output-available') continue
|
|
20
|
+
const parsed = previewSchema.safeParse(part.output)
|
|
21
|
+
if (parsed.success)
|
|
22
|
+
shown = { url: parsed.data.url, callId: part.toolCallId }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return shown
|
|
26
|
+
}
|