experimental-a2 0.12.0 → 0.14.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.
Files changed (106) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/{actor-DJi3RsNu.d.ts → actor-BfQSE0KC.d.ts} +4 -4
  3. package/dist/{actor-DJi3RsNu.d.ts.map → actor-BfQSE0KC.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-client.js +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +2 -2
  8. package/dist/{actor-shared-DI7J5upy.js → actor-shared-B5tJfzt-.js} +2 -2
  9. package/dist/{actor-shared-DI7J5upy.js.map → actor-shared-B5tJfzt-.js.map} +1 -1
  10. package/dist/actor.d.ts +1 -1
  11. package/dist/actor.js +3 -3
  12. package/dist/ai-Cai-lCbj.d.ts +580 -0
  13. package/dist/ai-Cai-lCbj.d.ts.map +1 -0
  14. package/dist/ai-control-CcD4hh3y.js +119 -0
  15. package/dist/ai-control-CcD4hh3y.js.map +1 -0
  16. package/dist/ai-server.d.ts +16 -8
  17. package/dist/ai-server.d.ts.map +1 -1
  18. package/dist/ai-server.js +1322 -475
  19. package/dist/ai-server.js.map +1 -1
  20. package/dist/ai.d.ts +2 -334
  21. package/dist/ai.js +838 -85
  22. package/dist/ai.js.map +1 -1
  23. package/dist/{client-P_NNNRM-.d.ts → client-BAEABRZB.d.ts} +2 -2
  24. package/dist/{client-P_NNNRM-.d.ts.map → client-BAEABRZB.d.ts.map} +1 -1
  25. package/dist/{client-Bf6uSEAk.js → client-BYzHjkwU.js} +21 -6
  26. package/dist/client-BYzHjkwU.js.map +1 -0
  27. package/dist/client.d.ts +1 -1
  28. package/dist/client.js +1 -1
  29. package/dist/{contract-48bUMgcL.js → contract-CKRg_E4q.js} +3 -26
  30. package/dist/contract-CKRg_E4q.js.map +1 -0
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.js +1 -1
  33. package/dist/react.d.ts +2 -2
  34. package/dist/react.js +1 -1
  35. package/dist/{reducer-DJKWm3cp.d.ts → reducer-BcS9VDKC.d.ts} +4 -1
  36. package/dist/{reducer-DJKWm3cp.d.ts.map → reducer-BcS9VDKC.d.ts.map} +1 -1
  37. package/dist/reducer-DEMjEY_O.js +29 -0
  38. package/dist/reducer-DEMjEY_O.js.map +1 -0
  39. package/dist/scheduler-qstash.d.ts +2 -2
  40. package/dist/scheduler-qstash.js +1 -1
  41. package/dist/scheduler-vercel.d.ts +2 -2
  42. package/dist/scheduler-vercel.js +1 -1
  43. package/dist/{server-DjZZa1wr.d.ts → server-Bp5Nd1pF.d.ts} +3 -3
  44. package/dist/{server-DjZZa1wr.d.ts.map → server-Bp5Nd1pF.d.ts.map} +1 -1
  45. package/dist/{server-BeNADlCI.js → server-CjJSGcF7.js} +4 -3
  46. package/dist/server-CjJSGcF7.js.map +1 -0
  47. package/dist/server.d.ts +3 -3
  48. package/dist/server.js +1 -1
  49. package/dist/{store-DtDOWLSn.d.ts → store-D_yhNdPz.d.ts} +7 -2
  50. package/dist/{store-DtDOWLSn.d.ts.map → store-D_yhNdPz.d.ts.map} +1 -1
  51. package/dist/store-N8PXxDAS.js.map +1 -1
  52. package/dist/store-memory.d.ts +1 -1
  53. package/dist/store-postgres.d.ts +1 -1
  54. package/dist/store-postgres.js +19 -0
  55. package/dist/store-postgres.js.map +1 -1
  56. package/dist/store-redis-http.d.ts +1 -1
  57. package/dist/store-redis-http.js +1 -1
  58. package/dist/{store-redis-notify-BUCyXOn0.js → store-redis-notify-D2EI6gwX.js} +27 -2
  59. package/dist/store-redis-notify-D2EI6gwX.js.map +1 -0
  60. package/dist/store-redis.d.ts +1 -1
  61. package/dist/store-redis.js +1 -1
  62. package/dist/store-sqlite.d.ts +1 -1
  63. package/docs/guides/06-ai-agents.mdx +361 -76
  64. package/docs/reference/01-api.mdx +159 -27
  65. package/examples/playground/app/agent/[agentId]/agent-client.tsx +145 -33
  66. package/examples/playground/app/agent/[agentId]/agent-queue.tsx +289 -0
  67. package/examples/playground/app/agent/[agentId]/compaction/route.ts +14 -0
  68. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +38 -0
  69. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +294 -0
  70. package/examples/playground/app/agent/compaction-settings.test.ts +144 -0
  71. package/examples/playground/app/agent/compaction-settings.ts +49 -0
  72. package/examples/playground/app/agent/compaction-timeline.test.ts +337 -0
  73. package/examples/playground/app/agent/compaction-timeline.ts +198 -0
  74. package/examples/playground/app/agent/model.ts +56 -1
  75. package/examples/playground/app/agent/server.ts +9 -2
  76. package/examples/playground/app/chat/[chatId]/chat-client.tsx +3 -13
  77. package/examples/playground/app/chat/model.ts +2 -2
  78. package/examples/playground/app/chat/server.ts +24 -17
  79. package/examples/playground/app/globals.css +333 -0
  80. package/examples/playground/package.json +1 -1
  81. package/package.json +1 -1
  82. package/src/ai-client-state.ts +185 -0
  83. package/src/ai-control-server.ts +829 -0
  84. package/src/ai-control-state.ts +152 -0
  85. package/src/ai-control.ts +139 -0
  86. package/src/ai-coordinator.ts +99 -32
  87. package/src/ai-model-metadata.ts +108 -0
  88. package/src/ai-progress-batches.ts +68 -0
  89. package/src/ai-projector.ts +76 -15
  90. package/src/ai-sdk-step.ts +5 -2
  91. package/src/ai-server.ts +920 -638
  92. package/src/ai.ts +650 -110
  93. package/src/client.ts +31 -9
  94. package/src/licenses/Apache-2.0.txt +55 -0
  95. package/src/parse-partial-json.ts +441 -0
  96. package/src/reducer.ts +6 -0
  97. package/src/server.ts +8 -4
  98. package/src/store-postgres.ts +27 -0
  99. package/src/store-redis-core.ts +53 -1
  100. package/src/store-redis-notify.ts +1 -0
  101. package/src/store.ts +6 -0
  102. package/dist/ai.d.ts.map +0 -1
  103. package/dist/client-Bf6uSEAk.js.map +0 -1
  104. package/dist/contract-48bUMgcL.js.map +0 -1
  105. package/dist/server-BeNADlCI.js.map +0 -1
  106. package/dist/store-redis-notify-BUCyXOn0.js.map +0 -1
@@ -697,6 +697,13 @@ Raw events from the session, oldest first. `gte` and `lte` are inclusive event
697
697
  indexes; omit a bound to leave that end of the log open.
698
698
  Bounds are non-negative safe integers. `lte: 0` returns `[]`; `gte > lte` throws.
699
699
 
700
+ Redis reads a fixed committed range in pages of at most 256 events, with up to
701
+ sixteen page reads in flight. Public history omits dispatch metadata. PostgreSQL
702
+ uses one indexed range query selecting only public event columns. Custom store
703
+ adapters can implement `readHistory(sessionId, options)` with the same exclusive
704
+ `afterIndex` and inclusive `throughIndex` bounds as `read()`. A2 falls back to
705
+ `read()` when that capability is absent.
706
+
700
707
  ### `session.state(reducer, options?)`
701
708
 
702
709
  ```ts
@@ -1033,14 +1040,12 @@ result and is verified against the executing server's contract: a
1033
1040
  mismatched agent, or a call outside any tool execution, throws a
1034
1041
  `TypeError`.
1035
1042
 
1036
- The returned `AgentToolContext<D>` is a union of the current A2
1037
- handler's `event`, `attempt`, `session`, and `signal`. Automatic
1038
- execution has an `ai.tool.called` event; execution after approval has an
1039
- `ai.approval.responded` event. Narrow `ctx.event.type` when
1040
- event-specific payload fields matter. Both variants have a session typed
1041
- from the supplied agent's complete event vocabulary. The agent does not
1042
- hold a scheduler. Calls such as `ctx.session.schedule()` use the
1043
- scheduler of the server executing the tool.
1043
+ The returned `AgentToolContext<D>` supplies the current A2 handler's `event`,
1044
+ `attempt`, `session`, and `signal`. Local tools execute the explicit
1045
+ `ai.tool.execution.requested` work event. Its payload contains the admitted
1046
+ `call`, `generation`, `turnId`, and execution `version`. The session has the
1047
+ supplied agent's complete event vocabulary. Calls such as
1048
+ `ctx.session.schedule()` use the scheduler of the server executing the tool.
1044
1049
 
1045
1050
  The context is runtime capability, not event data. A2 creates it for the
1046
1051
  current attempt, never persists it, and never sends it to the model or a
@@ -1060,29 +1065,72 @@ Pure typed inputs for `append()` and `push()`:
1060
1065
 
1061
1066
  | Input | Events |
1062
1067
  | --- | --- |
1063
- | `inputs.message(message, { generate? })` | `ai.message.created`; pass `generate: false` to record context without scheduling |
1064
- | `inputs.seed(message)` | `ai.message.created` with `generate: false`; non-user roles require a trusted append |
1065
- | `inputs.approval(response)` | `ai.approval.responded` only |
1066
- | `inputs.input(response)` | `ai.input.responded` only |
1067
- | `inputs.requestInput(request)` | `ai.input.requested` for a trusted server append |
1068
- | `inputs.retry(options)` | `ai.retry.requested` |
1069
- | `inputs.interrupt(options)` | `ai.message.interrupted` |
1068
+ | `inputs.message(message, { generate? })` | queues input; `generate: false` records passive context at admission |
1069
+ | `inputs.seed(message)` | queues passive context; non-user roles require a trusted append |
1070
+ | `inputs.approval(response)` | answers a pending tool approval |
1071
+ | `inputs.input(response)` | answers a pending application input |
1072
+ | `inputs.requestInput(request)` | requests application input from trusted server code |
1073
+ | `inputs.retry(options)` | retries a failed response |
1074
+ | `inputs.stop({ turnId, lastSeenIndex? })` | ends the observed turn and advances when admission is open |
1075
+ | `inputs.steer({ turnId, message, lastSeenIndex? })` | puts the new input first for a handoff after the observed turn's current step |
1076
+ | `inputs.queue.edit({ message, expectedRevision })` | changes a pending input; its ID stays fixed |
1077
+ | `inputs.queue.move({ inputId, beforeId })` | moves pending input before an ID; `null` means the end |
1078
+ | `inputs.queue.remove({ inputId })` | removes pending input; its identity stays reserved |
1079
+ | `inputs.queue.sendNow({ inputId, turnId, lastSeenIndex? })` | promotes an existing queued input for a handoff after the current step; preserves pause |
1080
+ | `inputs.pause({ when: 'now' \| 'after-turn' })` | pauses execution now or inbox admission after the turn |
1081
+ | `inputs.resume()` | resumes retained work, then inbox admission |
1082
+ | `inputs.toolResult(result)` | records a deferred provider-executed tool result from trusted server code |
1083
+ | `inputs.interrupt(options)` | interrupts a specific generation/request owner |
1084
+
1085
+ All builders emit `ai.control.requested`. Persistence and application are
1086
+ separate: `ai.control.decided` returns `{ commandId, outcome, reason? }` through
1087
+ the existing stream. Outcomes are `applied` or `rejected`. Rejection reasons
1088
+ include `already-active`, `revision-conflict`, `stale-turn`, `not-found`,
1089
+ `duplicate-input`, `not-user-input`, and `closed`. `state.receipt` holds the latest user receipt.
1090
+
1091
+ Steering validates and prioritizes new input even if the observed turn has
1092
+ finished. A matching active turn finishes its current model response and tool
1093
+ batch, then yields before another model step. Running local workers and deferred
1094
+ provider results settle first; unanswered human approval/input waits can be
1095
+ closed at that point. Pause stays closed. Otherwise the input starts when idle
1096
+ or waits first behind a different active turn. The priority change is atomic;
1097
+ the later handoff and new work are committed atomically at the step boundary.
1098
+ Steer and Send now do not use `lastSeenIndex` to cut output.
1099
+
1100
+ Queue removal leaves the log intact and retains the removed identity's reservation.
1101
+ Send now preserves the queued input ID and latest accepted revision. It requires
1102
+ an observed `turnId` (or null for idle), preserves pause, and only accepts
1103
+ user-role input. It promotes passive user input to generation after reserving its
1104
+ response ID. Missing targets, stale turns, and identity collisions reject before
1105
+ any queue change.
1106
+
1107
+ `state.inbox` contains `paused` and ordered `items`. Each item contains
1108
+ `{ id, revision, message, generate, afterStepOf? }`. The optional turn ID
1109
+ `afterStepOf` records the observed steering turn while that item remains queued;
1110
+ a matching active turn hands off after its current step.
1111
+ Removal cancels it and admission consumes it. `state.active` is null or contains
1112
+ `{ turnId, input, phase }`; admission freezes `input`. On the server, pending
1113
+ inputs are absent from `messages` and model context. The `paused` status and the `pausing`/`paused`
1114
+ active phases represent retained work. Resume uses a fresh model request and
1115
+ execution version. Tools without terminal outcomes can retry; their stable
1116
+ `toolCallId` supports external idempotency.
1070
1117
 
1071
1118
  `inputs` deliberately has no session lifecycle methods. Append explicit
1072
1119
  `ai.session.created` and `ai.session.closed` events from trusted server code
1073
- when an application uses them. Input event ids are stable for the interaction
1074
- they describe, so a lost append acknowledgment can be resent safely. Browser
1120
+ when an application uses them. Message, approval, input, and retry builders use stable interaction IDs.
1121
+ Other commands use IDs assigned by `push()` or `append()`; transport retries
1122
+ preserve those IDs. Browser
1075
1123
  ingress accepts user messages, approval and input responses, interruptions,
1076
1124
  and explicit retries. Only built-in server handlers append generation requests
1077
- and AI lifecycle events. `inputs.requestInput()` and non-user messages built
1125
+ and AI lifecycle events. `inputs.requestInput()`, `inputs.toolResult()`, and non-user messages built
1078
1126
  with `inputs.seed()` are for trusted server appends.
1079
1127
 
1080
- `inputs.message(message, { generate: false })` is valid browser input. It keeps
1081
- the user message in `AIState.messages` without starting a model turn. The next
1128
+ `inputs.message(message, { generate: false })` is valid browser input. It admits
1129
+ the user message to `AIState.messages` without starting a model turn. The next
1082
1130
  user message that allows generation includes passive user messages before it
1083
1131
  in model context. Passive user messages after that trigger wait for a later
1084
1132
  generating message. Omitting the option preserves the default scheduling
1085
- behavior and the existing `{ message }` event payload. `inputs.seed()` writes
1133
+ behavior. `inputs.seed()` writes
1086
1134
  the same passive flag. Trusted assistant and system seeds are context, not user
1087
1135
  queue cutpoints. The server never schedules them, regardless of the `generate`
1088
1136
  field. Model output is generation progress rather than another
@@ -1117,12 +1165,44 @@ Completed tool results at or before `lastSeenIndex` remain completed. A final
1117
1165
  tool result that races an ordinary generation failure remains authoritative in
1118
1166
  either commit order, while the generation stays failed.
1119
1167
 
1168
+ Interruption and failure projections discard tool parts still in
1169
+ `input-streaming`. These argument drafts are not completed tool calls and do not
1170
+ enter later model prompts. Raw progress remains in the log. Available inputs,
1171
+ approval-waiting calls, and completed results retain their existing terminal
1172
+ handling.
1173
+
1120
1174
  ### `events` and `createEvents(options?)`
1121
1175
 
1122
1176
  The built-in Standard Schema definitions. Use `events` for the default AI SDK
1123
1177
  `UIMessage`; use `createEvents({ messageSchema })` to validate a more specific
1124
1178
  message type. Both are isomorphic.
1125
1179
 
1180
+ ### Optimistic AI client state
1181
+
1182
+ Clients using `agent().reducer` or `createReducer()` expose `state.messages`,
1183
+ `state.inbox`, and `state.starting` with pending send, edit, move, remove,
1184
+ Send now, steer, pause, and resume commands applied for display. Idle, unpaused
1185
+ sends appear in the conversation immediately. A generating input occupies the
1186
+ optimistic turn, so subsequent sends queue. Busy or paused sessions queue new
1187
+ sends. `starting` indicates a speculative turn awaiting acceptance; it does not
1188
+ indicate model execution. Confirmed turns take precedence during reconciliation.
1189
+ Steering inputs, and Send now inputs targeting an active turn, appear in
1190
+ `state.messages` after the current response and are omitted from the client
1191
+ inbox. This display survives receipt and hydration, including while paused.
1192
+ They remain pending server inputs until admission and do not enter the model
1193
+ prompt early. Multiple steering inputs follow inbox admission order.
1194
+
1195
+ `state.active`, model execution, and revision numbers stay confirmed.
1196
+ Server reads and reducer folds retain confirmed messages and inbox entries;
1197
+ `state.starting` is false there. Custom reducers that call `reduceAIState()` or
1198
+ nest the AI fold do not inherit the client display behavior. Disable repeat editing and Send now while an
1199
+ edit for that input is present in `state.pendingQueueCommands`.
1200
+
1201
+ The pending-command ledger survives request echo and retires on the matching
1202
+ `ai.control.decided` receipt or session close. Failed pushes roll back through
1203
+ the client's existing optimistic overlay. This display adds no I/O and does not
1204
+ read raw history.
1205
+
1126
1206
  ### `createReducer(options)`
1127
1207
 
1128
1208
  ```ts
@@ -1130,7 +1210,8 @@ createReducer({ contract, name? }): Reducer<AIState>
1130
1210
  ```
1131
1211
 
1132
1212
  Builds the standard AI projection for a compatible contract. `AIState`
1133
- contains session lifecycle, messages, generation status, pending approvals
1213
+ contains `inbox`, `active`, `starting`, the latest command `receipt`, session lifecycle,
1214
+ messages, generation status, pending approvals
1134
1215
  and input, tool activity, compaction, usage, the last error, and
1135
1216
  `activeRequestId`, `activeResponseMessageId`,
1136
1217
  `responseGenerationIds: Record<string, string>`, and terminal request and
@@ -1140,7 +1221,7 @@ delayed requests before their generation starts. `activeResponseMessageId`
1140
1221
  identifies the requested response until `activeGeneration` exists.
1141
1222
  `activeProjection` holds the indexed generation frontier while a generation is
1142
1223
  active, after a generation step completes while its response waits on tool,
1143
- approval, or input barriers, and after a generation fails while it awaits retry
1224
+ approval, or input barriers, while paused, and after a generation fails while it awaits retry
1144
1225
  or interruption. Response completion, interruption, supersession, retry, or a
1145
1226
  later generation clears or replaces it. `responseGenerationIds` keeps the
1146
1227
  latest generation owner for each response message, so late events from a
@@ -1149,7 +1230,19 @@ superseded owner cannot alter the projection. `terminalRequestIds` and
1149
1230
  and supersession fences across snapshots and recovery. They are optional
1150
1231
  snapshot-compatible fields with the shapes `Record<string, true>` and
1151
1232
  `Record<string, 'completed' | 'failed' | 'interrupted' | 'superseded'>`.
1152
- Extension events are ignored. The default reducer name is `a2.ai.state.v8`.
1233
+ The active projection also stores its prompt boundary and a serializable stream
1234
+ cursor for open text, reasoning, and tool-input parts. Ordered progress applies
1235
+ only its new chunks. `activeProjection.batches` stores indexed batches as
1236
+ `{ length, tail, blocks }`: a bounded tail and shared completed blocks. Appending
1237
+ ordered progress does not copy the full retained batch list. The blocks are
1238
+ ordinary JSON with logarithmic nesting depth, so checkpoints can be serialized
1239
+ and cloned. Retained batches support exact interruption cutoffs and out-of-order
1240
+ reconstruction. The cursor and blocks share the active projection's lifetime.
1241
+ Checkpoint size and serialization cost still grow with retained progress.
1242
+ Tool-input deltas update `input-streaming` parts with best-effort partial JSON
1243
+ for display. The state stays `input-streaming` until a completed input arrives;
1244
+ partial parsing does not authorize execution or change interruption handling.
1245
+ Extension events are ignored. The default reducer name is `a2.ai.state.v17`.
1153
1246
 
1154
1247
  ### `deriveUIMessages(history)` and `reduceAIState(state, event)`
1155
1248
 
@@ -1243,9 +1336,48 @@ support wait for their provider result instead of a local executor. An
1243
1336
  authenticated provider callback appends the terminal `ai.tool.result` through
1244
1337
  the trusted server session API; the browser push allowlist rejects it.
1245
1338
 
1246
- `compaction` has `shouldCompact(context)` and `compact(context)` callbacks.
1247
- When selected, both the request and the replacement messages enter the log.
1248
- `progress` controls durable batching with `maxChunks` and `maxDelayMs`.
1339
+ `compaction` defaults to automatic for supported Gateway requests. It accepts
1340
+ `false` to disable compaction and discovery, `{ thresholdTokens?, instructions? }`
1341
+ to override automatic behavior, or the existing custom
1342
+ `{ shouldCompact(context), compact(context) }` policy. It also accepts a
1343
+ resolver `(context: AgentResolverContext) => CompactionOptions | Promise<CompactionOptions>`.
1344
+ The resolver runs once per generation and returns a valid policy or `false`.
1345
+ It can await application reducers through `context.session.state(reducer)`, whose
1346
+ default boundary is the captured prompt frontier. Model, instruction, compaction,
1347
+ metadata, and custom-generation contexts expose state access and omit raw history.
1348
+ Static options are validated at construction; resolved options are validated
1349
+ before that generation starts. Each session uses its own resolved value. `thresholdTokens` is an
1350
+ optional positive safe integer. `instructions` adds to A2's internal summary
1351
+ prompt without replacing the agent's instructions.
1352
+
1353
+ Without an explicit threshold, the first generation batches a metadata request
1354
+ into its existing start write. An independent handler reads the public Gateway
1355
+ catalog and records that session/model's limits. It does not delay model dispatch.
1356
+ The default threshold is 75% of the context window, lowered for a larger configured
1357
+ output allowance. A pending or unavailable lookup leaves the threshold unknown;
1358
+ the first request proceeds, and provider context-limit errors fail normally.
1359
+ `state.modelMetadata` retains each model's pending, resolved, or unavailable state.
1360
+ Pending also covers exhausted handler retries; no new request is added per turn.
1361
+ The catalog has a shared one-hour process cache and a five-second fetch timeout.
1362
+
1363
+ Direct/custom providers, fallback model lists, and custom generators need explicit
1364
+ compaction configuration. Provider-executed tools, structured output, and forced
1365
+ tool choices skip implicit compaction and reject explicit automatic policies.
1366
+ Custom policies remain available for these configurations.
1367
+
1368
+ The input estimate includes messages, tools, and instructions, and uses prior
1369
+ reported input usage to anchor later growth where applicable. It is not a provider
1370
+ tokenizer. Completed generations record `inputTokenEstimate` beside usage. The
1371
+ built-in summary adds one model request and preserves the request prefix for
1372
+ caching. It does not invoke application generation callbacks, transforms, or tool
1373
+ hooks. Both custom policy callbacks and `generate` receive `modelMessages`, the
1374
+ model-ready prompt including summaries, alongside typed UI `messages`.
1375
+
1376
+ Built-in summaries remain separate from application-typed messages. Render
1377
+ `state.compaction?.status === 'running'` as current activity, and the durable
1378
+ `ai.compaction.*` events as historical timeline markers. The conversation in
1379
+ `state.messages` remains intact. `progress` controls durable batching with
1380
+ `maxChunks` and `maxDelayMs`.
1249
1381
 
1250
1382
  This entry point is server-only and resolves to a throwing browser stub.
1251
1383
 
@@ -6,10 +6,14 @@ import type {
6
6
  } from 'react'
7
7
  import { useEffect, useRef, useState } from 'react'
8
8
  import type { UIMessage } from 'ai'
9
- import { inputs } from 'experimental-a2/ai'
9
+ import { inputs, type ControlCommand } from 'experimental-a2/ai'
10
10
  import { ActivityFeed } from '@/app/components/activity-feed'
11
11
  import { ConnectionPill } from '@/app/components/connection-pill'
12
12
  import { useSession } from '../session'
13
+ import { compactionTimeline } from '../compaction-timeline'
14
+ import { CompactionEvent } from './compaction-event'
15
+ import { CompactionPanel } from './compaction-panel'
16
+ import { AgentQueue } from './agent-queue'
13
17
 
14
18
  type MessagePart = UIMessage['parts'][number]
15
19
  type ToolPartView = {
@@ -26,6 +30,7 @@ const STATUS_LABEL = {
26
30
  waiting: 'approval needed',
27
31
  failed: 'failed',
28
32
  closed: 'closed',
33
+ paused: 'paused',
29
34
  } as const
30
35
 
31
36
  const toolTitle = (name: string): string =>
@@ -113,11 +118,11 @@ const hasVisibleParts = (message: UIMessage): boolean =>
113
118
  toolPartView(part) !== null,
114
119
  )
115
120
 
116
- function Thinking(): ReactNode {
121
+ function Thinking({ starting = false }: { starting?: boolean }): ReactNode {
117
122
  return (
118
123
  <p className="agent-thinking" role="status">
119
124
  <span aria-hidden="true" />
120
- Thinking…
125
+ {starting ? 'Starting…' : 'Thinking…'}
121
126
  </p>
122
127
  )
123
128
  }
@@ -163,24 +168,32 @@ function submitOnEnter(event: ReactKeyboardEvent<HTMLTextAreaElement>): void {
163
168
 
164
169
  export function AgentClient({ agentId }: { agentId: string }): ReactNode {
165
170
  const { state, push, events, index, connection } = useSession()
171
+ const { inbox } = state
166
172
  const promptRef = useRef<HTMLTextAreaElement>(null)
167
173
  const [prompt, setPrompt] = useState('')
168
174
  const [error, setError] = useState<string | null>(null)
169
175
  const [responding, setResponding] = useState(false)
170
176
 
171
177
  const generating = state.status === 'generating'
172
- const canSend = state.status === 'idle' || state.status === 'failed'
178
+ const compacting = state.compaction?.status === 'running'
179
+ const canSend = state.status !== 'closed'
180
+ const responseMessageId =
181
+ state.activeGeneration?.responseMessageId ??
182
+ state.activeResponseMessageId ??
183
+ state.activeProjection?.responseMessageId
184
+ const timeline = compactionTimeline({ state, events })
173
185
 
174
186
  useEffect(() => {
175
- if (canSend) promptRef.current?.focus()
187
+ if (canSend) promptRef.current?.focus({ preventScroll: true })
176
188
  }, [canSend])
177
189
 
178
- const send = async (event: FormEvent): Promise<void> => {
179
- event.preventDefault()
190
+ const send = async (event?: FormEvent, steer = false): Promise<void> => {
191
+ event?.preventDefault()
180
192
  const submittedPrompt = prompt
181
193
  const text = prompt.trim()
182
194
  if (!text || !canSend) return
183
195
  setPrompt('')
196
+ promptRef.current?.focus({ preventScroll: true })
184
197
  setError(null)
185
198
  try {
186
199
  const message: UIMessage = {
@@ -188,7 +201,14 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
188
201
  role: 'user',
189
202
  parts: [{ type: 'text', text }],
190
203
  }
191
- await push(...inputs.message(message))
204
+ await push(
205
+ ...(steer && state.active
206
+ ? inputs.steer({
207
+ turnId: state.active.turnId,
208
+ message,
209
+ })
210
+ : inputs.message(message)),
211
+ )
192
212
  } catch (err) {
193
213
  setPrompt((current) => (current === '' ? submittedPrompt : current))
194
214
  setError(err instanceof Error ? err.message : String(err))
@@ -220,18 +240,27 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
220
240
  }
221
241
  }
222
242
 
243
+ const command = async (
244
+ commands: {
245
+ type: 'ai.control.requested'
246
+ payload: ControlCommand
247
+ id?: string
248
+ }[],
249
+ ): Promise<void> => {
250
+ setError(null)
251
+ try {
252
+ await push(...commands)
253
+ } catch (err) {
254
+ setError(err instanceof Error ? err.message : String(err))
255
+ }
256
+ }
257
+
223
258
  const stop = async (): Promise<void> => {
224
- const active = state.activeGeneration
225
- if (!active) return
259
+ if (!state.active) return
226
260
  setError(null)
227
261
  try {
228
262
  await push(
229
- ...inputs.interrupt({
230
- messageId: active.responseMessageId,
231
- generationId: active.generationId,
232
- reason: 'operator stopped the agent',
233
- lastSeenIndex: index,
234
- }),
263
+ ...inputs.stop({ turnId: state.active.turnId, lastSeenIndex: index }),
235
264
  )
236
265
  } catch (err) {
237
266
  setError(err instanceof Error ? err.message : String(err))
@@ -250,36 +279,56 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
250
279
  state.status === 'waiting' ? ' waiting' : ''
251
280
  }${state.status === 'failed' ? ' cut' : ''}`}
