@jxsuite/studio 5.5.0 → 5.7.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 (244) hide show
  1. package/LICENSE +1 -1
  2. package/dist/chunks/{2020-g8mwpbkq.js → 2020-cs2sb9t1.js} +3 -3
  3. package/dist/chunks/adapter-y5gdq19c.js +4 -0
  4. package/dist/chunks/cf-account-picker-882a7ecm.js +4 -0
  5. package/dist/chunks/cf-settings-20dznc3j.js +4 -0
  6. package/dist/chunks/components-q037dya7.js +4 -0
  7. package/dist/chunks/content-source-37x3vsap.js +4 -0
  8. package/dist/chunks/convert-to-repeater-grfep2w8.js +4 -0
  9. package/dist/chunks/editor-j5sq149q.js +4 -0
  10. package/dist/chunks/entry-commands-c5h3nqck.js +4 -0
  11. package/dist/chunks/extension-sections-dp8endhx.js +4 -0
  12. package/dist/chunks/file-ops-937cvqk6.js +4 -0
  13. package/dist/chunks/files-tznwe1p1.js +4 -0
  14. package/dist/chunks/format-host-e2pjvd25.js +4 -0
  15. package/dist/chunks/fs-events-fv64ak3s.js +4 -0
  16. package/dist/chunks/git-panel-gb713ttc.js +4 -0
  17. package/dist/chunks/grid-view-5amhg604.js +51 -0
  18. package/dist/chunks/grid-view-5mqv1cbd.css +1 -0
  19. package/dist/chunks/iframe-host-t819mcg5.js +4 -0
  20. package/dist/chunks/{index-arg188jq.js → index-9ydfe04y.js} +3 -3
  21. package/dist/chunks/index-m0x5ztax.js +4 -0
  22. package/dist/chunks/{javascript-zcdmye2k.js → javascript-d9pqgc2p.js} +2 -2
  23. package/dist/chunks/{jsonMode-6x5e1nbf.js → jsonMode-800yvbem.js} +5 -5
  24. package/dist/chunks/jx-validate-zc9zp77s.js +4 -0
  25. package/dist/chunks/layers-4bsr82wz.js +4 -0
  26. package/dist/chunks/layers-panel-b6shnaax.js +4 -0
  27. package/dist/chunks/lit-html-cfn61dn7.js +4 -0
  28. package/dist/chunks/media-picker-ej0kdh1j.js +4 -0
  29. package/dist/chunks/menu-rtxf9chb.js +4 -0
  30. package/dist/chunks/{monaco-binding-4az2kh5p.js → monaco-binding-62dtmgbf.js} +3 -3
  31. package/dist/chunks/monaco-buffer-nn4dtx27.js +4 -0
  32. package/dist/chunks/{monaco-setup-d7mwkcx3.js → monaco-setup-tj13ndtm.js} +3 -3
  33. package/dist/chunks/notify-ahs57a2z.js +4 -0
  34. package/dist/chunks/platform-v9xyfkbx.js +4 -0
  35. package/dist/chunks/publish-panel-bq36xcmj.js +4 -0
  36. package/dist/chunks/quick-search-zm00h9dh.js +4 -0
  37. package/dist/chunks/references-99ws0b0m.js +4 -0
  38. package/dist/chunks/right-panel-pq2f71q3.js +4 -0
  39. package/dist/chunks/section-registry-b2yzp9sw.js +4 -0
  40. package/dist/chunks/studio-01kbbqg3.js +928 -0
  41. package/dist/chunks/{studio-hf4qdjc8.js → studio-01qza1n3.js} +4 -4
  42. package/dist/chunks/{studio-pkkw32j0.js → studio-03wwfvyt.js} +4 -4
  43. package/dist/chunks/{studio-7gqyhjns.js → studio-0bbxhhce.js} +4 -4
  44. package/dist/chunks/studio-0k9mvpgj.js +14 -0
  45. package/dist/chunks/studio-0scebvb9.js +6 -0
  46. package/dist/chunks/studio-1a99w39s.js +5 -0
  47. package/dist/chunks/studio-1wvfxkyy.js +5 -0
  48. package/dist/chunks/studio-1x8x26k3.js +5 -0
  49. package/dist/chunks/studio-222rqyyc.js +5 -0
  50. package/dist/chunks/studio-4xppwncr.js +4 -0
  51. package/dist/chunks/{studio-xcng8nx7.js → studio-54g077x3.js} +5 -5
  52. package/dist/chunks/studio-56z7evx4.js +10 -0
  53. package/dist/chunks/studio-5e9zsamh.js +9 -0
  54. package/dist/chunks/studio-65fhspdd.js +5 -0
  55. package/dist/chunks/studio-693nhyky.js +5 -0
  56. package/dist/chunks/{studio-7jazdy6y.js → studio-7pgy3xaf.js} +4 -4
  57. package/dist/chunks/studio-888knnkc.js +5 -0
  58. package/dist/chunks/studio-9mqfamps.js +5 -0
  59. package/dist/chunks/studio-a7xqhc3n.js +5 -0
  60. package/dist/chunks/studio-aje966s9.js +5 -0
  61. package/dist/chunks/studio-aqmph6k4.js +5 -0
  62. package/dist/chunks/studio-c6w3tq7t.js +5 -0
  63. package/dist/chunks/{studio-6j2jn55a.js → studio-c6zdm25z.js} +8 -8
  64. package/dist/chunks/{studio-f8jyp45j.js → studio-c9d3pxf7.js} +4 -4
  65. package/dist/chunks/studio-ceb34dxj.js +5 -0
  66. package/dist/chunks/studio-cvbkkfqb.js +5 -0
  67. package/dist/chunks/studio-cxt150pj.js +7 -0
  68. package/dist/chunks/studio-dkn3x3hz.js +5 -0
  69. package/dist/chunks/studio-e6re3rk1.js +5 -0
  70. package/dist/chunks/{studio-5n1km79v.js → studio-edbtvcd1.js} +4 -4
  71. package/dist/chunks/{studio-xc8ebsts.js → studio-eh0rac07.js} +4 -4
  72. package/dist/chunks/{studio-625ezz59.js → studio-evwgp1ym.js} +4 -4
  73. package/dist/chunks/{studio-he2fhrgp.js → studio-f195h2hq.js} +4 -4
  74. package/dist/chunks/studio-fqq890ap.js +4 -0
  75. package/dist/chunks/studio-frb0qbf7.js +6 -0
  76. package/dist/chunks/studio-gfn1kpxj.js +6 -0
  77. package/dist/chunks/studio-gh97gngt.js +5 -0
  78. package/dist/chunks/{studio-awqghqxv.js → studio-gp02nqnw.js} +4 -4
  79. package/dist/chunks/{studio-x9sagq2d.js → studio-hjbg8d5y.js} +7 -7
  80. package/dist/chunks/studio-j6gnhvaa.js +5 -0
  81. package/dist/chunks/studio-mwxgyn9h.js +7 -0
  82. package/dist/chunks/{studio-x5esgzct.js → studio-n78gjfy8.js} +4 -4
  83. package/dist/chunks/studio-nbxqcgvf.js +5 -0
  84. package/dist/chunks/{studio-p1eggxcf.js → studio-nt06yybj.js} +4 -4
  85. package/dist/chunks/{studio-hc9q4j24.js → studio-p7c1agz0.js} +5 -5
  86. package/dist/chunks/{studio-8r4y2x0z.js → studio-pwqfmt4p.js} +4 -4
  87. package/dist/chunks/{studio-5wfrgee9.js → studio-r3kaka4d.js} +4 -4
  88. package/dist/chunks/studio-semd1s8s.js +5 -0
  89. package/dist/chunks/studio-skzanb0k.js +5 -0
  90. package/dist/chunks/studio-spf2skgc.js +5 -0
  91. package/dist/chunks/{studio-b4epb26c.js → studio-st75kn99.js} +7 -7
  92. package/dist/chunks/{studio-eq27rrr0.js → studio-sxc0zvpt.js} +3 -3
  93. package/dist/chunks/studio-t1eedsyx.js +4 -0
  94. package/dist/chunks/{studio-73czgk6a.js → studio-tp642n5g.js} +4 -4
  95. package/dist/chunks/studio-tw7rre6s.js +5 -0
  96. package/dist/chunks/{studio-7by9a93k.js → studio-tzegmy4w.js} +4 -4
  97. package/dist/chunks/studio-vavmwshr.js +5 -0
  98. package/dist/chunks/studio-wma2c6g8.js +5 -0
  99. package/dist/chunks/studio-x92tgnee.js +64 -0
  100. package/dist/chunks/studio-xk0z6msk.js +5 -0
  101. package/dist/chunks/{studio-c4pnt1c6.js → studio-xry7tk3z.js} +5 -5
  102. package/dist/chunks/studio-xta2vxp6.js +9 -0
  103. package/dist/chunks/{studio-21enxdr8.js → studio-xy3tv3az.js} +4 -4
  104. package/dist/chunks/studio-y7jehjrp.js +5 -0
  105. package/dist/chunks/{studio-k1eaqyzm.js → studio-yr04mv2j.js} +4 -4
  106. package/dist/chunks/{studio-mhhc9btt.js → studio-zd4kayn2.js} +4 -4
  107. package/dist/chunks/studio-zpnk016a.js +16 -0
  108. package/dist/chunks/{tsMode-rtwt7w1a.js → tsMode-k9s9fcag.js} +5 -5
  109. package/dist/chunks/workspace-b69mgm0e.js +4 -0
  110. package/dist/chunks/ws-client-0ja08haa.js +4 -0
  111. package/dist/iframe-entry.js +14 -14
  112. package/dist/manifest.json +62 -59
  113. package/dist/studio.css +1 -1
  114. package/dist/studio.js +121 -123
  115. package/package.json +5 -5
  116. package/src/account/action-flow.ts +294 -0
  117. package/src/account/action-required.ts +68 -0
  118. package/src/account-status.ts +29 -1
  119. package/src/browse/library-model.ts +3 -1
  120. package/src/browse/library-preview.ts +6 -2
  121. package/src/canvas/asset-refs.ts +3 -1
  122. package/src/canvas/asset-resolve.ts +79 -1
  123. package/src/canvas/iframe-entry.ts +38 -1
  124. package/src/canvas/iframe-host.ts +85 -0
  125. package/src/canvas/iframe-protocol.ts +13 -0
  126. package/src/canvas/stage-scrollbar.ts +7 -7
  127. package/src/files/asset-versions.ts +294 -0
  128. package/src/files/file-ops.ts +5 -8
  129. package/src/files/files.ts +61 -9
  130. package/src/files/fs-events.ts +279 -28
  131. package/src/files/media-meta.ts +2 -1
  132. package/src/files/media-paths.ts +5 -2
  133. package/src/files/media-upload.ts +5 -1
  134. package/src/new-project/add-repo-modal.ts +12 -1
  135. package/src/new-project/new-project-modal.ts +12 -1
  136. package/src/panels/git-panel.ts +25 -2
  137. package/src/panels/jump-bar.ts +132 -158
  138. package/src/panels/pane-context.ts +8 -8
  139. package/src/panels/pane-grid.ts +18 -26
  140. package/src/panels/style-panel.ts +2 -2
  141. package/src/platform-errors.ts +61 -1
  142. package/src/platforms/cloud.ts +389 -50
  143. package/src/platforms/devserver.ts +17 -1
  144. package/src/publish/pages-service.ts +17 -7
  145. package/src/services/ai-models.ts +30 -6
  146. package/src/services/ai-system-prompt.ts +81 -51
  147. package/src/services/context-manager.ts +41 -70
  148. package/src/services/document-assistant.ts +12 -3
  149. package/src/services/harness/chat-reducer.ts +115 -0
  150. package/src/services/harness/turn-hooks.ts +50 -0
  151. package/src/services/tool-executor.ts +69 -288
  152. package/src/services/tool-outcomes.ts +12 -10
  153. package/src/settings/preferences-accounts.ts +42 -3
  154. package/src/settings/preferences-dialog.ts +8 -1
  155. package/src/studio.ts +20 -6
  156. package/src/surfaces/block-action-bar.json +6 -1
  157. package/src/surfaces/dialog.ts +5 -0
  158. package/src/surfaces/jump-bar.json +9 -12
  159. package/src/surfaces/jump-bar.ts +15 -16
  160. package/src/surfaces/pane-context.json +2 -2
  161. package/src/surfaces/pane-grid.json +8 -25
  162. package/src/surfaces/pane-grid.ts +7 -8
  163. package/src/surfaces/statusbar.json +71 -3
  164. package/src/surfaces/statusbar.ts +66 -139
  165. package/src/surfaces/style-panel.json +3 -3
  166. package/src/surfaces/style-panel.ts +11 -1
  167. package/src/surfaces/welcome.json +86 -1
  168. package/src/surfaces/welcome.ts +61 -2
  169. package/src/types.ts +84 -2
  170. package/src/ui/ai-managed-connect.ts +41 -4
  171. package/src/ui/media-picker.ts +2 -1
  172. package/dist/chunks/adapter-bsbs78x9.js +0 -4
  173. package/dist/chunks/cf-account-picker-gnm7xeqb.js +0 -4
  174. package/dist/chunks/cf-settings-9y6jyscp.js +0 -4
  175. package/dist/chunks/components-s51h3b7c.js +0 -4
  176. package/dist/chunks/content-source-ty3be0qg.js +0 -4
  177. package/dist/chunks/convert-to-repeater-c3e7q61z.js +0 -4
  178. package/dist/chunks/editor-t002b41m.js +0 -4
  179. package/dist/chunks/entry-commands-sb72e9gy.js +0 -4
  180. package/dist/chunks/extension-sections-ktn2ntnj.js +0 -4
  181. package/dist/chunks/file-ops-78zeytkm.js +0 -4
  182. package/dist/chunks/files-gatzrz8v.js +0 -4
  183. package/dist/chunks/format-host-r59b1651.js +0 -4
  184. package/dist/chunks/fs-events-f8h91ndt.js +0 -4
  185. package/dist/chunks/git-panel-y12zf7nj.js +0 -4
  186. package/dist/chunks/grid-view-rfd8b2pg.js +0 -51
  187. package/dist/chunks/grid-view-tafenfm2.css +0 -1
  188. package/dist/chunks/iframe-host-vsrsj20g.js +0 -4
  189. package/dist/chunks/index-5aghyrfh.js +0 -4
  190. package/dist/chunks/jx-validate-653krkrf.js +0 -4
  191. package/dist/chunks/layers-32p8gtae.js +0 -4
  192. package/dist/chunks/layers-panel-rs58m105.js +0 -4
  193. package/dist/chunks/lit-html-54bmkwz3.js +0 -4
  194. package/dist/chunks/media-picker-5bb04wct.js +0 -4
  195. package/dist/chunks/menu-cbha7w2e.js +0 -4
  196. package/dist/chunks/monaco-buffer-9vbbyfr1.js +0 -4
  197. package/dist/chunks/notify-dc1nz5ar.js +0 -4
  198. package/dist/chunks/platform-2m49s1zf.js +0 -4
  199. package/dist/chunks/publish-panel-nx3ycmma.js +0 -4
  200. package/dist/chunks/quick-search-yzrzh9x3.js +0 -4
  201. package/dist/chunks/references-vx7mbj9f.js +0 -4
  202. package/dist/chunks/right-panel-7jzpbxja.js +0 -4
  203. package/dist/chunks/section-registry-gjryse41.js +0 -4
  204. package/dist/chunks/studio-3znfhcxg.js +0 -5
  205. package/dist/chunks/studio-58qam27v.js +0 -4
  206. package/dist/chunks/studio-6j59fwaw.js +0 -5
  207. package/dist/chunks/studio-7z69g1gx.js +0 -5
  208. package/dist/chunks/studio-93j3v6zx.js +0 -5
  209. package/dist/chunks/studio-9gf6v8zy.js +0 -5
  210. package/dist/chunks/studio-9rhmettf.js +0 -4
  211. package/dist/chunks/studio-ac5aq677.js +0 -10
  212. package/dist/chunks/studio-as65vbns.js +0 -5
  213. package/dist/chunks/studio-bkv0sga4.js +0 -6
  214. package/dist/chunks/studio-bttmqef4.js +0 -5
  215. package/dist/chunks/studio-c3y9d41k.js +0 -5
  216. package/dist/chunks/studio-ccegp7gz.js +0 -9
  217. package/dist/chunks/studio-chxabn98.js +0 -6
  218. package/dist/chunks/studio-cktcdsxz.js +0 -16
  219. package/dist/chunks/studio-cnspfke5.js +0 -5
  220. package/dist/chunks/studio-de3p1ktm.js +0 -5
  221. package/dist/chunks/studio-fsbcn530.js +0 -928
  222. package/dist/chunks/studio-gftga7a3.js +0 -64
  223. package/dist/chunks/studio-hf1rpcq0.js +0 -4
  224. package/dist/chunks/studio-hz02hq70.js +0 -5
  225. package/dist/chunks/studio-jnb285we.js +0 -5
  226. package/dist/chunks/studio-jsv3s3s1.js +0 -5
  227. package/dist/chunks/studio-ks7cksh0.js +0 -5
  228. package/dist/chunks/studio-nptx0dbs.js +0 -5
  229. package/dist/chunks/studio-pg0z7k98.js +0 -5
  230. package/dist/chunks/studio-pve0b2f2.js +0 -5
  231. package/dist/chunks/studio-q7zmjv3b.js +0 -6
  232. package/dist/chunks/studio-rpmvw7kj.js +0 -5
  233. package/dist/chunks/studio-rwhss52s.js +0 -5
  234. package/dist/chunks/studio-sbg3g3h0.js +0 -5
  235. package/dist/chunks/studio-sq1bn60g.js +0 -14
  236. package/dist/chunks/studio-vwz7rma2.js +0 -5
  237. package/dist/chunks/studio-wa55e8gf.js +0 -5
  238. package/dist/chunks/studio-x0mzdha2.js +0 -5
  239. package/dist/chunks/studio-xrgpakvc.js +0 -9
  240. package/dist/chunks/studio-xt3djk1w.js +0 -7
  241. package/dist/chunks/studio-yh3nggx1.js +0 -7
  242. package/dist/chunks/studio-zmecp6hq.js +0 -5
  243. package/dist/chunks/workspace-g6dvaete.js +0 -4
  244. package/dist/chunks/ws-client-shf41j5p.js +0 -4
