@agent-native/core 0.152.0 → 0.152.1

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/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8107
34
+ - template files: 8109
@@ -1,15 +1,13 @@
1
1
  import { useT } from "@agent-native/core/client/i18n";
2
2
  import {
3
3
  IconBold,
4
+ IconCheck,
5
+ IconChevronDown,
4
6
  IconItalic,
5
7
  IconStrikethrough,
6
8
  IconCode,
7
9
  IconLink,
8
10
  IconMessageCircle,
9
- IconH1,
10
- IconH2,
11
- IconH3,
12
- IconH4,
13
11
  } from "@tabler/icons-react";
14
12
  import {
15
13
  NodeSelection,
@@ -20,8 +18,13 @@ import {
20
18
  import { Decoration, DecorationSet } from "@tiptap/pm/view";
21
19
  import type { Editor } from "@tiptap/react";
22
20
  import { BubbleMenu } from "@tiptap/react/menus";
23
- import { useCallback, useEffect, useState } from "react";
21
+ import { useCallback, useEffect, useRef, useState } from "react";
24
22
 
23
+ import {
24
+ Popover,
25
+ PopoverContent,
26
+ PopoverTrigger,
27
+ } from "@/components/ui/popover";
25
28
  import {
26
29
  Tooltip,
27
30
  TooltipContent,
@@ -56,6 +59,15 @@ type SelectionFillRange = {
56
59
  to: number;
57
60
  };
58
61
 
62
+ type TextStyle = "paragraph" | 1 | 2 | 3 | 4 | 5 | 6;
63
+
64
+ function activeTextStyle(editor: Editor): TextStyle {
65
+ for (const level of [1, 2, 3, 4, 5, 6] as const) {
66
+ if (editor.isActive("heading", { level })) return level;
67
+ }
68
+ return "paragraph";
69
+ }
70
+
59
71
  const selectionFillPluginKey = new PluginKey<SelectionFillRange | null>(
60
72
  "contentSelectionFill",
61
73
  );
@@ -106,6 +118,86 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {
106
118
  const t = useT();
107
119
  const [showLinkInput, setShowLinkInput] = useState(false);
108
120
  const [linkUrl, setLinkUrl] = useState("");
121
+ const [textStyleOpen, setTextStyleOpen] = useState(false);
122
+ const textStyleSelection = useRef<{ from: number; to: number } | null>(null);
123
+ const textStyleApplied = useRef(false);
124
+ const restoreEditorFocusOnClose = useRef(false);
125
+ const [textStyle, setTextStyle] = useState<TextStyle>(() =>
126
+ activeTextStyle(editor),
127
+ );
128
+
129
+ useEffect(() => {
130
+ const syncTextStyle = () => {
131
+ setTextStyle(activeTextStyle(editor));
132
+ const { from, to } = editor.state.selection;
133
+ if (from !== to) textStyleSelection.current = { from, to };
134
+ };
135
+ editor.on("selectionUpdate", syncTextStyle);
136
+ editor.on("transaction", syncTextStyle);
137
+ syncTextStyle();
138
+ return () => {
139
+ editor.off("selectionUpdate", syncTextStyle);
140
+ editor.off("transaction", syncTextStyle);
141
+ };
142
+ }, [editor]);
143
+
144
+ const textStyles = [
145
+ {
146
+ value: "paragraph" as const,
147
+ shortLabel: t("editor.slash.text"),
148
+ menuLabel: "T",
149
+ label: t("editor.slash.text"),
150
+ },
151
+ {
152
+ value: 1 as const,
153
+ shortLabel: "H1",
154
+ menuLabel: "H1",
155
+ label: t("editor.heading1"),
156
+ },
157
+ {
158
+ value: 2 as const,
159
+ shortLabel: "H2",
160
+ menuLabel: "H2",
161
+ label: t("editor.heading2"),
162
+ },
163
+ {
164
+ value: 3 as const,
165
+ shortLabel: "H3",
166
+ menuLabel: "H3",
167
+ label: t("editor.heading3"),
168
+ },
169
+ {
170
+ value: 4 as const,
171
+ shortLabel: "H4",
172
+ menuLabel: "H4",
173
+ label: t("editor.heading4"),
174
+ },
175
+ ];
176
+ const selectedTextStyle =
177
+ textStyles.find((style) => style.value === textStyle) ??
178
+ (typeof textStyle === "number"
179
+ ? {
180
+ value: textStyle,
181
+ shortLabel: `H${textStyle}`,
182
+ menuLabel: `H${textStyle}`,
183
+ label: textStyle === 5 ? t("editor.heading5") : t("editor.heading6"),
184
+ }
185
+ : textStyles[0]);
186
+
187
+ const applyTextStyle = (style: TextStyle) => {
188
+ const chain = editor.chain();
189
+ if (textStyleSelection.current) {
190
+ chain.setTextSelection(textStyleSelection.current);
191
+ }
192
+ if (style === "paragraph") {
193
+ chain.setParagraph().focus().run();
194
+ } else {
195
+ chain.setHeading({ level: style }).focus().run();
196
+ }
197
+ textStyleApplied.current = true;
198
+ setTextStyle(style);
199
+ setTextStyleOpen(false);
200
+ };
109
201
 
110
202
  const openLinkInput = useCallback(() => {
111
203
  setLinkUrl(editor.getAttributes("link").href || "");
@@ -251,30 +343,7 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {
251
343
  isActive: () => editor.isActive("code"),
252
344
  },
253
345
  { type: "divider" as const },
254
- {
255
- icon: IconH1,
256
- title: t("editor.heading1"),
257
- action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
258
- isActive: () => editor.isActive("heading", { level: 1 }),
259
- },
260
- {
261
- icon: IconH2,
262
- title: t("editor.heading2"),
263
- action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
264
- isActive: () => editor.isActive("heading", { level: 2 }),
265
- },
266
- {
267
- icon: IconH3,
268
- title: t("editor.heading3"),
269
- action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
270
- isActive: () => editor.isActive("heading", { level: 3 }),
271
- },
272
- {
273
- icon: IconH4,
274
- title: t("editor.heading4"),
275
- action: () => editor.chain().focus().toggleHeading({ level: 4 }).run(),
276
- isActive: () => editor.isActive("heading", { level: 4 }),
277
- },
346
+ { type: "text-style" as const },
278
347
  { type: "divider" as const },
279
348
  {
280
349
  icon: IconLink,
@@ -354,7 +423,7 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {
354
423
  </div>
355
424
  ) : (
356
425
  <div
357
- className="flex items-center gap-0.5 overflow-x-auto"
426
+ className="flex max-w-[calc(100vw-1rem)] items-center gap-0.5"
358
427
  onMouseDown={(e) => e.preventDefault()}
359
428
  >
360
429
  {items.map((item, i) => {
@@ -363,6 +432,103 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {
363
432
  <div key={`d-${i}`} className="w-px h-5 bg-border mx-0.5" />
364
433
  );
365
434
  }
435
+ if ("type" in item && item.type === "text-style") {
436
+ return (
437
+ <Popover
438
+ key="text-style"
439
+ open={textStyleOpen}
440
+ onOpenChange={(open) => {
441
+ if (open) {
442
+ textStyleApplied.current = false;
443
+ restoreEditorFocusOnClose.current = false;
444
+ const { from, to } = editor.state.selection;
445
+ if (from !== to)
446
+ textStyleSelection.current = { from, to };
447
+ }
448
+ setTextStyleOpen(open);
449
+ }}
450
+ >
451
+ <PopoverTrigger asChild>
452
+ <button
453
+ type="button"
454
+ aria-label={`${t("editor.slash.turnInto")}: ${selectedTextStyle.label}`}
455
+ className="flex h-8 min-w-14 items-center justify-between gap-1 rounded px-2 text-sm font-medium text-popover-foreground/85 hover:bg-accent hover:text-accent-foreground"
456
+ >
457
+ <span>{selectedTextStyle.shortLabel}</span>
458
+ <IconChevronDown size={14} strokeWidth={2} />
459
+ </button>
460
+ </PopoverTrigger>
461
+ <PopoverContent
462
+ portalled={false}
463
+ align="start"
464
+ sideOffset={24}
465
+ className="w-44 p-1"
466
+ onEscapeKeyDown={() => {
467
+ restoreEditorFocusOnClose.current = true;
468
+ window.setTimeout(() => editor.commands.focus(), 0);
469
+ }}
470
+ onCloseAutoFocus={(event) => {
471
+ if (
472
+ textStyleApplied.current ||
473
+ restoreEditorFocusOnClose.current
474
+ ) {
475
+ event.preventDefault();
476
+ }
477
+ if (restoreEditorFocusOnClose.current) {
478
+ editor.commands.focus();
479
+ }
480
+ textStyleApplied.current = false;
481
+ restoreEditorFocusOnClose.current = false;
482
+ }}
483
+ >
484
+ <div className="px-2 py-1 text-xs font-medium text-muted-foreground">
485
+ {t("editor.slash.turnInto")}
486
+ </div>
487
+ <div
488
+ role="menu"
489
+ aria-label={t("editor.slash.turnInto")}
490
+ className="flex flex-col gap-0.5"
491
+ >
492
+ {textStyles.map((style) => {
493
+ const isSelected = style.value === textStyle;
494
+ return (
495
+ <button
496
+ key={style.value}
497
+ type="button"
498
+ role="menuitemradio"
499
+ aria-checked={isSelected}
500
+ onPointerDown={(event) => {
501
+ event.preventDefault();
502
+ event.stopPropagation();
503
+ applyTextStyle(style.value);
504
+ }}
505
+ onClick={(event) => {
506
+ if (event.detail === 0) {
507
+ applyTextStyle(style.value);
508
+ }
509
+ }}
510
+ className={cn(
511
+ "flex h-8 w-full items-center gap-2 rounded px-2 text-left text-sm",
512
+ isSelected
513
+ ? "bg-accent text-accent-foreground"
514
+ : "text-popover-foreground hover:bg-accent hover:text-accent-foreground",
515
+ )}
516
+ >
517
+ <span className="w-6 shrink-0 text-xs font-semibold text-muted-foreground">
518
+ {style.menuLabel}
519
+ </span>
520
+ <span className="flex-1">{style.label}</span>
521
+ {isSelected ? (
522
+ <IconCheck size={15} strokeWidth={2.25} />
523
+ ) : null}
524
+ </button>
525
+ );
526
+ })}
527
+ </div>
528
+ </PopoverContent>
529
+ </Popover>
530
+ );
531
+ }
366
532
  const {
367
533
  icon: Icon,
368
534
  title,
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-03
4
+ ---
5
+
6
+ Choose Text or a heading from one compact formatting control.
@@ -0,0 +1,26 @@
1
+ # Content text selector visual evidence
2
+
3
+ Matched desktop captures for the compact Text/H1-H4 selector in the Content editor.
4
+
5
+ ## Before
6
+
7
+ - Source revision: `dcc028cb7d67a0f9279f9235cdc4c16710ce5689`
8
+ - Route: `http://127.0.0.1:3317/page/j4a5KAEDBHXY`
9
+ - Viewport: `1496 x 846`, desktop Chrome
10
+ - Fixture account: disposable local account `toolbar-demo@example.com`
11
+ - Fixture page: `Toolbar comparison`
12
+ - Fixture paragraph: `Select this sentence to compare the formatting toolbar.`
13
+ - UI state: Personal workspace expanded, agent panel closed, full paragraph selected, formatting bubble open, text-style submenu closed
14
+
15
+ ![Before: separate H1-H4 toolbar buttons](./before.png)
16
+
17
+ ## After
18
+
19
+ - Source: this pull request's production build
20
+ - Route: `http://127.0.0.1:3317/page/vDisteHisXMq`
21
+ - Viewport: `1496 x 846`, desktop Chrome
22
+ - Fixture account, page title, paragraph, sidebar state, and agent-panel state match the before capture
23
+ - UI state: full paragraph selected, formatting bubble open, Text selector expanded, Text checked, H1-H4 visible
24
+ - The route differs because the isolated worktree uses its own disposable local fixture database; the rendered fixture and viewport are matched
25
+
26
+ ![After: compact Text selector with H1-H4 menu](./after.png)
@@ -235,6 +235,14 @@ export interface ActionEntry {
235
235
  * `needsApproval` option. See `packages/core/docs/content/actions.mdx`.
236
236
  */
237
237
  needsApproval?: boolean | ((args: any, ctx?: import("../action.js").ActionRunContext) => boolean | Promise<boolean>);
238
+ /**
239
+ * The action hands control back to the user: once it succeeds the loop stops
240
+ * the turn instead of asking the model for another step, and any remaining
241
+ * tool calls in the same assistant message do not execute. Only for actions
242
+ * whose whole purpose is to wait on a human (`ask-question`) — telling the
243
+ * model to stop in the tool result does not make it stop.
244
+ */
245
+ endsTurn?: boolean;
238
246
  /** Which framework tool group contributed this action. Set by the framework,
239
247
  * never by an app: apps own their action names, and a tagged action is one
240
248
  * the app can switch off wholesale through `frameworkTools`. Tagged actions
@@ -3823,6 +3823,23 @@ export async function runAgentLoop(opts) {
3823
3823
  emptyFinalResponseRetries = 0;
3824
3824
  flushUnstreamedAssistantText();
3825
3825
  let requestedActionStop = null;
3826
+ // An `endsTurn` action ran and handed control to the user. Distinct from
3827
+ // `requestedActionStop`, which also covers failure stops that must not
3828
+ // suppress the remaining tool calls.
3829
+ let turnYieldedToUser = false;
3830
+ // Called by every path that completes a tool call successfully — a fresh
3831
+ // invocation, a journal replay, a ledger recovery. A replayed
3832
+ // `ask-question` that skipped this would let the resumed run ask a second
3833
+ // time over the card the user is already looking at.
3834
+ const noteToolCallSucceeded = (actionEntry) => {
3835
+ if (actionEntry.endsTurn !== true)
3836
+ return;
3837
+ turnYieldedToUser = true;
3838
+ requestedActionStop ??= {
3839
+ message: "Waiting for your answer before continuing.",
3840
+ errorCode: "awaiting-user-input",
3841
+ };
3842
+ };
3826
3843
  const noteRepeatedToolCall = (toolName, input) => {
3827
3844
  const key = toolCallCacheKey(toolName, input);
3828
3845
  const count = (repeatedToolCalls.get(key) ?? 0) + 1;
@@ -4167,6 +4184,7 @@ export async function runAgentLoop(opts) {
4167
4184
  completedSideEffect: true,
4168
4185
  });
4169
4186
  recordToolResult(result, false);
4187
+ noteToolCallSucceeded(actionEntry);
4170
4188
  return {
4171
4189
  type: "tool-result",
4172
4190
  toolCallId: toolCall.id,
@@ -4220,6 +4238,7 @@ export async function runAgentLoop(opts) {
4220
4238
  ...(actionEntry.chatUI ? { chatUI: actionEntry.chatUI } : {}),
4221
4239
  });
4222
4240
  recordToolResult(result, false);
4241
+ noteToolCallSucceeded(actionEntry);
4223
4242
  return {
4224
4243
  type: "tool-result",
4225
4244
  toolCallId: toolCall.id,
@@ -4645,6 +4664,7 @@ export async function runAgentLoop(opts) {
4645
4664
  });
4646
4665
  recordToolResult(result, isError);
4647
4666
  if (!isError) {
4667
+ noteToolCallSucceeded(actionEntry);
4648
4668
  if (cacheKey) {
4649
4669
  readOnlyToolResultCache.set(cacheKey, result);
4650
4670
  }
@@ -4715,7 +4735,46 @@ export async function runAgentLoop(opts) {
4715
4735
  parallelBatchKind = null;
4716
4736
  toolResultParts.push(...(await Promise.all(batch.map(runToolCall))));
4717
4737
  };
4738
+ // An `endsTurn` action already handed control to the user, so the rest of
4739
+ // this assistant message belongs to a turn that is over. Report those calls
4740
+ // as not executed rather than running them: a second `ask-question` would
4741
+ // overwrite the first one's card before anyone could answer it.
4742
+ const skipToolCallAfterYield = (toolCall) => {
4743
+ const result = `Not executed: ${toolCall.name} was called after an action that ends the turn. ` +
4744
+ `The turn is paused for the user's answer — call it again on a later turn if still needed.`;
4745
+ send({
4746
+ type: "tool_start",
4747
+ id: toolCall.id,
4748
+ tool: toolCall.name,
4749
+ input: toolCall.input,
4750
+ });
4751
+ send({
4752
+ type: "tool_done",
4753
+ id: toolCall.id,
4754
+ tool: toolCall.name,
4755
+ input: toolCall.input,
4756
+ result,
4757
+ completedSideEffect: false,
4758
+ });
4759
+ toolResultHistory.push({
4760
+ name: toolCall.name,
4761
+ content: result,
4762
+ isError: false,
4763
+ });
4764
+ return {
4765
+ type: "tool-result",
4766
+ toolCallId: toolCall.id,
4767
+ toolName: toolCall.name,
4768
+ toolInput: JSON.stringify(toolCall.input ?? {}),
4769
+ content: result,
4770
+ };
4771
+ };
4718
4772
  for (const toolCall of toolCallParts) {
4773
+ if (turnYieldedToUser) {
4774
+ await flushParallelBatch();
4775
+ toolResultParts.push(skipToolCallAfterYield(toolCall));
4776
+ continue;
4777
+ }
4719
4778
  const batchKind = getParallelBatchKind(toolCall);
4720
4779
  if (batchKind) {
4721
4780
  if (parallelBatchKind && parallelBatchKind !== batchKind) {
@@ -4829,6 +4888,13 @@ export async function runAgentLoop(opts) {
4829
4888
  message: terminalActionStop.message,
4830
4889
  });
4831
4890
  }
4891
+ else if (terminalActionStop?.errorCode === "awaiting-user-input") {
4892
+ reportOutcome({
4893
+ state: "input_required",
4894
+ code: "awaiting_user_input",
4895
+ message: terminalActionStop.message,
4896
+ });
4897
+ }
4832
4898
  else if (terminalActionStop) {
4833
4899
  reportOutcome({
4834
4900
  state: "failed",
@@ -4083,11 +4083,13 @@ const AssistantChatInner = forwardRef(function AssistantChatInner({ emptyStateTe
4083
4083
  // GuidedQuestionPayload to application_state under "guided-questions". The
4084
4084
  // hook polls that key, and on submit/skip composes the answer as a normal
4085
4085
  // user turn (via the shared sendToAgentChat) and clears the persisted key so
4086
- // the question does not reappear.
4086
+ // the question does not reappear. The key is per browser tab, so `threadId`
4087
+ // is what keeps a pending question in the chat that asked it.
4087
4088
  const { questions: guidedQuestions, title: guidedQuestionsTitle, description: guidedQuestionsDescription, skipLabel: guidedQuestionsSkipLabel, submitLabel: guidedQuestionsSubmitLabel, handleSubmit: handleGuidedQuestionsSubmit, handleSkip: handleGuidedQuestionsSkip, } = useGuidedQuestionFlow({
4088
4089
  stateKey: "guided-questions",
4089
4090
  queryKey: ["guided-questions"],
4090
4091
  ...(browserTabId ? { browserTabId } : {}),
4092
+ ...(threadId ? { threadId } : {}),
4091
4093
  });
4092
4094
  const hasComposerAccessoryAboveStack = Boolean(composerError ||
4093
4095
  showComposerSlot ||
@@ -46,6 +46,13 @@ export interface GuidedQuestionPayload {
46
46
  * promise with the answer instead of forwarding it to the agent chat.
47
47
  */
48
48
  clientResolveId?: string;
49
+ /**
50
+ * Chat thread that asked. Set by the agent's `ask-question` tool. A payload
51
+ * carrying this only renders in that conversation; one without it (app code
52
+ * calling {@link askUserQuestion}, deterministic writes) is not thread-bound
53
+ * and renders wherever the flow is mounted.
54
+ */
55
+ threadId?: string;
49
56
  }
50
57
  export declare function isOtherGuidedAnswer(value: unknown): value is string;
51
58
  export declare function getOtherGuidedAnswerText(value: unknown): string;
@@ -137,6 +144,13 @@ export interface UseGuidedQuestionFlowOptions {
137
144
  * (which it almost always does — see `sessionBrowserTabId`).
138
145
  */
139
146
  browserTabId?: string;
147
+ /**
148
+ * The conversation this flow is mounted in. Agent-written payloads name the
149
+ * thread that asked, and a pending question belongs to that conversation
150
+ * only — the application-state key is per browser tab, so without this the
151
+ * same card follows the user into every other chat in the tab.
152
+ */
153
+ threadId?: string;
140
154
  queryKey?: readonly unknown[];
141
155
  refetchInterval?: number | false;
142
156
  submitMessage?: string;
@@ -147,7 +161,7 @@ export interface UseGuidedQuestionFlowOptions {
147
161
  }) => string;
148
162
  buildSkipContext?: () => string;
149
163
  }
150
- export declare function useGuidedQuestionFlow({ enabled, stateKey, browserTabId, queryKey, refetchInterval, submitMessage, skipMessage, buildSubmitContext, buildSkipContext, }?: UseGuidedQuestionFlowOptions): {
164
+ export declare function useGuidedQuestionFlow({ enabled, stateKey, browserTabId, threadId, queryKey, refetchInterval, submitMessage, skipMessage, buildSubmitContext, buildSkipContext, }?: UseGuidedQuestionFlowOptions): {
151
165
  payload: GuidedQuestionPayload | null;
152
166
  questions: GuidedQuestion[] | null;
153
167
  title: string | undefined;
@@ -406,7 +406,17 @@ function normalizeBrowserTabId(browserTabId) {
406
406
  const trimmed = browserTabId.trim();
407
407
  return SAFE_BROWSER_TAB_ID_RE.test(trimmed) ? trimmed : undefined;
408
408
  }
409
- export function useGuidedQuestionFlow({ enabled = true, stateKey = "show-questions", browserTabId, queryKey = ["show-questions"], refetchInterval = false, submitMessage = "Here are my answers — go ahead.", skipMessage = "Skip the questions — decide for me.", buildSubmitContext, buildSkipContext, } = {}) {
409
+ /**
410
+ * Whether a stored payload belongs to the conversation currently on screen.
411
+ * Payloads with no `threadId` are not thread-bound and always match.
412
+ */
413
+ function payloadBelongsToThread(payload, threadId) {
414
+ const asker = typeof payload.threadId === "string" ? payload.threadId.trim() : "";
415
+ if (!asker)
416
+ return true;
417
+ return asker === (threadId ?? "").trim();
418
+ }
419
+ export function useGuidedQuestionFlow({ enabled = true, stateKey = "show-questions", browserTabId, threadId, queryKey = ["show-questions"], refetchInterval = false, submitMessage = "Here are my answers — go ahead.", skipMessage = "Skip the questions — decide for me.", buildSubmitContext, buildSkipContext, } = {}) {
410
420
  const queryClient = useQueryClient();
411
421
  const [payload, setPayload] = useState(null);
412
422
  const normalizedBrowserTabId = useMemo(() => normalizeBrowserTabId(browserTabId), [browserTabId]);
@@ -458,7 +468,11 @@ export function useGuidedQuestionFlow({ enabled = true, stateKey = "show-questio
458
468
  if (prev &&
459
469
  guidedQuestionsFingerprint(prev.questions) ===
460
470
  guidedQuestionsFingerprint(data.questions) &&
461
- prev.clientResolveId === data.clientResolveId) {
471
+ prev.clientResolveId === data.clientResolveId &&
472
+ // Two chats can ask a word-for-word identical question. Keeping the
473
+ // old payload would bind the new one to the wrong thread, hiding it
474
+ // in the chat that asked and re-showing it in the one that didn't.
475
+ prev.threadId === data.threadId) {
462
476
  return prev;
463
477
  }
464
478
  return data;
@@ -468,6 +482,10 @@ export function useGuidedQuestionFlow({ enabled = true, stateKey = "show-questio
468
482
  setPayload(null);
469
483
  }
470
484
  }, [data]);
485
+ // A question asked in another conversation stays in application state — the
486
+ // user can still answer it by going back to that chat — but it must not
487
+ // render here.
488
+ const visiblePayload = payload && payloadBelongsToThread(payload, threadId) ? payload : null;
471
489
  const clear = useCallback(() => {
472
490
  setPayload(null);
473
491
  queryClient.setQueryData(resolvedQueryKey, null);
@@ -481,15 +499,15 @@ export function useGuidedQuestionFlow({ enabled = true, stateKey = "show-questio
481
499
  const handleSubmit = useCallback((answers) => {
482
500
  // Client-initiated question (askUserQuestion): resolve the caller's
483
501
  // promise with the answer instead of forwarding it to the agent chat.
484
- const resolveId = payload?.clientResolveId;
502
+ const resolveId = visiblePayload?.clientResolveId;
485
503
  if (resolveId) {
486
- const firstId = payload?.questions?.[0]?.id ?? "q1";
504
+ const firstId = visiblePayload?.questions?.[0]?.id ?? "q1";
487
505
  resolveClientQuestion(resolveId, extractSingleAnswer(answers, firstId));
488
506
  clear();
489
507
  return;
490
508
  }
491
509
  const formattedAnswers = formatGuidedAnswersForAgent(answers);
492
- const resolvedSubmitMessage = payload?.submitMessage ?? submitMessage;
510
+ const resolvedSubmitMessage = visiblePayload?.submitMessage ?? submitMessage;
493
511
  const context = buildSubmitContext?.({ answers, formattedAnswers }) ??
494
512
  defaultGuidedSubmitContext(formattedAnswers);
495
513
  sendToAgentChat({
@@ -498,28 +516,28 @@ export function useGuidedQuestionFlow({ enabled = true, stateKey = "show-questio
498
516
  submit: true,
499
517
  });
500
518
  clear();
501
- }, [buildSubmitContext, clear, payload, submitMessage]);
519
+ }, [buildSubmitContext, clear, visiblePayload, submitMessage]);
502
520
  const handleSkip = useCallback(() => {
503
- const resolveId = payload?.clientResolveId;
521
+ const resolveId = visiblePayload?.clientResolveId;
504
522
  if (resolveId) {
505
523
  resolveClientQuestion(resolveId, null);
506
524
  clear();
507
525
  return;
508
526
  }
509
527
  sendToAgentChat({
510
- message: payload?.skipMessage ?? skipMessage,
528
+ message: visiblePayload?.skipMessage ?? skipMessage,
511
529
  context: buildSkipContext?.(),
512
530
  submit: true,
513
531
  });
514
532
  clear();
515
- }, [buildSkipContext, clear, payload, skipMessage]);
533
+ }, [buildSkipContext, clear, visiblePayload, skipMessage]);
516
534
  return {
517
- payload,
518
- questions: payload?.questions ?? null,
519
- title: payload?.title,
520
- description: payload?.description,
521
- skipLabel: payload?.skipLabel,
522
- submitLabel: payload?.submitLabel,
535
+ payload: visiblePayload,
536
+ questions: visiblePayload?.questions ?? null,
537
+ title: visiblePayload?.title,
538
+ description: visiblePayload?.description,
539
+ skipLabel: visiblePayload?.skipLabel,
540
+ submitLabel: visiblePayload?.submitLabel,
523
541
  clear,
524
542
  handleSubmit,
525
543
  handleSkip,
@@ -13,8 +13,8 @@
13
13
  * Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
14
14
  */
15
15
  export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
- error: string;
17
16
  ok?: undefined;
17
+ error: string;
18
18
  } | {
19
19
  error?: undefined;
20
20
  ok: boolean;
@@ -1534,14 +1534,22 @@ function getQueuedA2AContinuationAgent(completedRun) {
1534
1534
  }
1535
1535
  function extractSlackInputRequest(completedRun) {
1536
1536
  const events = completedRun.events.map((runEvent) => runEvent.event);
1537
- const didRequestInput = events.some((event) => event.type === "tool_done" &&
1537
+ const delivered = events.find((event) => event.type === "tool_done" &&
1538
1538
  event.tool === "ask-question" &&
1539
1539
  String(event.result ?? "").startsWith("Asked the user a clarifying question and rendered it in the chat."));
1540
- if (!didRequestInput)
1540
+ if (!delivered)
1541
1541
  return null;
1542
+ // Match the start to the delivered call by id. A turn can also contain
1543
+ // `ask-question` calls that never ran — the loop skips the rest of the
1544
+ // message once one of them ends the turn, but still emits their start events
1545
+ // — and scanning for the last start would project a question the user's chat
1546
+ // never showed.
1547
+ const deliveredId = delivered.type === "tool_done" ? delivered.id : "";
1542
1548
  for (let index = events.length - 1; index >= 0; index--) {
1543
1549
  const event = events[index];
1544
- if (event.type !== "tool_start" || event.tool !== "ask-question") {
1550
+ if (event.type !== "tool_start" ||
1551
+ event.tool !== "ask-question" ||
1552
+ event.id !== deliveredId) {
1545
1553
  continue;
1546
1554
  }
1547
1555
  const input = event.input;
@@ -137,13 +137,13 @@ export declare function screenMemoryMcpToolDefinitions(): ({
137
137
  inputSchema: {
138
138
  type: string;
139
139
  properties: {
140
- count?: undefined;
141
140
  query?: undefined;
142
141
  minutes?: undefined;
143
142
  limit?: undefined;
144
143
  clientHint?: undefined;
145
144
  timestamp?: undefined;
146
145
  chapterId?: undefined;
146
+ count?: undefined;
147
147
  startAt?: undefined;
148
148
  endAt?: undefined;
149
149
  reason?: undefined;
@@ -159,7 +159,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
159
159
  inputSchema: {
160
160
  type: string;
161
161
  properties: {
162
- count?: undefined;
163
162
  query: {
164
163
  type: string;
165
164
  description: string;
@@ -175,6 +174,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
175
174
  clientHint?: undefined;
176
175
  timestamp?: undefined;
177
176
  chapterId?: undefined;
177
+ count?: undefined;
178
178
  startAt?: undefined;
179
179
  endAt?: undefined;
180
180
  reason?: undefined;
@@ -190,7 +190,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
190
190
  inputSchema: {
191
191
  type: string;
192
192
  properties: {
193
- count?: undefined;
194
193
  minutes: {
195
194
  type: string;
196
195
  description: string;
@@ -200,6 +199,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
200
199
  clientHint?: undefined;
201
200
  timestamp?: undefined;
202
201
  chapterId?: undefined;
202
+ count?: undefined;
203
203
  startAt?: undefined;
204
204
  endAt?: undefined;
205
205
  reason?: undefined;
@@ -216,7 +216,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
216
216
  type: string;
217
217
  required: string[];
218
218
  properties: {
219
- count?: undefined;
220
219
  query: {
221
220
  type: string;
222
221
  description: string;
@@ -235,6 +234,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
235
234
  };
236
235
  timestamp?: undefined;
237
236
  chapterId?: undefined;
237
+ count?: undefined;
238
238
  startAt?: undefined;
239
239
  endAt?: undefined;
240
240
  reason?: undefined;
@@ -250,7 +250,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
250
250
  type: string;
251
251
  required: string[];
252
252
  properties: {
253
- count?: undefined;
254
253
  query?: undefined;
255
254
  minutes?: undefined;
256
255
  limit?: undefined;
@@ -264,6 +263,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
264
263
  description: string;
265
264
  };
266
265
  chapterId?: undefined;
266
+ count?: undefined;
267
267
  startAt?: undefined;
268
268
  endAt?: undefined;
269
269
  includeMicrophone?: undefined;
@@ -314,13 +314,13 @@ export declare function screenMemoryMcpToolDefinitions(): ({
314
314
  type: string;
315
315
  required: string[];
316
316
  properties: {
317
- count?: undefined;
318
317
  query?: undefined;
319
318
  minutes?: undefined;
320
319
  limit?: undefined;
321
320
  clientHint?: undefined;
322
321
  timestamp?: undefined;
323
322
  chapterId?: undefined;
323
+ count?: undefined;
324
324
  startAt: {
325
325
  type: string;
326
326
  description: string;
@@ -349,13 +349,13 @@ export declare function screenMemoryMcpToolDefinitions(): ({
349
349
  type: string;
350
350
  required: string[];
351
351
  properties: {
352
- count?: undefined;
353
352
  query?: undefined;
354
353
  minutes?: undefined;
355
354
  limit?: undefined;
356
355
  clientHint?: undefined;
357
356
  timestamp?: undefined;
358
357
  chapterId?: undefined;
358
+ count?: undefined;
359
359
  startAt?: undefined;
360
360
  endAt?: undefined;
361
361
  reason?: undefined;
@@ -16,18 +16,18 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
16
16
  ok?: undefined;
17
17
  error?: undefined;
18
18
  } | {
19
- count?: undefined;
20
19
  updated: number;
21
20
  ok?: undefined;
22
21
  error?: undefined;
23
- } | {
24
22
  count?: undefined;
23
+ } | {
25
24
  updated?: undefined;
26
25
  error: string;
27
26
  ok?: undefined;
28
- } | {
29
27
  count?: undefined;
28
+ } | {
30
29
  updated?: undefined;
31
30
  ok: boolean;
32
31
  error?: undefined;
32
+ count?: undefined;
33
33
  }>>;
@@ -41,27 +41,27 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
41
41
  thumbsUpRate: number;
42
42
  avgEvalScore: number;
43
43
  } | {
44
- error?: undefined;
45
- ok?: undefined;
46
44
  summary: import("./types.js").TraceSummary;
47
45
  spans: import("./types.js").TraceSpan[];
48
46
  id?: undefined;
49
- } | {
50
47
  error?: undefined;
51
48
  ok?: undefined;
49
+ } | {
52
50
  summary?: undefined;
53
51
  spans?: undefined;
54
52
  id: string;
55
- } | {
53
+ error?: undefined;
56
54
  ok?: undefined;
55
+ } | {
57
56
  summary?: undefined;
58
57
  spans?: undefined;
59
58
  id?: undefined;
60
59
  error: any;
60
+ ok?: undefined;
61
61
  } | {
62
- error?: undefined;
63
62
  summary?: undefined;
64
63
  spans?: undefined;
65
64
  id?: undefined;
65
+ error?: undefined;
66
66
  ok: boolean;
67
67
  }>>;
@@ -15,6 +15,6 @@ export declare function createProgressHandler(): import("h3").EventHandlerWithFe
15
15
  error: string;
16
16
  ok?: undefined;
17
17
  } | {
18
- error?: undefined;
19
18
  ok: boolean;
19
+ error?: undefined;
20
20
  }>>;
@@ -34,37 +34,37 @@ export declare function createListSecretsHandler(): import("h3").EventHandlerWit
34
34
  /** POST /_agent-native/secrets/:key — write a secret. */
35
35
  export declare function createWriteSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
36
36
  error: string;
37
- status?: undefined;
38
37
  ok?: undefined;
38
+ status?: undefined;
39
39
  } | {
40
+ error?: undefined;
40
41
  ok: boolean;
41
42
  status: string;
42
- error?: undefined;
43
43
  } | {
44
+ ok?: undefined;
44
45
  error: string;
45
46
  removed?: undefined;
46
- ok?: undefined;
47
47
  } | {
48
+ error?: undefined;
48
49
  ok: boolean;
49
50
  removed: boolean;
50
- error?: undefined;
51
51
  }>>;
52
52
  /**
53
53
  * POST /_agent-native/secrets/:key/test — validate an optional candidate value
54
54
  * or the current stored value without changing anything.
55
55
  */
56
56
  export declare function createTestSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
57
+ ok?: undefined;
57
58
  error: string;
58
59
  note?: undefined;
59
- ok?: undefined;
60
60
  } | {
61
+ error?: undefined;
61
62
  ok: boolean;
62
63
  note?: undefined;
63
- error?: undefined;
64
64
  } | {
65
+ error?: undefined;
65
66
  ok: boolean;
66
67
  note: string;
67
- error?: undefined;
68
68
  } | {
69
69
  note?: undefined;
70
70
  ok: boolean;
@@ -95,11 +95,11 @@ export interface AdHocSecretPayload {
95
95
  export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
96
96
  error: string;
97
97
  } | {
98
+ error?: undefined;
98
99
  ok: boolean;
99
100
  key: string;
100
- error?: undefined;
101
101
  } | {
102
+ error?: undefined;
102
103
  ok: boolean;
103
104
  removed: boolean;
104
- error?: undefined;
105
105
  }>>;
@@ -376,8 +376,12 @@ export function createUrlTools() {
376
376
  },
377
377
  },
378
378
  "ask-question": {
379
+ // The turn is over once the question is on screen. Without this the loop
380
+ // asks the model for another step, and it keeps working (and re-asking)
381
+ // over an unanswered question no matter what the tool result says.
382
+ endsTurn: true,
379
383
  tool: {
380
- description: "Ask the user a multiple-choice clarifying question and render it inline in the chat. Use this ONLY when you are genuinely blocked on a decision you cannot resolve from context and a wrong guess would be costly — an ambiguous metric, date range, or grain; a real fork in approach. Present 2-5 concrete options and mark the most likely one recommended. Do NOT use it for confirmations, for things the user already specified, or to dodge easy work you could just do. Ask at most once per turn. Calling this yields the turn: stop and wait for the user's answer.",
384
+ description: "Ask the user a multiple-choice clarifying question and render it inline in the chat. Use this ONLY when you are genuinely blocked on a decision you cannot resolve from context and a wrong guess would be costly — an ambiguous metric, date range, or grain; a real fork in approach. Present 2-5 concrete options and mark the most likely one recommended. Do NOT use it for confirmations, for things the user already specified, or to dodge easy work you could just do. Calling this ends the turn: one question per turn, and any other tool call you emit alongside it will not run.",
381
385
  parameters: {
382
386
  type: "object",
383
387
  properties: {
@@ -408,9 +412,12 @@ export function createUrlTools() {
408
412
  },
409
413
  },
410
414
  run: async (args) => {
415
+ // These must throw, not return an error string: `endsTurn` yields the
416
+ // turn on any non-error result, so a returned string would leave the
417
+ // run waiting on a question card that was never written.
411
418
  const question = String(args?.question ?? "").trim();
412
419
  if (!question)
413
- return "Error: 'question' is required.";
420
+ throw new Error("'question' is required.");
414
421
  const header = String(args?.header ?? "").trim();
415
422
  const allowMultiple = String(args?.allowMultiple ?? "") === "true";
416
423
  const allowFreeText = String(args?.allowFreeText ?? "true") !== "false";
@@ -419,10 +426,10 @@ export function createUrlTools() {
419
426
  parsedOptions = JSON.parse(String(args?.options ?? "[]"));
420
427
  }
421
428
  catch {
422
- return "Error: 'options' must be a JSON array of { label, value?, description?, recommended? }.";
429
+ throw new Error("'options' must be a JSON array of { label, value?, description?, recommended? }.");
423
430
  }
424
431
  if (!Array.isArray(parsedOptions) || parsedOptions.length === 0) {
425
- return "Error: 'options' must be a non-empty JSON array of { label, value?, description?, recommended? }.";
432
+ throw new Error("'options' must be a non-empty JSON array of { label, value?, description?, recommended? }.");
426
433
  }
427
434
  const options = parsedOptions
428
435
  .map((raw) => {
@@ -450,14 +457,26 @@ export function createUrlTools() {
450
457
  })
451
458
  .filter((opt) => opt !== null);
452
459
  if (options.length === 0) {
453
- return "Error: 'options' must contain at least one option with a label.";
460
+ throw new Error("'options' must contain at least one option with a label.");
454
461
  }
455
462
  // Shape must match the GuidedQuestionFlow renderer in
456
463
  // client/guided-questions.tsx: a `text-options` question whose options
457
464
  // carry `value`, with `multiSelect` for multi-pick and `allowOther` for
458
465
  // free text. The renderer otherwise injects "Explore"/"Decide" options,
459
466
  // which would be noise for a focused clarifying question, so disable them.
467
+ // The application-state key is scoped per browser tab, not per chat, so
468
+ // the payload has to name the thread that asked. The client hides a
469
+ // pending question in every other conversation instead of following the
470
+ // user from chat to chat.
471
+ // A request that carried no thread id falls back to the run id (see
472
+ // `onRunStart`), and a per-run value would never match the chat the user
473
+ // is looking at. Leave those surfaces unbound — they have one chat.
474
+ const askingRunCtx = getRequestRunContext();
475
+ const askingThreadId = askingRunCtx?.threadId && askingRunCtx.threadId !== askingRunCtx.runId
476
+ ? askingRunCtx.threadId
477
+ : undefined;
460
478
  const payload = {
479
+ ...(askingThreadId ? { threadId: askingThreadId } : {}),
461
480
  questions: [
462
481
  {
463
482
  id: "q1",
@@ -475,7 +494,9 @@ export function createUrlTools() {
475
494
  };
476
495
  const { writeAppState } = await import("../../application-state/script-helpers.js");
477
496
  await writeAppState(appStateKeyForBrowserTab("guided-questions", getRequestRunContext()?.browserTabId), payload);
478
- return "Asked the user a clarifying question and rendered it in the chat. Stop here and wait for their answer — do not proceed or assume an answer.";
497
+ // The `startsWith` of this text is how integration surfaces recognize a
498
+ // delivered question (see `extractSlackInputRequest`). Keep the prefix.
499
+ return "Asked the user a clarifying question and rendered it in the chat. This turn is over — their answer arrives as a new message.";
479
500
  },
480
501
  },
481
502
  };
@@ -27,10 +27,10 @@ export declare function resolveAgentEngineApiKeyWriteTarget(event: H3Event, scop
27
27
  export declare function createAgentEngineApiKeyHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
28
28
  error: any;
29
29
  } | {
30
+ error?: undefined;
30
31
  ok: boolean;
31
32
  key: string;
32
33
  baseUrlKey?: string;
33
34
  scope: AgentEngineApiKeyScope;
34
- error?: undefined;
35
35
  }>>;
36
36
  export {};
@@ -26,8 +26,8 @@ export declare function createRealtimeTokenHandler(): import("h3").EventHandlerW
26
26
  expiresAt?: undefined;
27
27
  ttlSeconds?: undefined;
28
28
  } | {
29
- error?: undefined;
30
29
  token: string;
31
30
  expiresAt: string;
32
31
  ttlSeconds: number;
32
+ error?: undefined;
33
33
  }>>;
@@ -20,6 +20,6 @@ export declare function createTranscribeVoiceHandler(): import("h3").EventHandle
20
20
  error: string;
21
21
  text?: undefined;
22
22
  } | {
23
- error?: undefined;
24
23
  text: string;
24
+ error?: undefined;
25
25
  }>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.152.0",
3
+ "version": "0.152.1",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {