@jxsuite/studio 5.6.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 (173) hide show
  1. package/dist/chunks/{2020-zvax9fmb.js → 2020-cs2sb9t1.js} +3 -3
  2. package/dist/chunks/adapter-y5gdq19c.js +4 -0
  3. package/dist/chunks/{cf-account-picker-zfycs4k2.js → cf-account-picker-882a7ecm.js} +3 -3
  4. package/dist/chunks/cf-settings-20dznc3j.js +4 -0
  5. package/dist/chunks/components-q037dya7.js +4 -0
  6. package/dist/chunks/content-source-37x3vsap.js +4 -0
  7. package/dist/chunks/{convert-to-repeater-x19jr6c7.js → convert-to-repeater-grfep2w8.js} +3 -3
  8. package/dist/chunks/editor-j5sq149q.js +4 -0
  9. package/dist/chunks/entry-commands-c5h3nqck.js +4 -0
  10. package/dist/chunks/{extension-sections-y90sa0n4.js → extension-sections-dp8endhx.js} +3 -3
  11. package/dist/chunks/file-ops-937cvqk6.js +4 -0
  12. package/dist/chunks/files-tznwe1p1.js +4 -0
  13. package/dist/chunks/format-host-e2pjvd25.js +4 -0
  14. package/dist/chunks/fs-events-fv64ak3s.js +4 -0
  15. package/dist/chunks/git-panel-gb713ttc.js +4 -0
  16. package/dist/chunks/{grid-view-d9mybhn0.js → grid-view-5amhg604.js} +10 -10
  17. package/dist/chunks/iframe-host-t819mcg5.js +4 -0
  18. package/dist/chunks/{index-vnwy9d44.js → index-9ydfe04y.js} +3 -3
  19. package/dist/chunks/index-m0x5ztax.js +4 -0
  20. package/dist/chunks/{javascript-f0nrbdjb.js → javascript-d9pqgc2p.js} +2 -2
  21. package/dist/chunks/{jsonMode-nfa90dtf.js → jsonMode-800yvbem.js} +4 -4
  22. package/dist/chunks/jx-validate-zc9zp77s.js +4 -0
  23. package/dist/chunks/layers-4bsr82wz.js +4 -0
  24. package/dist/chunks/layers-panel-b6shnaax.js +4 -0
  25. package/dist/chunks/lit-html-cfn61dn7.js +4 -0
  26. package/dist/chunks/media-picker-ej0kdh1j.js +4 -0
  27. package/dist/chunks/menu-rtxf9chb.js +4 -0
  28. package/dist/chunks/{monaco-binding-antn79jf.js → monaco-binding-62dtmgbf.js} +3 -3
  29. package/dist/chunks/monaco-buffer-nn4dtx27.js +4 -0
  30. package/dist/chunks/{monaco-setup-k8s40vwg.js → monaco-setup-tj13ndtm.js} +3 -3
  31. package/dist/chunks/notify-ahs57a2z.js +4 -0
  32. package/dist/chunks/platform-v9xyfkbx.js +4 -0
  33. package/dist/chunks/publish-panel-bq36xcmj.js +4 -0
  34. package/dist/chunks/quick-search-zm00h9dh.js +4 -0
  35. package/dist/chunks/references-99ws0b0m.js +4 -0
  36. package/dist/chunks/right-panel-pq2f71q3.js +4 -0
  37. package/dist/chunks/section-registry-b2yzp9sw.js +4 -0
  38. package/dist/chunks/{studio-y17505ge.js → studio-01kbbqg3.js} +207 -207
  39. package/dist/chunks/{studio-gxna6yta.js → studio-01qza1n3.js} +4 -4
  40. package/dist/chunks/{studio-n2e133d6.js → studio-03wwfvyt.js} +4 -4
  41. package/dist/chunks/{studio-09n3fyy0.js → studio-0bbxhhce.js} +4 -4
  42. package/dist/chunks/{studio-dknr8m7b.js → studio-0k9mvpgj.js} +7 -7
  43. package/dist/chunks/{studio-s85vah66.js → studio-0scebvb9.js} +5 -5
  44. package/dist/chunks/studio-1a99w39s.js +5 -0
  45. package/dist/chunks/{studio-eegyf5vg.js → studio-1wvfxkyy.js} +4 -4
  46. package/dist/chunks/{studio-ft8pfpp0.js → studio-1x8x26k3.js} +4 -4
  47. package/dist/chunks/{studio-28pz4s1k.js → studio-222rqyyc.js} +4 -4
  48. package/dist/chunks/{studio-6exg5z4x.js → studio-4xppwncr.js} +3 -3
  49. package/dist/chunks/{studio-0c3fpdqj.js → studio-54g077x3.js} +5 -5
  50. package/dist/chunks/{studio-z47mm077.js → studio-56z7evx4.js} +6 -6
  51. package/dist/chunks/{studio-zkkdbbvd.js → studio-5e9zsamh.js} +6 -6
  52. package/dist/chunks/{studio-4yztve2t.js → studio-65fhspdd.js} +4 -4
  53. package/dist/chunks/{studio-b29h7g7s.js → studio-693nhyky.js} +4 -4
  54. package/dist/chunks/{studio-654fxcr0.js → studio-7pgy3xaf.js} +4 -4
  55. package/dist/chunks/{studio-k9pwvmz8.js → studio-888knnkc.js} +4 -4
  56. package/dist/chunks/{studio-b5rxwkj2.js → studio-9mqfamps.js} +4 -4
  57. package/dist/chunks/{studio-011em5cf.js → studio-a7xqhc3n.js} +4 -4
  58. package/dist/chunks/{studio-mf41717j.js → studio-aje966s9.js} +4 -4
  59. package/dist/chunks/studio-aqmph6k4.js +5 -0
  60. package/dist/chunks/studio-c6w3tq7t.js +5 -0
  61. package/dist/chunks/{studio-tzs6njnb.js → studio-c6zdm25z.js} +7 -7
  62. package/dist/chunks/{studio-1pxvgx1t.js → studio-c9d3pxf7.js} +4 -4
  63. package/dist/chunks/{studio-pvj970st.js → studio-ceb34dxj.js} +4 -4
  64. package/dist/chunks/studio-cvbkkfqb.js +5 -0
  65. package/dist/chunks/{studio-sadckcpp.js → studio-cxt150pj.js} +5 -5
  66. package/dist/chunks/{studio-891t1tv9.js → studio-dkn3x3hz.js} +4 -4
  67. package/dist/chunks/studio-e6re3rk1.js +5 -0
  68. package/dist/chunks/{studio-9n2j9nqk.js → studio-edbtvcd1.js} +4 -4
  69. package/dist/chunks/{studio-wr0pq67n.js → studio-eh0rac07.js} +4 -4
  70. package/dist/chunks/{studio-mm8x32gg.js → studio-evwgp1ym.js} +4 -4
  71. package/dist/chunks/{studio-9tkaykt5.js → studio-f195h2hq.js} +4 -4
  72. package/dist/chunks/{studio-0nm2skex.js → studio-fqq890ap.js} +3 -3
  73. package/dist/chunks/{studio-02y9cb4h.js → studio-frb0qbf7.js} +5 -5
  74. package/dist/chunks/{studio-je15p5v8.js → studio-gfn1kpxj.js} +5 -5
  75. package/dist/chunks/{studio-ymsjnvp9.js → studio-gh97gngt.js} +4 -4
  76. package/dist/chunks/{studio-zkvyd3ev.js → studio-gp02nqnw.js} +4 -4
  77. package/dist/chunks/{studio-5fc0728f.js → studio-hjbg8d5y.js} +7 -7
  78. package/dist/chunks/{studio-dtmdjmqb.js → studio-j6gnhvaa.js} +4 -4
  79. package/dist/chunks/{studio-d007j0z3.js → studio-mwxgyn9h.js} +6 -6
  80. package/dist/chunks/{studio-36s6qjgq.js → studio-n78gjfy8.js} +4 -4
  81. package/dist/chunks/studio-nbxqcgvf.js +5 -0
  82. package/dist/chunks/{studio-ks0ecgjx.js → studio-nt06yybj.js} +4 -4
  83. package/dist/chunks/{studio-ebt49j3c.js → studio-p7c1agz0.js} +5 -5
  84. package/dist/chunks/{studio-y164brht.js → studio-pwqfmt4p.js} +4 -4
  85. package/dist/chunks/{studio-6zyt56ek.js → studio-r3kaka4d.js} +4 -4
  86. package/dist/chunks/{studio-tj28pxa1.js → studio-semd1s8s.js} +4 -4
  87. package/dist/chunks/studio-skzanb0k.js +5 -0
  88. package/dist/chunks/{studio-z1vdxq0e.js → studio-spf2skgc.js} +4 -4
  89. package/dist/chunks/{studio-7w04zw8h.js → studio-st75kn99.js} +5 -5
  90. package/dist/chunks/{studio-dmymvk2j.js → studio-sxc0zvpt.js} +3 -3
  91. package/dist/chunks/studio-t1eedsyx.js +4 -0
  92. package/dist/chunks/{studio-153mwkyq.js → studio-tp642n5g.js} +4 -4
  93. package/dist/chunks/{studio-r7yeeqc4.js → studio-tw7rre6s.js} +4 -4
  94. package/dist/chunks/{studio-t0v47dpc.js → studio-tzegmy4w.js} +4 -4
  95. package/dist/chunks/studio-vavmwshr.js +5 -0
  96. package/dist/chunks/{studio-805fwp9k.js → studio-wma2c6g8.js} +4 -4
  97. package/dist/chunks/studio-x92tgnee.js +64 -0
  98. package/dist/chunks/studio-xk0z6msk.js +5 -0
  99. package/dist/chunks/{studio-njmg0e73.js → studio-xry7tk3z.js} +5 -5
  100. package/dist/chunks/{studio-sgzss898.js → studio-xta2vxp6.js} +7 -7
  101. package/dist/chunks/{studio-jsr14n1s.js → studio-xy3tv3az.js} +4 -4
  102. package/dist/chunks/{studio-6k1eswp9.js → studio-y7jehjrp.js} +4 -4
  103. package/dist/chunks/{studio-81twpmzj.js → studio-yr04mv2j.js} +4 -4
  104. package/dist/chunks/{studio-gj4j9e7k.js → studio-zd4kayn2.js} +4 -4
  105. package/dist/chunks/{studio-w8w9frjy.js → studio-zpnk016a.js} +12 -12
  106. package/dist/chunks/{tsMode-eq26t9gs.js → tsMode-k9s9fcag.js} +4 -4
  107. package/dist/chunks/workspace-b69mgm0e.js +4 -0
  108. package/dist/chunks/{ws-client-3gxqp06s.js → ws-client-0ja08haa.js} +3 -3
  109. package/dist/manifest.json +62 -62
  110. package/dist/studio.js +95 -95
  111. package/package.json +3 -3
  112. package/src/canvas/stage-scrollbar.ts +7 -7
  113. package/src/files/file-ops.ts +5 -8
  114. package/src/panels/jump-bar.ts +132 -158
  115. package/src/panels/pane-context.ts +8 -8
  116. package/src/panels/pane-grid.ts +18 -26
  117. package/src/panels/style-panel.ts +2 -2
  118. package/src/services/context-manager.ts +41 -70
  119. package/src/services/document-assistant.ts +12 -3
  120. package/src/services/harness/chat-reducer.ts +115 -0
  121. package/src/services/harness/turn-hooks.ts +50 -0
  122. package/src/services/tool-executor.ts +69 -288
  123. package/src/services/tool-outcomes.ts +12 -10
  124. package/src/studio.ts +2 -5
  125. package/src/surfaces/block-action-bar.json +6 -1
  126. package/src/surfaces/jump-bar.json +9 -12
  127. package/src/surfaces/jump-bar.ts +15 -16
  128. package/src/surfaces/pane-context.json +2 -2
  129. package/src/surfaces/pane-grid.json +8 -25
  130. package/src/surfaces/pane-grid.ts +7 -8
  131. package/src/surfaces/statusbar.json +71 -3
  132. package/src/surfaces/statusbar.ts +66 -139
  133. package/src/surfaces/style-panel.json +3 -3
  134. package/src/surfaces/style-panel.ts +11 -1
  135. package/dist/chunks/adapter-6zhjbpmd.js +0 -4
  136. package/dist/chunks/cf-settings-fpa485q3.js +0 -4
  137. package/dist/chunks/components-b0vh4sq8.js +0 -4
  138. package/dist/chunks/content-source-z5t0d0h9.js +0 -4
  139. package/dist/chunks/editor-7e23n4a8.js +0 -4
  140. package/dist/chunks/entry-commands-5cm6aba2.js +0 -4
  141. package/dist/chunks/file-ops-hv7gf757.js +0 -4
  142. package/dist/chunks/files-kb0epdxf.js +0 -4
  143. package/dist/chunks/format-host-eg1wy2bp.js +0 -4
  144. package/dist/chunks/fs-events-ky3vxfmh.js +0 -4
  145. package/dist/chunks/git-panel-yyg3bzp2.js +0 -4
  146. package/dist/chunks/iframe-host-9wdf0hes.js +0 -4
  147. package/dist/chunks/index-2tf0m2ne.js +0 -4
  148. package/dist/chunks/jx-validate-7w11db9v.js +0 -4
  149. package/dist/chunks/layers-7zasrpse.js +0 -4
  150. package/dist/chunks/layers-panel-9nzc4fdq.js +0 -4
  151. package/dist/chunks/lit-html-5qr7nbng.js +0 -4
  152. package/dist/chunks/media-picker-maevn9d4.js +0 -4
  153. package/dist/chunks/menu-wq6q0v3y.js +0 -4
  154. package/dist/chunks/monaco-buffer-9ry2rn5x.js +0 -4
  155. package/dist/chunks/notify-g91gjd6w.js +0 -4
  156. package/dist/chunks/platform-jdwjp2vt.js +0 -4
  157. package/dist/chunks/publish-panel-8yym447p.js +0 -4
  158. package/dist/chunks/quick-search-9bzm9kh3.js +0 -4
  159. package/dist/chunks/references-znssfqvq.js +0 -4
  160. package/dist/chunks/right-panel-czbzktm8.js +0 -4
  161. package/dist/chunks/section-registry-0rtq73qs.js +0 -4
  162. package/dist/chunks/studio-0mm38y62.js +0 -5
  163. package/dist/chunks/studio-27tc8q0h.js +0 -5
  164. package/dist/chunks/studio-7djymzt2.js +0 -5
  165. package/dist/chunks/studio-ant1zbp3.js +0 -5
  166. package/dist/chunks/studio-eb5sty2k.js +0 -5
  167. package/dist/chunks/studio-frv7vjhw.js +0 -5
  168. package/dist/chunks/studio-mb8jhtr4.js +0 -64
  169. package/dist/chunks/studio-nvykrgbj.js +0 -4
  170. package/dist/chunks/studio-nw4q9sdn.js +0 -5
  171. package/dist/chunks/studio-p6avneaq.js +0 -5
  172. package/dist/chunks/studio-vz7b43ng.js +0 -5
  173. package/dist/chunks/workspace-emt8454d.js +0 -4