@@ -1,11 +1,10 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Pages-service — Cloudflare Pages publish domain logic over the PAL's
4
- * `cfApi` passthrough. Works identically on every platform that provides it:
5
- * the dev server / desktop (user API token via cf-settings → /__studio/cf/proxy)
6
- * and the cloud platform (hosted OAuth). Publish state is written to
7
- * project.json `build.deploy` — it travels with the repo, so any Studio can
8
- * tell whether the publish workflow already exists.
3
+ * Pages-service — Cloudflare Pages publish domain logic over the PAL's `cfApi` passthrough. Works
4
+ * identically on every platform that provides it: the dev server / desktop (user API token via
5
+ * cf-settings → /__studio/cf/proxy) and the cloud platform (hosted OAuth). Publish state is written
6
+ * to project.json `build.deploy` — it travels with the repo, so any Studio can tell whether the
7
+ * publish workflow already exists.
9
8
  *
10
9
  * @license MIT
11
10
  */
@@ -13,6 +12,7 @@
13
12
  import { updateWranglerConfig } from "@jxsuite/create/scaffold";
14
13
  import type { DeployConfig, ProjectConfig } from "@jxsuite/schema/types";
15
14
  import { getPlatform } from "../platform";
15
+ import { actIfRequired } from "../account/action-flow";
16
16
  import { updateSiteConfig } from "../site-context";
