@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
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Turn-hooks.ts — what Studio does between the turn engine's steps.
3
+ *
4
+ * @license MIT
5
+ */
6
+
7
+ import type { TurnHooks } from "@jxsuite/ai/harness";
8
+ import { ensureProxyProbe, resetModelCache } from "../ai-models";
9
+
10
+ /** What Studio's hooks act on, injected so the hooks hold no state of their own. */
11
+ export interface StudioTurnHookDeps {
12
+ /** Whether the conversation the turn answers has left the transcript (another chat was opened). */
13
+ superseded: () => boolean;
14
+ /** Called once a call settles into a transcript that replaced the turn's own. */
15
+ onSuperseded: () => void;
16
+ /** Re-anchor the undo batch on the document the tools now edit (specs/ai.md §3.3). */
17
+ reanchor: () => void;
18
+ }
19
+
20
+ /**
21
+ * Studio's turn hooks.
22
+ *
23
+ * - **After each call**, a turn whose conversation has been replaced stops there: its reply belongs
24
+ * to a request that is no longer on screen, and writing it would land a stray `tool` message in
25
+ * the other conversation. Otherwise the undo batch follows the document the tools moved to, which
26
+ * a tool, project adoption or the author clicking another tab can each change.
27
+ * - **On a stream error**, a lapsed hosted grant (`cf_reconnect_required`) drops the model reading
28
+ * and re-probes (specs/ai.md §2.1): the probe settles once at boot, so without this every gate
29
+ * keeps offering an assistant that cannot answer, and the failed send is the only evidence.
30
+ *
31
+ * @param {StudioTurnHookDeps} deps
32
+ * @returns {TurnHooks}
33
+ */
34
+ export function studioTurnHooks(deps: StudioTurnHookDeps): TurnHooks {
35
+ return {
36
+ afterTool() {
37
+ if (deps.superseded()) {
38
+ deps.onSuperseded();
39
+ return;
40
+ }
41
+ deps.reanchor();
42
+ },
43
+ onStreamError(error) {
44
+ if (error.code === "cf_reconnect_required") {
45
+ resetModelCache();
46
+ ensureProxyProbe();
47
+ }
48
+ },
49
+ };
50
+ }
@@ -1,60 +1,40 @@
1
1
  /**
2
- * Tool-executor.js — Agentic loop driver for the document AI assistant
2
+ * Tool-executor.js — the agent loop, as Studio runs it.
3
3
  *
4
- * Streams a chat round, executes any tool calls the model makes (via a ToolRegistry backed by
5
- * `transactDoc()`), feeds the results back as `tool` messages, and re-streams — up to a capped
6
- * number of rounds (specs/ai.md §3.2 to §3.4).
4
+ * The loop itself is the turn engine in `@jxsuite/ai/harness` (specs/ai.md §3.8): it streams each
5
+ * round, runs the calls the model made, feeds their results back, and decides how the turn ends.
6
+ * What stays here is Studio's side of a turn, which no other host shares:
7
7
  *
8
- * Two `ai.md` §3.2 honesty rules live here rather than in the panel, because they are properties of
9
- * the RUN and a panel can only render what the run recorded:
10
- *
11
- * - **A partial success is not a failure.** Running out of rounds called `setError`, which paints
12
- * the turn red and — in `chat-state.ts` — DELETES the streaming message, so a turn that applied
13
- * four edits and then hit the cap reported as an error that had also erased its own account of
14
- * the four edits. The cap is now an ordinary assistant message whenever anything was applied, and
15
- * an error only when nothing was. **Applied means it wrote**: a call counts when it succeeded with
16
- * a summary AND the write ledger grew by an `ok` write while it ran, not merely when it returned a
17
- * summary, because a read returns a summary too, and a turn that only looked around was reported
18
- * as having changed things.
19
- * - **A turn that drew nothing says so.** A model that answered with neither text nor a tool call
20
- * left the author's message sitting there with no reply at all. That turn ends on an error row,
21
- * with Retry, instead.
22
- * - **A batch belongs to the tab it edits.** `beginBatch(getTab())` ran once, against whichever tab
23
- * happened to be active when the loop started. A turn that then moved to a second document closed
24
- * its batch against the FIRST tab, so the second document's edits got neither a history snapshot
25
- * nor a collab publish. The loop re-anchors the batch whenever the active tab changes under it.
8
+ * - **The chat store is the transcript of record.** The engine's events are applied to it as they
9
+ * happen (`services/harness/chat-reducer.ts`), and each round's request is projected from it, as
10
+ * it always was, so the provider sees the conversation the panel shows, with whatever the send
11
+ * path trimmed or sealed before the turn began.
12
+ * - **A batch belongs to the tab it edits** (§3.3). The undo batch opens on the active tab and is
13
+ * re-anchored after every call whose tools moved to another document, so a turn that edits two
14
+ * documents gives each its own history step and collab publish.
15
+ * - **The ledger is filed under the last message the turn drew** (§3.2), in this module's `finally`,
16
+ * so the transcript draws the turn's changed-files summary under it.
26
17
  *
27
18
  * @license MIT
28
19
  */