252
281
  >
253
- {STATUS_LABEL[state.status]}
282
+ {state.starting
283
+ ? 'starting…'
284
+ : compacting
285
+ ? 'compacting…'
286
+ : STATUS_LABEL[state.status]}
254
287
  </span>
255
288
  </header>
256
289
 
290
+ <CompactionPanel agentId={agentId} state={state} events={events} />
291
+
257
292
  <section className="agent-conversation" aria-label="Conversation">
258
293
  <div className="agent-transcript" aria-live="polite">
259
- {state.messages.length === 0 ? (
294
+ {timeline.length === 0 ? (
260
295
  <p className="agent-empty">
261
296
  Ask the agent to investigate an incident, run a Bash command, or
262
297
  set a reminder for itself. Its tools and decisions will appear
263
298
  here as they enter the log.
264
299
  </p>
265
300
  ) : (
266
- state.messages.map((message) => (
267
- <AgentMessage
268
- key={message.id}
269
- message={message}
270
- thinking={
271
- generating &&
272
- message.role === 'assistant' &&
273
- !hasVisibleParts(message)
274
- }
275
- />
276
- ))
301
+ timeline.map((entry) =>
302
+ entry.type === 'compaction' ? (
303
+ <CompactionEvent key={entry.id} entry={entry} />
304
+ ) : (
305
+ <AgentMessage
306
+ key={entry.id}
307
+ message={entry.message}
308
+ thinking={
309
+ generating &&
310
+ !state.starting &&
311
+ !compacting &&
312
+ entry.lastSegment &&
313
+ entry.message.id ===
314
+ state.activeGeneration?.responseMessageId &&
315
+ !hasVisibleParts(entry.message)
316
+ }
317
+ />
318
+ ),
319
+ )
277
320
  )}
278
- {generating && state.messages.at(-1)?.role === 'user' ? (
321
+ {state.starting ||
322
+ (generating &&
323
+ !compacting &&
324
+ state.messages.at(-1)?.role === 'user' &&
325
+ !state.messages.some(
326
+ (message) => message.id === responseMessageId,
327
+ )) ? (
279
328
  <div className="agent-message assistant">
280
329
  <span className="agent-role">Agent</span>
281
330
  <div>
282
- <Thinking />
331
+ <Thinking starting={state.starting} />
283
332
  </div>
284
333
  </div>
285
334
  ) : null}
@@ -335,12 +384,43 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
335
384
  </p>
336
385
  ) : null}
337
386
 
387
+ <AgentQueue
388
+ items={inbox.items}
389
+ paused={inbox.paused}
390
+ pendingEdits={(state.pendingQueueCommands ?? []).flatMap(
391
+ ({ command: pending }) =>
392
+ pending.action === 'edit' ? [pending.message.id] : [],
393
+ )}
394
+ remove={async (inputId) => {
395
+ await command(inputs.queue.remove({ inputId }))
396
+ }}
397
+ sendNow={async (inputId) => {
398
+ await command(
399
+ inputs.queue.sendNow({
400
+ inputId,
401
+ turnId: state.active?.turnId ?? null,
402
+ }),
403
+ )
404
+ }}
405
+ edit={async (message, expectedRevision) => {
406
+ await push(...inputs.queue.edit({ message, expectedRevision }))
407
+ }}
408
+ move={(inputId, beforeId) =>
409
+ void command(inputs.queue.move({ inputId, beforeId }))
410
+ }
411
+ />
412
+ {state.receipt?.outcome === 'rejected' ? (
413
+ <p className="error" role="alert">
414
+ Command rejected: {state.receipt.reason}. Refresh the input or turn
415
+ you want to change.
416
+ </p>
417
+ ) : null}
418
+
338
419
  <form className="agent-form" onSubmit={(event) => void send(event)}>
339
420
  <label>
340
421
  Message
341
422
  <textarea
342
423
  ref={promptRef}
343
- autoFocus
344
424
  rows={3}
345
425
  value={prompt}
346
426
  onChange={(event) => setPrompt(event.target.value)}
@@ -355,11 +435,43 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
355
435
  <button
356
436
  type="button"
357
437
  className="danger"
358
- disabled={!generating}
438
+ disabled={!state.active}
359
439
  onClick={() => void stop()}
360
440
  >
361
441
  Stop
362
442
  </button>
443
+ <button
444
+ type="button"
445
+ disabled={!canSend || !prompt.trim()}
446
+ onClick={() => void send(undefined, true)}
447
+ >
448
+ Steer
449
+ </button>
450
+ {state.inbox.paused ? (
451
+ <button
452
+ type="button"
453
+ onClick={() => void command(inputs.resume())}
454
+ >
455
+ Resume
456
+ </button>
457
+ ) : (
458
+ <>
459
+ <button
460
+ type="button"
461
+ onClick={() =>
462
+ void command(inputs.pause({ when: 'after-turn' }))
463
+ }
464
+ >
465
+ Pause after turn
466
+ </button>
467
+ <button
468
+ type="button"
469
+ onClick={() => void command(inputs.pause({ when: 'now' }))}
470
+ >
471
+ Pause now
472
+ </button>
473
+ </>
474
+ )}
363
475
  </div>
364
476
  </form>
365
477
  </section>