@@ -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
  }
package/src/studio.ts CHANGED
@@ -94,7 +94,6 @@ import { runInsertZoneAction } from "./editor/insert-zone-action";
94
94
  import { canvasSlashHandler } from "./editor/canvas-slash-bridge";
95
95
  import { makeCanvasContextMenuHandler } from "./editor/canvas-context-menu";
96
96
  import { mountStatusbar, renderStatusbar } from "./surfaces/statusbar";
97
- import { mountJumpBar } from "./panels/jump-bar";
98
97
  import { cellForPane, paneGridReady } from "./panels/pane-grid";
99
98
  import { notify } from "./services/notify";
100
99
  import { createLiveSurfaceSaver } from "./services/live-surfaces";
@@ -916,11 +915,9 @@ registerRenderer("seoModal", renderSeoModal);
916
915
  registerRenderer("chatPanel", () => chatPanelMod.render());
917
916
  registerRenderer("overlays", () => overlaysPanel.render());
918
917
  renderStatusbar();
918
+ // The status bar mounts the jump bar too: the focused pane's breadcrumb trail is its DOCUMENT
919
+ // Field, and the only breadcrumb in the shell.
919
920
  mountStatusbar();
920
- // The jump bar, in the pane's own grid cell above the context bar. It renders the whole address
921
- // — project › file › node › node — and it is the only breadcrumb in the shell: the pane context
922
- // Bar drew a second one, and it named a sub-document stack nothing could push onto.
923
- mountJumpBar(primaryCell?.jump ?? document.createElement("div"));
924
921
  mountActivityBar();