29
20
 
30
21
  import type { createChatState } from "@jxsuite/ai/chat-state";
22
+ import { DEFAULT_TURN_POLICY, runTurn } from "@jxsuite/ai/harness";
23
+ import type { TurnRun } from "@jxsuite/ai/harness";
24
+ import { toChatMessages, toOpenAITools } from "@jxsuite/ai/messages";
31
25
  import type { StreamingClient } from "@jxsuite/ai/streaming-client";
32
- import { createSessionFacts, createToolContext, linkCallSignal } from "@jxsuite/ai/tools";
33
- import type { Actor, SessionFacts, ToolRegistry } from "@jxsuite/ai/tools";
26
+ import { createSessionFacts } from "@jxsuite/ai/tools";
27
+ import type { SessionFacts, ToolRegistry } from "@jxsuite/ai/tools";
34
28
 
35
29
  import type { Tab } from "../tabs/tab";
36
- import type { ImportProgressEvent } from "../types";
37
30
  import { batchTab, beginBatch, endBatch } from "../tabs/transact";
38
- import { ensureProxyProbe, resetModelCache } from "./ai-models";
39
31
  import { estimatePromptTokens } from "./context-manager";
40
32
  import { fileTurn, openTurnLedger, turnAnchor } from "./ai-writes";
41
- import { recordImportProgress } from "./import-run";
42
-
43
- const MAX_ROUNDS = 5;
33
+ import { applyHarnessEvent } from "./harness/chat-reducer";
34
+ import { studioTurnHooks } from "./harness/turn-hooks";
44
35
 
45
36
  /** What a turn that drew nothing says: the model answered with neither text nor a tool call. */
46
- export const EMPTY_TURN_TEXT = "The model sent back an empty reply.";
47
-
48
- /**
49
- * Hard ceiling on rounds of every kind, so the loop terminates whatever the model does.
50
- *
51
- * {@link MAX_ROUNDS} bounds AUTONOMOUS work, and a round that ends by blocking on a person is the
52
- * opposite of runaway — it cannot advance without them, which is the property the cap was ever a
53
- * proxy for. So an interactive round does not spend that budget. This one is the backstop that
54
- * keeps `runAgentLoop` provably terminating anyway; a human answering ten questions will have
55
- * stopped it long before.
56
- */
57
- const MAX_TOTAL_ROUNDS = 25;
37
+ export const EMPTY_TURN_TEXT = DEFAULT_TURN_POLICY.emptyText;
58
38
 
