@oberik/sdk 0.53.1 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/index.js CHANGED
@@ -426,13 +426,28 @@ class AgentApiError extends Error {
426
426
  * an intermediary, which is replaced by the same summary as `message`. Keeping four
427
427
  * kilobytes of someone else's markup here helped nobody and buried the status. */
428
428
  detail;
429
- constructor(status, detail) {
429
+ /** The run that failed, when the server said which.
430
+ *
431
+ * A failed `chat.send` used to carry neither this nor {@link sessionId}, so the reason
432
+ * the server stores for the turn — the only durable answer to "why did this fail" —
433
+ * could not be looked up by the person who hit it. `chat.stream` had both all along;
434
+ * this is the same handle on the blocking path (OBE-142). */
435
+ runId;
436
+ /** The conversation the failed turn belongs to, when the server said which.
437
+ *
438
+ * Present even when the turn failed before producing anything: the session exists from
439
+ * the moment the turn is prepared, and whatever it wrote is readable with
440
+ * `ai.chat.sessions.messages(err.sessionId)`. */
441
+ sessionId;
442
+ constructor(status, detail, ids) {
430
443
  const described = describeDetail(status, detail);
431
444
  super(described);
432
445
  this.name = "AgentApiError";
433
446
  this.status = status;
434
447
  this.detail =
435
448
  typeof detail === "string" && looksLikeHtml(detail) ? described : detail;
449
+ this.runId = ids?.runId;
450
+ this.sessionId = ids?.sessionId;
436
451
  }
437
452
  }
438
453
  exports.AgentApiError = AgentApiError;
@@ -874,6 +889,7 @@ class AgentFramework {
874
889
  let element = null;
875
890
  let dragging = false;
876
891
  let lastMove = 0;
892
+ let focused = false;
877
893
  const compute = () => {
878
894
  const streaming = !!live;
879
895
  const from = streaming ? clip : polled;
@@ -923,6 +939,7 @@ class AgentFramework {
923
939
  style,
924
940
  interactive: handoff.interactive,
925
941
  checking,
942
+ focused,
926
943
  autoDone,
927
944
  handoff,
928
945
  };
@@ -1105,6 +1122,14 @@ class AgentFramework {
1105
1122
  return out;
1106
1123
  };
1107
1124
  const button = (event) => event.button === 2 ? "right" : event.button === 1 ? "middle" : "left";
1125
+ /** Keys that only ever modify another one. Relaying them alone presses nothing and
1126
+ * would burn a request per Shift. */
1127
+ const MODIFIER_KEYS = new Set(["Shift", "Control", "Alt", "Meta", "AltGraph", "CapsLock"]);
1128
+ /** Ctrl/Cmd chords that mean something INSIDE a page and are worth taking over.
1129
+ * Select-all before retyping a field, and undo. Everything else a chord can do here
1130
+ * either belongs to the viewer's own browser or acts on a clipboard the person
1131
+ * cannot reach — see `onKeyDown`. */
1132
+ const PAGE_CHORDS = new Set(["a", "z"]);
1108
1133
  const attach = (el) => {
1109
1134
  element = el;
1110
1135
  const onDown = (event) => {
@@ -1117,6 +1142,10 @@ class AgentFramework {
1117
1142
  // Capture, so a drag that wanders off the image still ends properly. Without it
1118
1143
  // the pointer stays down on the page and every later click compounds the mess.
1119
1144
  el.setPointerCapture?.(event.pointerId);
1145
+ // Pointing at the page is also how you start typing on it. Requiring a separate
1146
+ // click-to-focus would be a step nobody is told about, and the symptom of missing
1147
+ // it — keystrokes going nowhere — looks exactly like the bug this fixes.
1148
+ el.focus?.();
1120
1149
  dragging = true;
1121
1150
  lastMove = 0;
1122
1151
  void send({ type: "pointer_down", ...p, button: button(event), modifiers: held(event) });
@@ -1157,12 +1186,96 @@ class AgentFramework {
1157
1186
  });
1158
1187
  };
1159
1188
  const onContextMenu = (event) => event.preventDefault?.();
1189
+ /**
1190
+ * A keystroke, relayed into the page.
1191
+ *
1192
+ * Pointer events were relayed from the first version of this and keys never were,
1193
+ * so a hand-off could work a slider puzzle and could not fill in a login form —
1194
+ * which is the other case the tool that opens a hand-off names in its own
1195
+ * description. The only way to enter text was a separate box in the host UI that
1196
+ * typed wherever focus happened to be, so nothing could Tab between fields, correct
1197
+ * a typo, or submit a form.
1198
+ *
1199
+ * A printable character goes as `type` rather than `key`: a key press is a physical
1200
+ * key and gets the wrong result for anything the person's own layout produces with a
1201
+ * modifier — a Turkish `ı`, an accented character, anything behind AltGr. Text is
1202
+ * what they meant, and text is what the page receives.
1203
+ */
1204
+ const onKeyDown = (event) => {
1205
+ if (!active || !view.interactive)
1206
+ return;
1207
+ const key = String(event.key ?? "");
1208
+ // A modifier on its own carries nothing; it rides along with the next key.
1209
+ if (!key || key === "Unidentified" || MODIFIER_KEYS.has(key))
1210
+ return;
1211
+ const chord = !!(event.ctrlKey || event.metaKey);
1212
+ const printable = key.length === 1 && !chord && !event.altKey;
1213
+ // A chord that is not one of the two worth relaying stays with the VIEWER's
1214
+ // browser — reload, new tab, devtools, the address bar. An allow-list rather
1215
+ // than a block-list, so a combination nobody thought about keeps working where
1216
+ // the person expects it instead of being swallowed by a picture.
1217
+ //
1218
+ // Only two are worth having, and the reason the rest are not is the clipboard:
1219
+ // Ctrl-C and Ctrl-X inside the page copy to the REMOTE Chrome's clipboard, which
1220
+ // the person cannot reach. Pasting INTO the page is the case that matters and it
1221
+ // is handled properly below, from the real paste event.
1222
+ if (chord && !PAGE_CHORDS.has(key.toLowerCase()))
1223
+ return;
1224
+ // Taken over from here: relayed and not also acted on locally, or Tab moves the
1225
+ // viewer's own focus away mid-form and Backspace navigates the host app back.
1226
+ event.preventDefault?.();
1227
+ event.stopPropagation?.();
1228
+ if (printable)
1229
+ void send({ type: "type", text: key });
1230
+ else
1231
+ void send({ type: "key", key, modifiers: held(event) });
1232
+ };
1233
+ /**
1234
+ * Paste, relayed as text.
1235
+ *
1236
+ * Not reachable as a keystroke: Ctrl-V inside the remote page pastes the remote
1237
+ * Chrome's clipboard, which is empty and is not the person's. Reading the real
1238
+ * clipboard from the real paste event and sending the text is the only version of
1239
+ * this that works — and it is the one that matters here, because a password out of
1240
+ * a password manager arrives no other way.
1241
+ */
1242
+ const onPaste = (event) => {
1243
+ if (!active || !view.interactive)
1244
+ return;
1245
+ const text = event.clipboardData?.getData?.("text") ?? "";
1246
+ if (!text)
1247
+ return;
1248
+ event.preventDefault?.();
1249
+ void send({ type: "type", text });
1250
+ };
1251
+ const onFocus = () => {
1252
+ if (focused)
1253
+ return;
1254
+ focused = true;
1255
+ publish();
1256
+ };
1257
+ const onBlur = () => {
1258
+ if (!focused)
1259
+ return;
1260
+ focused = false;
1261
+ publish();
1262
+ };
1263
+ // Focusable, so keystrokes have somewhere to land. An <img> cannot hold focus on
1264
+ // its own, and a keydown listener on an element that can never be focused is a
1265
+ // listener that never fires — which is indistinguishable from the relay not
1266
+ // existing, and is how this looked.
1267
+ if (el.tabIndex === undefined || el.tabIndex < 0)
1268
+ el.tabIndex = 0;
1160
1269
  el.addEventListener("pointerdown", onDown);
1161
1270
  el.addEventListener("pointermove", onMove);
1162
1271
  el.addEventListener("pointerup", onUp);
1163
1272
  el.addEventListener("pointercancel", onUp);
1164
1273
  el.addEventListener("wheel", onWheel);
1165
1274
  el.addEventListener("contextmenu", onContextMenu);
1275
+ el.addEventListener("keydown", onKeyDown);
1276
+ el.addEventListener("paste", onPaste);
1277
+ el.addEventListener("focus", onFocus);
1278
+ el.addEventListener("blur", onBlur);
1166
1279
  paint();
1167
1280
  return () => {
1168
1281
  el.removeEventListener("pointerdown", onDown);
@@ -1171,7 +1284,12 @@ class AgentFramework {
1171
1284
  el.removeEventListener("pointercancel", onUp);
1172
1285
  el.removeEventListener("wheel", onWheel);
1173
1286
  el.removeEventListener("contextmenu", onContextMenu);
1287
+ el.removeEventListener("keydown", onKeyDown);
1288
+ el.removeEventListener("paste", onPaste);
1289
+ el.removeEventListener("focus", onFocus);
1290
+ el.removeEventListener("blur", onBlur);
1174
1291
  dragging = false;
1292
+ focused = false;
1175
1293
  if (element === el)
1176
1294
  element = null;
1177
1295
  };
@@ -1504,15 +1622,32 @@ class AgentFramework {
1504
1622
  }
1505
1623
  if (!res.ok) {
1506
1624
  let detail = await res.text();
1625
+ // Alongside `detail`, when the server said which turn this was about. Read here
1626
+ // because this is the one place an API error is built from a response — every route's
1627
+ // refusal passes through it, so a route that starts saying which run failed needs no
1628
+ // change on this side.
1629
+ let ids;
1507
1630
  try {
1508
- detail = JSON.parse(detail);
1509
- if (detail && typeof detail === "object" && "detail" in detail)
1510
- detail = detail.detail;
1631
+ const body = JSON.parse(detail);
1632
+ if (body && typeof body === "object") {
1633
+ const b = body;
1634
+ if ("detail" in b)
1635
+ detail = b.detail;
1636
+ else
1637
+ detail = body;
1638
+ const runId = typeof b.run_id === "string" ? b.run_id : undefined;
1639
+ const sessionId = typeof b.session_id === "string" ? b.session_id : undefined;
1640
+ if (runId || sessionId)
1641
+ ids = { runId, sessionId };
1642
+ }
1643
+ else {
1644
+ detail = body;
1645
+ }
1511
1646
  }
1512
1647
  catch {
1513
1648
  /* leave as text */
1514
1649
  }
1515
- throw new AgentApiError(res.status, detail);
1650
+ throw new AgentApiError(res.status, detail, ids);
1516
1651
  }
1517
1652
  return res;
1518
1653
  }
@@ -2555,7 +2690,13 @@ class AgentFramework {
2555
2690
  // This used to be a bare message, so a stream that died at minute
2556
2691
  // eighteen threw away the answer AND the only handle on where the rest
2557
2692
  // of it had been written.
2558
- throw new AgentStreamError("server: " + ev.data.detail, {
2693
+ // The server's sentence, unprefixed. This read `"server: " + detail`, so
2694
+ // every streamed failure reached the customer as
2695
+ // `server: the upstream endpoint answered 408 …` — a lowercase field name in
2696
+ // front of the first word they read, and the one difference between the two
2697
+ // paths' otherwise identical message (OBE-140). `AgentStreamError` already
2698
+ // says where it came from; the label said it again, worse.
2699
+ throw new AgentStreamError(ev.data.detail, {
2559
2700
  code: ev.data.code,
2560
2701
  // The status the same refusal carries on `POST /chat` — 429 for a spend
2561
2702
  // cap or a rate limit. Without it a streaming caller had no number to