925
922
 
926
923
  /* The background-click deselect moved into `editor/shortcuts.ts`'s `installStageGestures`, beside
@@ -423,7 +423,12 @@
423
423
  "body": [
424
424
  {
425
425
  "stopPropagation": true
426
- },
426
+ }
427
+ ]
428
+ },
429
+ "onchange": {
430
+ "$prototype": "Function",
431
+ "body": [
427
432
  {
428
433
  "operator": "call",
429
434
  "target": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://jxsuite.com/schema/v1",
3
3
  "$id": "StudioJumpBar",
4
- "$description": "The jump bar: one pane's address, outermost step first, every step a command the adapter has already resolved against the registry. A step is a button when its command can run and a readout when it cannot — an address with a hole in it is a lie about containment, so a step never disappears. A step with more than one place to go carries the chevron that opens its siblings; one alternative is not a choice and draws no control. Nothing here decides what the bar says, which step is where you are, or what a chevron opens.",
4
+ "$description": "The jump bar: the focused pane's address as a breadcrumb trail, the document first and the selected element last, every step a command the adapter has already resolved against the registry. It is the status bar's DOCUMENT field, mounted into the slot `surfaces/statusbar.json` leaves for it. A step is a button when its command can run and a readout when it cannot — an address with a hole in it is a lie about containment, so a step never disappears. A step with more than one place to go carries the chevron that opens its siblings, upward, because the bar is the window's floor; one alternative is not a choice and draws no control. Nothing here decides what the bar says, which step is where you are, or what a chevron opens.",
5
5
  "tagName": "nav",
6
6
  "attributes": {
7
7
  "part": "bar",
@@ -10,22 +10,18 @@
10
10
  "hidden": "${state.empty ? '' : null}"
11
11
  },
12
12
  "style": {
13
- "$description": "The `.jump-bar` rules the bar used to take from `styles/shell.css`, brought here and re-valued onto the kit's own layer. It scrolls rather than wraps — a deep selection must not push the stage down by a second line — and the overflow is taken at the START, because the leaf is the part being read. The height is the one number this file shares with its adapter: the stage's `--jump-bar-h` offset must be the box this rule draws.",
13
+ "$description": "A field of the status bar, so it takes the bar's own row, font and colour rather than drawing a band of its own. It scrolls rather than wraps, because a deep selection must not grow the status bar a second line.",
14
14
  "display": "flex",
15
15
  "alignItems": "center",
16
16
  "gap": "1px",
17
- "height": "24px",
18
- "padding": "0 var(--jx-space-3)",
17
+ "flex": "1",
18
+ "height": "100%",
19
+ "padding": "0 var(--jx-space-2)",
19
20
  "minWidth": "0",
20
21
  "overflowX": "auto",
21
22
  "scrollbarWidth": "none",
22
23
  "whiteSpace": "nowrap",
23
24
  "boxSizing": "border-box",
24
- "fontFamily": "var(--jx-font-sans)",
25
- "fontSize": "var(--jx-text-xs)",
26
- "color": "var(--jx-fg-dim)",
27
- "background": "var(--jx-bg-panel)",
28
- "borderBottom": "1px solid var(--jx-border)",
29
25
  "&[hidden]": {
30
26
  "display": "none"
31
27
  },
@@ -44,7 +40,7 @@
44
40
  "opacity": "0.6"
45
41
  },
46
42
  "& [part=\"crumb\"]": {
47
- "maxWidth": "28ch",
43
+ "maxWidth": "32ch",
48
44
  "overflow": "hidden",
49
45
  "margin": "0",
50
46
  "padding": "0 var(--jx-space-2)",
@@ -74,7 +70,8 @@
74
70
  },
75
71
  "& [part=\"crumb\"][aria-current]": {
76
72
  "$description": "Where you ARE is stated by weight, not by taking the control away: the leaf still selects, which is how a stray click in the canvas is undone without leaving the bar.",
77
- "color": "var(--jx-fg)"
73
+ "color": "var(--jx-fg)",
74
+ "fontWeight": "600"
78
75
  },
79
76
  "& span[part=\"crumb\"]": {
80
77
  "$description": "A step with no runnable command is a readout, not a dead button. No hover, no pointer, no promise.",
@@ -213,7 +210,7 @@
213
210
  "aria-label": "${$map.item.altsLabel}",
214
211
  "title": "${$map.item.altsLabel}"
215
212
  },
216
- "textContent": "⌄",
213
+ "textContent": "⌃",
217
214
  "onclick": {
218
215
  "$prototype": "Function",
219
216
  "body": [
@@ -4,19 +4,18 @@
4
4
  *
5
5
  * `jump-bar.json` is the markup, the ARIA and the style; `panels/jump-bar.ts` is the flow — which
6
6
  * pane this bar is about, what its address is, which of its steps the registry can actually run,
7
- * and what a chevron opens. This module is the seam between them: one scope per pane, one document
8
- * mounted into that pane's cell, and an `update()` that ASSIGNS rather than re-mounts.
7
+ * and what a chevron opens. This module is the seam between them: one scope, one document mounted
8
+ * into the slot the status bar leaves for it, and an `update()` that ASSIGNS rather than
9
+ * re-mounts.
9
10
  *
10
- * **One mount per pane, not one per bar.** Two cells draw two addresses, so `attachJumpBarHost`
11
- * hands this module a host per pane and gets a handle back. The scope is reactive, so a repaint is
12
- * an assignment to `segments` and the keyed `$map` reconciles: a step whose key survives keeps its
13
- * node, and the chevron the reader is aiming at does not move under them.
11
+ * The scope is reactive, so a repaint is an assignment to `segments` and the keyed `$map`
12
+ * reconciles: a step whose key survives keeps its node, and the chevron the reader is aiming at
13
+ * does not move under them.
14
14
  *
15
- * **The bar hides rather than un-mounts when a pane has no address.** A pane with no tab open has
16
- * no address to print, and the document's own `hidden` is what takes it off the screen — the mount
17
- * stays, because the next tab open is one assignment away and re-mounting would cost a frame of
18
- * blank chrome. The stage's `--jump-bar-h` offset is written by the flow, which is the one number
19
- * this surface and its stylesheet-free document share.
15
+ * **The bar hides rather than un-mounts when there is no address.** With no tab open there is
16
+ * nothing to print, and the document's own `hidden` takes it off the screen — the mount stays,
17
+ * because the next tab open is one assignment away and re-mounting would cost a frame of blank
18
+ * chrome.
20
19
  *
21
20
  * @docs studio/interface
22
21
  */