59
39
  interface RunAgentLoopOptions {
60
40
  chatState: ReturnType<typeof createChatState>;
@@ -80,35 +60,15 @@ export async function runAgentLoop({
80
60
  getTab,
81
61
  session = createSessionFacts(),
82
62
  }: RunAgentLoopOptions): Promise<void> {
83
- const allErrors: string[] = [];
84
- const appliedSummaries: string[] = [];
85
-
86
63
  /* The ledger is filed under the turn's last DRAWN assistant message (see services/ai-writes), so
87
64
  the scan starts at the message this turn answers. */
88
65
  const turnUserId = chatState.messages.findLast((m) => m.role === "user")?.id;
89
- /* What every call's context carries (specs/ai.md §3.7): this turn's ledger, the conversation's
90
- facts, and who the calls act as. The signal and the call id are the call's own, below. */
91
66
  const ledger = openTurnLedger(`turn:${chatState.messages.length}`);
92
- const actor: Actor = {
93
- id: `assistant:${session.sessionId ?? "local"}:${ledger.turnId}`,
94
- kind: "assistant",
95
- model: chatState.model,
96
- sessionId: session.sessionId,
97
- turnId: ledger.turnId,
98
- };
99
67
 
100
68
  // Batch all tool-call mutations into a single undo step, anchored on the tab being edited.
101
69
  if (getTab) {
102
70
  beginBatch(getTab());
103
71
  }
104
-
105
- /**
106
- * Close the batch and re-open it when the tools have moved to a different document.
107
- *
108
- * Runs after every tool execution rather than only inside `open_document`, because a tool is not
109
- * the only thing that can change the active tab: project adoption replaces every tab in the
110
- * workspace, and the user is free to click another tab while the model is still streaming.
111
- */
112
72
  const reanchorBatch = () => {
113
73
  if (!getTab) {
114
74
  return;
@@ -120,237 +80,58 @@ export async function runAgentLoop({
120
80
  }
121
81
  };
122
82
 
83
+ /* The placeholder `sendMessage` pushed is round one's message, so the turn answers into it; the
84
+ conversation the turn continues is everything before it. */
85
+ const last = chatState.messages.at(-1);
86
+ const placeholder =
87
+ chatState.status === "streaming" && last?.role === "assistant" ? last : undefined;
88
+ const history = toChatMessages(
89
+ placeholder ? chatState.messages.slice(0, -1) : chatState.messages,
90
+ );
91
+
92
+ /* Once another chat has replaced the one this turn answers, nothing more of the turn is written
93
+ into the transcript: its events belong to a conversation that is no longer on screen. */
94
+ let superseded = false;
95
+ let run: TurnRun | undefined;
123
96
  try {
124
- /* Rounds that DID something, which is what MAX_ROUNDS bounds — see MAX_TOTAL_ROUNDS. */
125
- let workRounds = 0;
126
- for (let round = 1; round <= MAX_TOTAL_ROUNDS && workRounds < MAX_ROUNDS; round++) {
127
- const messages = chatState.toMessagesArray();
128
- const tools = toolRegistry.listForLLM();
129
-
130
- const toolCalls = new Map<string, { name: string; arguments: string }>();
131
- let stopReason = "stop";
132
- let streamError = null;
133
- let streamErrorCode: string | undefined;
134
-
135
- for await (const event of streamingClient.streamChat(
136
- messages,
137
- tools,
138
- systemPrompt,
139
- signal as AbortSignal,
140
- )) {
141
- switch (event.type) {
142
- case "delta": {
143
- chatState.appendDelta(event.content);
144
- break;
145
- }
146
- case "reasoning": {
147
- /* Kept on the turn, not shown as answer text — the next round has to replay it (see
148
- chat-state's toMessagesArray). */
149
- chatState.appendReasoning(event.content);
150
- break;
151
- }
152
- case "tool_call_start": {
153
- chatState.appendToolCallStart(event.id, event.name);
154
- toolCalls.set(event.id, { name: event.name, arguments: "" });
155
- break;
156
- }
157
- case "tool_call_delta": {
158
- chatState.appendToolCallDelta(event.id, event.args);
159
- const tc = toolCalls.get(event.id);
160
- if (tc) {
161
- tc.arguments += event.args;
162
- }
163
- break;
164
- }
165
- case "tool_call_end": {
166
- chatState.appendToolCallEnd(event.id);
167
- break;
168
- }
169
- case "usage": {
170
- /* The provider's own count replaces the four-characters-per-token estimate. The prompt
171
- it covered is recorded with it, because the next send rebuilds the prompt. */
172
- chatState.recordUsage(event, { systemTokens: estimatePromptTokens(systemPrompt) });
173
- break;
174
- }
175
- case "done": {
176
- ({ stopReason } = event);
177
- break;
178
- }
179
- case "error": {
180
- streamError = event.message;
181
- streamErrorCode = event.code;
182
- break;
183
- }
184
- default: {
185
- break;
186
- }
187
- }
188
- }
189
-
190
- if (streamError) {
191
- /* A lapsed hosted grant is the one stream error that makes the app's OWN reading wrong.
192
- The probe settles once at boot, so without this every gate keeps offering an assistant
193
- that cannot answer, and the send that just failed is the only evidence anyone has. Drop
194
- the reading and re-probe, so the next paint carries the Reconnect CTA instead. */
195
- if (streamErrorCode === "cf_reconnect_required") {
196
- resetModelCache();
197
- ensureProxyProbe();
198
- }
199
- /* `setError` alone, never after `finishStream`: it removes the round's partial message,
200
- and only while that message is still the streaming one. Finishing first let go of it, so
201
- the partial (a tool call cut off mid-arguments, as often as not) stayed in the
202
- transcript and went out on the next send. */
203
- chatState.setError(streamError);
204
- return;
205
- }
206
-
207
- /* A turn that has drawn nothing by the end of a round with no calls ends here, as an error
208
- the author can see and retry, rather than as silence under their message. `setError`
209
- before `finishStream`, so it removes the empty reply. A stopped round is not empty: the
210
- author ended it. */
211
- const stopped = stopReason === "cancelled" || signal?.aborted === true;
212
- if (toolCalls.size === 0 && !stopped && turnAnchor(chatState.messages, turnUserId) === null) {
213
- chatState.setError(EMPTY_TURN_TEXT);
214
- return;
215
- }
216
-
217
- chatState.finishStream(stopReason);
218
-
219
- /* The calls the model streamed decide whether tools run, not the finish reason the provider
220
- reported beside them. Some OpenAI-compatible backends (Workers AI among them) end a
221
- tool-calling turn with `finish_reason: "stop"`, and gating on `tool_calls` dropped those
222
- calls unrun — the turn ended looking finished, and the next send sealed them as failures.
223
- A call cut off by `length` still runs: its arguments fail to parse, and the model reads
224
- that as a tool error it can correct.
225
-
226
- Cancellation is the exception, and it is not a finish reason at all: it is the author
227
- pressing Stop. A stopped round runs nothing it streamed, including calls whose arguments
228
- were already complete, because Stop is the one control that must not be outrun. */
229
- if (toolCalls.size === 0 || stopReason === "cancelled" || signal?.aborted) {
230
- return;
231
- }
232
-
233
- let didWork = false;
234
- for (const [id, call] of toolCalls) {
235
- /* A Stop that lands between calls (while `ask_user` waits, or during a long import) stops
236
- the calls after it too. They stay unanswered, and the send path seals them. */
237
- if (signal?.aborted) {
238
- return;
97
+ run = runTurn({
98
+ history,
99
+ system: [{ text: systemPrompt }],
100
+ /* Projected from the chat store, the transcript of record. The streaming client is read on
101
+ every round, and the turn's own signal is the host's, so a request is the one it was. */
102
+ model: (request) =>
103
+ streamingClient.streamChat(
104
+ chatState.toMessagesArray(),
105
+ toOpenAITools(request.tools),
106
+ systemPrompt,
107
+ signal as AbortSignal,
108
+ ),
109
+ modelInfo: { family: "openai-compat", model: chatState.model },
110
+ tools: toolRegistry,
111
+ ...(signal ? { signal } : {}),
112
+ turnId: ledger.turnId,
113
+ ...(placeholder ? { firstMessageId: placeholder.id } : {}),
114
+ policy: { estimateTokens: estimatePromptTokens },
115
+ hooks: studioTurnHooks({
116
+ superseded: () =>
117
+ turnUserId !== undefined && !chatState.messages.some((m) => m.id === turnUserId),
118
+ onSuperseded: () => {
119
+ superseded = true;
120
+ run?.cancel("superseded");
121
+ },
122
+ reanchor: reanchorBatch,
123
+ }),
124
+ session,
125
+ ledger,
126
+ onEvent: (event) => {
127
+ if (!superseded) {
128
+ applyHarnessEvent(chatState, event);
239
129
  }
240
- let result;
241
- // The ledger's length before the call, so the call can be judged by what it recorded.
242
- const writesBefore = ledger.writes.length;
243
- /* The call's own signal: it aborts with the turn, so a tool waiting on a person or a crawl
244
- learns of a Stop, and it is unlinked once the call settles, so a Stop later in the turn
245
- reaches nothing that has already finished. */
246
- const link = signal ? linkCallSignal(signal) : null;
247
- const ctx = createToolContext({
248
- actor,
249
- callId: id,
250
- ledger,
251
- progress: (event) => {
252
- recordImportProgress(id, event as unknown as ImportProgressEvent);
253
- },
254
- session,
255
- ...(link ? { signal: link.signal } : {}),
256
- });
257
- try {
258
- result = await toolRegistry.execute(call.name, parseToolArguments(call.arguments), ctx);
259
- } catch (error) {
260
- result = {
261
- success: false,
262
- error: `Failed to parse arguments: ${(error as Error).message}`,
263
- };
264
- } finally {
265
- link?.release();
266
- }
267
- /* The transcript was replaced while the call ran: another chat was opened from Chat
268
- History, which stops the turn. The reply belongs to a request that is no longer there,
269
- so writing it would land a stray `tool` message in the other conversation, and could
270
- overwrite a restored record's result where a provider reuses call ids. */
271
- if (turnUserId !== undefined && !chatState.messages.some((m) => m.id === turnUserId)) {
272
- return;
273
- }
274
- // A call that suspends the turn on a person does not spend its work budget.
275
- if (toolRegistry.getDefinition(call.name)?.interactive !== true) {
276
- didWork = true;
277
- }
278
- reanchorBatch();
279
- if (!result.success && result.error) {
280
- allErrors.push(result.error);
281
- }
282
- const wrote = ledger.writes.slice(writesBefore).some((write) => write.ok);
283
- if (result.success && result.summary && wrote) {
284
- appliedSummaries.push(result.summary);
285
- }
286
- chatState.appendToolResult(id, result);
287
- chatState.pushToolResultMessage(id, JSON.stringify(result));
288
- }
289
-
290
- if (didWork) {
291
- workRounds += 1;
292
- }
293
- /* A Stop that landed during the round's last call. Nothing is left to stop in this round, so
294
- without this the loop opened the next one: a placeholder pushed after the author stopped,
295
- and a request streamed on the aborted signal. */
296
- if (signal?.aborted) {
297
- return;
298
- }
299
- if (workRounds < MAX_ROUNDS && round < MAX_TOTAL_ROUNDS) {
300
- chatState.beginAssistantTurn();
301
- }
302
- }
303
-
304
- /*
305
- * The round cap. Surface the actual errors so the user knows what went wrong, not just a
306
- * generic "I couldn't do it" — and, when anything was applied, say it as the ASSISTANT rather
307
- * than as a failure (`ai.md` §3.2). A partial success is not a failure.
308
- */
309
- const uniqueErrors = [...new Set(allErrors)];
310
- const applied =
311
- appliedSummaries.length > 0
312
- ? `\n\nChanges applied so far:\n${appliedSummaries.map((s) => `- ${s}`).join("\n")}`
313
- : "";
314
- const errors =
315
- uniqueErrors.length > 0
316
- ? `\n\nErrors encountered:\n${uniqueErrors.map((e) => `- ${e}`).join("\n")}`
317
- : "";
318
- const tail =
319
- `I ran out of tool-call rounds (${MAX_ROUNDS}) before finishing.${applied}${errors}` +
320
- `\n\nYou can continue by sending another message, or try a more specific request.`;
321
- if (appliedSummaries.length > 0) {
322
- chatState.beginAssistantTurn();
323
- chatState.appendDelta(tail);
324
- chatState.finishStream("length");
325
- return;
326
- }
327
- chatState.setError(tail);
130
+ },
131
+ });
132
+ await run.outcome;
328
133
  } finally {
329
134
  endBatch();
330
135
  fileTurn(turnAnchor(chatState.messages, turnUserId) ?? "", ledger.writes);
331
136
  }
332
137
  }
333
-
334
- /**
335
- * A call's arguments as the object a tool takes.
336
- *
337
- * An empty string is no arguments. JSON that parses to something other than an object (`null`, an
338
- * array, a number) is refused here with its own sentence, rather than reaching the registry, where
339
- * a validator reading a property of `null` threw a message about the validator. The prefix is the
340
- * one a JSON syntax error already carries, so the model reads both as one kind of mistake.
341
- *
342
- * @param {string} text - The call's accumulated argument text
343
- * @returns {object}
344
- * @throws {SyntaxError | TypeError} Caught by the loop and returned to the model as a tool error
345
- */
346
- function parseToolArguments(text: string): object {
347
- if (!text) {
348
- return {};
349
- }
350
- const parsed = JSON.parse(text) as unknown;
351
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
352
- const type = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
353
- throw new TypeError(`arguments must be a JSON object, got ${type}`);
354
- }
355
- return parsed;
356
- }
@@ -4,27 +4,29 @@
4
4
  * A tool call's outcome is recorded twice while a turn runs: as the `result` on its
5
5
  * `ToolCallRecord`, which the chip renders, and as the `tool` message that answers it, which the
6
6
  * provider reads. The second is the one that must survive, because it is the one the wire carries,
7
- * and it is the one that always did: until the turn became accountable (specs/ai.md §3.2) the first was never populated for a
8
- * finished call (the loop looked for the record after closing the stream), so every persisted
9
- * `result` was `null`: every restored chip rendered as still pending, and an answered question as
10
- * one still open (specs/ai.md §3.4).
7
+ * and it is the one that always did: until the turn became accountable (specs/ai.md §3.2) the first
8
+ * was never populated for a finished call (the loop looked for the record after closing the
9
+ * stream), so every persisted `result` was `null`: every restored chip rendered as still pending,
10
+ * and an answered question as one still open (specs/ai.md §3.4).
11
11
  *
12
12
  * So the tool message is the single source. A save strips `result` from every record, which keeps
13
13
  * the payload free of a second, disagreeing copy; a restore backfills each record from the reply
14
- * that answers it. A question nothing answered keeps no result, which is what makes it render
15
- * inert rather than as one still waiting; an ordinary call nothing answered never completed, and
16
- * says so.
14
+ * that answers it. A question nothing answered keeps no result, which is what makes it render inert
15
+ * rather than as one still waiting; an ordinary call nothing answered never completed, and says
16
+ * so.
17
17
  *
18
18
  * **A seal is not an answer.** When a question was left open, the send path seals its request with
19
19
  * a synthesized failure so the wire stays well-formed (`pruneOrphanToolMessages`). For an ordinary
20
20
  * call that failure IS the outcome to show: it never completed. For a question it is the absence of
21
21
  * an answer, so a restore leaves it without a result, and the chip draws the inert "still open when
22
- * the session was reloaded" card rather than a failure.
22
+ * the session was reloaded" card rather than a failure. Either seal counts, the one for a call that
23
+ * was cut off mid-arguments as well (`isSealContent`).
23
24
  *
24
- * @docs studio/ai/chat
25
25
  * @license MIT
26
+ * @docs studio/ai/chat
26
27
  */
27
28
 
29
+ import { isSealContent } from "@jxsuite/ai/messages";
28
30
  import type { ToolResult } from "@jxsuite/ai/tools";
29
31
  import type { PersistedMessage } from "./ai-session-store";
30
32
  import { UNANSWERED_TOOL_RESULT } from "./context-manager";
@@ -114,7 +116,7 @@ export function backfillToolResults(messages: readonly PersistedMessage[]): Pers
114
116
  const result = parseToolResult(reply.content);
115
117
  if (typeof reply.toolCallId === "string" && result && !replies.has(reply.toolCallId)) {
116
118
  replies.set(reply.toolCallId, result);
117
- if (reply.content === UNANSWERED_TOOL_RESULT) {
119
+ if (isSealContent(reply.content)) {
118
120
  sealed.add(reply.toolCallId);
119
121
  }
120
122
  }
@@ -30,7 +30,9 @@ import { clearCfConnection, getCfAccountId, getCfToken } from "../services/cf-se
30
30
  import { clearAiProvider, getBaseUrl, hasOpenAiKey } from "../services/ai-settings";
31
31
  import { preferredModel, resetModelCache } from "../services/ai-models";
32
32
  import { getPlatform, hasPlatform } from "../platform";
33
- import type { CfConnection } from "../types";
33
+ import { getAccountEntries } from "../account-status";
34
+ import { runOfferedAction } from "../account/action-flow";
35
+ import type { AccountEntry, CfConnection, OfferedAction } from "../types";
34
36
 
35
37
  /** One thing a row can do besides existing. */
36
38
  export interface AccountAction {
@@ -228,11 +230,47 @@ function localCloudflareRecord(): AccountRecord {
228
230
  };
229
231
  }
230
232
 
233
+ // ─── The platform's own rows ─────────────────────────────────────────────────
234
+
235
+ /**
236
+ * One row the platform adds (desktop.md §10.4) — a membership, a role, a plan; Studio does not know
237
+ * which, and draws it in the platform's words.
238
+ *
239
+ * Its id is namespaced so no platform can shadow one of Studio's own rows by naming it `github`.
240
+ * `revoke` does nothing: whatever the row describes is not a credential this app holds, so there is
241
+ * nothing here to forget, and its verbs are the actions the platform offers with it.
242
+ */
243
+ function platformAction(action: OfferedAction): AccountAction {
244
+ const verb: AccountAction = {
245
+ id: action.id,
246
+ label: action.label,
247
+ run: async () => {
248
+ await runOfferedAction(action);
249
+ },
250
+ };
251
+ if (action.primary) {
252
+ verb.variant = "accent";
253
+ }
254
+ return verb;
255
+ }
256
+
257
+ function platformRecord(entry: AccountEntry): AccountRecord {
258
+ return {
259
+ actions: (entry.actions ?? []).map((action) => platformAction(action)),
260
+ connected: entry.connected ?? false,
261
+ detail: entry.detail,
262
+ id: `platform:${entry.id}`,
263
+ label: entry.label,
264
+ revoke: () => {},
265
+ };
266
+ }
267
+
231
268
  /**
232
- * The three accounts, always all three.
269
+ * The three accounts, always all three — then any rows the platform adds.
233
270
  *
234
271
  * A disconnected account still gets a row: "you are not signed in to GitHub" is information, and a
235
- * list that hides what is absent cannot answer the question the section exists to answer.
272
+ * list that hides what is absent cannot answer the question the section exists to answer. The
273
+ * platform's rows are the platform's to include or leave out; Studio adds none of its own.
236
274
  */
237
275
  export function listAccounts(): AccountRecord[] {
238
276
  const githubToken = githubTokenStored();
@@ -260,6 +298,7 @@ export function listAccounts(): AccountRecord[] {
260
298
  revoke: clearAiProvider,
261
299
  },
262
300
  platformBrokersCf() ? brokeredCloudflareRecord() : localCloudflareRecord(),
301
+ ...getAccountEntries().map((entry) => platformRecord(entry)),
263
302
  ];
264
303
  }
265
304
 
@@ -19,7 +19,7 @@
19
19
  * this, `clearGithubToken()` had zero callers: signing out of GitHub was not expressible.
20
20
  * - **Keyboard** — GENERATED from `commands/reference.ts`'s `shortcutReference()`, the same
21
21
  * projection `docs/studio/interface/shortcuts.md` is built from. The registry is the one place a
22
- * chord is declared, so the sheet cannot drift from the app or from the docs. Per CLAUDE.md's
22
+ * chord is declared, so the sheet cannot drift from the app or from the docs. Per AGENTS.md's
23
23
  * Screenshot Policy there is deliberately no screenshot of it — photographing generated content
24
24
  * is a bug. It is searchable two ways, because there are two questions ("what is the shortcut for
25
25
  * X" and "what did I just press"), and rebindable — as a LAYER over the registry
@@ -44,6 +44,8 @@
44
44
 
45
45
  import { CHROME_THEMES, setChromeTheme, shell } from "../shell";
46
46
  import { layerHost } from "../ui/layers";
47
+ import { hydrateAccountStatus } from "../account-status";
48
+ import { getPlatform, hasPlatform } from "../platform";
47
49
  import { createAiCredentialsForm } from "../ui/ai-credentials-form";
48
50
  import { createManagedConnect } from "../ui/ai-managed-connect";
49
51
  import { activeRegistry } from "../commands/active-registry";
@@ -563,6 +565,11 @@ export function openPreferences(section?: string): Promise<null> {
563
565
  grant can lapse between visits, and a row that repaints the previous answer first is stating
564
566
  something it has no reason to believe. */
565
567
  resetCfConnectionCache();
568
+ /* And the platform's own account rows, for the same reason: whatever they report can change
569
+ between visits. They repaint when the answer lands; until then they show the last one. */
570
+ if (hasPlatform() && getPlatform().getAccountStatus) {
571
+ void hydrateAccountStatus().then(repaint);
572
+ }
566
573
  _section = section !== undefined && isPreferencesSection(section) ? section : _section;
567
574
  if (_surface) {
568
575
  repaint();