experimental-a2 0.20.0 → 0.22.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 +55 -0
- package/README.md +1 -1
- package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
- package/dist/actor-CqibLgjQ.d.ts.map +1 -0
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +2 -2
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +5 -5
- package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
- package/dist/actor-shared-DFET5q16.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +54 -43
- package/dist/actor.js.map +1 -1
- package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
- package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
- package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
- package/dist/ai-EEfKyc-z.js.map +1 -0
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +4 -2
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +214 -67
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
- package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
- package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
- package/dist/ai-harness-dQeJaR1t.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +3 -3
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +14 -17
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +86 -73
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
- package/dist/client-BQS4i_V9.js.map +1 -0
- package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
- package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
- package/dist/contract-CWukt01d.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
- package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
- package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
- package/dist/internal-CvsrprnU.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/otel.js.map +1 -1
- package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
- package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
- package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
- package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
- package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
- package/dist/runtime-consumer-CIeL33wj.js +12 -0
- package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +3 -3
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
- package/dist/server-DtFrWxi3.js.map +1 -0
- package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
- package/dist/server-qgez0Xny.d.ts.map +1 -0
- package/dist/server.browser.js +1 -1
- package/dist/server.browser.js.map +1 -1
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
- package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
- package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
- package/dist/session-clear-wHd5GCpZ.js.map +1 -0
- package/dist/store-D0MhBvg6.d.ts +532 -0
- package/dist/store-D0MhBvg6.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-codec-DwrWlgQx.js +65 -0
- package/dist/store-codec-DwrWlgQx.js.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +406 -225
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
- package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +1598 -587
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
- package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +4 -4
- package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
- package/dist/store-redis-notify-CsJw_hfD.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 +4 -5
- 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 +527 -299
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-DrTyiIuo.js +57 -0
- package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
- package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
- package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
- package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
- package/dist/wire-CV-Z7wz-.js.map +1 -0
- package/docs/01-quickstart.mdx +21 -15
- package/docs/02-examples.mdx +1 -1
- package/docs/actors/01-introduction.mdx +13 -2
- package/docs/actors/02-concurrency.mdx +1 -1
- package/docs/agents/01-introduction.mdx +1 -1
- package/docs/agents/02-tools.mdx +12 -11
- package/docs/agents/03-conversations.mdx +36 -25
- package/docs/agents/04-harnesses.mdx +14 -7
- package/docs/client/03-presence.mdx +18 -14
- package/docs/contract/01-events.mdx +2 -2
- package/docs/contract/02-state.mdx +18 -14
- package/docs/contract/03-sessions.mdx +32 -25
- package/docs/index.mdx +29 -24
- package/docs/reference/01-core.mdx +7 -6
- package/docs/reference/02-server.mdx +250 -191
- package/docs/reference/04-ai.mdx +33 -23
- package/docs/reference/05-telemetry.mdx +27 -13
- package/docs/reference/06-errors.mdx +25 -19
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +8 -8
- package/docs/schedulers/02-vercel-queues.mdx +2 -2
- package/docs/schedulers/03-qstash.mdx +4 -4
- package/docs/server/01-consumers.mdx +418 -0
- package/docs/server/02-timers.mdx +31 -21
- package/docs/server/03-cancellation.mdx +52 -44
- package/docs/server/04-routes.mdx +4 -1
- package/docs/server/05-sql-projections.mdx +391 -0
- package/docs/server/06-guarantees.mdx +61 -51
- package/docs/stores/01-overview.mdx +3 -3
- package/docs/stores/02-redis.mdx +24 -10
- package/docs/stores/03-redis-http.mdx +8 -6
- package/docs/stores/04-postgres.mdx +65 -14
- package/docs/stores/05-sqlite.mdx +5 -3
- package/docs/upgrades/01-overview.mdx +31 -0
- package/docs/upgrades/78-0-22.mdx +115 -0
- package/docs/upgrades/79-0-21.mdx +165 -0
- package/docs/upgrades/80-0-20.mdx +43 -0
- package/docs/upgrades/81-0-19.mdx +33 -0
- package/docs/upgrades/82-0-18.mdx +33 -0
- package/docs/upgrades/83-0-17.mdx +69 -0
- package/docs/upgrades/meta.ts +3 -0
- package/examples/playground/DEPLOY.md +39 -7
- package/examples/playground/app/a0/chat.test.tsx +2 -2
- package/examples/playground/app/a0/sandbox-log.ts +17 -15
- package/examples/playground/app/a0/sandbox.ts +58 -13
- package/examples/playground/app/a0/server.ts +4 -4
- package/examples/playground/app/a2slack/a2slack.css +1067 -0
- package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
- package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
- package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
- package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
- package/examples/playground/app/a2slack/consumers/search.ts +37 -0
- package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
- package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
- package/examples/playground/app/a2slack/db/a2.ts +209 -0
- package/examples/playground/app/a2slack/db/index.ts +73 -0
- package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
- package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
- package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
- package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
- package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
- package/examples/playground/app/a2slack/db/queries.ts +263 -0
- package/examples/playground/app/a2slack/db/schema.ts +145 -0
- package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
- package/examples/playground/app/a2slack/drills/harness.ts +244 -0
- package/examples/playground/app/a2slack/events/route.ts +36 -0
- package/examples/playground/app/a2slack/identity.ts +44 -0
- package/examples/playground/app/a2slack/inbox/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/page.tsx +101 -0
- package/examples/playground/app/a2slack/lag.ts +73 -0
- package/examples/playground/app/a2slack/layout.tsx +43 -0
- package/examples/playground/app/a2slack/messages/route.ts +17 -0
- package/examples/playground/app/a2slack/model.test.ts +208 -0
- package/examples/playground/app/a2slack/model.ts +369 -0
- package/examples/playground/app/a2slack/models.test.ts +72 -0
- package/examples/playground/app/a2slack/page.tsx +10 -0
- package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
- package/examples/playground/app/a2slack/search/route.ts +40 -0
- package/examples/playground/app/a2slack/search-index.ts +108 -0
- package/examples/playground/app/a2slack/server.test.ts +315 -0
- package/examples/playground/app/a2slack/server.ts +55 -0
- package/examples/playground/app/a2slack/sessions.ts +50 -0
- package/examples/playground/app/a2slack/thread/route.ts +8 -0
- package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
- package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
- package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
- package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
- package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
- package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
- package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
- package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
- package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
- package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
- package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
- package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
- package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
- package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
- package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
- package/examples/playground/app/agent/server.ts +3 -3
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
- package/examples/playground/app/chat/server.ts +45 -35
- package/examples/playground/app/components/demo-icons.tsx +7 -0
- package/examples/playground/app/components/demos.tsx +7 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
- package/examples/playground/app/orders/page.tsx +1 -1
- package/examples/playground/app/orders/server.ts +49 -30
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
- package/examples/playground/app/recovery/page.tsx +4 -4
- package/examples/playground/app/recovery/server.ts +23 -32
- package/examples/playground/drizzle.config.ts +14 -0
- package/examples/playground/lib/store.ts +1 -1
- package/examples/playground/next.config.ts +6 -1
- package/examples/playground/package.json +12 -1
- package/examples/playground/scripts/migrate.mjs +31 -0
- package/examples/playground/vercel.json +9 -0
- package/examples/playground/vitest.drills.config.ts +32 -0
- package/package.json +5 -2
- package/sql/postgres/drizzle-schema.ts +209 -0
- package/sql/postgres/schema.sql +227 -67
- package/src/actor-shared.ts +10 -2
- package/src/actor.ts +101 -71
- package/src/ai-agent.ts +162 -111
- package/src/ai-control-server.ts +8 -8
- package/src/ai-fx.ts +324 -82
- package/src/ai-harness.ts +17 -2
- package/src/ai-pi.ts +2 -2
- package/src/ai-server.ts +2 -2
- package/src/ai.ts +7 -7
- package/src/client.ts +19 -2
- package/src/contract.ts +1 -1
- package/src/deterministic-id.ts +27 -4
- package/src/errors.ts +8 -5
- package/src/idempotent-replay.ts +0 -1
- package/src/index.ts +1 -1
- package/src/internal.ts +57 -16
- package/src/otel.ts +1 -1
- package/src/postgres-schema.ts +98 -57
- package/src/presence-recovery.ts +18 -11
- package/src/presence-tombstones.ts +52 -0
- package/src/push-envelope.ts +20 -0
- package/src/runtime-consumer.ts +10 -0
- package/src/scheduler-task.ts +2 -2
- package/src/scheduler-vercel.ts +1 -1
- package/src/server.browser.ts +1 -1
- package/src/server.ts +819 -218
- package/src/session-socket.ts +1 -1
- package/src/store-codec.ts +66 -0
- package/src/store-memory.ts +604 -286
- package/src/store-postgres.ts +2006 -857
- package/src/store-presence-polling.ts +59 -34
- package/src/store-redis-core.ts +1961 -1333
- package/src/store-redis-notify.ts +76 -26
- package/src/store-redis.ts +2 -3
- package/src/store-sqlite.ts +915 -516
- package/src/store.ts +241 -102
- package/src/telemetry.ts +2 -1
- package/src/wire.ts +1 -0
- package/dist/actor-C7b-1arn.d.ts.map +0 -1
- package/dist/actor-shared-t7w8xIKP.js.map +0 -1
- package/dist/ai-harness-Clbg5ei7.js.map +0 -1
- package/dist/ai-mMiWul1T.js.map +0 -1
- package/dist/client-BhHLoNKF.js.map +0 -1
- package/dist/contract-BYKASlCV.js.map +0 -1
- package/dist/idempotent-replay-JdgMQRRm.js +0 -21
- package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
- package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
- package/dist/internal-CscSY7K_.js.map +0 -1
- package/dist/server-DYAATFuT.d.ts.map +0 -1
- package/dist/server-qyb5aJJG.js.map +0 -1
- package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
- package/dist/session-clear-DGI4jTQ5.js.map +0 -1
- package/dist/store-CyLUy86v.d.ts +0 -404
- package/dist/store-CyLUy86v.d.ts.map +0 -1
- package/dist/store-codec-DTG0Ftek.js +0 -10
- package/dist/store-codec-DTG0Ftek.js.map +0 -1
- package/dist/store-presence-polling-B1ixeqIS.js +0 -94
- package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
- package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
- package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
- package/dist/stream-buffer-Cx7AAPGq.js +0 -23
- package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
- package/dist/wire-BKxH1vbP.js.map +0 -1
- package/docs/server/01-handlers.mdx +0 -227
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { ContractEvent } from 'experimental-a2'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
channelsReducer,
|
|
5
|
+
liveInbox,
|
|
6
|
+
liveMessages,
|
|
7
|
+
liveThread,
|
|
8
|
+
toMessage,
|
|
9
|
+
workspace,
|
|
10
|
+
type Author,
|
|
11
|
+
type InboxPage,
|
|
12
|
+
type MessagePage,
|
|
13
|
+
type WorkspaceEvent,
|
|
14
|
+
} from './model'
|
|
15
|
+
|
|
16
|
+
type Events = typeof workspace.events
|
|
17
|
+
|
|
18
|
+
const ada: Author = { id: 'user-1', name: 'ada' }
|
|
19
|
+
const bob: Author = { id: 'user-2', name: 'bob' }
|
|
20
|
+
|
|
21
|
+
const event = <K extends keyof Events & string>(
|
|
22
|
+
index: number,
|
|
23
|
+
type: K,
|
|
24
|
+
payload: ContractEvent<Events, K>['payload'],
|
|
25
|
+
): ContractEvent<Events, K> =>
|
|
26
|
+
({
|
|
27
|
+
id: `event-${index}`,
|
|
28
|
+
type,
|
|
29
|
+
payload,
|
|
30
|
+
index,
|
|
31
|
+
sessionId: 'workspace',
|
|
32
|
+
createdAt: new Date(index * 1_000),
|
|
33
|
+
}) as ContractEvent<Events, K>
|
|
34
|
+
|
|
35
|
+
const posted = (
|
|
36
|
+
index: number,
|
|
37
|
+
text: string,
|
|
38
|
+
extra: { channel?: string; threadId?: string; author?: Author } = {},
|
|
39
|
+
) =>
|
|
40
|
+
event(index, 'message.posted', {
|
|
41
|
+
channel: extra.channel ?? 'general',
|
|
42
|
+
author: extra.author ?? ada,
|
|
43
|
+
text,
|
|
44
|
+
...(extra.threadId === undefined ? {} : { threadId: extra.threadId }),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** The page a read at `through` would return: the log up to it, folded. */
|
|
48
|
+
const pageOf = (events: WorkspaceEvent[], through: number): MessagePage => ({
|
|
49
|
+
messages: liveMessages(
|
|
50
|
+
{ messages: [], through: 0, more: false },
|
|
51
|
+
events.filter((e) => e.index <= through),
|
|
52
|
+
{ channel: 'general', append: true },
|
|
53
|
+
),
|
|
54
|
+
through,
|
|
55
|
+
more: false,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('channelsReducer', () => {
|
|
59
|
+
it('lists channels once, in creation order', () => {
|
|
60
|
+
const state = [
|
|
61
|
+
event(1, 'channel.created', { id: 'general', by: ada }),
|
|
62
|
+
event(2, 'channel.created', { id: 'random', by: bob }),
|
|
63
|
+
event(3, 'channel.created', { id: 'general', by: bob }),
|
|
64
|
+
].reduce(
|
|
65
|
+
(folded, e) => channelsReducer.fold(folded, e),
|
|
66
|
+
channelsReducer.initialState,
|
|
67
|
+
)
|
|
68
|
+
expect(state.channels).toEqual(['general', 'random'])
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('liveMessages', () => {
|
|
73
|
+
const log = [
|
|
74
|
+
posted(1, 'hello'),
|
|
75
|
+
posted(2, 'elsewhere', { channel: 'random' }),
|
|
76
|
+
posted(3, 'a reply', { threadId: 'event-1', author: bob }),
|
|
77
|
+
event(4, 'reaction.added', {
|
|
78
|
+
channel: 'general',
|
|
79
|
+
messageId: 'event-1',
|
|
80
|
+
emoji: '👍',
|
|
81
|
+
by: bob,
|
|
82
|
+
}),
|
|
83
|
+
posted(5, 'second'),
|
|
84
|
+
event(6, 'message.edited', {
|
|
85
|
+
channel: 'general',
|
|
86
|
+
messageId: 'event-5',
|
|
87
|
+
text: 'second, edited',
|
|
88
|
+
by: ada,
|
|
89
|
+
}),
|
|
90
|
+
event(7, 'message.labeled', {
|
|
91
|
+
channel: 'general',
|
|
92
|
+
messageId: 'event-1',
|
|
93
|
+
label: 'question',
|
|
94
|
+
}),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
it('applies only the events after the page, to its own channel', () => {
|
|
98
|
+
for (const through of [0, 1, 3, 5, 7]) {
|
|
99
|
+
const live = liveMessages(pageOf(log, through), log, {
|
|
100
|
+
channel: 'general',
|
|
101
|
+
append: true,
|
|
102
|
+
})
|
|
103
|
+
// Wherever the page was read, the result is the same.
|
|
104
|
+
expect(live).toEqual(pageOf(log, 7).messages)
|
|
105
|
+
}
|
|
106
|
+
expect(pageOf(log, 7).messages).toMatchObject([
|
|
107
|
+
{
|
|
108
|
+
text: 'hello',
|
|
109
|
+
replyCount: 1,
|
|
110
|
+
label: 'question',
|
|
111
|
+
reactions: [{ emoji: '👍', by: [bob] }],
|
|
112
|
+
},
|
|
113
|
+
{ text: 'second, edited', editedAt: new Date(6_000).toISOString() },
|
|
114
|
+
])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('leaves new messages off an older page, but keeps its messages live', () => {
|
|
118
|
+
const older: MessagePage = {
|
|
119
|
+
messages: [toMessage(posted(1, 'hello'))],
|
|
120
|
+
through: 1,
|
|
121
|
+
more: true,
|
|
122
|
+
}
|
|
123
|
+
expect(
|
|
124
|
+
liveMessages(older, log, { channel: 'general', append: false }),
|
|
125
|
+
).toMatchObject([{ text: 'hello', replyCount: 1, label: 'question' }])
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('liveThread', () => {
|
|
130
|
+
it('adds replies after the page once, and decorates them', () => {
|
|
131
|
+
const page = {
|
|
132
|
+
root: toMessage(posted(1, 'root')),
|
|
133
|
+
replies: [toMessage(posted(2, 'first', { threadId: 'event-1' }))],
|
|
134
|
+
through: 2,
|
|
135
|
+
}
|
|
136
|
+
const live = liveThread(
|
|
137
|
+
page,
|
|
138
|
+
[
|
|
139
|
+
posted(2, 'first', { threadId: 'event-1' }),
|
|
140
|
+
posted(3, 'second', { threadId: 'event-1', author: bob }),
|
|
141
|
+
posted(4, 'not this thread', { threadId: 'event-9' }),
|
|
142
|
+
event(5, 'message.deleted', {
|
|
143
|
+
channel: 'general',
|
|
144
|
+
messageId: 'event-2',
|
|
145
|
+
by: ada,
|
|
146
|
+
}),
|
|
147
|
+
],
|
|
148
|
+
'event-1',
|
|
149
|
+
)
|
|
150
|
+
expect(live.root).toMatchObject({ replyCount: 1 })
|
|
151
|
+
expect(live.replies.map((m) => [m.text, m.deleted ?? false])).toEqual([
|
|
152
|
+
['', true],
|
|
153
|
+
['second', false],
|
|
154
|
+
])
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('liveThread, opened before the feed has its root', () => {
|
|
159
|
+
it('adopts the root and its replies as they arrive', () => {
|
|
160
|
+
const page = { root: null, replies: [], through: 0 }
|
|
161
|
+
const live = liveThread(
|
|
162
|
+
page,
|
|
163
|
+
[
|
|
164
|
+
posted(1, 'root'),
|
|
165
|
+
posted(2, 'first', { threadId: 'event-1' }),
|
|
166
|
+
posted(3, 'elsewhere', { threadId: 'event-9' }),
|
|
167
|
+
],
|
|
168
|
+
'event-1',
|
|
169
|
+
)
|
|
170
|
+
expect(live.root).toMatchObject({ text: 'root', replyCount: 1 })
|
|
171
|
+
expect(live.replies.map((m) => m.text)).toEqual(['first'])
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('liveInbox', () => {
|
|
176
|
+
const notification = (index: number, to: string, threadId: string) =>
|
|
177
|
+
event(index, 'notification.sent', {
|
|
178
|
+
channel: 'general',
|
|
179
|
+
messageId: `m-${index}`,
|
|
180
|
+
to,
|
|
181
|
+
reason: 'reply',
|
|
182
|
+
threadId,
|
|
183
|
+
messageIndex: index - 1,
|
|
184
|
+
from: bob,
|
|
185
|
+
text: `news ${index}`,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('raises a thread for each notification to me, and clears it on read', () => {
|
|
189
|
+
const page: InboxPage = { items: [], through: 0 }
|
|
190
|
+
const events = [
|
|
191
|
+
notification(1, ada.id, 't1'),
|
|
192
|
+
notification(2, bob.id, 't1'),
|
|
193
|
+
notification(3, ada.id, 't2'),
|
|
194
|
+
notification(4, ada.id, 't1'),
|
|
195
|
+
event(5, 'inbox.read', { by: ada.id, threadId: 't2', through: 3 }),
|
|
196
|
+
]
|
|
197
|
+
expect(
|
|
198
|
+
liveInbox(page, events, ada.id).map(({ threadId, text, unread }) => [
|
|
199
|
+
threadId,
|
|
200
|
+
text,
|
|
201
|
+
unread,
|
|
202
|
+
]),
|
|
203
|
+
).toEqual([
|
|
204
|
+
['t1', 'news 4', 2],
|
|
205
|
+
['t2', 'news 3', 0],
|
|
206
|
+
])
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2slack's contract, and how the client keeps a page live.
|
|
3
|
+
*
|
|
4
|
+
* The whole workspace is one A2 session, as vboard's board is: every
|
|
5
|
+
* channel's messages, reactions, labels, and notifications are events in one
|
|
6
|
+
* log. Nothing folds that log into one big state. Consumers build tables from
|
|
7
|
+
* it (server/), every read comes from those tables, and a page read at log
|
|
8
|
+
* index `through` stays live by applying the events after `through`, with
|
|
9
|
+
* the functions below.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from 'zod'
|
|
12
|
+
import { contract, reducer, type ContractEvent } from 'experimental-a2'
|
|
13
|
+
|
|
14
|
+
/** The feed writes these to Postgres, whose text can't hold NUL: refused at the door. */
|
|
15
|
+
const text = () =>
|
|
16
|
+
z.string().refine((value) => !value.includes('\u0000'), 'contains NUL')
|
|
17
|
+
|
|
18
|
+
export const authorSchema = z.object({
|
|
19
|
+
id: text().min(1).max(64),
|
|
20
|
+
name: text().min(1).max(32),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export type Author = z.output<typeof authorSchema>
|
|
24
|
+
|
|
25
|
+
export const channelIdSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,31}$/)
|
|
26
|
+
|
|
27
|
+
/** The one session the workspace is. */
|
|
28
|
+
export const WORKSPACE = 'workspace'
|
|
29
|
+
|
|
30
|
+
/** Posts each channel's opening message. */
|
|
31
|
+
export const SYSTEM: Author = { id: 'system', name: 'system' }
|
|
32
|
+
|
|
33
|
+
/** The bot: `@bot` notifies it, and so does a reply in a thread it's in. */
|
|
34
|
+
export const BOT: Author = { id: 'a2slack-bot', name: 'bot' }
|
|
35
|
+
|
|
36
|
+
const messageText = text().min(1).max(2000)
|
|
37
|
+
|
|
38
|
+
/** What triage calls a channel's top-level message. */
|
|
39
|
+
export const LABELS = ['question', 'idea', 'update', 'chatter'] as const
|
|
40
|
+
|
|
41
|
+
export const labelSchema = z.enum(LABELS)
|
|
42
|
+
|
|
43
|
+
export type Label = z.output<typeof labelSchema>
|
|
44
|
+
|
|
45
|
+
/** Why someone was notified: named in the message, or in its thread. */
|
|
46
|
+
export const REASONS = ['mention', 'reply'] as const
|
|
47
|
+
|
|
48
|
+
export type Reason = (typeof REASONS)[number]
|
|
49
|
+
|
|
50
|
+
/** A message's id, which is its post's event id: a UUID. Postgres indexes it, so it's capped. */
|
|
51
|
+
const messageId = () => text().max(64)
|
|
52
|
+
|
|
53
|
+
const ref = { channel: channelIdSchema, messageId: messageId() }
|
|
54
|
+
|
|
55
|
+
const a2slack = contract({
|
|
56
|
+
name: 'a2slack',
|
|
57
|
+
events: {
|
|
58
|
+
'channel.created': z.object({ id: channelIdSchema, by: authorSchema }),
|
|
59
|
+
'message.posted': z.object({
|
|
60
|
+
channel: channelIdSchema,
|
|
61
|
+
author: authorSchema,
|
|
62
|
+
text: messageText,
|
|
63
|
+
threadId: messageId().optional(),
|
|
64
|
+
}),
|
|
65
|
+
'message.edited': z.object({ ...ref, text: messageText, by: authorSchema }),
|
|
66
|
+
'message.deleted': z.object({ ...ref, by: authorSchema }),
|
|
67
|
+
'reaction.added': z.object({
|
|
68
|
+
...ref,
|
|
69
|
+
emoji: text().min(1).max(8),
|
|
70
|
+
by: authorSchema,
|
|
71
|
+
}),
|
|
72
|
+
'reaction.removed': z.object({
|
|
73
|
+
...ref,
|
|
74
|
+
emoji: text().min(1).max(8),
|
|
75
|
+
by: authorSchema,
|
|
76
|
+
}),
|
|
77
|
+
'message.labeled': z.object({ ...ref, label: labelSchema }),
|
|
78
|
+
/** A message someone should hear about, addressed to them. */
|
|
79
|
+
'notification.sent': z.object({
|
|
80
|
+
...ref,
|
|
81
|
+
to: authorSchema.shape.id,
|
|
82
|
+
reason: z.enum(REASONS),
|
|
83
|
+
threadId: messageId(),
|
|
84
|
+
messageIndex: z.number().int().positive(),
|
|
85
|
+
from: authorSchema,
|
|
86
|
+
text: text(),
|
|
87
|
+
}),
|
|
88
|
+
/** `by` has seen a thread's notifications through log index `through`. */
|
|
89
|
+
'inbox.read': z.object({
|
|
90
|
+
by: authorSchema.shape.id,
|
|
91
|
+
threadId: messageId(),
|
|
92
|
+
through: z.number().int().nonnegative(),
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
presence: {
|
|
96
|
+
typing: z.object({ name: z.string().min(1).max(32), channel: z.string() }),
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
export type WorkspaceEvent = ContractEvent<typeof a2slack.events>
|
|
101
|
+
|
|
102
|
+
const reactionSchema = z.object({
|
|
103
|
+
emoji: z.string(),
|
|
104
|
+
by: z.array(authorSchema),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
export type Reaction = z.output<typeof reactionSchema>
|
|
108
|
+
|
|
109
|
+
export type ChatMessage = {
|
|
110
|
+
id: string
|
|
111
|
+
index: number
|
|
112
|
+
channel: string
|
|
113
|
+
author: Author
|
|
114
|
+
text: string
|
|
115
|
+
at: string
|
|
116
|
+
editedAt?: string
|
|
117
|
+
deleted?: boolean
|
|
118
|
+
reactions: Reaction[]
|
|
119
|
+
threadId?: string
|
|
120
|
+
replyCount: number
|
|
121
|
+
lastReplyAt?: string
|
|
122
|
+
label?: Label
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A channel's top-level messages, oldest first, read at log index `through`. */
|
|
126
|
+
export type MessagePage = {
|
|
127
|
+
messages: ChatMessage[]
|
|
128
|
+
through: number
|
|
129
|
+
/** Older messages exist before this page. */
|
|
130
|
+
more: boolean
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A thread's root and replies, oldest first, read at log index `through`. */
|
|
134
|
+
export type ThreadPage = {
|
|
135
|
+
root: ChatMessage | null
|
|
136
|
+
replies: ChatMessage[]
|
|
137
|
+
through: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One thread with news for someone: its latest notification, and how many are unread. */
|
|
141
|
+
export type InboxItem = {
|
|
142
|
+
threadId: string
|
|
143
|
+
channel: string
|
|
144
|
+
reason: Reason
|
|
145
|
+
from: Author
|
|
146
|
+
text: string
|
|
147
|
+
messageId: string
|
|
148
|
+
at: string
|
|
149
|
+
/** The latest notification's log index. */
|
|
150
|
+
index: number
|
|
151
|
+
unread: number
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type InboxPage = { items: InboxItem[]; through: number }
|
|
155
|
+
|
|
156
|
+
/** One hit from the search route. */
|
|
157
|
+
export type SearchResult = {
|
|
158
|
+
channel: string
|
|
159
|
+
id: string
|
|
160
|
+
threadId?: string
|
|
161
|
+
author: Author
|
|
162
|
+
text: string
|
|
163
|
+
at: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const channelsState = z.object({
|
|
167
|
+
channels: z.array(z.string()),
|
|
168
|
+
/** The log index each channel was first created at. */
|
|
169
|
+
opened: z.record(z.string(), z.number()),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The channel list: the one state anything folds whole, because it stays
|
|
174
|
+
* small. The client folds it live; welcome reads `opened` to tell a new
|
|
175
|
+
* channel from one created twice. Re-applying a `channel.created` changes
|
|
176
|
+
* nothing, so a page may hand it events it already reflects.
|
|
177
|
+
*/
|
|
178
|
+
export const channelsReducer = reducer({
|
|
179
|
+
contract: a2slack,
|
|
180
|
+
name: 'channels',
|
|
181
|
+
initialState: { channels: [], opened: {} },
|
|
182
|
+
stateSchema: channelsState,
|
|
183
|
+
}).fold((state, event) => {
|
|
184
|
+
if (event.type !== 'channel.created') return state
|
|
185
|
+
if (state.channels.includes(event.payload.id)) return state
|
|
186
|
+
return {
|
|
187
|
+
channels: [...state.channels, event.payload.id],
|
|
188
|
+
opened: { ...state.opened, [event.payload.id]: event.index },
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
export const workspace = { ...a2slack, reducer: channelsReducer }
|
|
193
|
+
|
|
194
|
+
export function toMessage(
|
|
195
|
+
event: ContractEvent<typeof a2slack.events, 'message.posted'>,
|
|
196
|
+
): ChatMessage {
|
|
197
|
+
return {
|
|
198
|
+
id: event.id,
|
|
199
|
+
index: event.index,
|
|
200
|
+
channel: event.payload.channel,
|
|
201
|
+
author: event.payload.author,
|
|
202
|
+
text: event.payload.text,
|
|
203
|
+
at: event.createdAt.toISOString(),
|
|
204
|
+
reactions: [],
|
|
205
|
+
replyCount: 0,
|
|
206
|
+
...(event.payload.threadId !== undefined
|
|
207
|
+
? { threadId: event.payload.threadId }
|
|
208
|
+
: {}),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Applies an edit, deletion, label, or reaction; the same message otherwise. */
|
|
213
|
+
export function decorate(
|
|
214
|
+
message: ChatMessage,
|
|
215
|
+
event: WorkspaceEvent,
|
|
216
|
+
): ChatMessage {
|
|
217
|
+
switch (event.type) {
|
|
218
|
+
case 'message.edited':
|
|
219
|
+
if (event.payload.messageId !== message.id || message.deleted)
|
|
220
|
+
return message
|
|
221
|
+
return {
|
|
222
|
+
...message,
|
|
223
|
+
text: event.payload.text,
|
|
224
|
+
editedAt: event.createdAt.toISOString(),
|
|
225
|
+
}
|
|
226
|
+
case 'message.deleted':
|
|
227
|
+
if (event.payload.messageId !== message.id) return message
|
|
228
|
+
return { ...message, deleted: true, text: '', reactions: [] }
|
|
229
|
+
case 'message.labeled':
|
|
230
|
+
if (event.payload.messageId !== message.id || message.deleted)
|
|
231
|
+
return message
|
|
232
|
+
return { ...message, label: event.payload.label }
|
|
233
|
+
case 'reaction.added': {
|
|
234
|
+
if (event.payload.messageId !== message.id || message.deleted)
|
|
235
|
+
return message
|
|
236
|
+
const { emoji, by } = event.payload
|
|
237
|
+
const existing = message.reactions.find((r) => r.emoji === emoji)
|
|
238
|
+
if (existing?.by.some((a) => a.id === by.id)) return message
|
|
239
|
+
const reactions = existing
|
|
240
|
+
? message.reactions.map((r) =>
|
|
241
|
+
r.emoji === emoji ? { ...r, by: [...r.by, by] } : r,
|
|
242
|
+
)
|
|
243
|
+
: [...message.reactions, { emoji, by: [by] }]
|
|
244
|
+
return { ...message, reactions }
|
|
245
|
+
}
|
|
246
|
+
case 'reaction.removed': {
|
|
247
|
+
if (event.payload.messageId !== message.id) return message
|
|
248
|
+
const { emoji, by } = event.payload
|
|
249
|
+
const reactions = message.reactions
|
|
250
|
+
.map((r) =>
|
|
251
|
+
r.emoji === emoji
|
|
252
|
+
? { ...r, by: r.by.filter((a) => a.id !== by.id) }
|
|
253
|
+
: r,
|
|
254
|
+
)
|
|
255
|
+
.filter((r) => r.by.length > 0)
|
|
256
|
+
return { ...message, reactions }
|
|
257
|
+
}
|
|
258
|
+
default:
|
|
259
|
+
return message
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The events after `through`, in log order. */
|
|
264
|
+
const after = (
|
|
265
|
+
events: readonly WorkspaceEvent[],
|
|
266
|
+
through: number,
|
|
267
|
+
): WorkspaceEvent[] =>
|
|
268
|
+
events
|
|
269
|
+
.filter((event) => event.index > through)
|
|
270
|
+
.toSorted((a, b) => a.index - b.index)
|
|
271
|
+
|
|
272
|
+
/** A reply's effect on its root: one more reply, and when the last one was. */
|
|
273
|
+
const bump = (root: ChatMessage, reply: ChatMessage): ChatMessage => ({
|
|
274
|
+
...root,
|
|
275
|
+
replyCount: root.replyCount + 1,
|
|
276
|
+
lastReplyAt: reply.at,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* A page of `channel`'s top-level messages, live: the events after the page
|
|
281
|
+
* edit, react to, label, and reply to its messages. With `append`, the
|
|
282
|
+
* newest page, top-level messages posted after it join the end.
|
|
283
|
+
*/
|
|
284
|
+
export function liveMessages(
|
|
285
|
+
page: MessagePage,
|
|
286
|
+
events: readonly WorkspaceEvent[],
|
|
287
|
+
{ channel, append }: { channel: string; append: boolean },
|
|
288
|
+
): ChatMessage[] {
|
|
289
|
+
let messages = page.messages
|
|
290
|
+
for (const event of after(events, page.through)) {
|
|
291
|
+
if (!('channel' in event.payload) || event.payload.channel !== channel)
|
|
292
|
+
continue
|
|
293
|
+
if (event.type === 'message.posted') {
|
|
294
|
+
const posted = toMessage(event)
|
|
295
|
+
const rootId = posted.threadId
|
|
296
|
+
if (rootId !== undefined) {
|
|
297
|
+
messages = messages.map((m) => (m.id === rootId ? bump(m, posted) : m))
|
|
298
|
+
} else if (append && !messages.some((m) => m.id === posted.id)) {
|
|
299
|
+
messages = [...messages, posted]
|
|
300
|
+
}
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
messages = messages.map((m) => decorate(m, event))
|
|
304
|
+
}
|
|
305
|
+
return messages
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** A thread, live: replies posted after the page join it, and edits apply. */
|
|
309
|
+
export function liveThread(
|
|
310
|
+
page: ThreadPage,
|
|
311
|
+
events: readonly WorkspaceEvent[],
|
|
312
|
+
rootId: string,
|
|
313
|
+
): { root: ChatMessage | null; replies: ChatMessage[] } {
|
|
314
|
+
let { root, replies } = page
|
|
315
|
+
for (const event of after(events, page.through)) {
|
|
316
|
+
if (event.type === 'message.posted') {
|
|
317
|
+
// A root the feed hadn't applied when the page was read.
|
|
318
|
+
if (event.id === rootId && root === null) {
|
|
319
|
+
root = toMessage(event)
|
|
320
|
+
for (const reply of replies) root = bump(root, reply)
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
if (event.payload.threadId !== rootId) continue
|
|
324
|
+
const reply = toMessage(event)
|
|
325
|
+
if (replies.some((m) => m.id === reply.id)) continue
|
|
326
|
+
replies = [...replies, reply]
|
|
327
|
+
if (root) root = bump(root, reply)
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
if (root) root = decorate(root, event)
|
|
331
|
+
replies = replies.map((m) => decorate(m, event))
|
|
332
|
+
}
|
|
333
|
+
return { root, replies }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** `me`'s inbox, live: new notifications raise a thread, and reads clear it. */
|
|
337
|
+
export function liveInbox(
|
|
338
|
+
page: InboxPage,
|
|
339
|
+
events: readonly WorkspaceEvent[],
|
|
340
|
+
me: string,
|
|
341
|
+
): InboxItem[] {
|
|
342
|
+
let items = page.items
|
|
343
|
+
for (const event of after(events, page.through)) {
|
|
344
|
+
if (event.type === 'notification.sent' && event.payload.to === me) {
|
|
345
|
+
const { threadId } = event.payload
|
|
346
|
+
const previous = items.find((item) => item.threadId === threadId)
|
|
347
|
+
const item: InboxItem = {
|
|
348
|
+
threadId,
|
|
349
|
+
channel: event.payload.channel,
|
|
350
|
+
reason: event.payload.reason,
|
|
351
|
+
from: event.payload.from,
|
|
352
|
+
text: event.payload.text,
|
|
353
|
+
messageId: event.payload.messageId,
|
|
354
|
+
at: event.createdAt.toISOString(),
|
|
355
|
+
index: event.index,
|
|
356
|
+
unread: (previous?.unread ?? 0) + 1,
|
|
357
|
+
}
|
|
358
|
+
items = [item, ...items.filter((other) => other.threadId !== threadId)]
|
|
359
|
+
} else if (event.type === 'inbox.read' && event.payload.by === me) {
|
|
360
|
+
const { threadId, through } = event.payload
|
|
361
|
+
items = items.map((item) =>
|
|
362
|
+
item.threadId === threadId && item.index <= through
|
|
363
|
+
? { ...item, unread: 0 }
|
|
364
|
+
: item,
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return items
|
|
369
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { MockLanguageModelV4 } from 'ai/test'
|
|
2
|
+
import { beforeEach, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
// Triage and the bot run only where AI Gateway is reachable; these models
|
|
5
|
+
// stand in for the two they call.
|
|
6
|
+
vi.hoisted(() => {
|
|
7
|
+
process.env.AI_GATEWAY_API_KEY = 'test'
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const { threadPage } = await import('./db/queries')
|
|
11
|
+
const { BOT, WORKSPACE } = await import('./model')
|
|
12
|
+
const { a2slackServer } = await import('./server')
|
|
13
|
+
|
|
14
|
+
const usage = {
|
|
15
|
+
inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
|
|
16
|
+
outputTokens: { total: 1, text: 1, reasoning: 0 },
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const prompts: Record<string, string[]> = {}
|
|
20
|
+
|
|
21
|
+
/** A model that answers every call with `text`, recording its prompts. */
|
|
22
|
+
const answering = (id: string, text: string) =>
|
|
23
|
+
new MockLanguageModelV4({
|
|
24
|
+
modelId: id,
|
|
25
|
+
doGenerate: async (options) => {
|
|
26
|
+
;(prompts[id] ??= []).push(JSON.stringify(options.prompt))
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: 'text', text }],
|
|
29
|
+
finishReason: { unified: 'stop', raw: 'stop' },
|
|
30
|
+
usage,
|
|
31
|
+
warnings: [],
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
const models = {
|
|
38
|
+
'openai/gpt-6-luna-decisions': answering(
|
|
39
|
+
'openai/gpt-6-luna-decisions',
|
|
40
|
+
'{"result":"question"}',
|
|
41
|
+
),
|
|
42
|
+
'openai/gpt-5.4': answering('openai/gpt-5.4', 'ask ada, she owns it.'),
|
|
43
|
+
}
|
|
44
|
+
vi.stubGlobal('AI_SDK_DEFAULT_PROVIDER', {
|
|
45
|
+
specificationVersion: 'v4',
|
|
46
|
+
languageModel: (id: keyof typeof models) => models[id],
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const room = a2slackServer.session(WORKSPACE)
|
|
51
|
+
const ada = { id: 'user-1', name: 'ada' }
|
|
52
|
+
|
|
53
|
+
it('wakes the bot when someone mentions it, and it answers in the thread', async () => {
|
|
54
|
+
const [asked] = await room.append({
|
|
55
|
+
type: 'message.posted',
|
|
56
|
+
payload: { channel: 'bot', author: ada, text: '@bot who owns the deploy?' },
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
await vi.waitFor(
|
|
60
|
+
async () => {
|
|
61
|
+
const { replies } = await threadPage(asked!.id)
|
|
62
|
+
expect(replies.map(({ author, text }) => [author.name, text])).toEqual([
|
|
63
|
+
[BOT.name, 'ask ada, she owns it.'],
|
|
64
|
+
])
|
|
65
|
+
},
|
|
66
|
+
{ timeout: 10_000 },
|
|
67
|
+
)
|
|
68
|
+
expect(prompts['openai/gpt-5.4']).toHaveLength(1)
|
|
69
|
+
expect(prompts['openai/gpt-5.4']![0]).toContain(
|
|
70
|
+
'ada: @bot who owns the deploy?',
|
|
71
|
+
)
|
|
72
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export default function HomePage(): ReactNode {
|
|
4
|
+
return (
|
|
5
|
+
<div className="a2slack-empty">
|
|
6
|
+
<p className="a2slack-empty-title">a2slack</p>
|
|
7
|
+
<p className="a2slack-empty-hint">pick a channel — or start one</p>
|
|
8
|
+
</div>
|
|
9
|
+
)
|
|
10
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { A2Error } from 'experimental-a2'
|
|
2
|
+
import { feed } from '../consumers/feed'
|
|
3
|
+
import { searchIndex } from '../consumers/search'
|
|
4
|
+
import { WORKSPACE } from '../model'
|
|
5
|
+
import { hasSearchIndex, indexed, search } from '../search-index'
|
|
6
|
+
import { a2slackServer } from '../server'
|
|
7
|
+
|
|
8
|
+
const ms = (from: number, to: number): string => (to - from).toFixed(0)
|
|
9
|
+
|
|
10
|
+
/** Searches every channel's messages. */
|
|
11
|
+
export async function GET(request: Request): Promise<Response> {
|
|
12
|
+
const q = new URL(request.url).searchParams.get('q')?.trim() ?? ''
|
|
13
|
+
if (q.length === 0) return Response.json({ results: [] })
|
|
14
|
+
// The index fills in the background after each message. Catch it up first,
|
|
15
|
+
// and let Upstash finish indexing, so a message posted a moment ago is found.
|
|
16
|
+
const started = performance.now()
|
|
17
|
+
await a2slackServer
|
|
18
|
+
.session(WORKSPACE)
|
|
19
|
+
.consume(hasSearchIndex ? searchIndex : feed, { signal: request.signal })
|
|
20
|
+
.catch((error: unknown) => {
|
|
21
|
+
// A message the index gave up on leaves the rest searchable.
|
|
22
|
+
if (!(
|
|
23
|
+
error instanceof A2Error && error.code === 'CONSUMER_DEAD_LETTERED'
|
|
24
|
+
))
|
|
25
|
+
throw error
|
|
26
|
+
})
|
|
27
|
+
const consumed = performance.now()
|
|
28
|
+
await indexed()
|
|
29
|
+
const waited = performance.now()
|
|
30
|
+
const results = await search(q.slice(0, 200))
|
|
31
|
+
const done = performance.now()
|
|
32
|
+
return Response.json(
|
|
33
|
+
{ results },
|
|
34
|
+
{
|
|
35
|
+
headers: {
|
|
36
|
+
'server-timing': `consume;dur=${ms(started, consumed)}, indexing;dur=${ms(consumed, waited)}, query;dur=${ms(waited, done)}`,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
}
|