@@ -81,13 +80,13 @@ interface JumpBarScope extends Record<string, unknown>, JumpBarActions {
81
80
  }
82
81
 
83
82
  /**
84
- * Mount one pane's bar into the host its cell built for it.
83
+ * Mount the bar into the slot the status bar built for it.
85
84
  *
86
- * The host is CLEARED first: a cell hands the bar a slot of its own, and whatever was in it belongs
87
- * to a pane this bar is no longer drawing.
85
+ * The host is CLEARED first: the slot is the bar's own, and whatever was in it belongs to a mount
86
+ * this bar is no longer drawing.
88
87
  *
89
- * @param {HTMLElement} host The cell's slot for the bar.
90
- * @param {string} region The `data-jx-region` the bar stamps on itself — `pane.<id>/jump`.
88
+ * @param {HTMLElement} host The status bar's slot for the bar.
89
+ * @param {string} region The `data-jx-region` the bar stamps on itself — `statusbar/document`.
91
90
  * @param {JumpBarActions} actions What a press does. Read once, when the scope is made.
92
91
  * @returns {JumpBarSurface}
93
92
  */
@@ -497,7 +497,7 @@
497
497
  "$ref": "#/state/previewHint"
498
498
  }
499
499
  },
500
- "onclick": {
500
+ "onchange": {
501
501
  "$prototype": "Function",
502
502
  "body": [
503
503
  {
@@ -1227,7 +1227,7 @@
1227
1227
  "$ref": "$map/item/hint"
1228
1228
  }
1229
1229
  },
1230
- "onclick": {
1230
+ "onchange": {
1231
1231
  "$prototype": "Function",
1232
1232
  "body": [
1233
1233
  {