17
17
 
18
18
  export interface CfAccount {
@@ -52,12 +52,22 @@ export function platformSupportsPublish(): boolean {
52
52
  }
53
53
  }
54
54
 
55
+ /**
56
+ * One Cloudflare call. A refusal that offers an action (desktop.md §10.4) is offered here, once for
57
+ * every publish step, and still thrown: the step that asked failed, and its caller reports that in
58
+ * its own words while the offer says how to fix it.
59
+ */
55
60
  async function cfApi<T>(path: string, init?: { method?: string; body?: unknown }): Promise<T> {
56
61
  const api = getPlatform().cfApi;
57
62
  if (!api) {
58
63
  throw new Error("This platform cannot reach the Cloudflare API");
59
64
  }
60
- return (await api(path, init)) as T;
65
+ try {
66
+ return (await api(path, init)) as T;
67
+ } catch (error) {
68
+ void actIfRequired(error);
69
+ throw error;
70
+ }
61
71
  }
62
72
 
63
73
  export async function listAccounts(): Promise<CfAccount[]> {
@@ -2,17 +2,17 @@
2
2
  /**
3
3
  * Ai-models.js — available-model listing from the studio AI proxy.
4
4
  *
5
- * Fetches the proxy's /models endpoint (the chat endpoint's sibling), forwarding the
6
- * stored API key as X-Api-Key and the endpoint override as X-Api-Base-URL so the proxy
7
- * lists models from the user's chosen provider. Shared by the credentials form and the
8
- * chat composer's model picker; results are cached module-wide until invalidated (e.g.
9
- * after credentials change).
5
+ * Fetches the proxy's /models endpoint (the chat endpoint's sibling), forwarding the stored API key
6
+ * as X-Api-Key and the endpoint override as X-Api-Base-URL so the proxy lists models from the
7
+ * user's chosen provider. Shared by the credentials form and the chat composer's model picker;
8
+ * results are cached module-wide until invalidated (e.g. after credentials change).
10
9
  *
11
10
  * @license MIT
12
11
  */
13
12
 
14
- import type { AiModelsResponse } from "@jxsuite/protocol";
13
+ import type { AiModelsResponse, OfferedAction } from "@jxsuite/protocol";
15
14
  import { getPlatform } from "../platform";
15
+ import { offeredActions } from "../platform-errors";
16
16
  import { getBaseUrl, getOpenAiKey, hasOpenAiKey, storedModel } from "./ai-settings";
17
17
  import { SETTINGS } from "./settings/definitions";
18
18
  import { onSettingsChanged } from "./settings/kernel";
@@ -76,6 +76,14 @@ let proxyManaged = false;
76
76
  let proxyDefaultModel = "";
77
77
  let proxyCode: AiModelsResponse["code"];
78
78
  let proxyModelsError = "";
79
+ /** What an `action_required` probe offered; null for every other answer. */
80
+ let proxyAction: ProxyActionOffer | null = null;
81
+
82
+ /** What a managed backend says the assistant needs first, in the backend's own words. */
83
+ export interface ProxyActionOffer {
84
+ detail?: string;
85
+ actions: OfferedAction[];
86
+ }
79
87
 
80
88
  /**
81
89
  * One-shot capability probe shared by every credentials gate, plus the hosts to repaint when it
@@ -118,6 +126,7 @@ export function resetModelCache() {
118
126
  proxyDefaultModel = "";
119
127
  proxyCode = undefined;
120
128
  proxyModelsError = "";
129
+ proxyAction = null;
121
130
  /* The probe's result IS the flags above. Clearing them while keeping the settled promise
122
131
  would strand every gate on a permanent "unconfigured, unmanaged" reading — ensureProxyProbe
123
132
  would no-op forever and the managed option would vanish until a full reload. */
@@ -278,6 +287,14 @@ export function proxyModelsErrorMessage(): string {
278
287
  return proxyModelsError;
279
288
  }
280
289
 
290
+ /**
291
+ * The offer the last probe carried — set only when the backend answered `action_required` (ai.md
292
+ * §2.1), and null for every other answer, so no gate can offer an action the backend did not name.
293
+ */
294
+ export function proxyActionOffer(): ProxyActionOffer | null {
295
+ return proxyAction;
296
+ }
297
+
281
298
  /** The proxy's preferred model id ("" when it does not declare one). */
282
299
  export function getProxyDefaultModel(): string {
283
300
  return proxyDefaultModel;
@@ -369,6 +386,13 @@ export async function fetchAvailableModels(
369
386
  proxyDefaultModel = data.defaultModel ?? "";
370
387
  proxyCode = data.code;
371
388
  proxyModelsError = data.upstreamError !== undefined ? (data.upstreamMessage ?? "") : "";
389
+ proxyAction =
390
+ data.code === "action_required"
391
+ ? {
392
+ actions: offeredActions(data.actions),
393
+ ...(typeof data.detail === "string" && data.detail ? { detail: data.detail } : {}),
394
+ }
395
+ : null;
372
396
  /* Capabilities are kept, not dropped. The backend has reported `toolSupport` all along and the
373
397
  ingest mapped `{id, name}` only, so a Workers AI model that cannot call tools looked exactly
374
398
  like one that can — the agent loop ran, called nothing, and answered as if that were normal.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Ai-system-prompt.js — Dynamic system prompt builder for the Jx AI assistant
3
3
  *
4
- * Constructs the system prompt based on the current project context, open document,
5
- * and available components. The quality of AI output depends critically on this file.
4
+ * Constructs the system prompt based on the current project context, open document, and available
5
+ * components. The quality of AI output depends critically on this file.
6
6
  *
7
7
  * @license MIT
8
8
  */
@@ -656,6 +656,75 @@ When asked to build a site with multiple pages:
656
656
 
657
657
  // ─── System prompt builder ───────────────────────────────────────────────────
658
658
 
659
+ const ROLE = `You are an expert Jx builder assistant embedded in Jx Studio. You help users build websites, components, pages, and layouts using the Jx JSON schema. The live jxsuite.com marketing site is built entirely with Jx — you can produce production-quality Jx code.`;
660
+
661
+ /**
662
+ * What to do when a tool call fails. Static: it names tools and validation messages, never the
663
+ * state, so it belongs to the cacheable prefix rather than after the dynamic sections.
664
+ */
665
+ const ERROR_RECOVERY = `## Error Recovery
666
+
667
+ If a tool call fails (returns { success: false }):
668
+ 1. Read the error message carefully — it includes a "→ Fix:" hint telling you exactly how to correct the error.
669
+ 2. Each error points to a specific path in the document and a specific rule violation.
670
+ 3. Apply the suggested fix using set_property, delete_node, or add_child as appropriate.
671
+ 4. Do NOT re-issue the exact same tool call with the same arguments — you must CHANGE something.
672
+ 5. If you see the SAME error after 2 attempts, try a completely different approach (e.g., remove and re-add the node instead of patching it).
673
+
674
+ ### Common validation errors and their fixes:
675
+
676
+ | Error pattern | What happened | How to fix |
677
+ |---|---|---|
678
+ | "must NOT have additional property" in style | You used a non-camelCase CSS property (e.g. "background-color") or put an HTML attribute directly on the element. | Use camelCase: "backgroundColor". Put aria-*, data-*, role, and other non-IDL attributes inside the "attributes" object: { "attributes": { "aria-label": "..." } } |
679
+ | "must match pattern" on tagName | A custom element tag name doesn't contain a hyphen. | Add a hyphen: "newsletter-form" not "newsletter". Standard HTML elements use their exact name ("div", "p", "input"). |
680
+ | "must be string" | A value is an unquoted number, boolean, or bare word. | Wrap the value in quotes: "10px" not 10px. All CSS values and text must be strings. |
681
+ | "must be number" / "must be integer" | A numeric field (like index, tabIndex) is wrapped in quotes. | Remove the quotes: use 0 not "0". |
682
+ | "must have required property" | A required field is missing from the node. | Add the missing property. Every element must have "tagName". |
683
+ | "must be object" | A field that expects an object (like style or attributes) received a string or other type. | Use {} not a string. |
684
+ | "No node exists at path" | The path you provided doesn't point to an existing node. | Call read_document first to see the current structure and valid paths, then use the correct path. |
685
+
686
+ ### If you keep getting errors:
687
+ - Call read_document again — the document may have changed since you last read it.
688
+ - Remove the problematic node entirely with delete_node, then re-create it correctly with add_child.
689
+ - If the error message points to a different path than you expected, the node might have moved due to previous edits.`;
690
+
691
+ /** What every section of the prompt is joined with. */
692
+ const SECTION_SEPARATOR = "\n\n---\n\n";
693
+
694
+ /**
695
+ * The part of every system prompt that does not depend on the state, byte for byte: the role, the
696
+ * Jx reference sections, and the error-recovery guidance, each followed by the section separator.
697
+ *
698
+ * It exists for providers' prompt caches. A cache matches on a PREFIX of the request, and the
699
+ * system prompt is the request's first message, so the reference material (most of the prompt's
700
+ * tokens) only hits when nothing that changes between rounds comes before it. The prompt used to
701
+ * open with the role, the workflow for the current state and its tool list, so opening a document
702
+ * or registering one more command tool moved the first changed byte to the second paragraph and
703
+ * every request after it paid for the whole reference again. Everything dynamic — the mode, the
704
+ * tool list, the document, project and file summaries — now follows this prefix, and the closing
705
+ * instructions stay last. {@link buildSystemPrompt} always starts with it, and its tests hold that
706
+ * for every combination of the state flags.
707
+ */
708
+ export const SYSTEM_PROMPT_STATIC_PREFIX =
709
+ [
710
+ // 1. Role
711
+ ROLE,
712
+ // 2. Jx schema reference — kept in ALL modes (pre-project the model plans starter content)
713
+ JX_SCHEMA_REFERENCE,
714
+ // 2a. State shape decision tree
715
+ STATE_SHAPE_DECISION_TREE,
716
+ // 2b. Real-world patterns
717
+ REAL_WORLD_PATTERNS,
718
+ // 2c. Design principles — spacing, type, color, layout, restraint
719
+ DESIGN_PRINCIPLES,
720
+ // 2d. Control flow & reactivity — signals, list rendering ($map), conditionals ($switch)
721
+ CONTROL_FLOW_PATTERNS,
722
+ // 2e. Multi-page site building — layouts, file-based routing, navigation
723
+ MULTI_PAGE_PATTERNS,
724
+ // 3. Error recovery guidance
725
+ ERROR_RECOVERY,
726
+ ].join(SECTION_SEPARATOR) + SECTION_SEPARATOR;
727
+
659
728
  /**
660
729
  * Build a dynamic system prompt for the AI assistant.
661
730
  *
@@ -681,7 +750,7 @@ export function buildSystemPrompt({
681
750
  }: BuildSystemPromptOptions = {}) {
682
751
  const hasDocument = Boolean(document);
683
752
 
684
- // 1. Role, state-appropriate workflow, and the tool list for the current state.
753
+ // The state-appropriate workflow and the tool list for the current state.
685
754
  // The list the model is TOLD about and the list the gate will honour are the same filter, so a
686
755
  // Refusal is never a surprise to it. The command-projected tools arrive already filtered by
687
756
  // Their records' own gates, for the same reason — one function answers both the prompt and the
@@ -695,8 +764,6 @@ export function buildSystemPrompt({
695
764
  .map((blurb) => `- ${blurb}`)
696
765
  .join("\n");
697
766
 
698
- const role = `You are an expert Jx builder assistant embedded in Jx Studio. You help users build websites, components, pages, and layouts using the Jx JSON schema. The live jxsuite.com marketing site is built entirely with Jx — you can produce production-quality Jx code.`;
699
-
700
767
  let workflow: string;
701
768
  if (!hasProject && !hasDocument) {
702
769
  workflow = `No project is open yet. Your first job is to bootstrap one:
@@ -734,23 +801,10 @@ ${ASKING_THE_USER}
734
801
 
735
802
  Be concise. Don't explain what Jx is unless asked. Just build.`;
736
803
 
737
- const sections = [`${role}\n\n${workflow}\n\n${closing}`];
738
-
739
- // eslint-disable-next-line unicorn/no-immediate-mutation -- conditional section builder: later sections are pushed only when their context exists
740
- sections.push(
741
- // 2. Jx schema reference — kept in ALL modes (pre-project the model plans starter content)
742
- JX_SCHEMA_REFERENCE,
743
- // 3. State shape decision tree
744
- STATE_SHAPE_DECISION_TREE,
745
- // 4. Real-world patterns
746
- REAL_WORLD_PATTERNS,
747
- // 4a. Design principles — spacing, type, color, layout, restraint
748
- DESIGN_PRINCIPLES,
749
- // 4b. Control flow & reactivity — signals, list rendering ($map), conditionals ($switch)
750
- CONTROL_FLOW_PATTERNS,
751
- // 4c. Multi-page site building — layouts, file-based routing, navigation
752
- MULTI_PAGE_PATTERNS,
753
- );
804
+ /* 4. The mode. Everything from here on is a function of what is open right now, so it all follows
805
+ SYSTEM_PROMPT_STATIC_PREFIX: a provider's prompt cache matches on a prefix, and the first byte
806
+ that differs between two requests ends the match. */
807
+ const sections = [`## Current Mode\n\n${workflow}`];
754
808
 
755
809
  // 5. Current document context
756
810
  if (document) {
@@ -758,7 +812,7 @@ Be concise. Don't explain what Jx is unless asked. Just build.`;
758
812
  sections.push(`## Current Document\n\n${summary}`);
759
813
  }
760
814
 
761
- // 6. Project context
815
+ // 5a. Project context
762
816
  if (hasProject && (projectConfig || components || projectRoot)) {
763
817
  const projectSummary = buildProjectSummary({
764
818
  components,
@@ -771,7 +825,7 @@ Be concise. Don't explain what Jx is unless asked. Just build.`;
771
825
  }
772
826
  }
773
827
 
774
- // 6a. File inventory — a compact map of the project for cross-file work
828
+ // 5b. File inventory — a compact map of the project for cross-file work
775
829
  if (hasProject && fileInventory && fileInventory.length > 0) {
776
830
  const capped = fileInventory.slice(0, FILE_INVENTORY_CAP);
777
831
  const more =
@@ -781,34 +835,10 @@ Be concise. Don't explain what Jx is unless asked. Just build.`;
781
835
  sections.push(`## Project Files\n\n${capped.join("\n")}${more}`);
782
836
  }
783
837
 
784
- // 7. Error recovery guidance
785
- sections.push(`## Error Recovery
786
-
787
- If a tool call fails (returns { success: false }):
788
- 1. Read the error message carefully — it includes a "→ Fix:" hint telling you exactly how to correct the error.
789
- 2. Each error points to a specific path in the document and a specific rule violation.
790
- 3. Apply the suggested fix using set_property, delete_node, or add_child as appropriate.
791
- 4. Do NOT re-issue the exact same tool call with the same arguments — you must CHANGE something.
792
- 5. If you see the SAME error after 2 attempts, try a completely different approach (e.g., remove and re-add the node instead of patching it).
793
-
794
- ### Common validation errors and their fixes:
795
-
796
- | Error pattern | What happened | How to fix |
797
- |---|---|---|
798
- | "must NOT have additional property" in style | You used a non-camelCase CSS property (e.g. "background-color") or put an HTML attribute directly on the element. | Use camelCase: "backgroundColor". Put aria-*, data-*, role, and other non-IDL attributes inside the "attributes" object: { "attributes": { "aria-label": "..." } } |
799
- | "must match pattern" on tagName | A custom element tag name doesn't contain a hyphen. | Add a hyphen: "newsletter-form" not "newsletter". Standard HTML elements use their exact name ("div", "p", "input"). |
800
- | "must be string" | A value is an unquoted number, boolean, or bare word. | Wrap the value in quotes: "10px" not 10px. All CSS values and text must be strings. |
801
- | "must be number" / "must be integer" | A numeric field (like index, tabIndex) is wrapped in quotes. | Remove the quotes: use 0 not "0". |
802
- | "must have required property" | A required field is missing from the node. | Add the missing property. Every element must have "tagName". |
803
- | "must be object" | A field that expects an object (like style or attributes) received a string or other type. | Use {} not a string. |
804
- | "No node exists at path" | The path you provided doesn't point to an existing node. | Call read_document first to see the current structure and valid paths, then use the correct path. |
805
-
806
- ### If you keep getting errors:
807
- - Call read_document again — the document may have changed since you last read it.
808
- - Remove the problematic node entirely with delete_node, then re-create it correctly with add_child.
809
- - If the error message points to a different path than you expected, the node might have moved due to previous edits.`);
838
+ // 6. Closing — last, where a long prompt's final instructions carry the most weight.
839
+ sections.push(closing);
810
840
 
811
- return sections.join("\n\n---\n\n");
841
+ return SYSTEM_PROMPT_STATIC_PREFIX + sections.join(SECTION_SEPARATOR);
812
842
  }
813
843
 
814
844
  // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * Context-manager.js — Token budget management for the AI assistant
3
3
  *
4
- * Estimates token usage and trims conversation history before each send to keep the
5
- * context window within safe limits (specs/ai.md §2 and §2.1).
4
+ * Estimates token usage and trims conversation history before each send to keep the context window
5
+ * within safe limits (specs/ai.md §2 and §2.1).
6
6
  *
7
- * Strategy (MVP): keep the system prompt + the most recent messages, dropping the oldest
8
- * messages when the estimated total exceeds the configured token budget. Dropped messages
9
- * are replaced with a single summary note so the model knows history was truncated.
7
+ * Strategy (MVP): keep the system prompt + the most recent messages, dropping the oldest messages
8
+ * when the estimated total exceeds the configured token budget. Dropped messages are replaced with
9
+ * a single summary note so the model knows history was truncated.
10
10
  *
11
11
  * @license MIT
12
12
  */
13
13
 
14
14
  import type { createChatState } from "@jxsuite/ai/chat-state";
15
+ import {
16
+ SEAL_RELOADED,
17
+ repairToolPairs,
18
+ toChatMessages,
19
+ toLiveMessages,
20
+ } from "@jxsuite/ai/messages";
15
21
  import { modelContextWindow } from "./ai-models";
16
22
 
17
23
  /** Shape of a single entry returned by `chatState.toMessagesArray()`. */
@@ -273,91 +279,56 @@ export function trimContext(
273
279
  // ─── Orphaned tool calls ────────────────────────────────────────────────────
274
280
 
275
281
  /**
276
- * What a synthesized result says, so a reader and the model see the same reason. Exported so a
277
- * restore can tell a seal from a reply (services/tool-outcomes.ts).
282
+ * What a seal says for a call that was not cut off, so a reader and the model see the same reason.
283
+ * Exported so a restore can name the outcome of an ordinary call nothing answered
284
+ * (services/tool-outcomes.ts); `isSealContent` tells any seal from a reply.
278
285
  */
279
- export const UNANSWERED_TOOL_RESULT = JSON.stringify({
280
- success: false,
281
- error:
282
- "This tool call was never completed — the session was reloaded or the history was trimmed.",
283
- });
286
+ export const UNANSWERED_TOOL_RESULT = JSON.stringify({ success: false, error: SEAL_RELOADED });
284
287
 
285
288
  /**
286
289
  * Repair tool-call pairing in place, and report how many messages it touched.
287
290
  *
288
291
  * `toMessagesArray()` serializes the array verbatim: an assistant message with `tool_calls` becomes
289
292
  * a `tool_calls` request, and a `tool` message becomes a reply carrying `tool_call_id`. Providers
290
- * require the two to come in pairs, and **three ordinary things break the pairing**:
293
+ * require each reply directly after the request that made the call, and **four ordinary things
294
+ * break that**:
291
295
  *
292
296
  * 1. `saveSession` persists `msgs.slice(-MAX_PERSIST_MESSAGES)` and `restoreChat` pushes the result
293
297
  * back, so a session longer than the cap can be restored starting mid-pair.
294
298
  * 2. {@link trimContext} splices from the front for the token budget, with no more regard for pairs.
295
299
  * 3. A turn that stopped while a tool was still running — which `ask_user` makes routine, because its
296
300
  * tool is _designed_ to be outstanding — leaves a request with no reply.
301
+ * 4. A transcript written before one window ran one turn can hold a reply after a later message.
297
302
  *
298
303
  * Each of those produced a 400 on the NEXT send, from a conversation the user could see was fine.
299
- * So the repair belongs on the send path, after trimming, rather than at any one of the three
300
- * sites: a request with no reply gets a synthesized failure (keeping the assistant's text, which
301
- * dropping the message would lose), and a reply with no request is dropped.
304
+ * So the repair belongs on the send path, after trimming, rather than at any one of the sites. It
305
+ * is `repairToolPairs` (`@jxsuite/ai/messages`, specs/ai.md §3.4), the same repair the turn engine
306
+ * runs at turn start, applied to the chat store: pairing is per request rather than by call id
307
+ * across the transcript, because a provider may reuse `call_0` every round; a reply away from its
308
+ * request is moved back beside it; a request with no reply is sealed with a synthesized failure
309
+ * (keeping the assistant's text, which dropping the message would lose) that says whether the call
310
+ * was cut off mid-arguments; and a reply with no request is dropped.
311
+ *
312
+ * The store is rebuilt with one `splice`, and only when something changed. Every message the repair
313
+ * kept is the store's own object, which the streaming turn and the panel hold by identity, and the
314
+ * array itself is never reassigned.
302
315
  *
303
316
  * @param {ReturnType<typeof createChatState>} chatState
304
- * @returns {{ sealed: number; dropped: number }}
317
+ * @returns {{ dropped: number; moved: number; sealed: number }}
305
318
  */
306
319
  export function pruneOrphanToolMessages(chatState: ReturnType<typeof createChatState>): {
307
- sealed: number;
308
320
  dropped: number;
321
+ moved: number;
322
+ sealed: number;
309
323
  } {
310
- const { messages } = chatState;
311
-
312
- const requested = new Set<string>();
313
- const answered = new Set<string>();
314
- for (const msg of messages) {
315
- if (msg.role === "assistant" && msg.toolCalls) {
316
- for (const call of msg.toolCalls) {
317
- requested.add(call.id);
318
- }
319
- } else if (msg.role === "tool" && msg.toolCallId) {
320
- answered.add(msg.toolCallId);
321
- }
324
+ const live = chatState.messages;
325
+ const neutral = toChatMessages(live);
326
+ const { messages, sealed, dropped, moved } = repairToolPairs(neutral);
327
+ if (sealed.length + dropped.length + moved.length === 0) {
328
+ return { dropped: 0, moved: 0, sealed: 0 };
322
329
  }
323
-
324
- // Replies with no request, and replies with no id at all — neither can be serialized into a
325
- // Well-formed pair, and a reply is the half that carries no author text worth keeping.
326
- let dropped = 0;
327
- for (let i = messages.length - 1; i >= 0; i--) {
328
- const msg = messages[i]!;
329
- if (msg.role === "tool" && (!msg.toolCallId || !requested.has(msg.toolCallId))) {
330
- messages.splice(i, 1);
331
- dropped += 1;
332
- }
333
- }
334
-
335
- /* Requests with no reply. Walk backwards so each insertion is past the indices still to visit,
336
- and seal immediately after the requesting message — `toMessagesArray` emits in array order, so
337
- that is the only position the pair is adjacent in. */
338
- let sealed = 0;
339
- for (let i = messages.length - 1; i >= 0; i--) {
340
- const msg = messages[i]!;
341
- if (msg.role !== "assistant" || !msg.toolCalls) {
342
- continue;
343
- }
344
- const missing = msg.toolCalls.filter((call) => !answered.has(call.id));
345
- if (missing.length === 0) {
346
- continue;
347
- }
348
- messages.splice(
349
- i + 1,
350
- 0,
351
- ...missing.map((call, n) => ({
352
- content: UNANSWERED_TOOL_RESULT,
353
- id: `sealed_${call.id}_${n}`,
354
- role: "tool" as const,
355
- timestamp: msg.timestamp,
356
- toolCallId: call.id,
357
- })),
358
- );
359
- sealed += missing.length;
360
- }
361
-
362
- return { dropped, sealed };
330
+ const liveOf = new Map(neutral.map((message, index) => [message, live[index]!]));
331
+ const next = messages.map((message) => liveOf.get(message) ?? toLiveMessages([message])[0]!);
332
+ live.splice(0, live.length, ...next);
333
+ return { dropped: dropped.length, moved: moved.length, sealed: sealed.length };
363
334
  }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { createChatState, createProxyStreamingClient, createToolRegistry } from "@jxsuite/ai";
13
+ import { createTurnLock } from "@jxsuite/ai/harness";
13
14
  import { createSessionFacts } from "@jxsuite/ai/tools";
14
15
  import type { ProjectConfig } from "@jxsuite/schema/types";
15
16
  import { getPlatform } from "../platform";
@@ -233,14 +234,17 @@ export function createDocumentAssistant() {
233
234
  let controller: AbortController | null = null;
234
235
 
235
236
  /**
236
- * Whether a turn is in flight: true from the moment a send is accepted until its loop has ended.
237
+ * One window runs one turn. The lock is what decides it: a send is accepted only when it can take
238
+ * the lock, and holds it until its loop has ended.
237
239
  *
238
240
  * Not the chat's `status`, which belongs to the token stream: it reads idle while a round's tools
239
241
  * run, while a question waits on the author and during an import. Guarding on it let a second
240
242
  * send start a second turn beside the first, and let the composer offer Send in the middle of
241
- * one. One window runs one turn, and this is the fact that says whether it is running.
243
+ * one. `turnActive` is the lock's reactive reading, for the panel's effects.
242
244
  */
245
+ const turnLock = createTurnLock();
243
246
  const turnActive = shallowRef(false);
247
+ let turnCount = 0;
244
248
 
245
249
  /** Settles when the turn in flight has ended, finally included; already settled when none is. */
246
250
  let turnEnded: Promise<void> = Promise.resolve();
@@ -287,7 +291,7 @@ export function createDocumentAssistant() {
287
291
  }
288
292
 
289
293
  async function sendMessage(text: string) {
290
- if (!text.trim() || turnActive.value || chatState.status === "streaming") {
294
+ if (!text.trim() || turnLock.active !== null || chatState.status === "streaming") {
291
295
  return;
292
296
  }
293
297
 
@@ -320,6 +324,10 @@ export function createDocumentAssistant() {
320
324
  resolved inside the first request, on the armed signal. */
321
325
  controller = new AbortController();
322
326
  const { signal } = controller;
327
+ /* Taken here rather than at the guard so nothing above can leave it held. Everything since the
328
+ guard is synchronous, so no other send can have taken it in between: it is free. */
329
+ turnCount += 1;
330
+ const releaseTurn = turnLock.acquire(`send:${turnCount}`)!;
323
331
  turnActive.value = true;
324
332
  let settleTurnEnded!: () => void;
325
333
  turnEnded = new Promise<void>((settle) => {
@@ -363,6 +371,7 @@ export function createDocumentAssistant() {
363
371
  chatState.setError(error instanceof Error ? error.message : String(error));
364
372
  } finally {
365
373
  controller = null;
374
+ releaseTurn();
366
375
  turnActive.value = false;
367
376
  // Persist again once the stream settled so the completed reply (or the state
368
377
  // After an error/abort cleanup) survives a reload without another send.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Chat-reducer.ts — a turn's events, applied to Studio's chat store.
3
+ *
4
+ * The chat store is Studio's transcript of record: the panel renders it and the session store saves
5
+ * it. The turn engine (`@jxsuite/ai/harness`) reports what a turn does as events, and this is the
6
+ * one place they become chat-store calls, in the order the agent loop always made them (specs/ai.md
7
+ * §3.8): the recorded agent traces hold the two to the same sequence, call for call.
8
+ *
9
+ * @license MIT
10
+ */
11
+
12
+ import type { createChatState } from "@jxsuite/ai/chat-state";
13
+ import type { HarnessEvent, TurnOutcome } from "@jxsuite/ai/harness";
14
+ import type { ImportProgressEvent } from "../../types";
15
+ import { recordImportProgress } from "../import-run";
16
+
17
+ type ChatStore = ReturnType<typeof createChatState>;
18
+
19
+ /** How a turn's end reads in the transcript: the cap message, an error row, or nothing more. */
20
+ function applyOutcome(chat: ChatStore, outcome: TurnOutcome): void {
21
+ switch (outcome.kind) {
22
+ case "cap_partial": {
23
+ // A partial success is said as the assistant, not as a failure (specs/ai.md §3.2).
24
+ chat.beginAssistantTurn();
25
+ chat.appendDelta(outcome.cap?.text ?? "");
26
+ chat.finishStream("length");
27
+ break;
28
+ }
29
+ case "cap_failed":
30
+ case "empty":
31
+ case "error": {
32
+ /* `setError` alone, never after `finishStream`: it removes the round's partial message, and
33
+ only while that message is still the streaming one. */
34
+ chat.setError(outcome.error?.message ?? "");
35
+ break;
36
+ }
37
+ default: {
38
+ break;
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Apply one turn event to the chat store.
45
+ *
46
+ * Round one answers into the placeholder `sendMessage` already pushed, so only a later round opens
47
+ * a message. A round that failed, or a turn that drew nothing, is never finished: its error row
48
+ * removes the partial. `turn_start`, `tool_start` and `write` change nothing here; the ledger
49
+ * records writes itself. Nor does `transcript_repaired`: the send path ran the same repair on the
50
+ * chat store before the turn began (`pruneOrphanToolMessages`), so the store is already the history
51
+ * the engine repaired.
52
+ *
53
+ * @param {ChatStore} chat
54
+ * @param {HarnessEvent} event
55
+ */
56
+ export function applyHarnessEvent(chat: ChatStore, event: HarnessEvent): void {
57
+ switch (event.type) {
58
+ case "round_start": {
59
+ if (event.round > 1) {
60
+ chat.beginAssistantTurn();
61
+ }
62
+ break;
63
+ }
64
+ case "text": {
65
+ chat.appendDelta(event.text);
66
+ break;
67
+ }
68
+ case "reasoning": {
69
+ /* Kept on the turn, not shown as answer text: the next round has to replay it
70
+ (specs/ai.md §2.2). */
71
+ chat.appendReasoning(event.text);
72
+ break;
73
+ }
74
+ case "tool_call_start": {
75
+ chat.appendToolCallStart(event.callId, event.name);
76
+ break;
77
+ }
78
+ case "tool_call_delta": {
79
+ chat.appendToolCallDelta(event.callId, event.args);
80
+ break;
81
+ }
82
+ case "tool_call_end": {
83
+ chat.appendToolCallEnd(event.callId);
84
+ break;
85
+ }
86
+ case "usage": {
87
+ /* The provider's own count replaces the four-characters-per-token estimate; the prompt it
88
+ covered is recorded with it, because the next send rebuilds the prompt. */
89
+ chat.recordUsage(event.frame, { systemTokens: event.systemTokens });
90
+ break;
91
+ }
92
+ case "round_end": {
93
+ if (!event.dropped) {
94
+ chat.finishStream(event.stopReason);
95
+ }
96
+ break;
97
+ }
98
+ case "tool_progress": {
99
+ recordImportProgress(event.callId, event.progress as unknown as ImportProgressEvent);
100
+ break;
101
+ }
102
+ case "tool_result": {
103
+ chat.appendToolResult(event.callId, event.result);
104
+ chat.pushToolResultMessage(event.callId, JSON.stringify(event.result));
105
+ break;
106
+ }
107
+ case "turn_end": {
108
+ applyOutcome(chat, event.outcome);
109
+ break;
110
+ }
111
+ default: {
112
+ break;
113
+ }
114
+ }
115
+ }